779 lines
29 KiB
JavaScript
779 lines
29 KiB
JavaScript
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 <username>" 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;
|
|
// Crop if width/height > 0, OR if left/top > 0 (then we calculate width/height)
|
|
const hasCropOffset = settings.crop && (settings.crop.left > 0 || settings.crop.top > 0);
|
|
const hasCropSize = settings.crop && settings.crop.width > 0 && settings.crop.height > 0;
|
|
const shouldCrop = hasCropOffset || hasCropSize;
|
|
|
|
if (shouldRotate || shouldCrop) {
|
|
console.log(`Applying transformations for ${cameraId}:`, { rotation: settings.rotation, crop: settings.crop });
|
|
|
|
let imageBuffer = fs.readFileSync(req.file.path);
|
|
|
|
// Apply rotation first if needed
|
|
if (shouldRotate) {
|
|
imageBuffer = await sharp(imageBuffer).rotate(settings.rotation).toBuffer();
|
|
}
|
|
|
|
// Apply crop on the (potentially rotated) image
|
|
if (shouldCrop) {
|
|
const metadata = await sharp(imageBuffer).metadata();
|
|
// Calculate crop params, ensure at least 1px dimensions
|
|
const cropParams = {
|
|
left: Math.min(settings.crop.left || 0, metadata.width - 1),
|
|
top: Math.min(settings.crop.top || 0, metadata.height - 1),
|
|
width: settings.crop.width > 0
|
|
? settings.crop.width
|
|
: Math.max(1, metadata.width - (settings.crop.left || 0)),
|
|
height: settings.crop.height > 0
|
|
? settings.crop.height
|
|
: Math.max(1, metadata.height - (settings.crop.top || 0))
|
|
};
|
|
console.log(`Calculated crop params:`, cropParams);
|
|
imageBuffer = await sharp(imageBuffer).extract(cropParams).toBuffer();
|
|
}
|
|
|
|
// Write final result
|
|
fs.writeFileSync(req.file.path, imageBuffer);
|
|
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`);
|
|
});
|