diff --git a/.gitignore b/.gitignore index c132a53..bb2217d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ uploads/ users.json .DS_Store *.log -camera-settings.json \ No newline at end of file +camera-settings.json +openai.key \ No newline at end of file diff --git a/README.md b/README.md index c661c40..334f4f1 100644 --- a/README.md +++ b/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 diff --git a/demo/capture-upload.sh b/demo/capture-upload.sh index ac30d69..f099fd7 100755 --- a/demo/capture-upload.sh +++ b/demo/capture-upload.sh @@ -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 value - value=$(echo "$settings" | jq -r ".$ctrl // empty") - if [[ -n "$value" && "$value" != "null" ]]; then - v4l2-ctl -d "$VIDEO_DEVICE" --set-ctrl="${ctrl}=${value}" 2>/dev/null || true + 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 "$values" | jq -r ".[\"$ctrl\"] // empty") + if [[ -n "$value" && "$value" != "null" ]]; then + 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 diff --git a/filter_stats.js b/filter_stats.js new file mode 100644 index 0000000..9598ea1 --- /dev/null +++ b/filter_stats.js @@ -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); +} diff --git a/package-lock.json b/package-lock.json index 4a724dd..adca3fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": "*" + } } } } diff --git a/package.json b/package.json index 51507f9..5598ccb 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/index.html b/public/index.html index 165f021..2a987ca 100644 --- a/public/index.html +++ b/public/index.html @@ -177,7 +177,7 @@
@@ -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); - ctx.fillText(`${h}:00`, x, height - 10); + 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) => { diff --git a/server.js b/server.js index 7bc16d5..8ac7e00 100644 --- a/server.js +++ b/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)