This commit is contained in:
sebseb7
2025-12-21 22:48:26 +01:00
parent c90477aa52
commit bd6b20e6ed
8 changed files with 1209 additions and 142 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ users.json
.DS_Store .DS_Store
*.log *.log
camera-settings.json camera-settings.json
openai.key

View File

@@ -162,7 +162,7 @@ curl -H "X-API-Key: your-secret-key" http://localhost:3080/stats/front-door
GET /settings/:cameraId GET /settings/:cameraId
``` ```
Get v4l2 camera settings (focus, exposure, etc.) for a camera. Get v4l2 camera settings (focus, exposure, etc.) for a camera. The camclient automatically registers available controls on first connection.
**Example:** **Example:**
```bash ```bash
@@ -173,13 +173,44 @@ curl -H "X-API-Key: your-secret-key" http://localhost:3080/settings/front-door
```json ```json
{ {
"cameraId": "front-door", "cameraId": "front-door",
"settings": { "availableControls": [
"focus_automatic_continuous": 0, {"name": "brightness", "type": "int", "min": -64, "max": 64, "step": 1, "default": 0},
"focus_absolute": 30, {"name": "exposure_auto", "type": "menu", "min": 0, "max": 3, "default": 3, "options": {"1": "Manual Mode", "3": "Aperture Priority Mode"}}
"exposure_auto": 1, ],
"exposure_absolute": 200, "values": {
"brightness": 10,
"focus_absolute": 40
},
"config": {
"rotation": null,
"crop": null,
"ocr": null,
"chartLabel": null,
"insertBrightnessToDb": false
},
"updatedAt": "2025-12-21T22:00:00.000Z",
"updatedBy": "webcam1"
}
```
### Register Available Controls
```bash
POST /settings/:cameraId/available
```
Called by the camclient to register all available v4l2 controls with their metadata. The camclient parses `v4l2-ctl -L` output and reports the schema.
**Request Body:**
```json
{
"controls": [
{"name": "brightness", "type": "int", "min": -64, "max": 64, "step": 1, "default": 0},
{"name": "exposure_auto", "type": "menu", "options": {"1": "Manual", "3": "Auto"}, "default": 3}
],
"currentValues": {
"brightness": 0, "brightness": 0,
"contrast": 32 "exposure_auto": 3
} }
} }
``` ```
@@ -190,7 +221,7 @@ curl -H "X-API-Key: your-secret-key" http://localhost:3080/settings/front-door
PUT /settings/:cameraId PUT /settings/:cameraId
``` ```
Update camera settings. Settings are applied by the capture client via v4l2-ctl. Update camera v4l2 control values or config settings (rotation, crop, ocr). Settings are applied by the capture client via v4l2-ctl.
**Example:** **Example:**
```bash ```bash

View File

@@ -168,15 +168,136 @@ if [[ "$CAPTURE_METHOD" == "fswebcam" ]]; then
fi fi
fi fi
# Discover available v4l2 controls and report them to server
discover_and_report_controls() {
if ! command -v v4l2-ctl &>/dev/null; then
log "v4l2-ctl not installed, skipping control discovery"
return
fi
local AVAILABLE_URL="${API_URL%/upload}/settings/$CAMERA_ID/available"
log "Discovering available camera controls..."
# Parse v4l2-ctl -L output into JSON
# Example lines:
# brightness 0x00980900 (int) : min=-64 max=64 step=1 default=0 value=0
# exposure_auto 0x009a0901 (menu) : min=0 max=3 default=3 value=3
# 1: Manual Mode
# 3: Aperture Priority Mode
local controls_json="["
local current_values_json="{"
local first_control=true
local first_value=true
local current_ctrl=""
local ctrl_json=""
local menu_options=""
local in_menu=false
while IFS= read -r line || [[ -n "$line" ]]; do
# Check if this is a control line (starts with control name)
if [[ "$line" =~ ^[[:space:]]*([a-z_]+)[[:space:]]+0x[0-9a-f]+[[:space:]]+\(([a-z]+)\)[[:space:]]*:[[:space:]]*(.*) ]]; then
# Save previous control if exists
if [[ -n "$current_ctrl" ]]; then
if [[ "$in_menu" == "true" && -n "$menu_options" ]]; then
ctrl_json="${ctrl_json%, }, \"options\": {${menu_options%,}}}"
else
ctrl_json="${ctrl_json%,}}"
fi
if [[ "$first_control" == "true" ]]; then
first_control=false
else
controls_json+=","
fi
controls_json+="$ctrl_json"
fi
current_ctrl="${BASH_REMATCH[1]}"
local ctrl_type="${BASH_REMATCH[2]}"
local params="${BASH_REMATCH[3]}"
ctrl_json="{\"name\": \"$current_ctrl\", \"type\": \"$ctrl_type\","
menu_options=""
in_menu=false
# Parse parameters
if [[ "$params" =~ min=(-?[0-9]+) ]]; then
ctrl_json+=" \"min\": ${BASH_REMATCH[1]},"
fi
if [[ "$params" =~ max=(-?[0-9]+) ]]; then
ctrl_json+=" \"max\": ${BASH_REMATCH[1]},"
fi
if [[ "$params" =~ step=([0-9]+) ]]; then
ctrl_json+=" \"step\": ${BASH_REMATCH[1]},"
fi
if [[ "$params" =~ default=(-?[0-9]+) ]]; then
ctrl_json+=" \"default\": ${BASH_REMATCH[1]},"
fi
if [[ "$params" =~ value=(-?[0-9]+) ]]; then
local current_value="${BASH_REMATCH[1]}"
if [[ "$first_value" == "true" ]]; then
first_value=false
else
current_values_json+=","
fi
current_values_json+="\"$current_ctrl\": $current_value"
fi
if [[ "$ctrl_type" == "menu" ]]; then
in_menu=true
fi
# Check if this is a menu option line (indented with number: description)
elif [[ "$in_menu" == "true" && "$line" =~ ^[[:space:]]+([0-9]+):[[:space:]]+(.+)$ ]]; then
local opt_val="${BASH_REMATCH[1]}"
local opt_name="${BASH_REMATCH[2]}"
menu_options+="\"$opt_val\": \"$opt_name\","
fi
done < <(v4l2-ctl -d "$VIDEO_DEVICE" -L 2>/dev/null)
# Save last control
if [[ -n "$current_ctrl" ]]; then
if [[ "$in_menu" == "true" && -n "$menu_options" ]]; then
ctrl_json="${ctrl_json%, }, \"options\": {${menu_options%,}}}"
else
ctrl_json="${ctrl_json%,}}"
fi
if [[ "$first_control" != "true" ]]; then
controls_json+=","
fi
controls_json+="$ctrl_json"
fi
controls_json+="]"
current_values_json+="}"
# POST to server
local payload="{\"controls\": $controls_json, \"currentValues\": $current_values_json}"
local response
response=$(curl -s --max-time 10 \
-X POST \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d "$payload" \
"$AVAILABLE_URL" 2>/dev/null) || true
if echo "$response" | grep -q '"success"'; then
local count
count=$(echo "$response" | grep -o '"controlsRegistered":[0-9]*' | cut -d: -f2)
log "Reported $count available controls to server"
else
log "Failed to report available controls: $response"
fi
}
# Fetch and apply camera settings from server # Fetch and apply camera settings from server
apply_camera_settings() { apply_camera_settings() {
# Check if v4l2-ctl is available
if ! command -v v4l2-ctl &>/dev/null; then if ! command -v v4l2-ctl &>/dev/null; then
log "v4l2-ctl not installed, skipping camera settings" log "v4l2-ctl not installed, skipping camera settings"
return return
fi fi
# Derive settings URL from upload URL (replace /upload with /settings)
local SETTINGS_URL="${API_URL%/upload}/settings/$CAMERA_ID" local SETTINGS_URL="${API_URL%/upload}/settings/$CAMERA_ID"
log "Fetching camera settings from server..." log "Fetching camera settings from server..."
@@ -186,105 +307,67 @@ apply_camera_settings() {
"$SETTINGS_URL" 2>/dev/null) || true "$SETTINGS_URL" 2>/dev/null) || true
if [[ -z "$SETTINGS_RESPONSE" ]]; then if [[ -z "$SETTINGS_RESPONSE" ]]; then
log "Could not fetch settings, using camera defaults" log "Could not fetch settings, discovering and reporting controls..."
discover_and_report_controls
return return
fi fi
# Check if server has custom settings (look for updatedAt field) # Check if server has available controls registered
local has_custom_settings=false local has_controls=false
if command -v jq &>/dev/null; then if command -v jq &>/dev/null; then
local updated_at local ctrl_count
updated_at=$(echo "$SETTINGS_RESPONSE" | jq -r '.settings.updatedAt // empty') ctrl_count=$(echo "$SETTINGS_RESPONSE" | jq -r '.availableControls | length // 0')
if [[ -n "$updated_at" && "$updated_at" != "null" ]]; then if [[ "$ctrl_count" -gt 0 ]]; then
has_custom_settings=true has_controls=true
fi fi
else else
if echo "$SETTINGS_RESPONSE" | grep -q '"updatedAt"'; then if echo "$SETTINGS_RESPONSE" | grep -q '"availableControls":\[{'; then
has_custom_settings=true has_controls=true
fi fi
fi fi
if [[ "$has_custom_settings" == "false" ]]; then if [[ "$has_controls" == "false" ]]; then
# No custom settings on server - upload current camera settings log "No controls registered, discovering and reporting..."
log "No settings on server, uploading current camera settings..." discover_and_report_controls
upload_current_settings
return return
fi fi
# Apply settings from server # Apply all values from server dynamically
if command -v jq &>/dev/null; then if command -v jq &>/dev/null; then
local settings local values
settings=$(echo "$SETTINGS_RESPONSE" | jq -r '.settings // empty') values=$(echo "$SETTINGS_RESPONSE" | jq -r '.values // {}')
if [[ -n "$settings" ]]; then local keys
for ctrl in focus_automatic_continuous focus_absolute exposure_auto exposure_absolute brightness contrast; do keys=$(echo "$values" | jq -r 'keys[]' 2>/dev/null) || true
local applied=0
for ctrl in $keys; do
local value local value
value=$(echo "$settings" | jq -r ".$ctrl // empty") value=$(echo "$values" | jq -r ".[\"$ctrl\"] // empty")
if [[ -n "$value" && "$value" != "null" ]]; then if [[ -n "$value" && "$value" != "null" ]]; then
v4l2-ctl -d "$VIDEO_DEVICE" --set-ctrl="${ctrl}=${value}" 2>/dev/null || true if v4l2-ctl -d "$VIDEO_DEVICE" --set-ctrl="${ctrl}=${value}" 2>/dev/null; then
((applied++))
fi fi
fi
done
log "Applied $applied camera settings from server"
else
# Fallback: grep-based parsing for values
local values_block
values_block=$(echo "$SETTINGS_RESPONSE" | grep -o '"values":{[^}]*}' | sed 's/"values"://')
if [[ -n "$values_block" ]]; then
# Extract key:value pairs
while [[ "$values_block" =~ \"([a-z_]+)\":(-?[0-9]+) ]]; do
local ctrl="${BASH_REMATCH[1]}"
local value="${BASH_REMATCH[2]}"
v4l2-ctl -d "$VIDEO_DEVICE" --set-ctrl="${ctrl}=${value}" 2>/dev/null || true
values_block="${values_block#*${BASH_REMATCH[0]}}"
done done
log "Applied camera settings from server" log "Applied camera settings from server"
fi fi
else
for ctrl in focus_automatic_continuous focus_absolute exposure_auto exposure_absolute brightness contrast; do
local value
value=$(echo "$SETTINGS_RESPONSE" | grep -o "\"${ctrl}\":[0-9-]*" | cut -d: -f2)
if [[ -n "$value" ]]; then
v4l2-ctl -d "$VIDEO_DEVICE" --set-ctrl="${ctrl}=${value}" 2>/dev/null || true
fi
done
log "Applied camera settings from server"
fi fi
} }
# Read current camera settings and upload to server # Only handle settings for fswebcam/v4l2
upload_current_settings() {
local SETTINGS_URL="${API_URL%/upload}/settings/$CAMERA_ID"
# Read current values from camera
local focus_auto focus_abs exp_auto exp_abs brightness contrast
focus_auto=$(v4l2-ctl -d "$VIDEO_DEVICE" --get-ctrl=focus_automatic_continuous 2>/dev/null | cut -d: -f2 | tr -d ' ') || focus_auto=""
focus_abs=$(v4l2-ctl -d "$VIDEO_DEVICE" --get-ctrl=focus_absolute 2>/dev/null | cut -d: -f2 | tr -d ' ') || focus_abs=""
exp_auto=$(v4l2-ctl -d "$VIDEO_DEVICE" --get-ctrl=exposure_auto 2>/dev/null | cut -d: -f2 | tr -d ' ') || exp_auto=""
exp_abs=$(v4l2-ctl -d "$VIDEO_DEVICE" --get-ctrl=exposure_absolute 2>/dev/null | cut -d: -f2 | tr -d ' ') || exp_abs=""
brightness=$(v4l2-ctl -d "$VIDEO_DEVICE" --get-ctrl=brightness 2>/dev/null | cut -d: -f2 | tr -d ' ') || brightness=""
contrast=$(v4l2-ctl -d "$VIDEO_DEVICE" --get-ctrl=contrast 2>/dev/null | cut -d: -f2 | tr -d ' ') || contrast=""
# Build JSON payload
local json_parts=()
[[ -n "$focus_auto" ]] && json_parts+=("\"focus_automatic_continuous\": $focus_auto")
[[ -n "$focus_abs" ]] && json_parts+=("\"focus_absolute\": $focus_abs")
[[ -n "$exp_auto" ]] && json_parts+=("\"exposure_auto\": $exp_auto")
[[ -n "$exp_abs" ]] && json_parts+=("\"exposure_absolute\": $exp_abs")
[[ -n "$brightness" ]] && json_parts+=("\"brightness\": $brightness")
[[ -n "$contrast" ]] && json_parts+=("\"contrast\": $contrast")
if [[ ${#json_parts[@]} -eq 0 ]]; then
log "Could not read camera settings"
return
fi
# Join with commas
local IFS=','
local json_body="{${json_parts[*]}}"
# Upload to server
local response
response=$(curl -s --max-time 10 \
-X PUT \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d "$json_body" \
"$SETTINGS_URL" 2>/dev/null) || true
if echo "$response" | grep -q '"success"'; then
log "Uploaded current camera settings to server"
else
log "Failed to upload camera settings"
fi
}
# Only handle settings upload/apply for fswebcam/v4l2 for now
if [[ "$CAPTURE_METHOD" == "fswebcam" ]]; then if [[ "$CAPTURE_METHOD" == "fswebcam" ]]; then
apply_camera_settings apply_camera_settings
fi fi

35
filter_stats.js Normal file
View File

@@ -0,0 +1,35 @@
import fs from 'fs';
import path from 'path';
const statsFile = 'uploads/rpi-webcam-2/2025/12/20/stats.json';
if (fs.existsSync(statsFile)) {
try {
const raw = fs.readFileSync(statsFile, 'utf8');
const stats = JSON.parse(raw);
console.log(`Original count: ${stats.length}`);
const filtered = stats.filter(s => {
// Keep if ocr_val is missing (null/undefined) or >= 99
// User said: "filter values below 99".
// If ocr_val is null, it's not "below 99" technically, it's "no value".
// But if we are charting, we usually want to keep valid data.
// If ocr_val is strictly a number < 99, remove it.
if (typeof s.ocr_val === 'number') {
return s.ocr_val >= 99;
}
// Keep entries without ocr_val or null
return true;
});
console.log(`Filtered count: ${filtered.length}`);
fs.writeFileSync(statsFile, JSON.stringify(filtered, null, 2));
console.log('Stats file filtered and saved.');
} catch (e) {
console.error('Error processing stats:', e);
}
} else {
console.error('File not found:', statsFile);
}

539
package-lock.json generated
View File

@@ -9,9 +9,13 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.5.0",
"express": "^4.21.0", "express": "^4.21.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"ocrad.js": "^0.0.1",
"openai": "^6.15.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"tesseract.js": "^7.0.0",
"uuid": "^10.0.0" "uuid": "^10.0.0"
} }
}, },
@@ -515,6 +519,80 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.5.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.5.0.tgz",
"integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -539,6 +617,30 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -594,6 +696,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "1.6.2", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
@@ -660,6 +768,30 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -717,6 +849,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -762,6 +903,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -808,6 +958,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -844,6 +1000,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -890,6 +1052,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -958,12 +1126,44 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/idb-keyval": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
"license": "Apache-2.0"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -973,6 +1173,12 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/isarray": { "node_modules/isarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -1048,6 +1254,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": { "node_modules/minimist": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -1069,6 +1287,12 @@
"mkdirp": "bin/cmd.js" "mkdirp": "bin/cmd.js"
} }
}, },
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -1094,6 +1318,12 @@
"node": ">= 6.0.0" "node": ">= 6.0.0"
} }
}, },
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1103,6 +1333,38 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-abi": {
"version": "3.85.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1124,6 +1386,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/ocrad.js": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/ocrad.js/-/ocrad.js-0.0.1.tgz",
"integrity": "sha512-4W8Kcf4ewFJUgEGIBH4FxEgusHPmk0dSzD+CBMaaZeOb1RPM1Rv0+cA177pHDlN2yK8Gelb4KTczxtbQmq+p/w==",
"license": "GPL-3.0"
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -1136,6 +1404,45 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/openai": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.15.0.tgz",
"integrity": "sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1151,6 +1458,32 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -1170,6 +1503,16 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@@ -1209,6 +1552,21 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -1230,6 +1588,12 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1435,6 +1799,51 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1467,6 +1876,81 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tar-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
"license": "Apache-2.0"
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1476,6 +1960,12 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1483,6 +1973,18 @@
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1548,6 +2050,34 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -1556,6 +2086,15 @@
"engines": { "engines": {
"node": ">=0.4" "node": ">=0.4"
} }
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
} }
} }
} }

View File

@@ -15,9 +15,13 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.5.0",
"express": "^4.21.0", "express": "^4.21.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"ocrad.js": "^0.0.1",
"openai": "^6.15.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"tesseract.js": "^7.0.0",
"uuid": "^10.0.0" "uuid": "^10.0.0"
} }
} }

View File

@@ -177,7 +177,7 @@
<!-- Brightness Chart --> <!-- Brightness Chart -->
<div id="chartContainer" <div id="chartContainer"
style="margin-top: 40px; background: var(--card-bg); padding: 20px; border-radius: 8px; display: none;"> style="margin-top: 40px; background: var(--card-bg); padding: 20px; border-radius: 8px; display: none;">
<h3>Brightness Levels</h3> <h3 id="chartTitle">Brightness Levels</h3>
<canvas id="brightnessChart" style="width: 100%; height: 200px;"></canvas> <canvas id="brightnessChart" style="width: 100%; height: 200px;"></canvas>
</div> </div>
</div> </div>
@@ -193,7 +193,8 @@
const state = { const state = {
cameras: [], cameras: [],
selectedCamera: localStorage.getItem('lastCamera') || '', selectedCamera: localStorage.getItem('lastCamera') || '',
selectedDate: localStorage.getItem('lastDate') || '' selectedDate: localStorage.getItem('lastDate') || '',
currentSettings: {}
}; };
// DOM Elements // DOM Elements
@@ -258,6 +259,7 @@
if (state.selectedCamera) { if (state.selectedCamera) {
loadDates(state.selectedCamera); loadDates(state.selectedCamera);
loadSettings(state.selectedCamera);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -299,6 +301,17 @@
} }
} }
async function loadSettings(cameraId) {
try {
const res = await fetch(`settings/${cameraId}`, { headers: getHeaders() });
const data = await res.json();
state.currentSettings = data.settings || {};
} catch (err) {
console.error('Failed to load settings', err);
state.currentSettings = {};
}
}
async function loadImages(isAutoUpdate = false) { async function loadImages(isAutoUpdate = false) {
const cameraId = cameraSelect.value; const cameraId = cameraSelect.value;
const dateStr = dateSelect.value; // YYYY-MM-DD const dateStr = dateSelect.value; // YYYY-MM-DD
@@ -439,16 +452,49 @@
const width = canvas.width; const width = canvas.width;
const height = canvas.height; const height = canvas.height;
const padding = { top: 20, right: 20, bottom: 30, left: 40 }; const padding = { top: 20, right: 30, bottom: 30, left: 45 };
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
// Sort stats by timestamp just in case // Sort stats by timestamp just in case
stats.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); stats.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
// Determine data source and range
// If any entry has 'ocr_val', prefer that.
const hasOcr = stats.some(s => s.ocr_val !== undefined && s.ocr_val !== null);
const valueKey = hasOcr ? 'ocr_val' : 'brightness';
// Clean data (filter nulls)
const validPoints = stats
.filter(s => s[valueKey] !== undefined && s[valueKey] !== null)
.map(s => ({
t: s.timestamp,
v: Number(s[valueKey])
}));
if (validPoints.length === 0) return;
let minY = Math.min(...validPoints.map(p => p.v));
let maxY = Math.max(...validPoints.map(p => p.v));
// Add some padding to Y range
const range = maxY - minY;
if (range === 0) {
minY -= 10;
maxY += 10;
} else {
minY -= range * 0.1;
maxY += range * 0.1;
}
// For brightness, clamp to 0-255 if not OCR?
if (!hasOcr) {
minY = 0;
maxY = 255;
}
// Helper to parsing time to minutes from start of day (local time) // Helper to parsing time to minutes from start of day (local time)
const getMinutes = (iso) => { const getMinutes = (iso) => {
// Parse UCT timestamp to Date object // Parse UTC timestamp to Date object
const parts = iso.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})[-:](\d{2})[-:](\d{2})/); const parts = iso.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})[-:](\d{2})[-:](\d{2})/);
let d; let d;
if (parts) { if (parts) {
@@ -460,14 +506,15 @@
return d.getHours() * 60 + d.getMinutes(); return d.getHours() * 60 + d.getMinutes();
}; };
const points = stats.map(s => ({ const points = validPoints.map(p => ({
x: getMinutes(s.timestamp), x: getMinutes(p.t),
y: s.brightness y: p.v
})); }));
// Scales // Scales
const mapX = (minutes) => padding.left + (minutes / 1440) * (width - padding.left - padding.right); const mapX = (minutes) => padding.left + (minutes / 1440) * (width - padding.left - padding.right);
const mapY = (val) => height - padding.bottom - (val / 255) * (height - padding.top - padding.bottom); // Invert Y (0 at bottom)
const mapY = (val) => height - padding.bottom - ((val - minY) / (maxY - minY)) * (height - padding.top - padding.bottom);
// Draw Axes // Draw Axes
ctx.strokeStyle = '#555'; ctx.strokeStyle = '#555';
@@ -486,7 +533,7 @@
ctx.stroke(); ctx.stroke();
// Draw Line // Draw Line
ctx.strokeStyle = '#3b82f6'; ctx.strokeStyle = hasOcr ? '#10b981' : '#3b82f6'; // Green for OCR, Blue for Brightness
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
@@ -498,6 +545,14 @@
} }
ctx.stroke(); ctx.stroke();
// Draw Dots
ctx.fillStyle = hasOcr ? '#10b981' : '#3b82f6';
for (const p of points) {
ctx.beginPath();
ctx.arc(mapX(p.x), mapY(p.y), 3, 0, Math.PI * 2);
ctx.fill();
}
// Draw Labels (Time) // Draw Labels (Time)
ctx.fillStyle = '#ccc'; ctx.fillStyle = '#ccc';
ctx.font = '12px sans-serif'; ctx.font = '12px sans-serif';
@@ -505,16 +560,35 @@
for (let h = 0; h <= 24; h += 4) { for (let h = 0; h <= 24; h += 4) {
const x = mapX(h * 60); const x = mapX(h * 60);
if (x > padding.left) {
ctx.fillText(`${h}:00`, x, height - 10); ctx.fillText(`${h}:00`, x, height - 10);
} }
}
// Draw Labels (Brightness) // Draw Labels (Value)
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
for (let b = 0; b <= 255; b += 64) { // Draw 5 ticks
const y = mapY(b); for (let i = 0; i <= 4; i++) {
ctx.fillText(Math.round(b), padding.left - 5, y); const val = minY + (i / 4) * (maxY - minY);
const y = mapY(val);
ctx.fillText(Math.round(val), padding.left - 5, y);
} }
// Title
ctx.save();
ctx.translate(15, height / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = 'center';
ctx.textAlign = 'center';
const label = state.currentSettings.chartLabel || (hasOcr ? "Value" : "Brightness");
ctx.fillText(label, 0, 0);
// Update Header too
const titleEl = document.getElementById('chartTitle');
if (titleEl) titleEl.textContent = label;
ctx.restore();
} }
function getPrettyTime(ts) { function getPrettyTime(ts) {
@@ -541,8 +615,10 @@
// Listeners // Listeners
cameraSelect.addEventListener('change', (e) => { cameraSelect.addEventListener('change', (e) => {
state.selectedCamera = e.target.value; state.selectedCamera = e.target.value;
if (state.selectedCamera) loadDates(state.selectedCamera); if (state.selectedCamera) {
else dateSelect.disabled = true; loadDates(state.selectedCamera);
loadSettings(state.selectedCamera);
} else dateSelect.disabled = true;
}); });
dateSelect.addEventListener('change', (e) => { dateSelect.addEventListener('change', (e) => {

370
server.js
View File

@@ -4,6 +4,8 @@ import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import sharp from 'sharp'; import sharp from 'sharp';
import OpenAI from 'openai';
import Database from 'better-sqlite3';
import { validateApiKey, loadUsers } from './users.js'; import { validateApiKey, loadUsers } from './users.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -51,30 +53,23 @@ function authenticate(req, res, next) {
} }
// Configure multer storage // 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({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
const cameraId = req.body.cameraId || req.query.cameraId || 'default'; cb(null, TEMP_UPLOAD_DIR);
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);
}, },
filename: (req, file, cb) => { filename: (req, file, cb) => {
const cameraId = req.body.cameraId || req.query.cameraId || 'default'; // Temporary filename: {timestamp}_{random}.{ext}
const now = new Date(); const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
// Filename format: {cameraId}_{timestamp}.{ext}
const timestamp = now.toISOString().replace(/[:.]/g, '-');
const ext = path.extname(file.originalname) || '.jpg'; const ext = path.extname(file.originalname) || '.jpg';
cb(null, `${uniqueSuffix}${ext}`);
cb(null, `${cameraId}_${timestamp}${ext}`);
} }
}); });
@@ -114,6 +109,66 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => {
const cameraId = req.body.cameraId || req.query.cameraId || 'default'; 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 // Generate thumbnail
try { try {
const image = sharp(req.file.path); 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) // 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); 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 thumbnailFilename = req.file.filename.replace(path.extname(req.file.filename), '_thumb.avif');
const thumbnailPath = path.join(path.dirname(req.file.path), thumbnailFilename); const thumbnailPath = path.join(path.dirname(req.file.path), thumbnailFilename);
const dirPath = path.dirname(req.file.path); const dirPath = path.dirname(req.file.path);
@@ -145,7 +353,8 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => {
dailyStats.push({ dailyStats.push({
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
filename: req.file.filename, filename: req.file.filename,
brightness brightness,
ocr_val
}); });
// Write back stats // 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)); fs.writeFileSync(statsFile, JSON.stringify(dailyStats, null, 2));
} catch (e) { console.error('Error writing stats.json', e); } } 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({ res.json({
success: true, success: true,
cameraId, cameraId,
@@ -162,6 +394,7 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => {
thumbnailPath: thumbnailPath, thumbnailPath: thumbnailPath,
size: req.file.size, size: req.file.size,
brightness, brightness,
ocr_val,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}); });
} catch (error) { } catch (error) {
@@ -268,7 +501,6 @@ function saveCameraSettings(settings) {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
} }
// Get camera settings
// Get camera settings // Get camera settings
app.get('/settings/:cameraId', authenticate, (req, res) => { app.get('/settings/:cameraId', authenticate, (req, res) => {
const { cameraId } = req.params; const { cameraId } = req.params;
@@ -276,23 +508,65 @@ app.get('/settings/:cameraId', authenticate, (req, res) => {
const storageKey = `${user}:${cameraId}`; const storageKey = `${user}:${cameraId}`;
const allSettings = loadCameraSettings(); const allSettings = loadCameraSettings();
const settings = allSettings[storageKey] || {}; const cameraConfig = allSettings[storageKey] || {};
// Return the dynamic schema with available controls and current values
res.json({ res.json({
cameraId, cameraId,
settings: { availableControls: cameraConfig.availableControls || [],
focus_automatic_continuous: settings.focus_automatic_continuous ?? 0, values: cameraConfig.values || {},
focus_absolute: settings.focus_absolute ?? 30, updatedAt: cameraConfig.updatedAt || null,
exposure_auto: settings.exposure_auto ?? 1, updatedBy: cameraConfig.updatedBy || null,
exposure_absolute: settings.exposure_absolute ?? 200, // Include any extra config like rotation, crop, ocr, etc.
brightness: settings.brightness ?? 0, config: {
contrast: settings.contrast ?? 32, rotation: cameraConfig.rotation,
...settings 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) => { app.put('/settings/:cameraId', authenticate, express.json(), (req, res) => {
const { cameraId } = req.params; const { cameraId } = req.params;
const user = req.authenticatedUser; const user = req.authenticatedUser;
@@ -304,9 +578,28 @@ app.put('/settings/:cameraId', authenticate, express.json(), (req, res) => {
} }
const allSettings = loadCameraSettings(); 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] = {
...(allSettings[storageKey] || {}), ...existing,
...newSettings, ...newConfig,
values: {
...(existing.values || {}),
...newValues
},
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
updatedBy: user updatedBy: user
}; };
@@ -316,15 +609,20 @@ app.put('/settings/:cameraId', authenticate, express.json(), (req, res) => {
res.json({ res.json({
success: true, success: true,
cameraId, 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) { } catch (error) {
res.status(500).json({ error: 'Failed to save settings', details: error.message }); res.status(500).json({ error: 'Failed to save settings', details: error.message });
} }
}); });
// === Web Interface Endpoints === // === Web Interface Endpoints ===
// Serve static files (frontend) // Serve static files (frontend)