From dca8673b807052448949638b3bdac35041ab7a3f Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Sun, 11 Oct 2026 10:12:58 +0200 Subject: [PATCH] eufy account import for battery and solar cameras Sign in with a eufy account and import its cameras, including models without RTSP (SoloCam, eufyCam, doorbells). Live video is pulled on demand through @mega-yfue/eufy-sdk in a supervised Node.js worker, converted to H.264 1080p for playback everywhere, and stops when the last viewer leaves. Thumbnails use the camera's event pictures, motion events come from the camera, and unreachable cameras show as offline. --- README.md | 2 +- docs/API.md | 17 +- server/Dockerfile | 3 + server/bun.lock | 93 ++++++++ server/package.json | 3 +- server/scripts/build.ts | 15 +- server/src/app.ts | 112 +++++++++- server/src/brands.ts | 13 +- server/src/cameras.ts | 7 +- server/src/deps.ts | 47 +++- server/src/eufy-worker.gen.ts | 1 + server/src/eufy-worker.mjs | 330 +++++++++++++++++++++++++++++ server/src/eufy.ts | 256 ++++++++++++++++++++++ server/src/go2rtc.ts | 2 +- server/src/http/api.ts | 67 +++++- server/test/brands.test.ts | 14 ++ server/web/src/api.ts | 9 +- server/web/src/pages/AddCamera.tsx | 267 ++++++++++++++++++++++- server/web/src/styles/pages.css | 18 ++ server/web/src/types.ts | 28 ++- 20 files changed, 1279 insertions(+), 25 deletions(-) create mode 100644 server/src/eufy-worker.gen.ts create mode 100644 server/src/eufy-worker.mjs create mode 100644 server/src/eufy.ts diff --git a/README.md b/README.md index ea6b7f9..9793ec3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ No vendor cloud, no subscription, no account with us. ## What you get -- **Works with your cameras.** Brand presets for Tapo, Eufy, UniFi, Reolink, Hikvision, Dahua, Amcrest, Axis, Foscam, EZVIZ, Imou, Annke and Wyze, ONVIF discovery on your network, one-step UniFi Protect import. Cameras that can only send video can push RTMP or RTSP to the server. +- **Works with your cameras.** Brand presets for Tapo, Eufy, UniFi, Reolink, Hikvision, Dahua, Amcrest, Axis, Foscam, EZVIZ, Imou, Annke and Wyze, ONVIF discovery on your network, one-step UniFi Protect import, and eufy account import for battery and solar cameras that have no RTSP (SoloCam, eufyCam, doorbells). Cameras that can only send video can push RTMP or RTSP to the server. - **Live view in under a second.** WebRTC with MSE and HLS fallback, HD and SD streams, snapshots, two-way audio and PTZ on supported cameras. - **Recording.** Continuous or motion-only per camera, stored the way the camera sends it (no re-encoding), with a timeline, motion markers and clip export. - **Your own cloud.** Recordings are copied to Google Drive, S3-compatible storage, FTP, SFTP, SMB, WebDAV, Dropbox, OneDrive or a local folder and deleted automatically after the number of days you choose. Ideal for cameras without an SD card or cloud plan. diff --git a/docs/API.md b/docs/API.md index ae47eb4..9573622 100644 --- a/docs/API.md +++ b/docs/API.md @@ -26,7 +26,7 @@ Shared contract between `server/` and `app/`. JSON everywhere, ISO 8601 UTC time ```ts type Camera = { id: string; name: string; brand: BrandId; group?: string; order: number; enabled: boolean; - source: { kind: 'rtsp' | 'onvif' | 'tapo' | 'unifi' | 'http' | 'rtmp-push' | 'rtsp-push' | 'demo'; url: string /* password masked as *** */; subUrl?: string }; + source: { kind: 'rtsp' | 'onvif' | 'tapo' | 'unifi' | 'eufy' | 'http' | 'rtmp-push' | 'rtsp-push' | 'demo'; url: string /* password masked as *** */; subUrl?: string }; recording: { mode: 'continuous' | 'motion' | 'off'; useSubstream: boolean }; motion: { enabled: boolean; sensitivity: number /* 1-10 */; notify: boolean }; capabilities: { audio: boolean; twoWayAudio: boolean; ptz: boolean; substream: boolean }; @@ -53,6 +53,21 @@ type BrandId = 'tapo' | 'eufy' | 'unifi' | 'reolink' | 'hikvision' | 'dahua' | ' | POST | `/api/cameras/:id/ptz` | `{ action:'move', pan, tilt, zoom }` (-1..1) / `{ action:'stop' }` / `{ action:'preset', preset }` | `{ ok }` | | GET | `/api/discover` | | `{ candidates: { host, port, brand?, name?, model?, onvif: boolean, rtsp: boolean, alreadyAdded: boolean }[] }` (ONVIF WS-Discovery + LAN port probe, ≤ 8 s) | | POST | `/api/integrations/unifi/import` | `{ host, username, password, cameraIds? }` | `{ cameras: { id, name, model, added: boolean }[] }` | +| GET | `/api/integrations/eufy` (admin) | | `EufyAccount` | +| POST | `/api/integrations/eufy/login` (admin) | `{ email, password, country }` (ISO country of the eufy account, e.g. `DE`) | `EufyAccount` (`status` is `connected`, `2fa`, `captcha` or `error`) | +| POST | `/api/integrations/eufy/verify` (admin) | `{ code }` (code eufy sent by email) | `EufyAccount` | +| POST | `/api/integrations/eufy/captcha` (admin) | `{ answer }` | `EufyAccount` | +| POST | `/api/integrations/eufy/refresh` (admin) | | `EufyAccount` | +| POST | `/api/integrations/eufy/import` (admin) | `{ serials?: string[] }` | `{ cameras: { id, name, model, added: boolean, cameraId?, error? }[] }` | +| DELETE | `/api/integrations/eufy` (admin) | | `EufyAccount` (signs out, cameras stay but go offline) | + +```ts +type EufyAccount = { status: 'disconnected' | 'connecting' | 'captcha' | '2fa' | 'connected' | 'error'; email?: string; country?: string; + captcha?: string /* data URL of the image to solve */; method?: string; error?: string; + cameras: { serial: string; name: string; model: string; battery: boolean; batteryLevel?: number; rssi?: number /* WiFi dBm */; station?: string; added: boolean; cameraId?: string }[] } +``` + +eufy cameras (`source.kind: 'eufy'`) are reached through the eufy account, the way the eufy app does it, so models without RTSP work. The stream is pulled only while someone watches or a recording runs; imported cameras start with recording `off` and motion events come from the camera itself. The eufy client runs in a Node.js 24.5+ worker process next to the server (bundled in the Docker image, found on `PATH` or downloaded once otherwise; override with `OPENCCTV_NODE`). ## Recordings, timeline, events diff --git a/server/Dockerfile b/server/Dockerfile index 571599b..2e13db4 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -26,10 +26,13 @@ RUN curl -fsSL -o /tmp/rclone.zip "https://downloads.rclone.org/rclone-current-l FROM mwader/static-ffmpeg:9.0.2 AS ffmpeg +FROM node:24-bookworm-slim AS node + FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/* COPY --from=ffmpeg /ffmpeg /ffprobe /usr/local/bin/ COPY --from=tools /usr/local/bin/go2rtc /usr/local/bin/rclone /usr/local/bin/ +COPY --from=node /usr/local/bin/node /usr/local/bin/node COPY --from=build /out/opencctv /usr/local/bin/opencctv COPY demo/media/ /opt/opencctv/demo/ ENV OPENCCTV_DOCKER=1 \ diff --git a/server/bun.lock b/server/bun.lock index fcad311..dc74bbe 100644 --- a/server/bun.lock +++ b/server/bun.lock @@ -5,6 +5,7 @@ "": { "name": "opencctv-server", "dependencies": { + "@mega-yfue/eufy-sdk": "0.4.0", "qrcode-generator": "^1.4.4", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -18,6 +19,10 @@ }, }, "packages": { + "@babel/runtime": ["@babel/runtime@7.29.10", "", {}, "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA=="], + + "@mega-yfue/eufy-sdk": ["@mega-yfue/eufy-sdk@0.4.0", "", { "dependencies": { "jpeg-js": "^0.4.4", "mqtt": "^5.16.0", "protobufjs": "^8.8.0" } }, "sha512-+QN9IgqjIQsDhqGRS7JHtlYlZYRwbjIQ5AtWmrAMYcnnlO7yNtoDBipp0sowMmT1Wd/vpfsi0Niw8/+kQGzIJg=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], @@ -26,20 +31,108 @@ "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + "@types/readable-stream": ["@types/readable-stream@4.0.25", "", { "dependencies": { "@types/node": "*" } }, "sha512-7GsxL8Wd5X3jh73/qgW7WCnbBvGSdD8U3E4Jiuus3/8I+PN20DtlkJtwFhr/sImXfDDQ2Z03i/9wNiIF3TVSsw=="], + + "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="], + + "broker-factory": ["broker-factory@3.1.15", "", { "dependencies": { "@babel/runtime": "^7.29.7", "fast-unique-numbers": "^9.0.27", "tslib": "^2.8.1", "worker-factory": "^7.0.50" } }, "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "commist": ["commist@3.2.0", "", {}, "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="], + + "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "fast-unique-numbers": ["fast-unique-numbers@9.0.27", "", { "dependencies": { "@babel/runtime": "^7.29.2", "tslib": "^2.8.1" } }, "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A=="], + + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.7.3", "", {}, "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg=="], + + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mqtt": ["mqtt@5.16.0", "", { "dependencies": { "@types/readable-stream": "^4.0.21", "@types/ws": "^8.18.1", "commist": "^3.2.0", "concat-stream": "^2.0.0", "debug": "^4.4.1", "help-me": "^5.0.0", "lru-cache": "^10.4.3", "minimist": "^1.2.8", "mqtt-packet": "^9.0.2", "readable-stream": "^4.7.0", "rfdc": "^1.4.1", "socks": "^2.8.6", "split2": "^4.2.0", "worker-timers": "^8.0.23", "ws": "^8.18.3" }, "bin": { "mqtt": "build/bin/mqtt.js", "mqtt_pub": "build/bin/pub.js", "mqtt_sub": "build/bin/sub.js" } }, "sha512-G0VsEtuJz5sJUgZJaaADMVfW7SIFPzkIuHR+d6rk+tBeSwdCfd9BaIRycRfd2AbyFDEmLlFM+SDQeti/+IbOnw=="], + + "mqtt-packet": ["mqtt-packet@9.0.2", "", { "dependencies": { "bl": "^6.0.8", "debug": "^4.3.4", "process-nextick-args": "^2.0.1" } }, "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "protobufjs": ["protobufjs@8.8.0", "", { "dependencies": { "long": "^5.3.2" } }, "sha512-N3xhQ5yyBx3vQq4gubBfASzYhJGNzeDbjqBpu61g7UVylsN/qyffU96TKWD3GbbLOKF82VGNRNvv1+BFgE31Eg=="], + "qrcode-generator": ["qrcode-generator@1.5.2", "", {}, "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw=="], "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + "readable-stream": ["readable-stream@4.8.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "worker-factory": ["worker-factory@7.0.50", "", { "dependencies": { "@babel/runtime": "^7.29.7", "fast-unique-numbers": "^9.0.27", "tslib": "^2.8.1" } }, "sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ=="], + + "worker-timers": ["worker-timers@8.0.34", "", { "dependencies": { "@babel/runtime": "^7.29.7", "tslib": "^2.8.1", "worker-timers-broker": "^8.0.18", "worker-timers-worker": "^9.0.15" } }, "sha512-WXL+Dqsm0G6dnC66rQsvM3tPT2adhbqSirWUCZGglkALOr8ocS0KlpU0iuKbjflJOlu3ydZikMEVrYnHOM9suw=="], + + "worker-timers-broker": ["worker-timers-broker@8.0.18", "", { "dependencies": { "@babel/runtime": "^7.29.7", "broker-factory": "^3.1.15", "fast-unique-numbers": "^9.0.27", "tslib": "^2.8.1", "worker-timers-worker": "^9.0.15" } }, "sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg=="], + + "worker-timers-worker": ["worker-timers-worker@9.0.15", "", { "dependencies": { "@babel/runtime": "^7.29.7", "tslib": "^2.8.1", "worker-factory": "^7.0.50" } }, "sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA=="], + + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + + "concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], } } diff --git a/server/package.json b/server/package.json index 56befeb..aa5955c 100644 --- a/server/package.json +++ b/server/package.json @@ -11,9 +11,10 @@ "test": "bun test", "typecheck": "tsc --noEmit", "build": "bun run scripts/build.ts", - "build:local": "bun build --compile --production src/index.ts --outfile dist/opencctv" + "build:local": "bun run scripts/build.ts local" }, "dependencies": { + "@mega-yfue/eufy-sdk": "0.4.0", "qrcode-generator": "^1.4.4", "react": "^19.1.0", "react-dom": "^19.1.0" diff --git a/server/scripts/build.ts b/server/scripts/build.ts index f034c16..0dd7ff0 100644 --- a/server/scripts/build.ts +++ b/server/scripts/build.ts @@ -2,7 +2,8 @@ import { mkdirSync } from "node:fs"; import pkg from "../package.json" with { type: "json" }; const ALL = ["linux-x64", "linux-arm64", "darwin-arm64", "darwin-x64", "windows-x64"]; -const args = process.argv.slice(2); +const host = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`; +const args = process.argv.slice(2).map((a) => (a === "local" ? host : a)); const targets = args.length ? args : ALL; const defines: string[] = []; @@ -11,6 +12,12 @@ for (const [name, value] of [ ["OPENCCTV_BUILTIN_GOOGLE_CLIENT_SECRET", process.env.GOOGLE_CLIENT_SECRET], ]) if (value) defines.push("--define", `${name}=${JSON.stringify(value)}`); +const GEN = "src/eufy-worker.gen.ts"; +const worker = await Bun.build({ entrypoints: ["src/eufy-worker.mjs"], target: "node", format: "esm", minify: true }); +if (!worker.success) throw new Error(`eufy worker bundle failed: ${worker.logs.join("\n")}`); +await Bun.write(GEN, `export default ${JSON.stringify(await worker.outputs[0]!.text())};\n`); +const restoreGen = () => Bun.write(GEN, 'export default "";\n'); + mkdirSync("dist", { recursive: true }); for (const t of targets) { if (!ALL.includes(t)) throw new Error(`unknown target ${t}`); @@ -25,6 +32,10 @@ for (const t of targets) { ], { stdout: "inherit", stderr: "inherit" }, ); - if ((await p.exited) !== 0) process.exit(1); + if ((await p.exited) !== 0) { + await restoreGen(); + process.exit(1); + } } +await restoreGen(); console.log(`built OpenCCTV ${pkg.version}`); diff --git a/server/src/app.ts b/server/src/app.ts index a06b0b5..179a70b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -26,6 +26,7 @@ import { credsFromUrl, hostFromUrl } from "./brands.ts"; import { logger, errMsg } from "./log.ts"; import { HttpError, newId, randomToken, sha256 } from "./util.ts"; import { run } from "./proc.ts"; +import { EufyBridge } from "./eufy.ts"; const log = logger("app"); @@ -45,6 +46,7 @@ export class App { readonly push: Push; readonly onvif = new Onvif(); readonly hub: GatewayHub; + readonly eufy: EufyBridge; readonly internalSecret = randomToken(32); readonly startedAt = Date.now(); ready = false; @@ -85,6 +87,14 @@ export class App { this.recordings = new Recordings(this.db); this.push = new Push(this.db); this.hub = new GatewayHub(this.db, this.bus); + this.eufy = new EufyBridge(this.db, { + dir: dataPath(cfg, "eufy"), + binDir: this.dirs.bin, + allowDownload: !cfg.noDownload, + onChange: () => this.onEufyChange(), + onMotion: (serial, at) => this.onEufyMotion(serial, at), + ffmpegPath: () => this.bins.ffmpeg, + }); } get rtspAuth(): { user: string; pass: string } { @@ -137,7 +147,9 @@ export class App { this.settings.onChange((s, prev) => { if (s.recording.segmentSeconds !== prev.recording.segmentSeconds || s.recording.postMotionSec !== prev.recording.postMotionSec) this.scheduleApply(); }); + await this.eufy.init(); await this.applyCameras(); + void this.eufy.start(); this.recorder.start(); this.motion.start(); await this.storage.start(); @@ -159,6 +171,7 @@ export class App { this.storage?.stop(); await this.motion?.stopAll(); await this.recorder?.stopAll(); + await this.eufy.stop(); await this.go2rtc?.stop(); this.db.close(); } @@ -212,7 +225,7 @@ export class App { const enabled = cams.filter((c) => c.enabled); if (this.go2rtc.available && (enabled.length || this.go2rtc.running)) { const streams: StreamDef[] = []; - for (const c of enabled) streams.push(...go2rtcSources(c, !!this.bins.ffmpeg)); + for (const c of enabled) streams.push(...go2rtcSources(c, !!this.bins.ffmpeg, (serial) => this.eufy.sourceFor(serial))); const rtspPublic = this.cfg.rtspPublic || enabled.some((c) => c.kind === "rtsp-push"); if (rtspPublic) this.ports.rtsp = this.cfg.rtspPort; else if (!this.ports.rtsp || this.ports.rtsp === this.cfg.rtspPort) this.ports.rtsp = await freePort(); @@ -236,7 +249,10 @@ export class App { } const ready = this.go2rtc.running; this.recorder.sync(cams, ready); - this.motion.sync(cams, ready); + this.motion.sync( + cams.filter((c) => c.kind !== "eufy"), + ready, + ); for (const id of [...this.live.keys()]) if (!cams.some((c) => c.id === id)) this.live.delete(id); } @@ -302,6 +318,11 @@ export class App { l.bytesAt = now; if (sum.codec) l.codec = sum.codec === "h265" ? "hevc" : sum.codec; if (!l.probed && !l.probing && now - (l.lastProbeAttempt ?? 0) > 60_000) void this.probe(cam, l); + } else if (cam.kind === "eufy") { + l.bytes = undefined; + const serial = cam.fields.serial ?? ""; + l.online = this.eufy.reachable(serial); + l.error = l.online ? undefined : this.eufyError(serial); } else { l.bytes = undefined; if (l.lastSeen && now - Date.parse(l.lastSeen) > 30_000) l.online = false; @@ -381,6 +402,22 @@ export class App { const now = Date.now(); if (hit && now - hit.at < 2000 && hit.data.length) return hit.data; if (hit?.pending) return hit.pending; + if (cam.kind === "eufy") { + const serial = cam.fields.serial ?? ""; + if (hit && now - hit.at < (this.eufy.isWarm(serial) ? 2000 : 60_000) && hit.data.length) return hit.data; + const pending = this.eufySnapshot(cam, serial).then( + (data) => { + this.snapCache.set(key, { at: Date.now(), data }); + return data; + }, + (e) => { + this.snapCache.delete(key); + throw e; + }, + ); + this.snapCache.set(key, { at: 0, data: new Uint8Array(0), pending }); + return pending; + } const pending = this.go2rtc.frame(cam.streamKey, width).then( (data) => { this.snapCache.set(key, { at: Date.now(), data }); @@ -402,6 +439,32 @@ export class App { return pending; } + private async eufySnapshot(cam: CameraRecord, serial: string): Promise { + const file = join(this.dirs.snapshots, `${cam.id}.jpg`); + const keep = (data: Uint8Array) => { + try { + writeFileSync(file, data); + } catch {} + return data; + }; + if (this.eufy.isWarm(serial)) { + const live = await this.eufy.snapshot(serial, true); + if (live) return keep(live); + } + const stored = await this.eufy.snapshot(serial); + if (stored) return keep(stored); + const now = Date.now(); + const age = existsSync(file) ? now - statSync(file).mtimeMs : Infinity; + if (age > 30 * 60_000 && now - (this.eufyWakes.get(cam.id) ?? 0) > 30 * 60_000) { + this.eufyWakes.set(cam.id, now); + const live = await this.eufy.snapshot(serial, true); + if (live) return keep(live); + } + const last = this.lastSnapshot(cam.id); + if (last) return last; + throw new HttpError(404, "No picture yet. Open the live view to wake the camera."); + } + lastSnapshot(camId: string): Uint8Array | undefined { const file = join(this.dirs.snapshots, `${camId}.jpg`); return existsSync(file) ? new Uint8Array(readFileSync(file)) : undefined; @@ -454,13 +517,14 @@ export class App { private openEvents = new Map(); - private onMotionStart(cam: CameraRecord, at: number, score: number) { + private onMotionStart(cam: CameraRecord, at: number, score: number, grab?: () => Promise) { const id = newId(12); this.openEvents.set(cam.id, id); this.recordings.insertEvent({ id, camera_id: cam.id, start_ms: at, end_ms: null, score, snapshot_path: null }); void (async () => { try { - const jpg = await this.go2rtc.frame(cam.streamKey, 640, 8000); + const jpg = grab ? await grab() : await this.go2rtc.frame(cam.streamKey, 640, 8000); + if (!jpg) throw new Error("no event picture"); const rel = `${cam.id}/${id}.jpg`; mkdirSync(join(this.dirs.events, cam.id), { recursive: true }); writeFileSync(join(this.dirs.events, rel), jpg); @@ -491,6 +555,46 @@ export class App { if (ev) this.bus.emit({ type: "motion", event: this.recordings.eventJsonFor(ev) }); } + private eufyMotionTimers = new Map>(); + private eufyWakes = new Map(); + + private onEufyMotion(serial: string, at: number) { + const cam = this.cameras.list().find((c) => c.kind === "eufy" && c.fields.serial === serial && c.enabled && c.motionEnabled); + if (!cam) return; + const quietMs = Math.max(10, this.settings.get().recording.postMotionSec) * 1000; + const t = this.eufyMotionTimers.get(cam.id); + if (t) clearTimeout(t); + else + this.onMotionStart(cam, at, 100, async () => { + await Bun.sleep(4000); + return this.eufy.snapshot(serial); + }); + this.eufyMotionTimers.set( + cam.id, + setTimeout(() => { + this.eufyMotionTimers.delete(cam.id); + this.onMotionEnd(cam, at, Date.now(), 100); + }, quietMs), + ); + } + + private eufyError(serial: string): string { + if (!this.eufy.connected) return "eufy account not connected"; + return this.eufy.camera(serial) ? "Camera did not answer. It may be offline or out of battery." : "Not found in the connected eufy account"; + } + + private onEufyChange() { + for (const c of this.cameras.list()) { + if (c.kind !== "eufy") continue; + const l = this.live.get(c.id); + if (l) { + l.online = this.eufy.reachable(c.fields.serial ?? ""); + l.error = l.online ? undefined : this.eufyError(c.fields.serial ?? ""); + } + this.emitCamera(c.id); + } + } + private async motionKeeper() { const s = this.settings.get(); const pre = s.recording.preMotionSec * 1000; diff --git a/server/src/brands.ts b/server/src/brands.ts index 4980858..f7aa459 100644 --- a/server/src/brands.ts +++ b/server/src/brands.ts @@ -1,6 +1,6 @@ import { HttpError } from "./util.ts"; -export type SourceKind = "rtsp" | "onvif" | "tapo" | "unifi" | "http" | "rtmp-push" | "rtsp-push" | "demo"; +export type SourceKind = "rtsp" | "onvif" | "tapo" | "unifi" | "eufy" | "http" | "rtmp-push" | "rtsp-push" | "demo"; export type BrandId = | "tapo" | "eufy" | "unifi" | "reolink" | "hikvision" | "dahua" | "amcrest" | "axis" | "foscam" | "ezviz" | "imou" | "annke" | "wyze" | "ubiquiti" | "onvif" | "generic" | "demo"; @@ -59,8 +59,8 @@ export const BRANDS: Brand[] = [ defaultPort: 554, fields: [host, user(""), pass, port(554)], help: [ - "RTSP must be enabled per camera in the Eufy app: Camera > Settings > Storage > NAS (RTSP) (HomeBase 3: Storage > NAS (RTSP)). Set a username and password there.", - "Stream URL: rtsp://user:pass@IP/live0. Battery cameras only stream while RTSP is active and drain faster.", + "Easiest: use the eufy account import. It signs in to your eufy account and adds every camera, including battery and solar models without RTSP (SoloCam, eufyCam, doorbells). Live view starts on demand, so batteries last.", + "Manual RTSP (wired models only): enable it per camera in the eufy app under Camera > Settings > Storage > NAS (RTSP) and set a username and password there. Stream URL: rtsp://user:pass@IP/live0.", ].join("\n"), }, { @@ -238,8 +238,13 @@ export function buildSource(brandId: string, kind: string | undefined, fields: R const extra = f.cloudPassword ? [`tapo://${encodeURIComponent(f.cloudPassword)}@${f.host}`] : []; return { kind: f.cloudPassword ? "tapo" : "rtsp", url: `rtsp://${c}${h}/stream1`, subUrl: `rtsp://${c}${h}/stream2`, extra, twoWayAudio: extra.length > 0, ptzPort: 2020 }; } - case "eufy": + case "eufy": { + if (kind === "eufy" || f.serial) { + if (!/^[A-Za-z0-9]{6,32}$/.test(f.serial ?? "")) throw new HttpError(400, "eufy serial number is required"); + return { kind: "eufy", url: `eufy://${f.serial}` }; + } return { kind: "rtsp", url: `rtsp://${c}${hp(f, 554)}/live0` }; + } case "unifi": { const h = (f.host ?? "").trim(); if (!h || !/^[a-zA-Z0-9.\-:\[\]]+$/.test(h)) throw new HttpError(400, "Host is required"); diff --git a/server/src/cameras.ts b/server/src/cameras.ts index 5e4bcc7..59d7b88 100644 --- a/server/src/cameras.ts +++ b/server/src/cameras.ts @@ -108,9 +108,14 @@ export function subStreamName(c: CameraRecord): string | undefined { return c.subUrl ? `${c.streamKey}_sub` : undefined; } -export function go2rtcSources(c: CameraRecord, ffmpegAvailable: boolean): { name: string; sources: string[] }[] { +export function go2rtcSources(c: CameraRecord, ffmpegAvailable: boolean, eufySource?: (serial: string) => string | undefined): { name: string; sources: string[] }[] { const main: string[] = []; const out: { name: string; sources: string[] }[] = []; + if (c.kind === "eufy") { + const src = ffmpegAvailable ? eufySource?.(c.fields.serial ?? "") : undefined; + out.push({ name: c.streamKey, sources: src ? [src, `ffmpeg:${c.streamKey}#video=mjpeg`] : [] }); + return out; + } if (isPush(c.kind)) { out.push({ name: c.streamKey, sources: [] }); return out; diff --git a/server/src/deps.ts b/server/src/deps.ts index ca63c22..5f54a85 100644 --- a/server/src/deps.ts +++ b/server/src/deps.ts @@ -149,13 +149,13 @@ function extractFromZip(buf: Uint8Array, wanted: string[], binDir: string) { } } -async function extractTarXz(buf: Uint8Array, wanted: string[], binDir: string) { +async function extractTarXz(buf: Uint8Array, wanted: string[], binDir: string, gzip = false) { const tmp = join(binDir, `.extract-${Date.now()}`); mkdirSync(tmp, { recursive: true }); try { - const archive = join(tmp, "a.tar.xz"); + const archive = join(tmp, gzip ? "a.tar.gz" : "a.tar.xz"); writeFileSync(archive, buf); - const p = Bun.spawn(["tar", "-xJf", archive, "-C", tmp], { stderr: "pipe" }); + const p = Bun.spawn(["tar", gzip ? "-xzf" : "-xJf", archive, "-C", tmp], { stderr: "pipe" }); if ((await p.exited) !== 0) throw new Error(`tar failed: ${await new Response(p.stderr).text()}`); const walk = (d: string): string[] => readdirSync(d).flatMap((n) => { @@ -208,6 +208,47 @@ async function installFfmpeg(binDir: string) { } else throw new Error(`No ffmpeg build for ${process.platform}`); } +const NODE_MIN = [24, 5]; + +async function nodeVersionOk(path: string): Promise { + try { + const p = Bun.spawn([path, "-p", "process.versions.node"], { stdout: "pipe", stderr: "ignore" }); + const timer = setTimeout(() => p.kill(), 5000); + const out = (await new Response(p.stdout).text()).trim(); + clearTimeout(timer); + const [maj = 0, min = 0] = out.split(".").map(Number); + return maj > NODE_MIN[0]! || (maj === NODE_MIN[0] && min >= NODE_MIN[1]!); + } catch { + return false; + } +} + +export async function findNode(binDir: string): Promise { + const list = [process.env.OPENCCTV_NODE, join(binDir, exe("node")), join(dirname(process.execPath), exe("node")), Bun.which("node") ?? undefined]; + if (process.platform === "darwin") list.push("/opt/homebrew/bin/node", "/usr/local/bin/node"); + if (process.platform === "linux") list.push("/usr/local/bin/node", "/usr/bin/node"); + for (const c of list) { + try { + if (c && existsSync(c) && statSync(c).isFile() && (await nodeVersionOk(c))) return c; + } catch {} + } + return undefined; +} + +export async function installNode(binDir: string): Promise { + const base = "https://nodejs.org/dist/latest-v24.x"; + const os = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win" : "linux"; + const a = process.arch === "arm64" ? "arm64" : "x64"; + const sums = await (await fetch(`${base}/SHASUMS256.txt`, { headers: { "user-agent": "opencctv" } })).text(); + const file = sums.match(new RegExp(`node-v[\\d.]+-${os}-${a}\\.${os === "win" ? "zip" : "tar\\.gz"}`))?.[0]; + if (!file) throw new Error(`No Node.js build for ${process.platform}/${process.arch}`); + const buf = await download(`${base}/${file}`, "node"); + mkdirSync(binDir, { recursive: true }); + if (os === "win") extractFromZip(buf, ["node.exe"], binDir); + else await extractTarXz(buf, ["node"], binDir, true); + return findNode(binDir); +} + export async function ensureBinaries(binDir: string, allowDownload: boolean): Promise { mkdirSync(binDir, { recursive: true }); const installers: Record<"go2rtc" | "ffmpeg" | "rclone", (d: string) => Promise> = { diff --git a/server/src/eufy-worker.gen.ts b/server/src/eufy-worker.gen.ts new file mode 100644 index 0000000..9cf3c27 --- /dev/null +++ b/server/src/eufy-worker.gen.ts @@ -0,0 +1 @@ +export default ""; diff --git a/server/src/eufy-worker.mjs b/server/src/eufy-worker.mjs new file mode 100644 index 0000000..82a7759 --- /dev/null +++ b/server/src/eufy-worker.mjs @@ -0,0 +1,330 @@ +// Runs under Node.js (not Bun): the eufy P2P transport needs Node's UDP sockets. +import http from "node:http"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { EufyMega, FileFcmStore, FileSessionStore, LoginStatus, SessionExpiredError } from "@mega-yfue/eufy-sdk"; + +const DIR = process.env.EUFY_DIR; +const PORT = Number(process.env.EUFY_PORT); +const TOKEN = process.env.EUFY_TOKEN; +const FFMPEG = process.env.EUFY_FFMPEG || undefined; +const MAX_LIVE_MS = 15 * 60_000; +const DOWN_MS = 2 * 60_000; +const STALL_MS = 12_000; + +if (!DIR || !PORT || !TOKEN) { + console.error("eufy worker: missing environment"); + process.exit(2); +} +mkdirSync(DIR, { recursive: true, mode: 0o700 }); +const SESSION = join(DIR, "session.json"); +const PUSH = join(DIR, "push.json"); + +let client; +let state = { status: "disconnected" }; +const cams = new Map(); +const viewers = new Map(); +const down = new Map(); +const failed = new Set(); +const lastMotion = new Map(); + +const emit = (o) => process.stdout.write(JSON.stringify(o) + "\n"); +const log = (level, msg) => emit({ type: "log", level, msg: String(msg).slice(0, 600) }); +const errMsg = (e) => (e instanceof Error ? e.message : String(e)); +const isDown = (sn) => (down.get(sn) ?? 0) > Date.now(); +const snapshot = () => ({ + state, + cameras: [...cams.values()], + warm: [...viewers.keys()], + down: [...failed], +}); +const publish = () => emit({ type: "state", ...snapshot() }); + +function setDown(sn) { + if (viewers.has(sn)) return; + down.set(sn, Date.now() + DOWN_MS); + failed.add(sn); + publish(); +} + +function setUp(sn) { + down.delete(sn); + if (failed.delete(sn)) publish(); +} + +function open(acc) { + const old = client; + if (old) Promise.resolve(old.disconnect?.()).catch(() => {}); + const c = new EufyMega({ + email: acc.email, + password: acc.password, + countryCode: acc.country, + store: new FileSessionStore(SESSION), + pushStore: new FileFcmStore(PUSH), + ffmpegPath: FFMPEG, + noBroadcast: true, + logger: { debug() {}, info() {}, warn: (m) => log("warn", m), error: (m) => log("warn", m) }, + }); + c.on("error", (e) => log("warn", errMsg(e))); + c.on("sessionExpired", () => { + if (client !== c) return; + log("warn", "eufy session expired, signing in again"); + drive(c.login()).catch(fail); + }); + for (const ev of ["motion", "personDetected", "vehicleDetected", "petDetection", "doorbellPress"]) c.on(ev, (e) => motion(e?.deviceSn)); + client = c; + return c; +} + +function motion(sn) { + if (!sn || !cams.has(sn)) return; + const now = Date.now(); + if (now - (lastMotion.get(sn) ?? 0) < 5000) return; + lastMotion.set(sn, now); + emit({ type: "motion", serial: sn, at: now }); +} + +async function refresh() { + if (!client || state.status !== "connected") throw new Error("Connect your eufy account first"); + const list = await client.getDevices(); + cams.clear(); + for (const d of list) { + if (d.deviceClass !== "camera") continue; + let battery = false; + try { + battery = !!(await client.getDevice(d.sn)).has("battery"); + } catch {} + const num = (v) => (v !== undefined && v !== "" && Number.isFinite(Number(v)) ? Number(v) : undefined); + cams.set(d.sn, { + serial: d.sn, + name: d.name || d.sn, + model: d.model, + battery, + batteryLevel: battery ? num(d.params?.[1101]) : undefined, + rssi: num(d.params?.[1142]), + station: d.stationSn && d.stationSn !== d.sn ? d.stationSn : undefined, + }); + } +} + +async function drive(p) { + const r = await p; + const base = { email: state.email, country: state.country }; + if (r.status === LoginStatus.Ok) { + state = { ...base, status: "connected" }; + await refresh().catch((e) => log("warn", `device list: ${errMsg(e)}`)); + log("info", `eufy account connected, ${cams.size} camera(s)`); + } else if (r.status === LoginStatus.Captcha) { + state = { ...base, status: "captcha", captcha: r.image, error: r.retry ? "That was not right, try the new image" : undefined }; + } else if (r.status === LoginStatus.TwoFactor) { + state = { ...base, status: "2fa", method: r.method }; + } + publish(); +} + +function fail(e) { + const wait = e instanceof SessionExpiredError && e.retryAfterMs ? ` (retry in ${Math.ceil(e.retryAfterMs / 60_000)} min)` : ""; + state = { email: state.email, country: state.country, status: "error", error: (errMsg(e) + wait).slice(0, 300) }; + log("warn", `eufy login: ${state.error}`); + publish(); +} + +const rpc = { + async login({ email, password, country, fresh }) { + if (fresh) for (const f of [SESSION, PUSH]) rmSync(f, { force: true }); + state = { status: "connecting", email, country }; + publish(); + await drive(open({ email, password, country }).login()).catch(fail); + }, + async verify({ code }) { + if (!client || state.status !== "2fa") throw new Error("No verification pending"); + await drive(client.submitVerifyCode(String(code))).catch(fail); + }, + async captcha({ answer }) { + if (!client || state.status !== "captcha") throw new Error("No captcha pending"); + await drive(client.solveCaptcha(String(answer))).catch(fail); + }, + async refresh() { + await refresh(); + publish(); + }, + async logout() { + const c = client; + client = undefined; + cams.clear(); + await Promise.resolve(c?.disconnect?.()).catch(() => {}); + for (const f of [SESSION, PUSH]) rmSync(f, { force: true }); + state = { status: "disconnected" }; + publish(); + }, + async state() {}, +}; + +async function camera(sn) { + if (!client || state.status !== "connected") return undefined; + return (await client.getDevice(sn)).camera?.(); +} + +async function serveSnapshot(sn, live, res) { + try { + const cam = await camera(sn); + let jpg; + if (live) { + if (!isDown(sn)) jpg = (await cam?.snapshotLive?.())?.jpeg; + } else jpg = await cam?.snapshotStored?.(); + if (!jpg?.length) throw new Error("no picture"); + if (live) setUp(sn); + res.writeHead(200, { "content-type": "image/jpeg", "cache-control": "no-store" }).end(jpg); + } catch (e) { + if (live && !/no picture/.test(errMsg(e))) setDown(sn); + res.writeHead(404).end(errMsg(e)); + } +} + +async function serveLive(sn, req, res) { + const cam = await camera(sn).catch(() => undefined); + if (!cam?.recordFragments) return void res.writeHead(client ? 404 : 503).end("no live stream"); + if (isDown(sn)) return void res.writeHead(503).end("camera did not answer, trying again shortly"); + const name = cams.get(sn)?.name ?? sn; + const started = Date.now(); + const ac = new AbortController(); + let closed = false; + let current; + let handle; + const end = () => { + if (closed) return; + closed = true; + ac.abort(); + current?.stop(); + }; + res.on("close", end); + let it; + let first; + for (let attempt = 1; attempt <= 2 && !first && !closed; attempt++) { + const h = cam.recordFragments({ fragmentSeconds: 1, signal: ac.signal }); + current = h; + h.on("budget", (n) => { + if (Date.now() - started < MAX_LIVE_MS) n.extend(); + else log("info", `live ${name}: stopping after ${MAX_LIVE_MS / 60_000} min to save battery`); + }); + const i = h[Symbol.asyncIterator](); + try { + first = await i.next(); + handle = h; + it = i; + } catch (e) { + h.stop(); + if (closed) return; + log("warn", `live ${name}: wake attempt ${attempt} failed: ${errMsg(e)}`); + if (attempt === 2) { + setDown(sn); + return void res.writeHead(504).end("camera did not answer"); + } + } + } + if (!handle || !it || !first || first.done) { + handle?.stop(); + if (!closed) res.writeHead(504).end("camera sent no video"); + return; + } + viewers.set(sn, (viewers.get(sn) ?? 0) + 1); + failed.delete(sn); + down.delete(sn); + publish(); + log("info", `live ${name}: started after ${((Date.now() - started) / 1000).toFixed(1)}s`); + res.writeHead(200, { "content-type": "video/mp4", "cache-control": "no-store" }); + const write = (buf) => + new Promise((resolve) => { + if (closed || !buf?.length) return resolve(); + if (res.write(buf)) return resolve(); + res.once("drain", resolve); + res.once("close", resolve); + }); + let last = Date.now(); + let cleaned = false; + const watchdog = setInterval(() => { + if (Date.now() - last < STALL_MS) return; + log("warn", `live ${name}: no video for ${STALL_MS / 1000}s, reconnecting`); + end(); + res.destroy(); + }, 2000); + const cleanup = () => { + if (cleaned) return; + cleaned = true; + clearInterval(watchdog); + handle.stop(); + const n = (viewers.get(sn) ?? 1) - 1; + if (n > 0) viewers.set(sn, n); + else viewers.delete(sn); + publish(); + log("info", `live ${name}: stopped after ${Math.round((Date.now() - started) / 1000)}s`); + if (!res.destroyed) res.end(); + }; + res.on("close", cleanup); + try { + let r = first; + while (!r.done && !closed) { + last = Date.now(); + await write(r.value.init); + await write(r.value.data); + r = await it.next(); + } + } catch (e) { + if (!closed) log("warn", `live ${name}: ${errMsg(e)}`); + } finally { + cleanup(); + } +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let s = ""; + req.on("data", (c) => { + s += c; + if (s.length > 100_000) reject(new Error("too large")); + }); + req.on("end", () => { + try { + resolve(s ? JSON.parse(s) : {}); + } catch (e) { + reject(e); + } + }); + req.on("error", reject); + }); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent); + if (parts[0] !== TOKEN) return void res.writeHead(404).end(); + try { + if (req.method === "POST" && parts[1] === "rpc") { + const { method, params } = await readBody(req); + if (!Object.hasOwn(rpc, method)) throw new Error(`unknown method ${method}`); + await rpc[method](params ?? {}); + return void res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(snapshot())); + } + if (req.method === "GET" && parts[1] === "live" && parts[2]) return void (await serveLive(parts[2].replace(/\.mp4$/, ""), req, res)); + if (req.method === "GET" && parts[1] === "snapshot" && parts[2]) return void (await serveSnapshot(parts[2], url.searchParams.get("live") === "1", res)); + res.writeHead(404).end(); + } catch (e) { + if (!res.headersSent) res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: errMsg(e) })); + } +}); + +server.listen(PORT, "127.0.0.1", () => emit({ type: "ready" })); + +const parent = process.ppid; +setInterval(() => { + if (process.ppid !== parent) process.exit(0); +}, 2000).unref(); +process.on("unhandledRejection", (e) => log("warn", `unhandled: ${errMsg(e)}`)); +for (const sig of ["SIGTERM", "SIGINT"]) + process.on(sig, () => { + Promise.resolve(client?.disconnect?.()) + .catch(() => {}) + .finally(() => process.exit(0)); + setTimeout(() => process.exit(0), 1500).unref(); + }); diff --git a/server/src/eufy.ts b/server/src/eufy.ts new file mode 100644 index 0000000..3d7f228 --- /dev/null +++ b/server/src/eufy.ts @@ -0,0 +1,256 @@ +import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { DB } from "./db.ts"; +import { deleteSetting, getSetting, setSetting } from "./db.ts"; +import { findNode, installNode } from "./deps.ts"; +import { freePort } from "./go2rtc.ts"; +import { errMsg, logger } from "./log.ts"; +import { Supervisor } from "./proc.ts"; +import { HttpError, randomToken } from "./util.ts"; +import WORKER_BUNDLE from "./eufy-worker.gen.ts"; + +const log = logger("eufy"); + +export type EufyStatus = "disconnected" | "connecting" | "captcha" | "2fa" | "connected" | "error"; + +export type EufyState = { + status: EufyStatus; + email?: string; + country?: string; + captcha?: string; + method?: string; + error?: string; +}; + +export type EufyCamera = { serial: string; name: string; model: string; battery: boolean; batteryLevel?: number; rssi?: number; station?: string }; + +type Account = { email: string; password: string; country: string }; +type Snapshot = { state: EufyState; cameras: EufyCamera[]; warm: string[]; down: string[] }; + +const ACCOUNT_KEY = "eufy.account"; + +export type EufyHooks = { + dir: string; + binDir: string; + allowDownload: boolean; + onChange(): void; + onMotion(serial: string, at: number): void; + ffmpegPath?: () => string | undefined; +}; + +export class EufyBridge { + private state: EufyState = { status: "disconnected" }; + private cams = new Map(); + private warm = new Set(); + private down = new Set(); + private token = randomToken(18).replace(/[^A-Za-z0-9]/g, "x"); + private port = 0; + private node?: string; + private sup?: Supervisor; + private ready?: Promise; + private markReady?: () => void; + + constructor(private db: DB, private hooks: EufyHooks) {} + + get connected(): boolean { + return this.state.status === "connected"; + } + + status(): EufyState { + return { ...this.state }; + } + + cameras(): EufyCamera[] { + return [...this.cams.values()]; + } + + camera(serial: string): EufyCamera | undefined { + return this.cams.get(serial); + } + + isWarm(serial: string): boolean { + return this.warm.has(serial); + } + + reachable(serial: string): boolean { + return this.connected && this.cams.has(serial) && !this.down.has(serial); + } + + async init(): Promise { + this.port ||= await freePort(); + } + + async start(): Promise { + const acc = getSetting(this.db, ACCOUNT_KEY); + if (!acc) return; + this.state = { status: "connecting", email: acc.email, country: acc.country }; + try { + await this.call("login", acc); + } catch (e) { + this.fail(e, acc); + } + } + + async login(input: { email?: unknown; password?: unknown; country?: unknown }): Promise { + const email = String(input.email ?? "").trim(); + const password = String(input.password ?? ""); + const country = String(input.country ?? "DE").trim().toUpperCase().slice(0, 2) || "DE"; + if (!email || !password) throw new HttpError(400, "Email and password are required"); + const prev = getSetting(this.db, ACCOUNT_KEY); + const acc = { email, password, country }; + setSetting(this.db, ACCOUNT_KEY, acc); + try { + await this.call("login", { ...acc, fresh: !!prev && prev.email.toLowerCase() !== email.toLowerCase() }); + } catch (e) { + this.fail(e, acc); + } + return this.status(); + } + + async verify(code: string): Promise { + if (this.state.status !== "2fa") throw new HttpError(409, "No verification pending"); + await this.call("verify", { code: code.trim() }); + return this.status(); + } + + async solveCaptcha(answer: string): Promise { + if (this.state.status !== "captcha") throw new HttpError(409, "No captcha pending"); + await this.call("captcha", { answer: answer.trim() }); + return this.status(); + } + + async refresh(): Promise { + if (!this.connected) throw new HttpError(409, "Connect your eufy account first"); + await this.call("refresh", {}); + return this.cameras(); + } + + async logout(): Promise { + deleteSetting(this.db, ACCOUNT_KEY); + if (this.sup?.running) await this.call("logout", {}).catch(() => {}); + await this.stop(); + this.apply({ state: { status: "disconnected" }, cameras: [], warm: [], down: [] }); + } + + async stop(): Promise { + const s = this.sup; + this.sup = undefined; + this.ready = undefined; + await s?.stop(); + } + + sourceFor(serial: string): string | undefined { + if (!this.port || !/^[A-Za-z0-9]+$/.test(serial)) return undefined; + return `ffmpeg:${this.base}/live/${serial}.mp4#input=live#video=h264#width=1920#height=1080#audio=aac#audio=opus`; + } + + async snapshot(serial: string, live = false): Promise { + if (!this.connected || !this.sup?.running || !/^[A-Za-z0-9]+$/.test(serial)) return undefined; + try { + const r = await fetch(`${this.base}/snapshot/${serial}${live ? "?live=1" : ""}`, { signal: AbortSignal.timeout(live ? 45_000 : 8000) }); + if (!r.ok) return undefined; + const buf = new Uint8Array(await r.arrayBuffer()); + return buf.length > 100 ? buf : undefined; + } catch { + return undefined; + } + } + + private get base(): string { + return `http://127.0.0.1:${this.port}/${this.token}`; + } + + private fail(e: unknown, acc?: { email: string; country: string }) { + const msg = errMsg(e).slice(0, 300); + log.warn(msg); + this.state = { email: acc?.email ?? this.state.email, country: acc?.country ?? this.state.country, status: "error", error: msg }; + this.hooks.onChange(); + } + + private apply(s: Snapshot) { + this.state = s.state; + this.cams = new Map(s.cameras.map((c) => [c.serial, c])); + this.warm = new Set(s.warm); + this.down = new Set(s.down); + this.hooks.onChange(); + } + + private async call(method: string, params: Record): Promise { + await this.ensureWorker(); + const r = await fetch(`${this.base}/rpc`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ method, params }), + signal: AbortSignal.timeout(90_000), + }); + const j = (await r.json().catch(() => ({}))) as Partial & { error?: string }; + if (!r.ok || !j.state) throw new HttpError(r.status === 400 ? 409 : 502, j.error ?? `eufy worker error (${r.status})`); + this.apply(j as Snapshot); + } + + private async script(): Promise { + const src = join(import.meta.dir, "eufy-worker.mjs"); + if (!WORKER_BUNDLE && existsSync(src)) return src; + if (!WORKER_BUNDLE) throw new Error("This build does not include the eufy worker"); + const out = join(this.hooks.dir, "worker.mjs"); + writeFileSync(out, WORKER_BUNDLE); + return out; + } + + private ensureWorker(): Promise { + if (this.sup && this.ready) return this.ready; + this.ready = (async () => { + await this.init(); + mkdirSync(this.hooks.dir, { recursive: true }); + if (process.platform !== "win32") chmodSync(this.hooks.dir, 0o700); + this.node ??= await findNode(this.hooks.binDir); + if (!this.node && this.hooks.allowDownload) { + log.info("eufy cameras need Node.js 24.5 or newer, downloading it once"); + this.node = await installNode(this.hooks.binDir); + } + if (!this.node) throw new HttpError(503, "eufy cameras need Node.js 24.5 or newer on this server. Install it or set OPENCCTV_NODE."); + const node = this.node; + const script = await this.script(); + const up = new Promise((resolve) => (this.markReady = resolve)); + const sup = new Supervisor("eufy-worker", { + command: () => ({ + cmd: [node, script], + env: { EUFY_DIR: this.hooks.dir, EUFY_PORT: String(this.port), EUFY_TOKEN: this.token, EUFY_FFMPEG: this.hooks.ffmpegPath?.() ?? "" }, + }), + onStdout: (l) => this.onLine(l), + onStderr: (l) => log.warn(l.slice(0, 400)), + onExit: () => { + if (this.sup === sup) this.warm.clear(); + }, + minBackoffMs: 2000, + maxBackoffMs: 60_000, + }); + this.sup = sup; + sup.start(); + const ok = await Promise.race([up.then(() => true), Bun.sleep(15_000).then(() => false)]); + if (!ok) throw new HttpError(502, "The eufy worker did not start"); + })(); + this.ready.catch(() => { + void this.stop(); + }); + return this.ready; + } + + private onLine(line: string) { + let m: { type?: string; level?: string; msg?: string; serial?: string; at?: number } & Partial; + try { + m = JSON.parse(line); + } catch { + return log.debug(line.slice(0, 300)); + } + if (m.type === "ready") { + const restarted = !this.markReady; + this.markReady?.(); + this.markReady = undefined; + const acc = getSetting(this.db, ACCOUNT_KEY); + if (restarted && acc) void this.call("login", acc).catch((e) => this.fail(e, acc)); + } else if (m.type === "log") (m.level === "info" ? log.info : log.warn)(String(m.msg)); + else if (m.type === "state" && m.state) this.apply(m as Snapshot); + else if (m.type === "motion" && m.serial) this.hooks.onMotion(m.serial, m.at ?? Date.now()); + } +} diff --git a/server/src/go2rtc.ts b/server/src/go2rtc.ts index fc3a8c3..83df3b8 100644 --- a/server/src/go2rtc.ts +++ b/server/src/go2rtc.ts @@ -30,7 +30,7 @@ export function buildGo2rtcYaml(o: Go2rtcOptions): string { lines.push(" candidates:"); for (const c of o.webrtcCandidates) lines.push(` - ${q(c)}`); } - if (o.ffmpegBin) lines.push("ffmpeg:", ` bin: ${q(o.ffmpegBin)}`); + if (o.ffmpegBin) lines.push("ffmpeg:", ` bin: ${q(o.ffmpegBin)}`, ` live: ${q("-fflags nobuffer -flags low_delay -rw_timeout 30000000 -i {input}")}`); lines.push("log:", ` level: "info"`, ` format: "text"`); lines.push("streams:"); if (!o.streams.length) lines.push(" _idle: []"); diff --git a/server/src/http/api.ts b/server/src/http/api.ts index 6f0253d..aa26494 100644 --- a/server/src/http/api.ts +++ b/server/src/http/api.ts @@ -47,7 +47,7 @@ function knownHosts(ctx: Ctx): Set { r.get("/api/info", "public", (ctx) => { const { app } = ctx; const link = app.gatewayLink(); - const features = ["hls", "webrtc", "mjpeg", "player", "ptz", "timeline", "events", "clips", "push", "gateway", "discovery", "unifi", "storage", "pairing"]; + const features = ["hls", "webrtc", "mjpeg", "player", "ptz", "timeline", "events", "clips", "push", "gateway", "discovery", "unifi", "eufy", "storage", "pairing"]; if (app.gdrive?.capabilities().deviceFlow) features.push("gdrive-device"); return json({ name: app.settings.get().serverName, @@ -332,6 +332,71 @@ r.post("/api/integrations/unifi/import", "admin", async (ctx) => { return json({ cameras: out }); }); +function eufyJson(ctx: Ctx) { + const added = new Map(ctx.app.cameras.list().filter((c) => c.kind === "eufy").map((c) => [c.fields.serial, c.id])); + return { + ...ctx.app.eufy.status(), + cameras: ctx.app.eufy.cameras().map((c) => ({ ...c, added: added.has(c.serial), cameraId: added.get(c.serial) })), + }; +} + +r.get("/api/integrations/eufy", "admin", (ctx) => json(eufyJson(ctx))); + +r.post("/api/integrations/eufy/login", "admin", async (ctx) => { + const b = await readJson<{ email?: string; password?: string; country?: string }>(ctx.req); + await ctx.app.eufy.login({ email: str(b.email, "email", { required: true, max: 200 }), password: str(b.password, "password", { required: true, max: 200 }), country: b.country }); + return json(eufyJson(ctx)); +}); + +r.post("/api/integrations/eufy/verify", "admin", async (ctx) => { + const b = await readJson<{ code?: string }>(ctx.req); + await ctx.app.eufy.verify(str(b.code, "code", { required: true, max: 20 })); + return json(eufyJson(ctx)); +}); + +r.post("/api/integrations/eufy/captcha", "admin", async (ctx) => { + const b = await readJson<{ answer?: string }>(ctx.req); + await ctx.app.eufy.solveCaptcha(str(b.answer, "answer", { required: true, max: 20 })); + return json(eufyJson(ctx)); +}); + +r.post("/api/integrations/eufy/refresh", "admin", async (ctx) => { + await ctx.app.eufy.refresh(); + return json(eufyJson(ctx)); +}); + +r.delete("/api/integrations/eufy", "admin", async (ctx) => { + await ctx.app.eufy.logout(); + return json(eufyJson(ctx)); +}); + +r.post("/api/integrations/eufy/import", "admin", async (ctx) => { + const b = await readJson<{ serials?: string[] }>(ctx.req); + const want = Array.isArray(b.serials) ? new Set(b.serials.map(String)) : undefined; + if (!ctx.app.eufy.connected) throw new HttpError(409, "Connect your eufy account first"); + if (!ctx.app.eufy.cameras().length) await ctx.app.eufy.refresh(); + const existing = new Set(ctx.app.cameras.list().filter((c) => c.kind === "eufy").map((c) => c.fields.serial)); + const out: { id: string; name: string; model: string; added: boolean; cameraId?: string; error?: string }[] = []; + for (const e of ctx.app.eufy.cameras()) { + if (want && !want.has(e.serial)) continue; + if (existing.has(e.serial)) { + out.push({ id: e.serial, name: e.name, model: e.model, added: false, error: "already added" }); + continue; + } + const fields = { serial: e.serial, model: e.model }; + const cam = ctx.app.cameras.create({ + name: e.name, + brand: "eufy", + built: buildSource("eufy", "eufy", fields), + fields, + defaultSensitivity: ctx.app.settings.get().motion.defaultSensitivity, + recordingMode: "off", + }); + out.push({ id: e.serial, name: e.name, model: e.model, added: true, cameraId: cam.id }); + } + return json({ cameras: out }); +}); + // recordings, timeline, events r.get("/api/recordings", "user", (ctx) => { diff --git a/server/test/brands.test.ts b/server/test/brands.test.ts index a86da0f..8770f70 100644 --- a/server/test/brands.test.ts +++ b/server/test/brands.test.ts @@ -94,4 +94,18 @@ describe("go2rtc sources", () => { test("push camera has no source", () => { expect(go2rtcSources({ ...base, kind: "rtsp-push", streamKey: "c1_key" }, true)).toEqual([{ name: "c1_key", sources: [] }]); }); + test("eufy account camera pulls from the worker", () => { + const cam: CameraRecord = { ...base, brand: "eufy", kind: "eufy", fields: { serial: "T8170P1234567890" }, url: "eufy://T8170P1234567890", subUrl: undefined }; + const s = go2rtcSources(cam, true, (sn) => `ffmpeg:http://127.0.0.1:1/t/live/${sn}.mp4#video=h264`); + expect(s).toEqual([{ name: "c1", sources: ["ffmpeg:http://127.0.0.1:1/t/live/T8170P1234567890.mp4#video=h264", "ffmpeg:c1#video=mjpeg"] }]); + expect(go2rtcSources(cam, false, () => "x")).toEqual([{ name: "c1", sources: [] }]); + }); +}); + +describe("eufy source", () => { + test("account cameras use the serial, manual ones RTSP", () => { + expect(buildSource("eufy", "eufy", { serial: "T8170P1234567890", model: "T8170" })).toEqual({ kind: "eufy", url: "eufy://T8170P1234567890" }); + expect(buildSource("eufy", "rtsp", { host: "h" }).kind).toBe("rtsp"); + expect(() => buildSource("eufy", "eufy", { serial: "../x" })).toThrow(); + }); }); diff --git a/server/web/src/api.ts b/server/web/src/api.ts index dc18ef3..2abb8e4 100644 --- a/server/web/src/api.ts +++ b/server/web/src/api.ts @@ -1,7 +1,7 @@ import type { AuthResponse, Brand, Camera, CameraCreate, CameraPatch, CameraTestResult, DeepPartial, DiscoverCandidate, GdriveStart, GdriveStartInput, GdriveStatus, Info, MotionEvent, Page, PairingCode, Recording, Role, Settings, Site, - StorageInfo, StorageTarget, StorageTargetInput, SystemInfo, Timeline, UnifiImportResult, User, + StorageInfo, StorageTarget, StorageTargetInput, SystemInfo, Timeline, UnifiImportResult, User, EufyAccount, } from './types'; import { ApiError } from './errors'; import { mockMediaUrl, mockRequest } from './mock'; @@ -215,6 +215,13 @@ export const api = { discover: (signal?: AbortSignal) => get<{ candidates: DiscoverCandidate[] }>('/api/discover', undefined, signal), unifiImport: (b: { host: string; username: string; password: string; cameraIds?: string[] }) => post('/api/integrations/unifi/import', b), + eufy: () => get('/api/integrations/eufy'), + eufyLogin: (b: { email: string; password: string; country: string }) => post('/api/integrations/eufy/login', b), + eufyVerify: (code: string) => post('/api/integrations/eufy/verify', { code }), + eufyCaptcha: (answer: string) => post('/api/integrations/eufy/captcha', { answer }), + eufyRefresh: () => post('/api/integrations/eufy/refresh', {}), + eufyDisconnect: () => del('/api/integrations/eufy'), + eufyImport: (serials: string[]) => post('/api/integrations/eufy/import', { serials }), recordings: (q: { camera?: string; from?: string; to?: string; limit?: number; cursor?: string }) => get>('/api/recordings', q), diff --git a/server/web/src/pages/AddCamera.tsx b/server/web/src/pages/AddCamera.tsx index 9d70595..3de3e08 100644 --- a/server/web/src/pages/AddCamera.tsx +++ b/server/web/src/pages/AddCamera.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; import { api, errorMessage } from '../api'; import { navigate, setQuery, useRoute } from '../router'; import { useApp } from '../store'; -import type { Brand, Camera, CameraTestResult, DiscoverCandidate, UnifiImportResult } from '../types'; +import type { Brand, Camera, CameraTestResult, DiscoverCandidate, EufyAccount, UnifiImportResult } from '../types'; import { initials, resolution } from '../format'; import { FieldsForm, initialValues, missingRequired } from '../components/FieldsForm'; import { toast } from '../components/feedback'; @@ -18,6 +18,7 @@ const KIND_LABELS: Record = { onvif: 'ONVIF', tapo: 'Tapo (native)', unifi: 'UniFi Protect', + eufy: 'eufy account', http: 'HTTP / MJPEG', 'rtmp-push': 'RTMP push', 'rtsp-push': 'RTSP push', @@ -51,7 +52,7 @@ export function BrandMark({ id, name, size = 40, label, hue: hueProp }: { id: st ); } -type Tab = 'brands' | 'discover' | 'unifi'; +type Tab = 'brands' | 'discover' | 'unifi' | 'eufy'; interface Prefill { values: Record; @@ -94,13 +95,14 @@ export function AddCamera({ brands, brandsError }: { brands: Brand[] | null; bra Cameras - +
{( [ ['brands', 'Choose brand'], ['discover', 'Scan network'], ['unifi', 'UniFi Protect import'], + ['eufy', 'eufy account'], ] as [Tab, string][] ).map(([k, l]) => (
); } @@ -356,6 +359,264 @@ function UnifiImport() { ); } +/* ---------------- eufy ---------------- */ + +const EUFY_REGIONS: [string, string][] = [ + ['DE', 'Germany'], ['AT', 'Austria'], ['CH', 'Switzerland'], ['NL', 'Netherlands'], ['BE', 'Belgium'], ['FR', 'France'], + ['IT', 'Italy'], ['ES', 'Spain'], ['PL', 'Poland'], ['SE', 'Sweden'], ['DK', 'Denmark'], ['NO', 'Norway'], + ['GB', 'United Kingdom'], ['IE', 'Ireland'], ['US', 'United States'], ['CA', 'Canada'], ['AU', 'Australia'], +]; + +function EufyImport() { + const { refreshCameras } = useApp(); + const [acc, setAcc] = useState(null); + const [loadError, setLoadError] = useState(null); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [country, setCountry] = useState('DE'); + const [code, setCode] = useState(''); + const [picked, setPicked] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const apply = (a: EufyAccount) => { + setAcc(a); + if (a.email) setEmail(a.email); + if (a.country) setCountry(a.country); + setPicked(new Set(a.cameras.filter((c) => !c.added).map((c) => c.serial))); + }; + + useEffect(() => { + api.eufy().then(apply, (e) => setLoadError(errorMessage(e))); + }, []); + + useEffect(() => { + if (acc?.status !== 'connecting') return; + const t = setTimeout(() => api.eufy().then(apply, () => {}), 2000); + return () => clearTimeout(t); + }, [acc]); + + const run = async (fn: () => Promise) => { + setBusy(true); + setError(null); + try { + const a = await fn(); + apply(a); + setCode(''); + if (a.status === 'error') setError(a.error ?? 'Sign-in failed'); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusy(false); + } + }; + + const login = (e: FormEvent) => { + e.preventDefault(); + void run(() => api.eufyLogin({ email: email.trim(), password, country })); + }; + + const verify = (e: FormEvent) => { + e.preventDefault(); + void run(() => (acc?.status === 'captcha' ? api.eufyCaptcha(code.trim()) : api.eufyVerify(code.trim()))); + }; + + const importPicked = async () => { + setBusy(true); + setError(null); + try { + const r = await api.eufyImport([...picked]); + setResult(r); + void refreshCameras(); + apply(await api.eufy()); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusy(false); + } + }; + + const toggle = (serial: string) => + setPicked((p) => { + const n = new Set(p); + if (n.has(serial)) n.delete(serial); + else n.add(serial); + return n; + }); + + const help = ( + + ); + + if (!acc) + return ( +
+
{loadError ? {loadError} : }
+ {help} +
+ ); + + if (acc.status === 'connecting') + return ( +
+
+
+ Signing in to eufy as {acc.email}… +
+
+ {help} +
+ ); + + if (acc.status === 'captcha' || acc.status === '2fa') + return ( +
+
+
+
{acc.status === 'captcha' ? 'Security check' : 'Verification code'}
+

+ {acc.status === 'captcha' + ? 'eufy wants a captcha before it lets this server sign in. Type the characters you see.' + : `eufy sent a code${acc.method ? ` by ${acc.method}` : ''} to ${acc.email}. It is valid for a few minutes.`} +

+
+ {acc.status === 'captcha' && acc.captcha && Captcha} + + setCode(e.target.value)} + inputMode={acc.status === '2fa' ? 'numeric' : undefined} + autoComplete="one-time-code" + autoFocus + required + /> + + {(error || acc.error) && {error ?? acc.error}} +
+ + +
+
+ {help} +
+ ); + + if (acc.status === 'connected') { + const fresh = acc.cameras.filter((c) => !c.added); + return ( +
+
+
+ +
+
eufy account connected
+
{acc.email}
+
+ + +
+ {error && {error}} + {acc.cameras.length === 0 ? ( +

No cameras in this eufy account yet.

+ ) : ( +
+
Cameras in your account
+ {acc.cameras.map((c) => ( + + ))} +
+ )} + {fresh.length > 0 && ( +
+ +
+ )} + {result && ( +
+ + {result.cameras.filter((c) => c.added).length} added. Live view wakes a battery camera, the first picture can take a few seconds. + + +
+ )} +
+ {help} +
+ ); + } + + return ( +
+
+ + setEmail(e.target.value)} autoComplete="username" required /> + +
+ + setPassword(e.target.value)} autoComplete="current-password" required /> + + + + +
+ {(error || acc.error) && {error ?? acc.error}} +
+ +
+
+ {help} +
+ ); +} + /* ---------------- Configure + test ---------------- */ function ConfigureCamera({ diff --git a/server/web/src/styles/pages.css b/server/web/src/styles/pages.css index 35d0b72..a2a6a16 100644 --- a/server/web/src/styles/pages.css +++ b/server/web/src/styles/pages.css @@ -1990,3 +1990,21 @@ color: var(--accent-hover); font-weight: 600; } + +.import-row input[type='checkbox'] { + width: 18px; + height: 18px; + margin: 1px; + accent-color: var(--accent); + cursor: pointer; +} +label.import-row { + cursor: pointer; +} +.eufy-captcha { + align-self: flex-start; + height: 64px; + border-radius: var(--r-md); + background: #fff; + padding: 4px 8px; +} diff --git a/server/web/src/types.ts b/server/web/src/types.ts index b6aa824..e35e354 100644 --- a/server/web/src/types.ts +++ b/server/web/src/types.ts @@ -57,7 +57,7 @@ export interface Brand { defaultPort?: number; } -export type SourceKind = 'rtsp' | 'onvif' | 'tapo' | 'unifi' | 'http' | 'rtmp-push' | 'rtsp-push' | 'demo'; +export type SourceKind = 'rtsp' | 'onvif' | 'tapo' | 'unifi' | 'eufy' | 'http' | 'rtmp-push' | 'rtsp-push' | 'demo'; export type RecordingMode = 'continuous' | 'motion' | 'off'; export interface Camera { @@ -124,7 +124,31 @@ export interface DiscoverCandidate { } export interface UnifiImportResult { - cameras: { id: string; name: string; model: string; added: boolean }[]; + cameras: { id: string; name: string; model: string; added: boolean; error?: string }[]; +} + +export type EufyStatus = 'disconnected' | 'connecting' | 'captcha' | '2fa' | 'connected' | 'error'; + +export interface EufyCamera { + serial: string; + name: string; + model: string; + battery: boolean; + batteryLevel?: number; + rssi?: number; + station?: string; + added: boolean; + cameraId?: string; +} + +export interface EufyAccount { + status: EufyStatus; + email?: string; + country?: string; + captcha?: string; + method?: string; + error?: string; + cameras: EufyCamera[]; } export interface Recording {