u
This commit is contained in:
370
server.js
370
server.js
@@ -4,6 +4,8 @@ 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);
|
||||
@@ -51,30 +53,23 @@ function authenticate(req, res, 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) => {
|
||||
const cameraId = req.body.cameraId || req.query.cameraId || 'default';
|
||||
const now = new Date();
|
||||
|
||||
// Create directory structure: uploads/{cameraId}/{YYYY}/{MM}/{DD}/
|
||||
const year = now.getFullYear().toString();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
|
||||
const destPath = path.join(UPLOAD_DIR, cameraId, year, month, day);
|
||||
|
||||
fs.mkdirSync(destPath, { recursive: true });
|
||||
cb(null, destPath);
|
||||
cb(null, TEMP_UPLOAD_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const cameraId = req.body.cameraId || req.query.cameraId || 'default';
|
||||
const now = new Date();
|
||||
|
||||
// Filename format: {cameraId}_{timestamp}.{ext}
|
||||
const timestamp = now.toISOString().replace(/[:.]/g, '-');
|
||||
// Temporary filename: {timestamp}_{random}.{ext}
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const ext = path.extname(file.originalname) || '.jpg';
|
||||
|
||||
cb(null, `${cameraId}_${timestamp}${ext}`);
|
||||
cb(null, `${uniqueSuffix}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,6 +109,66 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => {
|
||||
|
||||
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
|
||||
if (settings.rotation || settings.crop) {
|
||||
console.log(`Applying transformations for ${cameraId}:`, { rotation: settings.rotation, crop: settings.crop });
|
||||
let pipeline = sharp(req.file.path);
|
||||
|
||||
if (settings.rotation) {
|
||||
pipeline = pipeline.rotate(settings.rotation);
|
||||
}
|
||||
|
||||
if (settings.crop) {
|
||||
// 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);
|
||||
@@ -124,6 +179,159 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => {
|
||||
// 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);
|
||||
@@ -145,7 +353,8 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => {
|
||||
dailyStats.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
filename: req.file.filename,
|
||||
brightness
|
||||
brightness,
|
||||
ocr_val
|
||||
});
|
||||
|
||||
// Write back stats
|
||||
@@ -153,6 +362,29 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => {
|
||||
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,
|
||||
@@ -162,6 +394,7 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => {
|
||||
thumbnailPath: thumbnailPath,
|
||||
size: req.file.size,
|
||||
brightness,
|
||||
ocr_val,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -268,7 +501,6 @@ function saveCameraSettings(settings) {
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
|
||||
}
|
||||
|
||||
// Get camera settings
|
||||
// Get camera settings
|
||||
app.get('/settings/:cameraId', authenticate, (req, res) => {
|
||||
const { cameraId } = req.params;
|
||||
@@ -276,23 +508,65 @@ app.get('/settings/:cameraId', authenticate, (req, res) => {
|
||||
const storageKey = `${user}:${cameraId}`;
|
||||
|
||||
const allSettings = loadCameraSettings();
|
||||
const settings = allSettings[storageKey] || {};
|
||||
const cameraConfig = allSettings[storageKey] || {};
|
||||
|
||||
// Return the dynamic schema with available controls and current values
|
||||
res.json({
|
||||
cameraId,
|
||||
settings: {
|
||||
focus_automatic_continuous: settings.focus_automatic_continuous ?? 0,
|
||||
focus_absolute: settings.focus_absolute ?? 30,
|
||||
exposure_auto: settings.exposure_auto ?? 1,
|
||||
exposure_absolute: settings.exposure_absolute ?? 200,
|
||||
brightness: settings.brightness ?? 0,
|
||||
contrast: settings.contrast ?? 32,
|
||||
...settings
|
||||
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
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update camera settings
|
||||
// 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] || {};
|
||||
|
||||
// Preserve existing configured values, but update available controls
|
||||
allSettings[storageKey] = {
|
||||
...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;
|
||||
@@ -304,9 +578,28 @@ app.put('/settings/:cameraId', authenticate, express.json(), (req, res) => {
|
||||
}
|
||||
|
||||
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] = {
|
||||
...(allSettings[storageKey] || {}),
|
||||
...newSettings,
|
||||
...existing,
|
||||
...newConfig,
|
||||
values: {
|
||||
...(existing.values || {}),
|
||||
...newValues
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: user
|
||||
};
|
||||
@@ -316,15 +609,20 @@ app.put('/settings/:cameraId', authenticate, express.json(), (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
cameraId,
|
||||
settings: allSettings[storageKey]
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user