u
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ users.json
|
||||
.DS_Store
|
||||
*.log
|
||||
camera-settings.json
|
||||
openai.key
|
||||
47
README.md
47
README.md
@@ -162,7 +162,7 @@ curl -H "X-API-Key: your-secret-key" http://localhost:3080/stats/front-door
|
||||
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:**
|
||||
```bash
|
||||
@@ -173,13 +173,44 @@ curl -H "X-API-Key: your-secret-key" http://localhost:3080/settings/front-door
|
||||
```json
|
||||
{
|
||||
"cameraId": "front-door",
|
||||
"settings": {
|
||||
"focus_automatic_continuous": 0,
|
||||
"focus_absolute": 30,
|
||||
"exposure_auto": 1,
|
||||
"exposure_absolute": 200,
|
||||
"availableControls": [
|
||||
{"name": "brightness", "type": "int", "min": -64, "max": 64, "step": 1, "default": 0},
|
||||
{"name": "exposure_auto", "type": "menu", "min": 0, "max": 3, "default": 3, "options": {"1": "Manual Mode", "3": "Aperture Priority Mode"}}
|
||||
],
|
||||
"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,
|
||||
"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
|
||||
```
|
||||
|
||||
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:**
|
||||
```bash
|
||||
|
||||
@@ -168,15 +168,136 @@ if [[ "$CAPTURE_METHOD" == "fswebcam" ]]; then
|
||||
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
|
||||
apply_camera_settings() {
|
||||
# Check if v4l2-ctl is available
|
||||
if ! command -v v4l2-ctl &>/dev/null; then
|
||||
log "v4l2-ctl not installed, skipping camera settings"
|
||||
return
|
||||
fi
|
||||
|
||||
# Derive settings URL from upload URL (replace /upload with /settings)
|
||||
local SETTINGS_URL="${API_URL%/upload}/settings/$CAMERA_ID"
|
||||
|
||||
log "Fetching camera settings from server..."
|
||||
@@ -186,105 +307,67 @@ apply_camera_settings() {
|
||||
"$SETTINGS_URL" 2>/dev/null) || true
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
# Check if server has custom settings (look for updatedAt field)
|
||||
local has_custom_settings=false
|
||||
# Check if server has available controls registered
|
||||
local has_controls=false
|
||||
if command -v jq &>/dev/null; then
|
||||
local updated_at
|
||||
updated_at=$(echo "$SETTINGS_RESPONSE" | jq -r '.settings.updatedAt // empty')
|
||||
if [[ -n "$updated_at" && "$updated_at" != "null" ]]; then
|
||||
has_custom_settings=true
|
||||
local ctrl_count
|
||||
ctrl_count=$(echo "$SETTINGS_RESPONSE" | jq -r '.availableControls | length // 0')
|
||||
if [[ "$ctrl_count" -gt 0 ]]; then
|
||||
has_controls=true
|
||||
fi
|
||||
else
|
||||
if echo "$SETTINGS_RESPONSE" | grep -q '"updatedAt"'; then
|
||||
has_custom_settings=true
|
||||
if echo "$SETTINGS_RESPONSE" | grep -q '"availableControls":\[{'; then
|
||||
has_controls=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$has_custom_settings" == "false" ]]; then
|
||||
# No custom settings on server - upload current camera settings
|
||||
log "No settings on server, uploading current camera settings..."
|
||||
upload_current_settings
|
||||
if [[ "$has_controls" == "false" ]]; then
|
||||
log "No controls registered, discovering and reporting..."
|
||||
discover_and_report_controls
|
||||
return
|
||||
fi
|
||||
|
||||
# Apply settings from server
|
||||
# Apply all values from server dynamically
|
||||
if command -v jq &>/dev/null; then
|
||||
local settings
|
||||
settings=$(echo "$SETTINGS_RESPONSE" | jq -r '.settings // empty')
|
||||
if [[ -n "$settings" ]]; then
|
||||
for ctrl in focus_automatic_continuous focus_absolute exposure_auto exposure_absolute brightness contrast; do
|
||||
local values
|
||||
values=$(echo "$SETTINGS_RESPONSE" | jq -r '.values // {}')
|
||||
local keys
|
||||
keys=$(echo "$values" | jq -r 'keys[]' 2>/dev/null) || true
|
||||
|
||||
local applied=0
|
||||
for ctrl in $keys; do
|
||||
local value
|
||||
value=$(echo "$settings" | jq -r ".$ctrl // empty")
|
||||
value=$(echo "$values" | jq -r ".[\"$ctrl\"] // empty")
|
||||
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
|
||||
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
|
||||
log "Applied camera settings from server"
|
||||
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
|
||||
}
|
||||
|
||||
# Read current camera settings and upload to server
|
||||
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
|
||||
# Only handle settings for fswebcam/v4l2
|
||||
if [[ "$CAPTURE_METHOD" == "fswebcam" ]]; then
|
||||
apply_camera_settings
|
||||
fi
|
||||
|
||||
35
filter_stats.js
Normal file
35
filter_stats.js
Normal 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
539
package-lock.json
generated
@@ -9,9 +9,13 @@
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"express": "^4.21.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"ocrad.js": "^0.0.1",
|
||||
"openai": "^6.15.0",
|
||||
"sharp": "^0.34.5",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
@@ -515,6 +519,80 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"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": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -539,6 +617,30 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -594,6 +696,12 @@
|
||||
"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": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||
@@ -660,6 +768,30 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -717,6 +849,15 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -762,6 +903,15 @@
|
||||
"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": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
@@ -808,6 +958,12 @@
|
||||
"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": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -844,6 +1000,12 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -890,6 +1052,12 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -958,12 +1126,44 @@
|
||||
"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": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"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": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -973,6 +1173,12 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
@@ -1048,6 +1254,18 @@
|
||||
"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": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
@@ -1069,6 +1287,12 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
@@ -1094,6 +1318,12 @@
|
||||
"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": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -1103,6 +1333,38 @@
|
||||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -1124,6 +1386,12 @@
|
||||
"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": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -1136,6 +1404,45 @@
|
||||
"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": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -1151,6 +1458,32 @@
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@@ -1170,6 +1503,16 @@
|
||||
"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": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
@@ -1209,6 +1552,21 @@
|
||||
"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": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
@@ -1230,6 +1588,12 @@
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"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": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -1435,6 +1799,51 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -1467,6 +1876,81 @@
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -1476,6 +1960,12 @@
|
||||
"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": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -1483,6 +1973,18 @@
|
||||
"license": "0BSD",
|
||||
"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": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@@ -1548,6 +2050,34 @@
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
@@ -1556,6 +2086,15 @@
|
||||
"engines": {
|
||||
"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": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,13 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"express": "^4.21.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"ocrad.js": "^0.0.1",
|
||||
"openai": "^6.15.0",
|
||||
"sharp": "^0.34.5",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
<!-- Brightness Chart -->
|
||||
<div id="chartContainer"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,7 +193,8 @@
|
||||
const state = {
|
||||
cameras: [],
|
||||
selectedCamera: localStorage.getItem('lastCamera') || '',
|
||||
selectedDate: localStorage.getItem('lastDate') || ''
|
||||
selectedDate: localStorage.getItem('lastDate') || '',
|
||||
currentSettings: {}
|
||||
};
|
||||
|
||||
// DOM Elements
|
||||
@@ -258,6 +259,7 @@
|
||||
|
||||
if (state.selectedCamera) {
|
||||
loadDates(state.selectedCamera);
|
||||
loadSettings(state.selectedCamera);
|
||||
}
|
||||
} catch (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) {
|
||||
const cameraId = cameraSelect.value;
|
||||
const dateStr = dateSelect.value; // YYYY-MM-DD
|
||||
@@ -439,16 +452,49 @@
|
||||
|
||||
const width = canvas.width;
|
||||
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);
|
||||
|
||||
// Sort stats by timestamp just in case
|
||||
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)
|
||||
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})/);
|
||||
let d;
|
||||
if (parts) {
|
||||
@@ -460,14 +506,15 @@
|
||||
return d.getHours() * 60 + d.getMinutes();
|
||||
};
|
||||
|
||||
const points = stats.map(s => ({
|
||||
x: getMinutes(s.timestamp),
|
||||
y: s.brightness
|
||||
const points = validPoints.map(p => ({
|
||||
x: getMinutes(p.t),
|
||||
y: p.v
|
||||
}));
|
||||
|
||||
// Scales
|
||||
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
|
||||
ctx.strokeStyle = '#555';
|
||||
@@ -486,7 +533,7 @@
|
||||
ctx.stroke();
|
||||
|
||||
// Draw Line
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
ctx.strokeStyle = hasOcr ? '#10b981' : '#3b82f6'; // Green for OCR, Blue for Brightness
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
|
||||
@@ -498,6 +545,14 @@
|
||||
}
|
||||
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)
|
||||
ctx.fillStyle = '#ccc';
|
||||
ctx.font = '12px sans-serif';
|
||||
@@ -505,16 +560,35 @@
|
||||
|
||||
for (let h = 0; h <= 24; h += 4) {
|
||||
const x = mapX(h * 60);
|
||||
if (x > padding.left) {
|
||||
ctx.fillText(`${h}:00`, x, height - 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Labels (Brightness)
|
||||
// Draw Labels (Value)
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (let b = 0; b <= 255; b += 64) {
|
||||
const y = mapY(b);
|
||||
ctx.fillText(Math.round(b), padding.left - 5, y);
|
||||
// Draw 5 ticks
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
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) {
|
||||
@@ -541,8 +615,10 @@
|
||||
// Listeners
|
||||
cameraSelect.addEventListener('change', (e) => {
|
||||
state.selectedCamera = e.target.value;
|
||||
if (state.selectedCamera) loadDates(state.selectedCamera);
|
||||
else dateSelect.disabled = true;
|
||||
if (state.selectedCamera) {
|
||||
loadDates(state.selectedCamera);
|
||||
loadSettings(state.selectedCamera);
|
||||
} else dateSelect.disabled = true;
|
||||
});
|
||||
|
||||
dateSelect.addEventListener('change', (e) => {
|
||||
|
||||
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