import express from 'express'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import sharp from 'sharp'; import OpenAI from 'openai'; import Database from 'better-sqlite3'; import { validateApiKey, loadUsers } from './users.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Configuration from environment variables const PORT = process.env.PICUPPER_PORT; const UPLOAD_DIR = process.env.PICUPPER_UPLOAD_DIR || path.join(__dirname, 'uploads'); const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB - typical webcam snapshot size if (!PORT) { console.error('ERROR: PICUPPER_PORT environment variable is required'); process.exit(1); } // Check for users const users = loadUsers(); if (Object.keys(users).length === 0) { console.warn('WARNING: No users configured. Use "node manage-users.js add " to create users.'); } // Ensure upload directory exists if (!fs.existsSync(UPLOAD_DIR)) { fs.mkdirSync(UPLOAD_DIR, { recursive: true }); } const app = express(); // API Key authentication middleware function authenticate(req, res, next) { const providedKey = req.headers['x-api-key'] || req.query.apiKey; if (!providedKey) { return res.status(401).json({ error: 'Unauthorized: Missing API key' }); } const username = validateApiKey(providedKey); if (!username) { return res.status(401).json({ error: 'Unauthorized: Invalid API key' }); } // Attach username to request for logging/tracking req.authenticatedUser = username; next(); } // Configure multer storage const TEMP_UPLOAD_DIR = path.join(UPLOAD_DIR, 'temp'); // Ensure temp directory exists if (!fs.existsSync(TEMP_UPLOAD_DIR)) { fs.mkdirSync(TEMP_UPLOAD_DIR, { recursive: true }); } // Configure multer storage - save to temp dir first const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, TEMP_UPLOAD_DIR); }, filename: (req, file, cb) => { // Temporary filename: {timestamp}_{random}.{ext} const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); const ext = path.extname(file.originalname) || '.jpg'; cb(null, `${uniqueSuffix}${ext}`); } }); // File filter - only accept images function fileFilter(req, file, cb) { const allowedMimes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; if (allowedMimes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error(`Invalid file type: ${file.mimetype}. Allowed: ${allowedMimes.join(', ')}`), false); } } const upload = multer({ storage, fileFilter, limits: { fileSize: MAX_FILE_SIZE } }); // Health check endpoint (no auth required) app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() }); }); // Upload endpoint app.post('/upload', authenticate, upload.single('image'), async (req, res) => { if (!req.file) { return res.status(400).json({ error: 'No image file provided. Use form field "image".' }); } const cameraId = req.body.cameraId || req.query.cameraId || 'default'; // Move file from temp to final destination const now = new Date(); const year = now.getFullYear().toString(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const finalDir = path.join(UPLOAD_DIR, cameraId, year, month, day); if (!fs.existsSync(finalDir)) { fs.mkdirSync(finalDir, { recursive: true }); } const timestamp = now.toISOString().replace(/[:.]/g, '-'); const ext = path.extname(req.file.originalname) || '.jpg'; const finalFilename = `${cameraId}_${timestamp}${ext}`; const finalPath = path.join(finalDir, finalFilename); try { fs.renameSync(req.file.path, finalPath); // Update req.file details for subsequent use req.file.path = finalPath; req.file.filename = finalFilename; req.file.destination = finalDir; } catch (moveError) { console.error('Failed to move file to final destination:', moveError); // Clean up temp file if move failed try { fs.unlinkSync(req.file.path); } catch (e) { } return res.status(500).json({ error: 'Failed to process upload storage' }); } // Apply rotation and cropping if configured try { const user = req.authenticatedUser; const storageKey = `${user}:${cameraId}`; const allSettings = loadCameraSettings(); const settings = allSettings[storageKey] || {}; // Check if we need to modify the image const shouldRotate = settings.rotation && settings.rotation !== 0; const shouldCrop = settings.crop && settings.crop.width > 0 && settings.crop.height > 0; if (shouldRotate || shouldCrop) { console.log(`Applying transformations for ${cameraId}:`, { rotation: settings.rotation, crop: settings.crop }); let pipeline = sharp(req.file.path); if (shouldRotate) { pipeline = pipeline.rotate(settings.rotation); } if (shouldCrop) { // crop format: { left, top, width, height } pipeline = pipeline.extract(settings.crop); } // Overwrite the original file with transformed version const buffer = await pipeline.toBuffer(); fs.writeFileSync(req.file.path, buffer); console.log(`Transformed image saved to ${req.file.path}`); } } catch (transformError) { console.error('Failed to transform image:', transformError); } // Generate thumbnail try { const image = sharp(req.file.path); const metadata = await image.metadata(); // Get metadata first const stats = await image.stats(); // Get stats (includes mean for channels) // Calculate Average Brightness // Simple average of R, G, B mean values (0-255) const brightness = Math.round((stats.channels[0].mean + stats.channels[1].mean + stats.channels[2].mean) / 3); // Reload settings for OCR check let ocr_val = null; try { const user = req.authenticatedUser; const storageKey = `${user}:${cameraId}`; const settings = loadCameraSettings()[storageKey] || {}; const ocrSettings = (typeof settings.ocr === 'object') ? settings.ocr : { enabled: !!settings.ocr }; if (ocrSettings.enabled) { console.log(`Running OCR for ${cameraId} using OpenAI...`); try { // Read API Key const keyPath = path.join(__dirname, 'openai.key'); if (fs.existsSync(keyPath)) { const apiKey = fs.readFileSync(keyPath, 'utf8').trim(); // console.log(`Debug: Key loaded`); const openai = new OpenAI({ apiKey }); // Gather historical context for OCR let last5Values = []; let fiveDayMin = null; let fiveDayMax = null; try { const cameraPath = path.join(UPLOAD_DIR, cameraId); const now = new Date(); const allOcrVals = []; // Check last 5 days for (let daysAgo = 0; daysAgo < 5; daysAgo++) { const d = new Date(now); d.setDate(d.getDate() - daysAgo); const y = d.getFullYear().toString(); const m = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); const statsPath = path.join(cameraPath, y, m, dd, 'stats.json'); if (fs.existsSync(statsPath)) { const dayStats = JSON.parse(fs.readFileSync(statsPath, 'utf8')); const validVals = dayStats .filter(e => e.ocr_val != null && typeof e.ocr_val === 'number') .map(e => e.ocr_val); allOcrVals.push(...validVals); // Get last 5 from today only if (daysAgo === 0 && validVals.length > 0) { last5Values = validVals.slice(-5); } } } if (allOcrVals.length > 0) { fiveDayMin = Math.min(...allOcrVals); fiveDayMax = Math.max(...allOcrVals); } } catch (histErr) { console.error('Error gathering OCR history:', histErr); } // Build context string let contextStr = ''; if (last5Values.length > 0) { contextStr += `Last ${last5Values.length} readings: ${last5Values.join(', ')}. `; } if (fiveDayMin !== null && fiveDayMax !== null) { contextStr += `Expected range (last 5 days): ${fiveDayMin} to ${fiveDayMax}.`; } const promptText = contextStr ? `Identify the numeric meter reading. ${contextStr}` : `Identify the numeric meter reading.`; // Prepare Image (Base64) const imageBuffer = await sharp(req.file.path) .resize({ width: 200, withoutEnlargement: true }) .toFormat('png') .toBuffer(); const base64Image = imageBuffer.toString('base64'); const dataUrl = `data:image/jpeg;base64,${base64Image}`; const response = await openai.chat.completions.create({ reasoning_effort: "minimal", model: "gpt-5-mini", messages: [ { "role": "user", "content": [ { "type": "text", "text": promptText }, { "type": "image_url", "image_url": { "url": dataUrl } } ] } ], response_format: { "type": "json_schema", "json_schema": { "name": "number_extraction", "strict": true, "schema": { "type": "object", "properties": { "number": { "type": "number" } }, "required": ["number"], "additionalProperties": false } } } }); const content = response.choices[0].message.content; if (content) { const result = JSON.parse(content); let val = result.number; if (ocrSettings.minval !== undefined && val < ocrSettings.minval) { console.log(`OCR value ${val} skipped (below minval ${ocrSettings.minval})`); val = null; } if (ocrSettings.maxval !== undefined && val > ocrSettings.maxval) { console.log(`OCR value ${val} skipped (above maxval ${ocrSettings.maxval})`); val = null; } if (val !== null) { ocr_val = val; console.log(`OpenAI OCR Result: ${ocr_val}`); // Insert CO2 reading into ac_data.db try { const co2DbPath = '/home/seb/src/actest/ac_data.db'; if (fs.existsSync(co2DbPath)) { const co2Db = new Database(co2DbPath); const stmt = co2Db.prepare(` INSERT INTO readings (dev_name, port, port_name, fan_speed) VALUES ('Wall', 2, 'CO2', ?) `); stmt.run(ocr_val); co2Db.close(); console.log(`CO2 reading ${ocr_val} ppm inserted into ac_data.db`); } } catch (dbErr) { console.error('Failed to insert CO2 into ac_data.db:', dbErr); } } } } else { console.error('openai.key file not found.'); } } catch (openaiError) { console.error('OpenAI OCR Failed:', openaiError); } } } catch (ocrErr) { console.error('OCR logic error:', ocrErr); } const thumbnailFilename = req.file.filename.replace(path.extname(req.file.filename), '_thumb.avif'); const thumbnailPath = path.join(path.dirname(req.file.path), thumbnailFilename); const dirPath = path.dirname(req.file.path); await image .resize(320) // Resize to 320px width, auto height .toFormat('avif') .toFile(thumbnailPath); // Update daily stats.json const statsFile = path.join(dirPath, 'stats.json'); let dailyStats = []; try { if (fs.existsSync(statsFile)) { dailyStats = JSON.parse(fs.readFileSync(statsFile, 'utf8')); } } catch (e) { console.error('Error reading stats.json', e); } dailyStats.push({ timestamp: new Date().toISOString(), filename: req.file.filename, brightness, ocr_val }); // Write back stats try { fs.writeFileSync(statsFile, JSON.stringify(dailyStats, null, 2)); } catch (e) { console.error('Error writing stats.json', e); } // Insert brightness to ac_data.db if enabled try { const user = req.authenticatedUser; const storageKey = `${user}:${cameraId}`; const settings = loadCameraSettings()[storageKey] || {}; if (settings.insertBrightnessToDb) { const dbPath = '/home/seb/src/actest/ac_data.db'; if (fs.existsSync(dbPath)) { const db = new Database(dbPath); const stmt = db.prepare(` INSERT INTO readings (dev_name, port, port_name, fan_speed) VALUES ('Wall', 3, 'Light', ?) `); stmt.run(brightness); db.close(); console.log(`Brightness ${brightness} inserted into ac_data.db as Light`); } } } catch (dbErr) { console.error('Failed to insert brightness into ac_data.db:', dbErr); } res.json({ success: true, cameraId, filename: req.file.filename, path: req.file.path, thumbnail: thumbnailFilename, thumbnailPath: thumbnailPath, size: req.file.size, brightness, ocr_val, timestamp: new Date().toISOString() }); } catch (error) { console.error('Processing failed:', error); // Still return success for the upload even if processing fails res.json({ success: true, cameraId, filename: req.file.filename, path: req.file.path, error: error.message, size: req.file.size, timestamp: new Date().toISOString() }); } }); // List cameras (directories in upload folder) app.get('/cameras', authenticate, (req, res) => { try { if (!fs.existsSync(UPLOAD_DIR)) { return res.json({ cameras: [] }); } const cameras = fs.readdirSync(UPLOAD_DIR, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); res.json({ cameras }); } catch (error) { res.status(500).json({ error: 'Failed to list cameras', details: error.message }); } }); // Get statistics for a camera app.get('/stats/:cameraId', authenticate, (req, res) => { const { cameraId } = req.params; const cameraPath = path.join(UPLOAD_DIR, cameraId); if (!fs.existsSync(cameraPath)) { return res.status(404).json({ error: `Camera '${cameraId}' not found` }); } try { let totalImages = 0; let totalSize = 0; let oldestImage = null; let newestImage = null; // Recursively count files function countFiles(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { countFiles(fullPath); } else if (entry.isFile()) { totalImages++; const stats = fs.statSync(fullPath); totalSize += stats.size; if (!oldestImage || stats.mtime < oldestImage) { oldestImage = stats.mtime; } if (!newestImage || stats.mtime > newestImage) { newestImage = stats.mtime; } } } } countFiles(cameraPath); res.json({ cameraId, totalImages, totalSizeBytes: totalSize, totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2), oldestImage: oldestImage ? oldestImage.toISOString() : null, newestImage: newestImage ? newestImage.toISOString() : null }); } catch (error) { res.status(500).json({ error: 'Failed to get camera stats', details: error.message }); } }); // Camera settings storage const SETTINGS_FILE = process.env.PICUPPER_SETTINGS_FILE || path.join(__dirname, 'camera-settings.json'); function loadCameraSettings() { try { if (fs.existsSync(SETTINGS_FILE)) { return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); } } catch (error) { console.error('Error loading camera settings:', error.message); } return {}; } function saveCameraSettings(settings) { fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); } // Get camera settings app.get('/settings/:cameraId', authenticate, (req, res) => { const { cameraId } = req.params; const user = req.authenticatedUser; const storageKey = `${user}:${cameraId}`; const allSettings = loadCameraSettings(); const cameraConfig = allSettings[storageKey] || {}; // Return the dynamic schema with available controls and current values res.json({ cameraId, availableControls: cameraConfig.availableControls || [], values: cameraConfig.values || {}, updatedAt: cameraConfig.updatedAt || null, updatedBy: cameraConfig.updatedBy || null, // Include any extra config like rotation, crop, ocr, etc. config: { rotation: cameraConfig.rotation, crop: cameraConfig.crop, ocr: cameraConfig.ocr, chartLabel: cameraConfig.chartLabel, insertBrightnessToDb: cameraConfig.insertBrightnessToDb } }); }); // Register available camera controls (called by camclient on startup) app.post('/settings/:cameraId/available', authenticate, express.json(), (req, res) => { const { cameraId } = req.params; const user = req.authenticatedUser; const storageKey = `${user}:${cameraId}`; const { controls, currentValues } = req.body; if (!controls || !Array.isArray(controls)) { return res.status(400).json({ error: 'controls array required in request body' }); } const allSettings = loadCameraSettings(); const existing = allSettings[storageKey] || {}; // Default config values for new entries const defaultConfig = { rotation: 0, crop: { left: 0, top: 0, width: 0, height: 0 }, ocr: { enabled: false, minval: 0, maxval: 9999 }, chartLabel: null, insertBrightnessToDb: false }; // Preserve existing configured values, but update available controls allSettings[storageKey] = { // Start with defaults, then overlay existing config ...defaultConfig, ...existing, availableControls: controls, // Merge current camera values as defaults if no values configured yet values: existing.values || currentValues || {}, updatedAt: new Date().toISOString(), updatedBy: user }; try { saveCameraSettings(allSettings); console.log(`Registered ${controls.length} available controls for ${storageKey}`); res.json({ success: true, cameraId, controlsRegistered: controls.length, values: allSettings[storageKey].values }); } catch (error) { res.status(500).json({ error: 'Failed to save available controls', details: error.message }); } }); // Update camera settings (values) app.put('/settings/:cameraId', authenticate, express.json(), (req, res) => { const { cameraId } = req.params; const user = req.authenticatedUser; const storageKey = `${user}:${cameraId}`; const newSettings = req.body; if (!newSettings || typeof newSettings !== 'object') { return res.status(400).json({ error: 'Settings object required in request body' }); } const allSettings = loadCameraSettings(); const existing = allSettings[storageKey] || {}; // Separate v4l2 control values from config settings const configKeys = ['rotation', 'crop', 'ocr', 'chartLabel', 'insertBrightnessToDb']; const newValues = {}; const newConfig = {}; for (const [key, value] of Object.entries(newSettings)) { if (configKeys.includes(key)) { newConfig[key] = value; } else { newValues[key] = value; } } allSettings[storageKey] = { ...existing, ...newConfig, values: { ...(existing.values || {}), ...newValues }, updatedAt: new Date().toISOString(), updatedBy: user }; try { saveCameraSettings(allSettings); res.json({ success: true, cameraId, values: allSettings[storageKey].values, config: { rotation: allSettings[storageKey].rotation, crop: allSettings[storageKey].crop, ocr: allSettings[storageKey].ocr, chartLabel: allSettings[storageKey].chartLabel, insertBrightnessToDb: allSettings[storageKey].insertBrightnessToDb } }); } catch (error) { res.status(500).json({ error: 'Failed to save settings', details: error.message }); } }); // === Web Interface Endpoints === // Serve static files (frontend) app.use(express.static(path.join(__dirname, 'public'))); // Serve uploads (images) app.use('/uploads', express.static(UPLOAD_DIR)); // Get available dates for a camera app.get('/cameras/:cameraId/dates', async (req, res) => { const { cameraId } = req.params; const cameraPath = path.join(UPLOAD_DIR, cameraId); if (!fs.existsSync(cameraPath)) { return res.json({ dates: [] }); } try { const dates = []; const years = fs.readdirSync(cameraPath).filter(name => /^\d{4}$/.test(name)); for (const year of years) { const yearPath = path.join(cameraPath, year); const months = fs.readdirSync(yearPath).filter(name => /^\d{2}$/.test(name)); for (const month of months) { const monthPath = path.join(yearPath, month); const days = fs.readdirSync(monthPath).filter(name => /^\d{2}$/.test(name)); for (const day of days) { dates.push(`${year}-${month}-${day}`); } } } // Sort newest first dates.sort((a, b) => b.localeCompare(a)); res.json({ dates }); } catch (error) { res.status(500).json({ error: 'Failed to list dates', details: error.message }); } }); // Get images for a specific date app.get('/cameras/:cameraId/:year/:month/:day', async (req, res) => { const { cameraId, year, month, day } = req.params; const datePath = path.join(UPLOAD_DIR, cameraId, year, month, day); if (!fs.existsSync(datePath)) { return res.status(404).json({ error: 'Date not found' }); } try { const files = fs.readdirSync(datePath); const images = []; // Group files by timestamp (original and thumbnail) // Expected format: {cameraId}_{timestamp}.jpg and {cameraId}_{timestamp}_thumb.avif for (const file of files) { // Skip existing thumbnails from the list loop to avoid duplicates // We'll attach them to the main image entry if (file.includes('_thumb.avif')) continue; // Only process image files if (!/\.(jpg|jpeg|png|webp|gif)$/i.test(file)) continue; const thumbName = file.replace(path.extname(file), '_thumb.avif'); const hasThumb = files.includes(thumbName); images.push({ filename: file, url: `uploads/${cameraId}/${year}/${month}/${day}/${file}`, thumbnailUrl: hasThumb ? `uploads/${cameraId}/${year}/${month}/${day}/${thumbName}` : `uploads/${cameraId}/${year}/${month}/${day}/${file}`, // Fallback to original timestamp: file.split('_')[1]?.replace(path.extname(file), '') || '' }); } // Sort by filename (timestamp) descending images.sort((a, b) => b.filename.localeCompare(a.filename)); // Load stats if available let stats = []; try { const statsFile = path.join(datePath, 'stats.json'); if (fs.existsSync(statsFile)) { stats = JSON.parse(fs.readFileSync(statsFile, 'utf8')); } } catch (e) { console.error('Error reading stats.json', e); } res.json({ images, stats }); } catch (error) { res.status(500).json({ error: 'Failed to list images', details: error.message }); } }); // Error handling middleware app.use((error, req, res, next) => { if (error instanceof multer.MulterError) { if (error.code === 'LIMIT_FILE_SIZE') { return res.status(413).json({ error: `File too large. Maximum size: ${MAX_FILE_SIZE / (1024 * 1024)}MB` }); } return res.status(400).json({ error: error.message }); } if (error) { return res.status(400).json({ error: error.message }); } next(); }); // Start server app.listen(PORT, () => { console.log(`PicUpper service running on port ${PORT}`); console.log(`Upload directory: ${UPLOAD_DIR}`); console.log(`Max file size: ${MAX_FILE_SIZE / (1024 * 1024)}MB`); });