From 27be1892ed90e787c3ac8968ea7ccb405bee9334 Mon Sep 17 00:00:00 2001 From: SRS IT Date: Sat, 28 Mar 2026 16:30:43 -0400 Subject: [PATCH] =?UTF-8?q?Initial=20release=20=E2=80=94=20Dibby=20Wemo=20?= =?UTF-8?q?Manager=20v2.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop (Electron/Windows): device dashboard, DWM scheduling engine, native firmware rules editor, Windows background service, web remote, sunrise/sunset support. Homebridge plugin (homebridge-dibby-wemo v1.0.0): HomeKit switches for all local Wemo devices, custom UI with DWM rules, device rules, scheduler heartbeat, and location-based sunrise/sunset scheduling. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 54 + LICENSE | 21 + README.md | 214 +++ apps/desktop/README.md | 194 +++ apps/desktop/electron.vite.config.js | 61 + apps/desktop/package.json | 122 ++ apps/desktop/resources/about.html | 121 ++ apps/desktop/resources/help.html | 1199 +++++++++++++++++ apps/desktop/resources/icon.ico | Bin 0 -> 53144 bytes apps/desktop/resources/icon.png | Bin 0 -> 38249 bytes apps/desktop/resources/web/index.html | 1100 +++++++++++++++ apps/desktop/scripts/bundle-standalone.js | 41 + apps/desktop/src/main/core/sun.js | 81 ++ apps/desktop/src/main/core/types.js | 76 ++ apps/desktop/src/main/firewall - Copy.js | 72 + apps/desktop/src/main/firewall.js | 72 + apps/desktop/src/main/index.js | 336 +++++ apps/desktop/src/main/ipc/devices.ipc.js | 75 ++ apps/desktop/src/main/ipc/rules.ipc.js | 84 ++ apps/desktop/src/main/ipc/scheduler.ipc.js | 43 + apps/desktop/src/main/ipc/system.ipc.js | 104 ++ apps/desktop/src/main/ipc/wifi.ipc.js | 22 + apps/desktop/src/main/scheduler-standalone.js | 412 ++++++ apps/desktop/src/main/scheduler.js | 723 ++++++++++ apps/desktop/src/main/service-manager-sync.js | 28 + apps/desktop/src/main/service-manager.js | 127 ++ apps/desktop/src/main/store.js | 94 ++ apps/desktop/src/main/web-server.js | 321 +++++ apps/desktop/src/main/wemo.js | 1017 ++++++++++++++ apps/desktop/src/preload/index.js | 101 ++ apps/desktop/src/renderer/index.html | 14 + apps/desktop/src/renderer/src/App.jsx | 151 +++ .../src/components/device/DeviceCard.jsx | 61 + .../src/components/device/DeviceInfoTab.jsx | 229 ++++ .../src/components/device/PowerButton.jsx | 34 + .../src/components/device/SignalMeter.jsx | 37 + .../src/components/layout/DetailPanel.jsx | 72 + .../src/components/layout/Sidebar.jsx | 370 +++++ .../src/components/rules/AllRulesTab.jsx | 446 ++++++ .../src/components/rules/DayPicker.jsx | 33 + .../src/components/rules/RuleEditor.jsx | 611 +++++++++ .../src/components/rules/RulesTab.jsx | 412 ++++++ .../rules/editors/AwayModeEditor.jsx | 171 +++ .../rules/editors/CountdownEditor.jsx | 108 ++ .../rules/editors/ScheduleEditor.jsx | 148 ++ .../src/components/shared/ConfirmDialog.jsx | 21 + .../src/components/shared/CopyField.jsx | 30 + .../renderer/src/components/shared/Modal.jsx | 19 + .../renderer/src/components/shared/Toast.jsx | 20 + .../renderer/src/components/wifi/ApList.jsx | 56 + .../src/components/wifi/NetworkStatus.jsx | 37 + .../renderer/src/components/wifi/WiFiTab.jsx | 168 +++ apps/desktop/src/renderer/src/main.jsx | 10 + .../desktop/src/renderer/src/store/devices.js | 37 + apps/desktop/src/renderer/src/store/rules.js | 24 + .../src/renderer/src/store/settings.js | 26 + apps/desktop/src/renderer/src/styles/app.css | 316 +++++ packages/homebridge-plugin/README.md | 222 +++ packages/homebridge-plugin/config.schema.json | 50 + .../homebridge-ui/public/index.html | 555 ++++++++ .../homebridge-ui/public/index.js | 768 +++++++++++ .../homebridge-plugin/homebridge-ui/server.js | 151 +++ packages/homebridge-plugin/index.js | 20 + packages/homebridge-plugin/lib/accessory.js | 95 ++ packages/homebridge-plugin/lib/platform.js | 188 +++ packages/homebridge-plugin/lib/scheduler.js | 721 ++++++++++ packages/homebridge-plugin/lib/store.js | 118 ++ packages/homebridge-plugin/lib/sun.js | 81 ++ packages/homebridge-plugin/lib/types.js | 76 ++ packages/homebridge-plugin/lib/wemo-client.js | 489 +++++++ packages/homebridge-plugin/package.json | 34 + packages/wemo-core/package.json | 6 + packages/wemo-core/src/index.js | 15 + packages/wemo-core/src/sun.js | 81 ++ packages/wemo-core/src/types.js | 76 ++ 75 files changed, 14322 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 apps/desktop/README.md create mode 100644 apps/desktop/electron.vite.config.js create mode 100644 apps/desktop/package.json create mode 100644 apps/desktop/resources/about.html create mode 100644 apps/desktop/resources/help.html create mode 100644 apps/desktop/resources/icon.ico create mode 100644 apps/desktop/resources/icon.png create mode 100644 apps/desktop/resources/web/index.html create mode 100644 apps/desktop/scripts/bundle-standalone.js create mode 100644 apps/desktop/src/main/core/sun.js create mode 100644 apps/desktop/src/main/core/types.js create mode 100644 apps/desktop/src/main/firewall - Copy.js create mode 100644 apps/desktop/src/main/firewall.js create mode 100644 apps/desktop/src/main/index.js create mode 100644 apps/desktop/src/main/ipc/devices.ipc.js create mode 100644 apps/desktop/src/main/ipc/rules.ipc.js create mode 100644 apps/desktop/src/main/ipc/scheduler.ipc.js create mode 100644 apps/desktop/src/main/ipc/system.ipc.js create mode 100644 apps/desktop/src/main/ipc/wifi.ipc.js create mode 100644 apps/desktop/src/main/scheduler-standalone.js create mode 100644 apps/desktop/src/main/scheduler.js create mode 100644 apps/desktop/src/main/service-manager-sync.js create mode 100644 apps/desktop/src/main/service-manager.js create mode 100644 apps/desktop/src/main/store.js create mode 100644 apps/desktop/src/main/web-server.js create mode 100644 apps/desktop/src/main/wemo.js create mode 100644 apps/desktop/src/preload/index.js create mode 100644 apps/desktop/src/renderer/index.html create mode 100644 apps/desktop/src/renderer/src/App.jsx create mode 100644 apps/desktop/src/renderer/src/components/device/DeviceCard.jsx create mode 100644 apps/desktop/src/renderer/src/components/device/DeviceInfoTab.jsx create mode 100644 apps/desktop/src/renderer/src/components/device/PowerButton.jsx create mode 100644 apps/desktop/src/renderer/src/components/device/SignalMeter.jsx create mode 100644 apps/desktop/src/renderer/src/components/layout/DetailPanel.jsx create mode 100644 apps/desktop/src/renderer/src/components/layout/Sidebar.jsx create mode 100644 apps/desktop/src/renderer/src/components/rules/AllRulesTab.jsx create mode 100644 apps/desktop/src/renderer/src/components/rules/DayPicker.jsx create mode 100644 apps/desktop/src/renderer/src/components/rules/RuleEditor.jsx create mode 100644 apps/desktop/src/renderer/src/components/rules/RulesTab.jsx create mode 100644 apps/desktop/src/renderer/src/components/rules/editors/AwayModeEditor.jsx create mode 100644 apps/desktop/src/renderer/src/components/rules/editors/CountdownEditor.jsx create mode 100644 apps/desktop/src/renderer/src/components/rules/editors/ScheduleEditor.jsx create mode 100644 apps/desktop/src/renderer/src/components/shared/ConfirmDialog.jsx create mode 100644 apps/desktop/src/renderer/src/components/shared/CopyField.jsx create mode 100644 apps/desktop/src/renderer/src/components/shared/Modal.jsx create mode 100644 apps/desktop/src/renderer/src/components/shared/Toast.jsx create mode 100644 apps/desktop/src/renderer/src/components/wifi/ApList.jsx create mode 100644 apps/desktop/src/renderer/src/components/wifi/NetworkStatus.jsx create mode 100644 apps/desktop/src/renderer/src/components/wifi/WiFiTab.jsx create mode 100644 apps/desktop/src/renderer/src/main.jsx create mode 100644 apps/desktop/src/renderer/src/store/devices.js create mode 100644 apps/desktop/src/renderer/src/store/rules.js create mode 100644 apps/desktop/src/renderer/src/store/settings.js create mode 100644 apps/desktop/src/renderer/src/styles/app.css create mode 100644 packages/homebridge-plugin/README.md create mode 100644 packages/homebridge-plugin/config.schema.json create mode 100644 packages/homebridge-plugin/homebridge-ui/public/index.html create mode 100644 packages/homebridge-plugin/homebridge-ui/public/index.js create mode 100644 packages/homebridge-plugin/homebridge-ui/server.js create mode 100644 packages/homebridge-plugin/index.js create mode 100644 packages/homebridge-plugin/lib/accessory.js create mode 100644 packages/homebridge-plugin/lib/platform.js create mode 100644 packages/homebridge-plugin/lib/scheduler.js create mode 100644 packages/homebridge-plugin/lib/store.js create mode 100644 packages/homebridge-plugin/lib/sun.js create mode 100644 packages/homebridge-plugin/lib/types.js create mode 100644 packages/homebridge-plugin/lib/wemo-client.js create mode 100644 packages/homebridge-plugin/package.json create mode 100644 packages/wemo-core/package.json create mode 100644 packages/wemo-core/src/index.js create mode 100644 packages/wemo-core/src/sun.js create mode 100644 packages/wemo-core/src/types.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd30deb --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# ── Dependencies ────────────────────────────────────────────────────────────── +node_modules/ +.pnp +.pnp.js + +# ── Build output ────────────────────────────────────────────────────────────── +dist/ +out/ +build/ +*.blockmap +builder-debug.yml +builder-effective-config.yaml + +# ── Electron app user data written at runtime ───────────────────────────────── +apps/desktop/WemoManagerData/ + +# ── Code signing certificate — NEVER commit private keys ───────────────────── +*.pfx +*.p12 +*.key +*.pem + +# ── Environment / secrets ───────────────────────────────────────────────────── +.env +.env.* +!.env.example + +# ── OS artefacts ───────────────────────────────────────────────────────────── +.DS_Store +Thumbs.db +desktop.ini + +# ── Logs ───────────────────────────────────────────────────────────────────── +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# ── Editor / IDE ────────────────────────────────────────────────────────────── +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# ── Temporary files ─────────────────────────────────────────────────────────── +*.tmp +*.temp +.cache/ + +# ── Claude agent internals ─────────────────────────────────────────────────── +.claude/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..04a8b88 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 SRS IT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7f60a5 --- /dev/null +++ b/README.md @@ -0,0 +1,214 @@ +# Dibby Wemo Manager + +**Local Wemo control — no Belkin cloud required.** + +Dibby Wemo Manager gives you full local control of Belkin Wemo smart switches and plugs from two interfaces: + +| Component | Description | +|---|---| +| 🖥️ **Desktop App** | Windows Electron app — device dashboard, power control, scheduling | +| 🏠 **Homebridge Plugin** | HomeKit integration with custom scheduling UI inside Homebridge | + +Both share the same local-network Wemo protocol (UPnP/SOAP) and the same DWM scheduling engine. No Belkin account, no cloud dependency, no internet required. + +--- + +## Repository Layout + +``` +wemo-manager/ +├── apps/ +│ └── desktop/ # Electron desktop app (Windows) +├── packages/ +│ ├── homebridge-plugin/ # homebridge-dibby-wemo Homebridge plugin +│ └── wemo-core/ # Shared Wemo protocol helpers +└── package.json # npm workspaces root +``` + +--- + +## Quick Start + +### Desktop App (Windows) + +Download the latest installer from [Releases](../../releases): + +- **`Dibby Wemo Manager Setup 2.0.0.exe`** — NSIS installer (recommended) +- **`Dibby Wemo Manager 2.0.0.exe`** — Portable single-file executable + +Run the installer, launch the app. Wemo devices are discovered automatically via SSDP on your local network. + +### Homebridge Plugin + +```bash +npm install -g homebridge-dibby-wemo +``` + +Then add to your Homebridge `config.json`: + +```json +{ + "platforms": [ + { + "platform": "DibbyWemo", + "name": "DibbyWemo" + } + ] +} +``` + +Restart Homebridge. Devices appear in HomeKit automatically. + +--- + +## Features + +### 🖥️ Desktop App + +- **Device dashboard** — real-time on/off status for all Wemo devices on your network +- **One-click power control** — toggle any device instantly +- **DWM Rules** — cross-device scheduling engine: + - **Schedule** — turn devices on/off at specific times on selected days + - **Countdown** — active-window timer (on at sunset, off at midnight, etc.) + - **Away Mode** — randomised on/off simulation while you're away + - **Always On** — enforce a device stays on; auto-corrects within 10 seconds + - **Trigger** — IFTTT-style: when device A changes state, control device B +- **Native firmware rules** — read, toggle and delete rules stored on the Wemo device itself +- **Standalone service** — Windows background service that enforces rules even when the GUI is closed +- **Web remote** — optional local web interface accessible from your phone +- **Sunrise/sunset support** — location-aware scheduling via city search + +### 🏠 Homebridge Plugin + +- All Wemo devices registered as **HomeKit switches** +- Custom Homebridge UI panel with five tabs: + - **Devices** — live device list with on/off toggle + - **DWM Rules** — full scheduling CRUD (same rule types as desktop) + - **Device Rules** — native firmware rule management + - **Settings** — city/location search for sunrise/sunset times + - **Help** — built-in documentation +- **Scheduler health monitor** — green/amber/red status bar shows scheduler state in real time +- **Catch-up on restart** — rules missed while Homebridge was restarting fire automatically on startup +- No cloud required; all communication is local SOAP/UPnP + +--- + +## Supported Devices + +Tested and confirmed working: + +| Model | Name | +|---|---| +| WLS0403 | Wemo 3-Way Smart Switch | +| WLS040 | Wemo Light Switch | +| F7C030 | Wemo Light Switch (older) | +| F7C027 | Wemo Switch / Mini Smart Plug | +| F7C029 | Wemo Insight Smart Plug | +| F7C063 | Wemo Mini Smart Plug v2 | + +> **Note:** Wemo Dimmer V2 (WDS060) with newer RTOS firmware does not expose the `FetchRules`/`StoreRules` UPnP service. These devices are detected and support on/off control but native firmware rule editing is unavailable. + +--- + +## Architecture + +### Wemo Protocol + +All communication is local UPnP/SOAP over HTTP — no Belkin cloud: + +| Operation | Method | +|---|---| +| Discovery | SSDP M-SEARCH multicast to `239.255.255.250:1900` | +| Device info | HTTP GET `http://:/setup.xml` | +| On/Off | UPnP SOAP `SetBinaryState` / `GetBinaryState` | +| State query | UPnP SOAP `GetBinaryState` | +| Native rules | UPnP SOAP `FetchRules` / `StoreRules` (ZIP + SQLite) | + +### Native Firmware Rules Database + +The Wemo device stores rules in a SQLite database inside a ZIP archive: + +1. `FetchRules` returns a URL to download the ZIP +2. The ZIP contains `temppluginRules.db` (SQLite) +3. Modify the SQLite, re-ZIP, base64-encode +4. `StoreRules` uploads the encoded database + +> **Critical:** `StoreRules` requires the base64 body wrapped in entity-encoded CDATA: +> `<![CDATA[base64data]]>` +> Standard XML builders cannot produce this format — the SOAP envelope must be hand-crafted. + +### DWM Scheduling Engine + +The DWM (Dibby Wemo Manager) scheduler is a Node.js process that: + +- Loads rules from a JSON store (`dibby-wemo.json`) +- Ticks every **30 seconds**, reloading rules on each tick (live edits take effect without restart) +- Pre-schedules events within a **65-second look-ahead window** +- On startup, catches up any rules missed within the last **10 minutes** +- Runs a **health monitor** every 10 seconds for AlwaysOn and Trigger rules +- Writes a **heartbeat** to the store on every tick so the UI can show scheduler status + +--- + +## Development + +### Prerequisites + +- Node.js ≥ 18 +- npm ≥ 9 + +### Install dependencies + +```bash +# From repo root +npm install +``` + +### Desktop App — dev mode + +```bash +cd apps/desktop +npm run dev +``` + +### Desktop App — build Windows installer + +```bash +cd apps/desktop +npm run build:win +``` + +Output in `apps/desktop/dist/`: +- `Dibby Wemo Manager Setup 2.0.0.exe` — NSIS installer +- `Dibby Wemo Manager 2.0.0.exe` — portable EXE + +### Homebridge Plugin — install locally + +```bash +cd packages/homebridge-plugin +npm install -g . +``` + +Then restart Homebridge. + +--- + +## Release Assets + +Each [GitHub Release](../../releases) includes: + +| File | Description | +|---|---| +| `Dibby Wemo Manager Setup 2.0.0.exe` | Windows NSIS installer (recommended) | +| `Dibby Wemo Manager 2.0.0.exe` | Windows portable executable | +| `homebridge-dibby-wemo-1.0.0.tgz` | Homebridge plugin npm package | + +--- + +## License + +MIT — see [LICENSE](LICENSE) + +--- + +*Built by SRS IT. All Wemo communication is local — your device data never leaves your network.* diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000..7160a5f --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,194 @@ +# Dibby Wemo Manager — Desktop App + +**Windows desktop application for local Belkin Wemo control.** + +Full device dashboard, power control, scheduling engine, Windows background service, and optional web remote — all communicating directly with your Wemo devices over your local network. No Belkin cloud account required. + +--- + +## Installation + +Download the latest release from [GitHub Releases](../../releases): + +| File | Description | +|---|---| +| `Dibby Wemo Manager Setup 2.0.0.exe` | **NSIS installer** — recommended, installs to Program Files, adds Start Menu shortcut | +| `Dibby Wemo Manager 2.0.0.exe` | **Portable** — single executable, no installation, runs from any folder | + +Run the installer or portable exe. The app opens and immediately begins discovering Wemo devices on your network. + +--- + +## Features + +### 🔍 Device Discovery & Control + +- Automatically discovers all Wemo devices on your LAN via SSDP +- Displays device name, model, firmware version, and IP address +- Toggle any device on or off with a single click +- Real-time status polling + +### ⏰ DWM Rules — Scheduling Engine + +Create automation rules across one or multiple devices: + +| Rule Type | Description | +|---|---| +| **📅 Schedule** | Turn devices on/off at fixed times on selected days of the week | +| **⏱ Countdown** | Active window — turns on at window start, off at window end (handles cross-midnight windows) | +| **🏠 Away Mode** | Simulates occupancy during a time window by randomly toggling devices on (30–90 min) then off (1–15 min) | +| **🔒 Always On** | Continuously enforces a device stays on; detects and corrects any off-state within 10 seconds | +| **⚡ Trigger** | IFTTT-style automation: when a source device changes state, control target devices (mirror, opposite, force on/off) | + +**Multi-device rules** — every rule can target multiple devices simultaneously. + +**Times use 12-hour AM/PM format** (e.g. `8:30 PM`, `6 AM`). + +**Rules reload live** — the scheduler picks up edits within 30 seconds. No restart needed. + +### 🔌 Native Firmware Rules + +Read and manage rules stored directly on the Wemo device's own firmware: + +- View all rules on a selected device +- Toggle rules on or off +- Delete rules +- Add new native firmware rules + +> Wemo Dimmer V2 (WDS060) with newer RTOS firmware does not support firmware rule editing. + +### 🛠️ Windows Background Service + +The DWM scheduler can run as a **Windows service** (`DibbyWemoService`) so rules continue to fire even when the GUI is closed or the user logs out. + +- Install/uninstall the service from the app's System tab +- The service reads rules from the shared data directory and syncs automatically when rules are saved in the GUI +- Service uses `node-windows` for reliable Windows service registration + +### 🌐 Web Remote + +Optional local web interface accessible from any device on your network (phone, tablet, another PC): + +- View device status +- Toggle devices on/off +- QR code for easy mobile access +- Configurable port; firewall rule created automatically (UAC prompt) + +### 📍 Sunrise/Sunset Scheduling + +Set your city in the Settings tab. Schedule rules can then use local sunrise and sunset times as start/end points. + +--- + +## Data Storage + +All app data is stored in `%APPDATA%\DibbyWemoManager\` (typically `C:\Users\\AppData\Roaming\DibbyWemoManager\`): + +| File | Description | +|---|---| +| `wemo-manager.json` | App settings, discovered devices, DWM rules | +| `dwm-rules.json` | DWM rules shared with the Windows background service | + +The standalone service reads `C:\ProgramData\DibbyWemoManager\dwm-rules.json`. The GUI syncs rules to this location after every create, update, or delete. + +--- + +## Architecture + +``` +Electron Main Process +├── wemo.js — Wemo UPnP/SOAP client + SSDP discovery +├── scheduler.js — DWM rule scheduling engine (tick every 30s) +├── store.js — JSON persistence layer +├── firewall.js — Windows Firewall rule management (elevated) +├── web-server.js — Express web remote server +├── service-manager.js— node-windows service install/uninstall +└── ipc/ + ├── devices.ipc.js + ├── rules.ipc.js + ├── scheduler.ipc.js + ├── system.ipc.js + └── wifi.ipc.js + +Electron Renderer (React 18 + Zustand) +├── DeviceCard — per-device power button + status +├── RulesTab — DWM rules list + inline editor +├── AllRulesTab — Native firmware rules per device +└── Settings — location, service, web remote config + +Standalone Service (scheduler-standalone.js) +└── Runs headless; reads dwm-rules.json; same scheduling logic +``` + +### Wemo Protocol Details + +| Operation | Protocol | +|---|---| +| Discovery | SSDP UDP multicast to `239.255.255.250:1900` | +| Device info | HTTP GET `/setup.xml` | +| Power on/off | UPnP SOAP `SetBinaryState` to `/upnp/control/basicevent1` | +| State query | UPnP SOAP `GetBinaryState` | +| Rules fetch | UPnP SOAP `FetchRules` → download ZIP → extract SQLite | +| Rules save | Modify SQLite → re-ZIP → base64 → `StoreRules` | + +Native firmware rules are stored in a SQLite database (`temppluginRules.db`) inside a ZIP archive. The app uses `sql.js` (WebAssembly SQLite) to read and write rules without any native compilation. + +--- + +## Building from Source + +### Prerequisites + +- Node.js ≥ 18 +- npm ≥ 9 +- Windows (for Windows builds) + +### Install dependencies + +```bash +# From repo root +npm install + +# Or just for the desktop app +cd apps/desktop +npm install +``` + +### Development mode + +```bash +cd apps/desktop +npm run dev +``` + +Opens the Electron app with hot-reload for the renderer. + +### Production build + +```bash +cd apps/desktop +npm run build:win +``` + +This: +1. Compiles the renderer with `electron-vite` +2. Bundles the standalone service script +3. Runs `electron-builder` to produce the NSIS installer and portable exe + +Output appears in `apps/desktop/dist/`. + +> **Code signing:** The build configuration expects a PFX certificate at `resources/srsit-codesign.pfx`. Remove the `win.certificateFile` entry from `package.json` if you don't have a certificate. + +--- + +## Requirements + +- Windows 10 or later (x64) +- Node.js ≥ 18 (only needed for building from source) +- Wemo devices on the same LAN + +--- + +## License + +MIT diff --git a/apps/desktop/electron.vite.config.js b/apps/desktop/electron.vite.config.js new file mode 100644 index 0000000..344a087 --- /dev/null +++ b/apps/desktop/electron.vite.config.js @@ -0,0 +1,61 @@ +import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +const CORE_DIR = resolve(__dirname, '../../packages/wemo-core/src'); + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + resolve: { + alias: { + // Bundle workspace package inline instead of externalizing it + '@wemo-manager/core/src': CORE_DIR, + '@wemo-manager/core': resolve(__dirname, '../../packages/wemo-core/src/index.js'), + }, + }, + build: { + rollupOptions: { + input: { + index: resolve(__dirname, 'src/main/index.js'), + wemo: resolve(__dirname, 'src/main/wemo.js'), + store: resolve(__dirname, 'src/main/store.js'), + 'ipc/devices.ipc': resolve(__dirname, 'src/main/ipc/devices.ipc.js'), + 'ipc/rules.ipc': resolve(__dirname, 'src/main/ipc/rules.ipc.js'), + 'ipc/wifi.ipc': resolve(__dirname, 'src/main/ipc/wifi.ipc.js'), + 'ipc/system.ipc': resolve(__dirname, 'src/main/ipc/system.ipc.js'), + 'ipc/scheduler.ipc': resolve(__dirname, 'src/main/ipc/scheduler.ipc.js'), + scheduler: resolve(__dirname, 'src/main/scheduler.js'), + 'scheduler-standalone': resolve(__dirname, 'src/main/scheduler-standalone.js'), + 'service-manager': resolve(__dirname, 'src/main/service-manager.js'), + 'service-manager-sync': resolve(__dirname, 'src/main/service-manager-sync.js'), + 'web-server': resolve(__dirname, 'src/main/web-server.js'), + 'firewall': resolve(__dirname, 'src/main/firewall.js'), + 'core/sun': resolve(__dirname, 'src/main/core/sun.js'), + 'core/types': resolve(__dirname, 'src/main/core/types.js'), + }, + }, + }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: { + index: resolve(__dirname, 'src/preload/index.js'), + }, + }, + }, + }, + renderer: { + root: resolve(__dirname, 'src/renderer'), + plugins: [react()], + build: { + rollupOptions: { + input: { + index: resolve(__dirname, 'src/renderer/index.html'), + }, + }, + }, + }, +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..aea0ede --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,122 @@ +{ + "name": "dibby-wemo-manager", + "productName": "Dibby Wemo Manager", + "version": "2.0.0", + "private": true, + "description": "Belkin Wemo device manager – local control, no cloud required", + "author": "SRS IT", + "main": "out/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build && node scripts/bundle-standalone.js", + "build:win": "electron-vite build && node scripts/bundle-standalone.js && electron-builder --win --x64", + "build:linux": "electron-vite build && node scripts/bundle-standalone.js && electron-builder --linux --x64", + "build:linux:arm64": "electron-vite build && node scripts/bundle-standalone.js && electron-builder --linux --arm64", + "build:all": "electron-vite build && node scripts/bundle-standalone.js && electron-builder --win --x64 && electron-builder --linux --x64", + "preview": "electron-vite preview" + }, + "dependencies": { + "adm-zip": "^0.5.14", + "axios": "^1.7.0", + "node-windows": "^1.0.0-beta.8", + "sql.js": "^1.12.0", + "qrcode": "^1.5.4", + "ws": "^8.18.0", + "xml2js": "^0.6.2", + "xmlbuilder2": "^4.0.3" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.0", + "electron": "33.4.11", + "electron-builder": "^25.1.8", + "electron-vite": "^2.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "vite": "^5.4.10", + "zustand": "^5.0.1" + }, + "build": { + "appId": "com.srsit.dibbywemomanager", + "productName": "Dibby Wemo Manager", + "directories": { + "output": "dist" + }, + "win": { + "target": [ + { + "target": "portable", + "arch": [ + "x64" + ] + }, + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ], + "icon": "resources/icon.ico", + "signtoolOptions": { + "certificateFile": "resources/srsit-codesign.pfx", + "certificatePassword": "SRSITSign2024!", + "signingHashAlgorithms": ["sha256"] + } + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true + }, + "linux": { + "target": [ + { "target": "AppImage", "arch": ["x64"] }, + { "target": "deb", "arch": ["x64"] }, + { "target": "rpm", "arch": ["x64"] } + ], + "icon": "resources/icon.png", + "category": "Utility", + "synopsis": "Belkin Wemo device manager — local control, no cloud required", + "description": "Control Belkin Wemo smart switches and plugs. Set schedules, countdowns and away mode. No Belkin account or internet required.", + "maintainer": "SRS IT" + }, + "deb": { + "depends": ["libgtk-3-0", "libnotify4", "libnss3", "libxss1", "libxtst6", "xdg-utils", "libatspi2.0-0", "libuuid1"] + }, + "asarUnpack": [ + "**/node_modules/sql.js/dist/**", + "**/node_modules/node-windows/**", + "out/main/scheduler-standalone.js", + "out/main/wemo.js" + ], + "extraResources": [ + { + "from": "out/main/scheduler-standalone.js", + "to": "scheduler-standalone.js" + }, + { + "from": "../../node_modules/sql.js/dist/sql-wasm.wasm", + "to": "sql-wasm.wasm" + }, + { + "from": "resources/web", + "to": "web" + }, + { + "from": "resources/icon.png", + "to": "icon.png" + }, + { + "from": "resources/help.html", + "to": "help.html" + }, + { + "from": "resources/about.html", + "to": "about.html" + } + ], + "files": [ + "out/**/*" + ] + } +} diff --git a/apps/desktop/resources/about.html b/apps/desktop/resources/about.html new file mode 100644 index 0000000..b4ea69a --- /dev/null +++ b/apps/desktop/resources/about.html @@ -0,0 +1,121 @@ + + + + + + About Dibby Wemo Manager + + + +
+
+ Dibby Wemo Manager +
+

Dibby Wemo Manager

+
Version 2.0
+
+
+

+ Manage Belkin Wemo device rules — schedules, timers, and automations.
+ Local control only. No cloud required.
+ Scheduler runs in the background even when this window is closed. +

+
+ Developed by SRS IT  ·  Dedicated to Dibby ❤️ +
+ +
+ + diff --git a/apps/desktop/resources/help.html b/apps/desktop/resources/help.html new file mode 100644 index 0000000..db95106 --- /dev/null +++ b/apps/desktop/resources/help.html @@ -0,0 +1,1199 @@ + + + + + + Dibby Wemo Manager — Help + + + +
+ + + + + +
+
+ Dibby Wemo Manager +
+

Dibby Wemo Manager

+

Complete help guide — everything you need to know.

+ + +

What is Dibby Wemo Manager?

+

+ Dibby Wemo Manager lets you control Belkin Wemo smart switches and plugs on your + home network — entirely from your PC. You can turn lights on and off, set up schedules, + and automate your home without needing the Belkin app, a Belkin account, or any internet connection. +

+

+ Belkin's cloud service that used to power Wemo schedules was permanently shut down. + This app replaces it completely — all scheduling runs locally on your computer, so your + automations keep working even without internet. +

+ +
+
💡
+
Control lights & plugs
+
Turn any Wemo device on or off instantly from the app.
+
+
📅
+
Set schedules
+
Turn devices on/off at specific times, every day or selected days.
+
+
+
Countdown timers
+
Turn a device off automatically after a set number of minutes.
+
+
🏠
+
Away mode
+
Randomly turn lights on and off to make your home look occupied.
+
+
📱
+
Web Remote
+
Control devices and manage rules from any phone or tablet on your home network.
+
+
+ + +

First time setup

+

When you open the app for the first time, follow these steps:

+ +
    +
  1. Make sure your Wemo devices are powered on and connected to the same + Wi-Fi network as your computer.
  2. +
  3. Click the 🔍 Discover button in the top-left panel. The app scans your + network and finds all Wemo devices automatically. This takes about 10 seconds.
  4. +
  5. Your devices appear in the left panel. Click any device to see its details and controls.
  6. +
  7. Create your first rule by selecting a device, clicking the Rules tab, + then clicking + New Rule. Rules you create here are + DWM Rules — the scheduler knows to fire them automatically.
  8. +
  9. To keep your schedules running automatically — even when the app is closed or you're + not logged in — click ⬆ Install Service in the scheduler section + (see Always-on service below).
  10. +
+ +
+ Tip: If a device doesn't appear after scanning, make sure it's on the same + Wi-Fi network. You can also add it manually by clicking the + button and + entering its IP address. +
+ + +

Finding your devices

+

+ The app automatically finds Wemo devices on your local network using a process called + discovery. You don't need to know any IP addresses or technical details. +

+ +

Automatic discovery

+

Click 🔍 Discover at the top of the device list. The app broadcasts a signal + on your network and all Wemo devices respond within a few seconds.

+ +

Manual add

+

If a device isn't found automatically (for example, it's on a different network segment), + click the + button and enter the device's IP address and port number. + Your router's admin page usually shows connected device IP addresses.

+ +
+ Note: Discovered devices are remembered between app sessions. + You only need to run discovery again when you add new devices to your home. +
+ + +

System tray — the app keeps running when closed

+

+ When you click the ✕ close button on the app window, Dibby Wemo Manager + does not fully exit. Instead, it hides to the system tray + (the small icons in the bottom-right corner of your Windows taskbar) and continues running + in the background. +

+

+ This means the in-app scheduler keeps firing your rules even when the + window is hidden. A balloon notification briefly appears to let you know. +

+ +

Reopening the app from the tray

+

+ Find the Dibby Wemo Manager icon in the system tray and double-click it, + or right-click it and choose Open. +

+ +

Tray menu items

+

Right-clicking the tray icon shows the following items:

+ + + + + + + + + + + +
ItemWhat it does
🟢 / ⚫ Scheduler running/stoppedStatus indicator — not clickable
📱 Web Remote: http://…:3456Shows the URL your phone uses to connect — not clickable
Copy Web Remote URLCopies the web remote address to the clipboard
Open Web Remote in BrowserOpens the web remote in your PC's default browser for testing
📷 Show QR CodeOpens a small window with a scannable QR code for the web remote
🔓 Open Port in Windows FirewallAdds inbound firewall rules for port 3456 and the app executable — required for phone access. Also removes any auto-created block rules. Shows ✅ Firewall rule active once done.
🗑 Delete Firewall RuleRemoves the DWM firewall rules (only visible when the rule is active). Use this to reset and re-apply if phone access stops working.
OpenBrings the main app window back to the foreground
QuitFully closes the app and stops the scheduler
+ +

Fully quitting the app

+

+ Right-click the tray icon and choose Quit, or use + File → Quit (Ctrl+Q) from the menu bar. This fully closes the app + and stops the in-app scheduler. The tray icon disappears. +

+ +
+ Tip: For fully automatic scheduling with no window required at all, + install the Windows Service. It runs at boot + before anyone logs in, so rules fire 24/7 even if the app window is never opened. +
+ + +

Turning devices on and off

+

+ Click any device in the left panel to select it. In the main area you'll see a large + power button. Click it to toggle the device on or off. + The button shows green when the device is on, and grey when it's off. +

+

+ You can also toggle devices directly from the device list without clicking into them — + each device card has a small power indicator on the right side. +

+ + +

Device information

+

Select a device and click the Info tab to see all details about it:

+ + + + + + + + + + + +
FieldWhat it means
NameThe friendly name you gave the device (e.g. "Kitchen Lights")
ModelThe Wemo hardware model number
IP AddressThe device's address on your home network
MAC AddressA unique hardware ID for the device
Serial NumberThe device's factory serial number
FirmwareThe software version running on the device
Signal StrengthHow strong the Wi-Fi connection is (higher is better)
Device ClockThe time the device thinks it is — click Sync to correct it
+ +

Each field has a copy button (📋) so you can paste the value elsewhere if needed.

+ + +

Renaming a device

+

+ On the Info tab, click the pencil icon next to the device name to rename it. + The new name is saved directly to the device — it will show the same name in any Wemo app. +

+ + +

DWM Rules and Wemo Rules — what's the difference?

+

+ When you click the Rules tab for any device, you'll see two sub-tabs: + DWM Rules and Wemo Rules. Understanding the difference + is important. +

+ +
+
+
🔵
+
DWM Rules
+
+ Rules created and managed through this app, stored locally on your PC + (not on the Wemo device). These are the only rules the scheduler will fire. + When you click + New Rule, it creates a DWM rule saved to your computer. +
+
+
+
⚙️
+
Wemo Rules
+
+ All rules stored on your Wemo devices — including rules from the old Belkin iOS app. + You can enable/disable or edit them here and the + changes are written directly to the device. Duplicates are removed so each rule + appears once across all devices. +
+
+
+ +

Where are DWM Rules stored?

+

+ DWM Rules live in a local database file on your PC + (wemo-manager.json + in your AppData folder). Nothing is written to the Wemo device itself — the device is + only contacted when a rule fires (to send the on/off command). +

+

+ This means DWM rules travel with your PC, not with the device. If you replace a Wemo switch, + your rules stay intact — just point them at the new device. +

+ +

Why the separation?

+

+ Many Wemo devices have old rules from when Belkin's cloud was working — rules that no longer + fire because the cloud was shut down. The scheduler ignores those device-stored rules entirely. + Only rules in your local DWM database are scheduled. +

+ +
+ In short: Create or copy rules into DWM Rules and they + fire automatically via the scheduler. In the Wemo Rules tab you can + toggle or edit native device rules directly — but those rules are not fired by + the DWM scheduler. Use 📥 Add to DWM to bring them under scheduler control. +
+ + +

Schedule rule — turn on/off at a set time

+

+ A schedule rule turns a device on or off at a specific time of day, on the days you choose. + This is the most common type of rule. +

+ +

How to create a schedule rule

+
    +
  1. Select the device you want to schedule from the left panel.
  2. +
  3. Click the Rules tab. Make sure you're on the DWM Rules + sub-tab, then click + New Rule.
  4. +
  5. Choose Schedule as the rule type.
  6. +
  7. Enter a name for the rule (e.g. "Evening lights").
  8. +
  9. Pick which days of the week the rule should apply. Click a day to toggle it on/off. + Use the quick buttons to select All, Weekdays, or Weekends.
  10. +
  11. Set the Start time — this is when the device turns ON.
  12. +
  13. Set the End time — this is when the device turns OFF. + Leave it blank if you only want to turn the device on.
  14. +
  15. Click 💾 Save Rule.
  16. +
+ +
+ Example: To turn your porch light on at sunset every day and off at 11 PM — + set Start to Sunset and End to 11:00 PM, with all days selected. +
+ +
+ Sunrise / Sunset: Instead of a fixed time, you can choose Sunrise or Sunset. + The device calculates the actual time each day based on your location. Make sure your + location is set in Settings for this to work accurately. +
+ + +

Countdown rule — turn off after a timer

+

+ A countdown rule turns a device off (or on) after a set number of minutes from when the + physical button on the device is pressed. This is useful for things like: +

+
    +
  • A bathroom fan that turns off automatically after 20 minutes
  • +
  • A bedside lamp that turns off after you fall asleep
  • +
  • An electric heater that shuts off after an hour for safety
  • +
+ +

How to create a countdown rule

+
    +
  1. Select the device, go to the DWM Rules sub-tab, click + New Rule.
  2. +
  3. Choose Countdown as the rule type.
  4. +
  5. Enter a name and set the number of minutes.
  6. +
  7. Optionally enable an Active Window to restrict the rule to specific hours (see below).
  8. +
  9. Select the Target Devices this rule should control. Use All / None for quick selection.
  10. +
  11. Click 💾 Save Rule.
  12. +
+ +
+ Note: The countdown starts when someone physically presses the button on + the Wemo switch. It does not start automatically — a button press is required to trigger it. +
+ +

Active Window — restrict countdown to certain hours

+

+ Enable the Active Window toggle on a countdown rule to restrict when it + is in effect. When a window is set, the scheduler will: +

+
    +
  • Turn the device ON at the window start time.
  • +
  • Turn the device OFF at the window end time (regardless of the countdown).
  • +
  • The countdown auto-off fires normally in between — but outside the window, the rule has no effect.
  • +
+

+ This is useful when you want the countdown rule to coexist with a separate schedule rule + outside those hours. For example: a 1-hour auto-off that only applies between 8 AM and 5 PM, + while a different schedule rule controls the device overnight. +

+
+ Cross-midnight windows: If the window end time is earlier in the day than + the start time (e.g. 8:00 PM → 3:00 AM), the app detects this and schedules the OFF command + on the following calendar day automatically. +
+ + +

Away mode — make your home look occupied

+

+ Away mode randomly turns lights on and off within a time window you set. This mimics + natural activity and is a great security feature when you're on holiday or away for the evening. +

+

+ For example, you could set a living room lamp to randomly switch on and off between + 7 PM and 10 PM. The DWM scheduler handles all randomisation — the device + turns on for 30–90 minutes at a time, then off for 1–15 minutes, cycling throughout the window. +

+ +
+ Requires the scheduler to be running. Away Mode randomisation is performed by + the DWM Scheduler on your PC — it is not stored on the Wemo device firmware. The app + (or Windows Service) must be running during the window for the rule to fire. +
+ +

How to create an away mode rule

+
    +
  1. Go to the DWM Rules sub-tab, click + New Rule.
  2. +
  3. Choose Away Mode as the rule type.
  4. +
  5. Set the Window Start and Window End — either a fixed time + or Sunrise / Sunset (with optional offset). For example: Start = Sunset, End = 10:30 PM.
  6. +
  7. Choose which Active Days the rule applies.
  8. +
  9. Select the Target Devices to control. Use All / None + for quick selection — Away Mode can randomise multiple devices simultaneously.
  10. +
  11. Click 💾 Save Rule.
  12. +
+ +
+ Sunrise / Sunset windows: Instead of a fixed time, the window start or end + can be set to Sunrise or Sunset with a minute offset. Make sure your location is configured + in ⚙️ Settings for accurate solar times. +
+ +
+ Mid-session restart: If you restart the app while an Away Mode window is + already active, the scheduler automatically resumes the randomisation loop from where it left + off — it does not wait until the next day's window start. +
+ + +

Always On rule — keep a device on at all times

+

+ An Always On rule tells the health monitor to check the device every + 10 seconds and turn it back on immediately if it is found to be off. This is useful for + devices that should never be manually switched off (e.g. a network switch, a refrigerator + plug, an always-lit lamp). +

+ +

How to create an Always On rule

+
    +
  1. Go to the DWM Rules sub-tab, click + New Rule.
  2. +
  3. Choose Always On as the rule type.
  4. +
  5. Select the Target Device(s) to keep on.
  6. +
  7. Click 💾 Save Rule.
  8. +
+ +
+ How enforcement works: The health monitor polls every 10 seconds. + If the device reports BinaryState=0, a SetBinaryState=1 command + is sent immediately and a [always-on] entry appears in the event log. +
+ + +

Trigger rule — if this device does X, do Y on another

+

+ A Trigger rule links two or more devices: when one device (the + trigger device) changes state, the scheduler automatically acts on one or more + other devices (the action devices). This is similar to IFTTT but runs entirely + on your local network — no cloud, no accounts. +

+ +

Example uses

+
    +
  • When the hallway switch turns ON, also turn ON the staircase light.
  • +
  • When the master switch turns OFF, turn OFF all room lights.
  • +
  • When any switch changes, mirror its state on a second switch.
  • +
+ +

How to create a Trigger rule

+
    +
  1. Go to the DWM Rules sub-tab, click + New Rule.
  2. +
  3. Choose Trigger as the rule type.
  4. +
  5. Select the Trigger Device — the device whose state change starts the chain.
  6. +
  7. Choose When it…: Turns ON / Turns OFF / Changes (either way).
  8. +
  9. Choose Then…: Turn ON / Turn OFF / Mirror state / Opposite state.
  10. +
  11. Select one or more Action Devices — these will receive the command.
  12. +
  13. Click 💾 Save Rule.
  14. +
+ +
+ How detection works: The health monitor polls every 10 seconds. + When it detects a state change on the trigger device it fires the action immediately + — typical response time is under 10 seconds from the physical toggle. +
+ +
+ Requires the scheduler to be running. Trigger rules are evaluated by + the DWM Scheduler on your PC — they are not stored on the Wemo device firmware. + Start the scheduler (or install the Windows Service) to keep triggers active at all times. +
+ + +

Managing rules

+ +

Editing a rule

+

In the DWM Rules tab, click the pencil icon (✏️) next to + any rule to open it and make changes.

+ +

Deleting a rule

+

Click the trash icon (🗑) next to a rule and confirm the deletion.

+ +

Enabling and disabling a rule

+

+ Each rule has an on/off toggle. Disabling a rule stops it from firing without deleting it — + useful if you want to pause a schedule temporarily (e.g. during the holidays) and turn it + back on later. +

+ +

Testing a rule

+

+ Click the ▶ Test button on any DWM rule to immediately turn the device on, + so you can verify the rule targets the right device. +

+ +

Refreshing the rule list

+

+ Click ⟳ Refresh to reload DWM rules from the local database. + Since rules are stored on your PC — not on the device — this is only needed if you + suspect the display is out of sync. +

+ + +

Managing Wemo Rules — edit, toggle, and copy to DWM

+

+ The Wemo Rules tab shows all rules stored directly on your Wemo devices, + including any created by the old Belkin iOS app. You can manage them from here without + leaving the app. +

+ +

Enable or disable a Wemo rule

+

+ Each rule card has a toggle switch on the right. Flipping it sets the rule's + State + field directly in the Wemo device's rule database — the schedule is preserved so you can + re-enable it at any time. If the same rule appears on multiple devices (deduplicated), + it is toggled on all of them simultaneously. +

+ +

Edit a Wemo rule

+

+ Click the ✏️ button on any rule card to open the rule editor. Changes are + written directly back to the Wemo device when you save. Use this to adjust times, days, or + actions for rules that were originally created in the Belkin app. Run a device scan first + if you see a "Device not found" message — the editor needs the device to be in the + discovered list. +

+ +

Copy a Wemo rule to DWM

+

+ If you previously had rules set up using the old Belkin Wemo app, those rules still exist + on your devices — they appear here but are not fired by the DWM scheduler. + You can bring them under scheduler control in one click. +

+ +
    +
  1. Click the Rules tab (any device), then click Wemo Rules.
  2. +
  3. Click ⟳ Load All Rules to fetch rules from all devices on your network.
  4. +
  5. Find the rule you want. Rules already in DWM show a + DWM badge — these don't need to be copied.
  6. +
  7. For any rule without a DWM badge, click + 📥 Add to DWM.
  8. +
  9. The rule is saved to your local DWM database and immediately + appears in the DWM Rules tab. The scheduler will fire it automatically.
  10. +
+ +
+ What gets copied? The rule's name, type, schedule, days, on/off actions, + and the devices it targets are all copied into your local DWM database. + Nothing is written to the Wemo device. + The original device rule remains unchanged. +
+ +
+ Tip: After copying, you can edit the DWM version of the rule + (click ✏️ in the DWM Rules tab) to adjust the target devices, time, or days. +
+ + +

Importing and exporting rules

+

+ You can back up your entire DWM rules database to a file and restore it at any time. + Because DWM rules are stored locally (not on the device), the export captures all your rules + in one file — regardless of which devices they target. +

+ +

Exporting rules

+

+ In the DWM Rules tab, click ↓ JSON or ↓ CSV. +

+
    +
  • JSON — best for backing up and restoring. Preserves all rule details including target devices.
  • +
  • CSV — best if you want to view rules in Excel or share a summary.
  • +
+ +

Importing rules

+

+ Click ↑ Import, select a JSON file, and the rules are added to your local + DWM database immediately — ready for the scheduler. +

+ +
+ Moving to a new PC? Export your rules to JSON on the old PC, copy the file + to the new PC, open the app there, and import the JSON. All your schedules are restored + instantly — no reconfiguration needed. +
+ + +

The Scheduler — how it works

+

+ Belkin's cloud service (which used to trigger Wemo rules) was permanently shut down. + Without it, rules stored on the device simply don't fire — the device waits for a cloud + signal that never comes. +

+

+ Dibby Wemo Manager's Scheduler replaces the cloud entirely. + It runs on your PC, reads your DWM Rules from the local database, and sends + the on/off commands at exactly the right time — all on your local network with no + internet required. +

+

+ The scheduler reads directly from the app's local rule database — it does not need to + contact Wemo devices to load rules. Only when a rule fires does the scheduler send a + command to the target device. +

+ +
+ Important: The scheduler only fires DWM Rules — rules in the + app's local database. Rules that appear only in the Wemo Rules tab + (created by the old Belkin iOS app or stored directly on the device) are intentionally + ignored. Use 📥 Add to DWM to bring any + of them under scheduler control. +
+ +

Starting the in-app scheduler

+

+ In the left panel, click ⏱ In-app sched. OFF to start the scheduler. + The label turns green and shows your next scheduled events for today. +

+

+ When you minimise or close the app window, the scheduler keeps running + in the background (the app hides to the system tray). Only choosing + Quit from the tray menu fully stops it. +

+ +

The "Next fires today" panel

+

+ When the scheduler is running, click the button next to it to expand + a list of all DWM rule actions scheduled for today — showing the rule name, time, and + whether it will turn the device ON or OFF. +

+

+ This list shows each unique action once (even if the same rule exists on multiple devices) + and updates automatically whenever you save, change, or delete a rule. +

+ +

Scheduler notifications

+

+ Each time the scheduler fires a rule, a small notification appears in the bottom-right + corner of the app. A green notification means it worked. A red notification means + the device was unreachable — check that it's still powered on and connected to Wi-Fi. +

+

+ The scheduler also verifies each action: 3 seconds after sending the + on/off command, it checks the device's actual state. If it didn't change, it retries + automatically one more time. +

+ + +

Always-on service — rules fire with no login required

+

+ The Windows Service is the recommended way to run the scheduler for a + home that's always automated. Once installed, the service: +

+
    +
  • Starts automatically every time your computer turns on — before you even log in
  • +
  • Runs in the background with no window open
  • +
  • Fires all your DWM schedules reliably, 24/7
  • +
  • Restarts itself automatically if it stops unexpectedly
  • +
+ +
+ Windows Service vs In-app scheduler: Both only fire DWM Rules. + The difference is when they run. The in-app scheduler runs only while the app window is + open (or hidden to tray). The Windows Service runs constantly, even when no one is logged in. + For a house you want automated 24/7, use the Windows Service. +
+ +

Installing the service (one-time setup)

+
    +
  1. First, make sure you've discovered your devices — click 🔍 Discover + and wait for them to appear.
  2. +
  3. In the left panel, click the service status area (it shows a grey dot and + "Service: not installed").
  4. +
  5. Click ⬆ Install Service.
  6. +
  7. Windows may ask for administrator permission — click Yes.
  8. +
  9. After a few seconds the dot turns green and + shows "Service: running". That's it — you're done.
  10. +
+ +
+ After adding new devices or changing your network: Click + 🔄 Sync device list to service to make sure the service knows about + the updated devices. Always do this after discovering new devices. +
+ +

Service status indicators

+ + + + + + + + + + + + + +
Service: runningEverything is working. DWM rules will fire automatically.
Service: stoppedThe service is installed but not running. Click Start.
Service: not installedNot yet set up. Click Install Service.
+ +

Removing the service

+

+ If you no longer want the service running, click the service status area to expand it, + then click 🗑 Remove. The service will be stopped and uninstalled. + Your rules remain on the devices — only the background scheduler is removed. +

+ + +

Web Remote — phone & tablet access

+ +

+ Dibby Wemo Manager includes a built-in web server that lets you control your devices and + manage DWM rules from any phone, tablet, or browser on the same local WiFi + network — no installation required on the phone. +

+ +

How to connect

+
    +
  1. Make sure the DWM desktop app is running on your PC.
  2. +
  3. Right-click the DWM tray icon in the system tray.
  4. +
  5. Note the URL shown: 📱 Web Remote: http://192.168.x.x:3456
  6. +
  7. Type that address into your phone's browser, or scan the QR code (see below).
  8. +
+ +
+ Same network required: Your phone must be connected to the same WiFi network + as the PC running DWM. The web remote is not accessible from outside your home network. +
+ +
+ Port in use? The web remote starts on port 3456 by default. If that port is + already occupied by another program, DWM automatically tries 3457, 3458 … up to 3465 until + it finds a free port. The tray menu always shows the actual URL in use — check it if + 3456 doesn't work. +
+ +

What the web remote can do

+
    +
  • Devices tab — lists all saved devices with large on/off toggle switches. + Tap ⟳ Scan to discover new devices.
  • +
  • Rules tab — shows all DWM rules with enable/disable toggles.
  • +
  • Status tab — live scheduler status and a real-time event log showing + every rule that fires (updates instantly via WebSocket).
  • +
+ +

Compatible clients — what can connect

+

+ The web remote is a standard HTTP server — any device with a modern browser on the same + local network can connect. No app or software installation needed on the client. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Client OS / DeviceHow to accessNotes
Android phone / tabletChrome, Firefox, Edge — scan QR code or type URLTested. Full touch UI with large toggles.
iPhone / iPad (iOS)Safari, Chrome — scan QR code or type URLFull support. Add to home screen via Share → Add to Home Screen for app-like access.
Windows PC / laptopAny browser — or use Open Web Remote in Browser from trayWorks on the same PC running DWM via localhost:3456.
Linux (Ubuntu, Fedora, etc.)Any browser on the same networkFull support. No drivers or agents needed.
Raspberry PiChromium or any browser on the PiUseful as a wall-mounted control panel. Run in kiosk mode for a dedicated display.
Linux Server / DockerREST API over HTTP (headless)Use curl or any HTTP client to control devices and rules programmatically.
Python 3.10+ (any OS)REST API via requests librarySee REST API section below for endpoint reference.
macOSSafari, Chrome, FirefoxFull browser support.
+ +

REST API — programmatic access

+

+ Every web remote action is available as a plain HTTP JSON API. The DWM app must be running on + the Windows PC. Base URL: http://<PC-IP>:3456 +

+ + + + + + + + +
MethodEndpointDescription
GET/api/devicesList all saved devices with current on/off state
POST/api/devices/discoverTrigger a network scan for new devices
POST/api/devices/:host/:port/stateTurn device on/off. Body: {"state":1} (1=on, 0=off)
GET/api/dwm-rulesList all DWM scheduler rules
PATCH/api/dwm-rules/:idEnable/disable a rule. Body: {"enabled":true}
GET/api/scheduler/statusCurrent scheduler status and next scheduled events
+

Python example:

+
import requests
+
+BASE = 'http://192.168.1.100:3456'
+
+# List devices
+devices = requests.get(f'{BASE}/api/devices').json()
+
+# Turn on a device
+requests.post(f'{BASE}/api/devices/192.168.1.50/49153/state', json={'state': 1})
+
+# Disable a DWM rule
+requests.patch(f'{BASE}/api/dwm-rules/42', json={'enabled': False})
+ +

QR code setup

+

+ The easiest way to connect a phone is to scan the QR code: +

+
    +
  1. Right-click the DWM tray icon.
  2. +
  3. Click 📷 Show QR Code.
  4. +
  5. A small window opens showing a scannable QR code for the web remote URL.
  6. +
  7. Point your phone camera at the QR code — tap the notification to open the remote.
  8. +
+ +
+ Tray shortcuts: The tray menu also has Copy Web Remote URL + (puts the URL on the clipboard) and Open Web Remote in Browser + (opens on the PC so you can test it). +
+ +

WiFi setup — connecting a device to your network

+

+ If you've just bought a new Wemo device, or your Wi-Fi network name/password has changed, + you can reconnect devices from the WiFi tab. +

+
    +
  1. Select the device and click the WiFi tab.
  2. +
  3. Click Scan Networks to see available Wi-Fi networks near the device.
  4. +
  5. Click your home network, or type the name manually.
  6. +
  7. Enter your Wi-Fi password and click Connect.
  8. +
  9. The app shows Connected once the device + joins the network successfully.
  10. +
+ +
+ Note: During the connection process the device temporarily creates its + own Wi-Fi hotspot. Your computer may briefly disconnect from your home network. + This is normal and it reconnects automatically. +
+ + +

Reset options

+

+ On the Info tab, scroll down to find three reset options. Use these + with care — they cannot be undone. +

+ + + + + + + + + + + + + + + + + + +
OptionWhat it doesWhen to use it
Clear DataWipes all rules and settings from the deviceWhen you want a clean start without losing Wi-Fi
Clear WiFiRemoves the saved Wi-Fi network from the deviceBefore moving a device to a different network
Factory ResetWipes everything — rules, settings, and Wi-FiWhen selling or giving away the device
+ +
+ Reboot Device: The ⏻ Reboot Device button on the Rules + tab restarts the device remotely. This is occasionally needed to refresh the device's + internal state. +
+ + +

Tips & troubleshooting

+ +

A device shows as offline

+
    +
  • Check the device is plugged in and the LED is on.
  • +
  • Make sure your PC and the device are on the same Wi-Fi network.
  • +
  • Try clicking 🔍 Discover again — sometimes devices take a moment to respond.
  • +
  • If it still doesn't appear, try unplugging the device, waiting 10 seconds, and plugging it back in.
  • +
+ +

A scheduled rule didn't fire

+
    +
  • Make sure the rule is in the DWM Rules tab — only DWM rules are + fired by the scheduler. If it only appears in Wemo Rules, use + Copy to DWM Rules to bring it under + scheduler control.
  • +
  • Make sure the Scheduler is running (green dot in the left panel) — + or the Windows Service is installed and running.
  • +
  • Check that the rule is enabled (the toggle next to it is on).
  • +
  • Verify the rule's days include today.
  • +
  • Check the rule's time — it may have already passed for today.
  • +
  • Make sure the device is online and reachable when the rule fires.
  • +
+ +

Rules show in the list but don't run

+

+ If the rules are in the Wemo Rules tab but not the DWM Rules + tab, they were created by the old Wemo iOS app and are not managed by this scheduler. + Go to the Wemo Rules tab and click 📥 Add to DWM on each rule you want + to schedule. They will then appear in DWM Rules and fire automatically. +

+ +

"Next fires today" shows no entries

+

+ This means no DWM rules are scheduled for later today. Possible reasons: +

+
    +
  • No DWM rules have been created yet — create rules in the DWM Rules tab, or copy + them from the Wemo Rules tab.
  • +
  • All of today's rules have already fired (it's late in the day).
  • +
  • The rules exist in Wemo Rules but haven't been copied to DWM Rules yet.
  • +
+ +

Windows asks "Do you want to allow Electron to access networks?"

+

+ This prompt appears the first time the app's built-in web server starts. Click + Allow. If you clicked Cancel by mistake, use the tray menu option below. +

+
+ The dialog shows "Publisher: GitHub, Inc." because the Electron framework is signed + by GitHub. This is normal — it does not mean the app is from GitHub. DWM is published by SRS IT. +
+ +

My phone can't reach the web remote after clicking Allow

+

+ Windows Firewall sometimes needs an explicit inbound rule. Right-click the DWM tray icon + and click 🔓 Open Port in Windows Firewall. A UAC elevation prompt will + appear — click Yes to let the app add the rule. The tray item changes to + ✅ Firewall rule active once it succeeds. Try the web remote URL again + on your phone. +

+ +

Firewall rule shows ✅ active but phone still can't connect

+

+ When Windows first asks "Do you want to allow Electron to access networks?" and you + click Block (or dismiss the prompt), Windows creates hidden block rules named + "Electron" in Windows Firewall. These block rules override any port-based allow + rules, so the port remains closed even though the DWM rule appears active. +

+

+ Quick fix — one step: Click 🔓 Open Port in Windows Firewall + from the DWM tray icon. This automatically: +

+
    +
  • Removes the Electron block rules
  • +
  • Removes any previous DWM allow rules
  • +
  • Creates fresh inbound allow rules for both the port and the app executable
  • +
  • Ensures the Private/Domain/Public profile isn't set to block all inbound connections
  • +
+

After the UAC prompt completes, the tray item shows ✅ Firewall rule active. Try your phone again.

+
+ Manual verification: Open Windows Defender Firewall with Advanced Security + (press Win+R, type wf.msc) → Inbound Rules. After running the tray option, + there should be no entry named Electron with a red ❌ block icon, and two entries named + DWM Web Remote (port rule) and DWM Web Remote (App) (application rule) with green ✔ allow icons. +
+
+ Confirmed fix: If turning off the Windows Private Firewall entirely makes your phone + connect, but it still fails with firewall on, the Electron block rules are the cause. + The 🔓 Open Port in Windows Firewall tray option was specifically designed to fix this. +
+ +

The web remote URL opens but the page is blank or says "Not found"

+
    +
  • Make sure Windows Firewall allowed access (see above).
  • +
  • Check the tray menu for the actual URL — the port may have shifted from 3456 to 3457 etc. if another program used 3456.
  • +
  • Try opening the URL on the same PC first (use Open Web Remote in Browser from the tray) to verify the server is running.
  • +
+ +

The app can't find any devices

+
    +
  • Ensure your PC is on the same network as the devices (not a guest network).
  • +
  • Some corporate or advanced home routers block device discovery signals. + Try adding devices manually using their IP address.
  • +
  • Check that Windows Firewall isn't blocking the app.
  • +
+ +

A device was replaced — how do I keep my rules?

+

+ Because DWM rules are stored locally on your PC (not on the Wemo device), your rules are + completely unaffected when you replace hardware. Just: +

+
    +
  1. Set up the new device on your Wi-Fi and discover it in the app.
  2. +
  3. Open the DWM Rules tab, find any rules that targeted the old device, and click ✏️ Edit.
  4. +
  5. In the Target Devices picker, deselect the old device and select the new one.
  6. +
  7. Save — the scheduler immediately starts using the new device's address.
  8. +
+
+ Tip: You can also export your rules to JSON as a backup before any + hardware change — use ↓ JSON in the DWM Rules tab. +
+ +

I see the same rule many times in the Wemo Rules tab

+

+ The Wemo Rules tab automatically removes duplicates. If a rule appears on multiple devices + (which is normal — the old Wemo app synced rules to all devices), it shows as a single + entry with small 📍 Device Name chips listing every device that holds it. +

+ +
+ Dedicated to Dibby ❤️
+ Dibby Wemo Manager was built with love. If something isn't working as expected, + the app is always being improved — check for updates regularly. +
+ +
+
+ + + + diff --git a/apps/desktop/resources/icon.ico b/apps/desktop/resources/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..34a33ba398a88f8f0c9e35fe0b0eb462d0bd92af GIT binary patch literal 53144 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 + + + + +DWM Remote + + + + +

+ + +
+
+
+

Devices

+ +
+ +
📡

Tap Scan to discover devices.

+
+ +
+
+

DWM Rules

+
+ + +
+
📅

Loading rules…

+
+ +
+
+

Wemo Rules

+ +
+

Select a device above to view its rules.

+
+ +
+

More

+
+ DWM +
+
Dibby Wemo Manager
+
Version 2.0  ·  Developed by SRS IT
+
Local control only. No cloud required.
+
Dedicated to Dibby ❤️
+
+
+ +
+ 📖 +
+
Help & Documentation
+
Guides, troubleshooting, REST API reference
+
+ +
+
+
+
🌐 Web Server
+ + + + +
URL
Port
Socket
+
+
+
📡 REST API Quick Reference
+ + + + + + + + + +
GET /api/devicesList devices
POST /api/devices/discoverScan network
POST /api/devices/:ip/:port/stateToggle power {"on":true}
GET /api/dwm-rulesList DWM rules
POST /api/dwm-rulesCreate rule
PUT /api/dwm-rules/:idUpdate rule
DELETE /api/dwm-rules/:idDelete rule
GET /api/scheduler/statusScheduler state
+
+
+ Web remote served by the DWM desktop app on your PC. +
+
+ +
+

Scheduler

+
+ + Unknown +
+
Live event log (via WebSocket)
+
Waiting for events…
+
+
+ + + + + + + + +
+
+
Delete Rule?
+
This cannot be undone.
+
+ + +
+
+
+ + + + diff --git a/apps/desktop/scripts/bundle-standalone.js b/apps/desktop/scripts/bundle-standalone.js new file mode 100644 index 0000000..6e4c611 --- /dev/null +++ b/apps/desktop/scripts/bundle-standalone.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Bundles scheduler-standalone.js into a self-contained Node.js file + * using esbuild. Overwrites the electron-vite output so electron-builder + * picks up the fully-bundled version (no node_modules required at runtime). + * + * Run after `electron-vite build`: + * node scripts/bundle-standalone.js + */ + +const esbuild = require('esbuild'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); + +esbuild.build({ + entryPoints: [path.join(ROOT, 'src/main/scheduler-standalone.js')], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: path.join(ROOT, 'out/main/scheduler-standalone.js'), + // Only keep true Node.js built-ins external — everything else (axios, adm-zip, + // xml2js, xmlbuilder2, sql.js, @wemo-manager/core …) gets bundled inline. + external: [ + 'electron', + 'fs', 'path', 'os', 'http', 'https', 'net', 'dgram', + 'crypto', 'zlib', 'stream', 'events', 'url', 'util', + 'child_process', 'cluster', 'worker_threads', 'assert', + 'buffer', 'string_decoder', 'querystring', 'readline', + 'tty', 'v8', 'vm', 'module', 'perf_hooks', 'timers', + ], + minify: false, + sourcemap: false, +}).then(() => { + console.log('[bundle-standalone] ✅ out/main/scheduler-standalone.js written'); +}).catch((e) => { + console.error('[bundle-standalone] ❌ Failed:', e.message); + process.exit(1); +}); diff --git a/apps/desktop/src/main/core/sun.js b/apps/desktop/src/main/core/sun.js new file mode 100644 index 0000000..feaa5f1 --- /dev/null +++ b/apps/desktop/src/main/core/sun.js @@ -0,0 +1,81 @@ +'use strict'; + +/** + * Calculate sunrise and sunset times for a given location and date. + * Pure JS – no external dependencies. + * + * Algorithm: NOAA Solar Calculator (Jean Meeus, Astronomical Algorithms). + * + * @param {number} lat Latitude in decimal degrees (positive = North) + * @param {number} lng Longitude in decimal degrees (positive = East) + * @param {Date} date Date to calculate for (default: today) + * @returns {{ sunrise: number|null, sunset: number|null }} + * Times as integer seconds from LOCAL midnight. + * null for each value if polar day or polar night. + */ +function sunTimes(lat, lng, date = new Date()) { + const D2R = Math.PI / 180; + const R2D = 180 / Math.PI; + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const A = Math.floor((14 - month) / 12); + const Y = year + 4800 - A; + const M = month + 12 * A - 3; + const JDN = day + Math.floor((153 * M + 2) / 5) + 365 * Y + + Math.floor(Y / 4) - Math.floor(Y / 100) + Math.floor(Y / 400) - 32045; + const JD = JDN - 0.5; + + const T = (JD - 2451545.0) / 36525.0; + + let L0 = 280.46646 + T * (36000.76983 + T * 0.0003032); + L0 = ((L0 % 360) + 360) % 360; + + let Mdeg = 357.52911 + T * (35999.05029 - 0.0001537 * T); + Mdeg = ((Mdeg % 360) + 360) % 360; + const Mrad = Mdeg * D2R; + + const C = (1.914602 - T * (0.004817 + 0.000014 * T)) * Math.sin(Mrad) + + (0.019993 - 0.000101 * T) * Math.sin(2 * Mrad) + + 0.000289 * Math.sin(3 * Mrad); + + const omega = 125.04 - 1934.136 * T; + const lambda = (L0 + C) - 0.00569 - 0.00478 * Math.sin(omega * D2R); + + const eps0 = 23.0 + + (26.0 + (21.448 - T * (46.8150 + T * (0.00059 - T * 0.001813))) / 60.0) / 60.0; + const eps = (eps0 + 0.00256 * Math.cos(omega * D2R)) * D2R; + + const sinDec = Math.sin(eps) * Math.sin(lambda * D2R); + const decl = Math.asin(sinDec); + + const e = 0.016708634 - T * (0.000042037 + 0.0000001267 * T); + const y = Math.pow(Math.tan(eps / 2), 2); + const EqT = 4 * R2D * ( + y * Math.sin(2 * L0 * D2R) + - 2 * e * Math.sin(Mrad) + + 4 * e * y * Math.sin(Mrad) * Math.cos(2 * L0 * D2R) + - 0.5 * y * y * Math.sin(4 * L0 * D2R) + - 1.25 * e * e * Math.sin(2 * Mrad) + ); + + const cosHA = (Math.cos(90.833 * D2R) - Math.sin(lat * D2R) * sinDec) + / (Math.cos(lat * D2R) * Math.cos(decl)); + + if (cosHA < -1 || cosHA > 1) { + return { sunrise: null, sunset: null }; + } + + const HA = Math.acos(cosHA) * R2D; + const tzOffsetMin = -date.getTimezoneOffset(); + const solarNoon = 720.0 - 4.0 * lng - EqT + tzOffsetMin; + + return { + sunrise: Math.round((solarNoon - HA * 4.0) * 60), + sunset: Math.round((solarNoon + HA * 4.0) * 60), + }; +} + +module.exports = { sunTimes }; diff --git a/apps/desktop/src/main/core/types.js b/apps/desktop/src/main/core/types.js new file mode 100644 index 0000000..9c4691f --- /dev/null +++ b/apps/desktop/src/main/core/types.js @@ -0,0 +1,76 @@ +'use strict'; + +/** Day numbers: 1=Monday ... 7=Sunday (Wemo convention) */ +const DAY_NUMBERS = { Monday:1, Tuesday:2, Wednesday:3, Thursday:4, Friday:5, Saturday:6, Sunday:7 }; +const DAY_NAMES = { 1:'Monday', 2:'Tuesday', 3:'Wednesday', 4:'Thursday', 5:'Friday', 6:'Saturday', 7:'Sunday' }; +const DAY_SHORT = { 1:'Mon', 2:'Tue', 3:'Wed', 4:'Thu', 5:'Fri', 6:'Sat', 7:'Sun' }; + +/** Rule types stored in RULES.Type */ +const RULE_TYPES = { + SCHEDULE: 'Schedule', + AWAY: 'Away', + COUNTDOWN: 'Countdown', + LONG_PRESS: 'Long Press', +}; + +/** Start/End action values */ +const ACTIONS = { ON: 1.0, OFF: 0.0, TOGGLE: 2.0, NONE: -1.0 }; + +/** Network status codes returned by GetNetworkStatus */ +const NETWORK_STATUS = { FAILED: '0', SUCCESS: '1', WRONG_PASSWORD: '2', CONNECTING: '3' }; + +/** Wemo device reset codes for ReSetup action */ +const RESET_CODES = { CLEAR_DATA: 1, FACTORY_RESET: 2, CLEAR_WIFI: 5 }; + +/** Default RULEDEVICES field values */ +const RD_DEFAULTS = { + GroupID: 0, + RuleDuration: 0, + StartAction: 1.0, + EndAction: -1.0, + SensorDuration: 2, + Type: -1, + Value: -1, + Level: -1, + ZBCapabilityStart: '', + ZBCapabilityEnd: '', + OnModeOffset: -1, + OffModeOffset: -1, + CountdownTime: 0, + EndTime: -1, +}; + +/** Sun time sentinel codes stored in RULEDEVICES.StartTime / EndTime */ +const SUN_CODES = { SUNRISE: -2, SUNSET: -3 }; + +function namesToDayNumbers(names) { + return names.map((n) => DAY_NUMBERS[n]).filter(Boolean).sort((a, b) => a - b); +} + +function dayNumbersToNames(numbers) { + return numbers.map((n) => DAY_NAMES[n]).filter(Boolean); +} + +function dayNumbersToShort(numbers) { + return numbers.map((n) => DAY_SHORT[n]).filter(Boolean); +} + +function timeToSecs(hhmm) { + if (!hhmm || !hhmm.includes(':')) return 0; + const [h, m] = hhmm.split(':').map(Number); + return h * 3600 + m * 60; +} + +function secsToHHMM(secs) { + if (secs === undefined || secs === null || secs < 0) return '00:00'; + const h = Math.floor(secs / 3600) % 24; + const m = Math.floor((secs % 3600) / 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +module.exports = { + DAY_NUMBERS, DAY_NAMES, DAY_SHORT, + RULE_TYPES, ACTIONS, NETWORK_STATUS, RESET_CODES, RD_DEFAULTS, SUN_CODES, + namesToDayNumbers, dayNumbersToNames, dayNumbersToShort, + timeToSecs, secsToHHMM, +}; diff --git a/apps/desktop/src/main/firewall - Copy.js b/apps/desktop/src/main/firewall - Copy.js new file mode 100644 index 0000000..6cf44b5 --- /dev/null +++ b/apps/desktop/src/main/firewall - Copy.js @@ -0,0 +1,72 @@ +'use strict'; + +const { exec } = require('child_process'); + +const RULE_NAME = 'DWM Web Remote'; + +/** + * Check whether the inbound firewall rule exists and is enabled. + * Calls back with (err, isActive). + */ +function checkRule(callback) { + const cmd = `powershell -NoProfile -Command "` + + `$r = Get-NetFirewallRule -DisplayName '${RULE_NAME}' -ErrorAction SilentlyContinue; ` + + `if ($r) { $r.Enabled } else { 'Missing' }"`; + exec(cmd, (err, stdout) => { + if (err) return callback(null, false); + callback(null, stdout.trim() === 'True'); + }); +} + +/** + * Create (or replace) an inbound TCP rule for the given port. + * Also removes any auto-created block rules for node/electron executables. + * Runs elevated via Start-Process -Verb RunAs, which triggers a UAC prompt. + * Calls back with (err). + */ +function openPort(port, execPath, callback) { + if (typeof execPath === 'function') { callback = execPath; execPath = null; } + + const appPath = (execPath || process.execPath).replace(/\\/g, '\\\\'); + + // Build the PowerShell block to run elevated + const script = [ + `$ErrorActionPreference='SilentlyContinue'`, + // Remove existing DWM rule + `Get-NetFirewallRule -DisplayName '${RULE_NAME}' | Remove-NetFirewallRule`, + // Remove Windows auto-generated block rules for Electron/node executables + `Get-NetFirewallRule -Direction Inbound -Action Block | Where-Object { $_.DisplayName -like '*Electron*' -or $_.DisplayName -like '*node*' -or ((Get-NetFirewallApplicationFilter -AssociatedNetFirewallRule $_ -ErrorAction SilentlyContinue).Program -eq '${appPath}') } | Remove-NetFirewallRule`, + // Create new allow-by-port rule + `New-NetFirewallRule -DisplayName '${RULE_NAME}' -Direction Inbound -Protocol TCP` + + ` -LocalPort ${port} -Action Allow -Profile Any` + + ` -Description 'Dibby Wemo Manager Web Remote (port ${port})'`, + // Create allow-by-application rule to handle Windows app-level blocking + `New-NetFirewallRule -DisplayName '${RULE_NAME} (App)' -Direction Inbound -Program '${appPath}'` + + ` -Action Allow -Profile Any` + + ` -Description 'Dibby Wemo Manager Web Remote — app rule'`, + // Ensure Private/Domain profiles don't have "Block all inbound" override set + `Set-NetFirewallProfile -Profile Private,Domain,Public -AllowInboundRules True`, + ].join('; '); + + // Base64-encode to avoid shell-quoting issues + const encoded = Buffer.from(script, 'utf16le').toString('base64'); + + const elevateCmd = `powershell -NoProfile -Command ` + + `"Start-Process powershell -ArgumentList '-NoProfile -EncodedCommand ${encoded}' -Verb RunAs -Wait"`; + + exec(elevateCmd, callback); +} + +/** + * Delete the inbound firewall rule (elevated). + * Calls back with (err). + */ +function deleteRule(callback) { + const script = `$ErrorActionPreference='SilentlyContinue'; Get-NetFirewallRule -DisplayName '${RULE_NAME}' | Remove-NetFirewallRule; Get-NetFirewallRule -DisplayName '${RULE_NAME} (App)' | Remove-NetFirewallRule`; + const encoded = Buffer.from(script, 'utf16le').toString('base64'); + const elevateCmd = `powershell -NoProfile -Command ` + + `"Start-Process powershell -ArgumentList '-NoProfile -EncodedCommand ${encoded}' -Verb RunAs -Wait"`; + exec(elevateCmd, callback); +} + +module.exports = { checkRule, openPort, deleteRule, RULE_NAME }; diff --git a/apps/desktop/src/main/firewall.js b/apps/desktop/src/main/firewall.js new file mode 100644 index 0000000..6cf44b5 --- /dev/null +++ b/apps/desktop/src/main/firewall.js @@ -0,0 +1,72 @@ +'use strict'; + +const { exec } = require('child_process'); + +const RULE_NAME = 'DWM Web Remote'; + +/** + * Check whether the inbound firewall rule exists and is enabled. + * Calls back with (err, isActive). + */ +function checkRule(callback) { + const cmd = `powershell -NoProfile -Command "` + + `$r = Get-NetFirewallRule -DisplayName '${RULE_NAME}' -ErrorAction SilentlyContinue; ` + + `if ($r) { $r.Enabled } else { 'Missing' }"`; + exec(cmd, (err, stdout) => { + if (err) return callback(null, false); + callback(null, stdout.trim() === 'True'); + }); +} + +/** + * Create (or replace) an inbound TCP rule for the given port. + * Also removes any auto-created block rules for node/electron executables. + * Runs elevated via Start-Process -Verb RunAs, which triggers a UAC prompt. + * Calls back with (err). + */ +function openPort(port, execPath, callback) { + if (typeof execPath === 'function') { callback = execPath; execPath = null; } + + const appPath = (execPath || process.execPath).replace(/\\/g, '\\\\'); + + // Build the PowerShell block to run elevated + const script = [ + `$ErrorActionPreference='SilentlyContinue'`, + // Remove existing DWM rule + `Get-NetFirewallRule -DisplayName '${RULE_NAME}' | Remove-NetFirewallRule`, + // Remove Windows auto-generated block rules for Electron/node executables + `Get-NetFirewallRule -Direction Inbound -Action Block | Where-Object { $_.DisplayName -like '*Electron*' -or $_.DisplayName -like '*node*' -or ((Get-NetFirewallApplicationFilter -AssociatedNetFirewallRule $_ -ErrorAction SilentlyContinue).Program -eq '${appPath}') } | Remove-NetFirewallRule`, + // Create new allow-by-port rule + `New-NetFirewallRule -DisplayName '${RULE_NAME}' -Direction Inbound -Protocol TCP` + + ` -LocalPort ${port} -Action Allow -Profile Any` + + ` -Description 'Dibby Wemo Manager Web Remote (port ${port})'`, + // Create allow-by-application rule to handle Windows app-level blocking + `New-NetFirewallRule -DisplayName '${RULE_NAME} (App)' -Direction Inbound -Program '${appPath}'` + + ` -Action Allow -Profile Any` + + ` -Description 'Dibby Wemo Manager Web Remote — app rule'`, + // Ensure Private/Domain profiles don't have "Block all inbound" override set + `Set-NetFirewallProfile -Profile Private,Domain,Public -AllowInboundRules True`, + ].join('; '); + + // Base64-encode to avoid shell-quoting issues + const encoded = Buffer.from(script, 'utf16le').toString('base64'); + + const elevateCmd = `powershell -NoProfile -Command ` + + `"Start-Process powershell -ArgumentList '-NoProfile -EncodedCommand ${encoded}' -Verb RunAs -Wait"`; + + exec(elevateCmd, callback); +} + +/** + * Delete the inbound firewall rule (elevated). + * Calls back with (err). + */ +function deleteRule(callback) { + const script = `$ErrorActionPreference='SilentlyContinue'; Get-NetFirewallRule -DisplayName '${RULE_NAME}' | Remove-NetFirewallRule; Get-NetFirewallRule -DisplayName '${RULE_NAME} (App)' | Remove-NetFirewallRule`; + const encoded = Buffer.from(script, 'utf16le').toString('base64'); + const elevateCmd = `powershell -NoProfile -Command ` + + `"Start-Process powershell -ArgumentList '-NoProfile -EncodedCommand ${encoded}' -Verb RunAs -Wait"`; + exec(elevateCmd, callback); +} + +module.exports = { checkRule, openPort, deleteRule, RULE_NAME }; diff --git a/apps/desktop/src/main/index.js b/apps/desktop/src/main/index.js new file mode 100644 index 0000000..0119b76 --- /dev/null +++ b/apps/desktop/src/main/index.js @@ -0,0 +1,336 @@ +'use strict'; + +const { app, BrowserWindow, Menu, Tray, nativeImage, dialog, clipboard, shell } = require('electron'); +const path = require('path'); +const wemo = require('./wemo'); +const store = require('./store'); +const webServer = require('./web-server'); +const firewall = require('./firewall'); + +// Portable mode: store data next to .exe +if (process.env.PORTABLE_EXECUTABLE_DIR) { + app.setPath('userData', path.join(process.env.PORTABLE_EXECUTABLE_DIR, 'WemoManagerData')); +} + +let mainWindow = null; +let tray = null; +let forceQuit = false; // set true only when user chooses Quit from tray/menu +let firewallActive = false; // cached result of last firewall check + +function getResourcesDir() { + return app.isPackaged + ? process.resourcesPath + : path.join(__dirname, '..', '..', 'resources'); +} +const ICON_PATH = () => path.join(getResourcesDir(), 'icon.png'); + +// ── Tray ──────────────────────────────────────────────────────────────────── + +function buildTrayMenu() { + const schedulerModule = (() => { try { return require('./scheduler'); } catch { return null; } })(); + const isRunning = schedulerModule?.getStatus?.()?.running ?? false; + const remoteURL = webServer.getURL(); + + return Menu.buildFromTemplate([ + { label: 'Dibby Wemo Manager', enabled: false }, + { type: 'separator' }, + { + label: isRunning ? '🟢 Scheduler running' : '⚫ Scheduler stopped', + enabled: false, + }, + { type: 'separator' }, + { + label: `📱 Web Remote: ${remoteURL}`, + enabled: false, + }, + { + label: 'Copy Web Remote URL', + click: () => clipboard.writeText(remoteURL), + }, + { + label: 'Open Web Remote in Browser', + click: () => shell.openExternal(remoteURL), + }, + { + label: '📷 Show QR Code', + click: () => showQR(remoteURL), + }, + ...(process.platform === 'win32' ? [ + { + label: firewallActive ? '✅ Firewall rule active' : '🔓 Open Port in Windows Firewall', + enabled: !firewallActive, + click: () => openFirewallPort(), + }, + { + label: '🗑 Delete Firewall Rule', + enabled: firewallActive, + click: () => deleteFirewallRule(), + }, + ] : []), + { type: 'separator' }, + { label: 'Open', click: showWindow }, + { type: 'separator' }, + { + label: 'Quit', + click: () => { + forceQuit = true; + app.quit(); + }, + }, + ]); +} + +function openFirewallPort() { + const port = webServer.WEB_PORT; + firewall.openPort(port, process.execPath, (err) => { + if (err) { + console.error('[Firewall] Failed to open port:', err.message); + return; + } + // Re-check status after a short delay to allow the rule to register + setTimeout(() => { + firewall.checkRule((_e, active) => { + firewallActive = active; + if (tray) tray.setContextMenu(buildTrayMenu()); + }); + }, 1500); + }); +} + +function deleteFirewallRule() { + firewall.deleteRule((err) => { + if (err) { + console.error('[Firewall] Failed to delete rule:', err.message); + return; + } + setTimeout(() => { + firewall.checkRule((_e, active) => { + firewallActive = active; + if (tray) tray.setContextMenu(buildTrayMenu()); + }); + }, 1500); + }); +} + +function createTray() { + const icon = nativeImage.createFromPath(ICON_PATH()); + tray = new Tray(icon.resize({ width: 16, height: 16 })); + tray.setToolTip('Dibby Wemo Manager — scheduler running'); + tray.setContextMenu(buildTrayMenu()); + tray.on('double-click', showWindow); + // Refresh menu every 5 s for scheduler + firewall status + setInterval(() => { + if (!tray) return; + firewall.checkRule((_e, active) => { + firewallActive = active; + tray.setContextMenu(buildTrayMenu()); + }); + }, 5000); + // Do an immediate firewall check on startup + firewall.checkRule((_e, active) => { + firewallActive = active; + if (tray) tray.setContextMenu(buildTrayMenu()); + }); +} + +function showWindow() { + if (!mainWindow) { + createWindow(); + } else if (mainWindow.isMinimized()) { + mainWindow.restore(); + } else { + mainWindow.show(); + } + mainWindow.focus(); +} + +// ── Main window ────────────────────────────────────────────────────────────── + +function createWindow() { + const isDark = store.getTheme() !== 'light'; + + mainWindow = new BrowserWindow({ + width: 1200, + height: 760, + minWidth: 820, + minHeight: 560, + backgroundColor: isDark ? '#0d1b27' : '#f0f4f0', + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + webPreferences: { + preload: path.join(__dirname, '..', '..', 'out', 'preload', 'index.js'), + contextIsolation: true, + nodeIntegration: false, + }, + show: false, + title: 'Dibby Wemo Manager', + icon: ICON_PATH(), + }); + + if (process.env['ELECTRON_RENDERER_URL']) { + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']); + } else { + mainWindow.loadFile(path.join(__dirname, '..', '..', 'out', 'renderer', 'index.html')); + } + + mainWindow.once('ready-to-show', () => mainWindow.show()); + + // Close button → hide to tray (keep scheduler alive); only quit when forceQuit + mainWindow.on('close', (e) => { + if (!forceQuit) { + e.preventDefault(); + mainWindow.hide(); + if (tray) { + tray.displayBalloon({ + title: 'Dibby Wemo Manager', + content: 'Minimized to tray — scheduler is still running.', + noSound: true, + }); + } + } + }); + + mainWindow.on('closed', () => { mainWindow = null; wemo.stopDiscovery(); }); +} + +// ── App menu ───────────────────────────────────────────────────────────────── + +function buildMenu() { + const template = [ + { + label: 'File', + submenu: [ + { label: 'Discover Devices', accelerator: 'CmdOrCtrl+R', + click: () => mainWindow?.webContents.send('trigger-discovery') }, + { type: 'separator' }, + { + label: 'Quit', + accelerator: 'CmdOrCtrl+Q', + click: () => { forceQuit = true; app.quit(); }, + }, + ], + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + { + label: 'Help', + submenu: [ + { + label: 'Help Guide', + accelerator: 'F1', + click: () => showHelp(), + }, + { type: 'separator' }, + { label: 'About Dibby Wemo Manager', click: () => showAbout() }, + ], + }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +let helpWindow = null; + +function showHelp() { + if (helpWindow && !helpWindow.isDestroyed()) { + helpWindow.focus(); + return; + } + helpWindow = new BrowserWindow({ + width: 960, + height: 760, + minWidth: 640, + minHeight: 480, + title: 'Dibby Wemo Manager — Help', + icon: ICON_PATH(), + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + autoHideMenuBar: true, + }); + helpWindow.loadFile(path.join(getResourcesDir(), 'help.html')); + helpWindow.on('closed', () => { helpWindow = null; }); +} + +let qrWindow = null; + +function showQR(remoteURL) { + if (qrWindow && !qrWindow.isDestroyed()) { qrWindow.focus(); return; } + qrWindow = new BrowserWindow({ + width: 360, height: 480, resizable: false, + title: 'DWM Web Remote — QR Code', + icon: ICON_PATH(), + webPreferences: { contextIsolation: true, nodeIntegration: false }, + autoHideMenuBar: true, minimizable: false, maximizable: false, + }); + qrWindow.loadURL((remoteURL || webServer.getURL()) + '/qr'); + qrWindow.on('closed', () => { qrWindow = null; }); +} + +let aboutWindow = null; + +function showAbout() { + if (aboutWindow && !aboutWindow.isDestroyed()) { + aboutWindow.focus(); + return; + } + aboutWindow = new BrowserWindow({ + width: 600, + height: 800, + resizable: false, + title: 'About Dibby Wemo Manager', + icon: ICON_PATH(), + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + autoHideMenuBar: true, + minimizable: false, + maximizable: false, + }); + aboutWindow.loadFile(path.join(getResourcesDir(), 'about.html')); + aboutWindow.on('closed', () => { aboutWindow = null; }); +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +app.whenReady().then(() => { + // Seed wemo module with stored location + const loc = store.getLocation(); + if (loc) wemo.setLocation(loc); + + // Register all IPC handlers + require('./ipc/devices.ipc')(); + require('./ipc/rules.ipc')(); + require('./ipc/wifi.ipc')(); + require('./ipc/system.ipc')(); + require('./ipc/scheduler.ipc')(); + + // Start embedded web remote server (phone access on local network) + const scheduler = (() => { try { return require('./scheduler'); } catch { return null; } })(); + webServer.start(scheduler, store, wemo); + + buildMenu(); + createWindow(); + createTray(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); +}); + +// Prevent default quit-on-all-windows-closed — tray keeps the app alive +app.on('window-all-closed', () => { + // On macOS keep running; on Windows/Linux only quit if forceQuit + if (process.platform === 'darwin' && !forceQuit) return; + if (!forceQuit) return; // window was just hidden to tray + app.quit(); +}); diff --git a/apps/desktop/src/main/ipc/devices.ipc.js b/apps/desktop/src/main/ipc/devices.ipc.js new file mode 100644 index 0000000..6c70527 --- /dev/null +++ b/apps/desktop/src/main/ipc/devices.ipc.js @@ -0,0 +1,75 @@ +'use strict'; + +const { ipcMain } = require('electron'); +const wemo = require('../wemo'); +const store = require('../store'); + +module.exports = function registerDeviceIpc() { + ipcMain.handle('discover-devices', async (_e, opts = {}) => { + const manual = store.getDevices().filter((d) => d.manual).map((d) => ({ host: d.host, port: d.port })); + return wemo.discoverDevices(opts.timeout || 10_000, [...manual, ...(opts.manualEntries || [])]); + }); + + ipcMain.handle('get-device-state', async (_e, { host, port }) => { + return wemo.getBinaryState(host, port); + }); + + ipcMain.handle('set-device-state', async (_e, { host, port, on }) => { + return wemo.setBinaryState(host, port, on); + }); + + ipcMain.handle('get-device-info', async (_e, { host, port }) => { + const [info, setup] = await Promise.allSettled([ + wemo.getDeviceInfo(host, port), + wemo.fetchSetupXml(host, port), + ]); + const infoData = info.status === 'fulfilled' ? info.value : {}; + const setupData = setup.status === 'fulfilled' ? setup.value : {}; + return { ...setupData, ...infoData }; + }); + + ipcMain.handle('check-online', async (_e, { host, port }) => { + try { + await wemo.getBinaryState(host, port); + return true; + } catch { + return false; + } + }); + + ipcMain.handle('set-device-time', async (_e, { host, port }) => { + return wemo.setDeviceTime(host, port); + }); + + ipcMain.handle('rename-device', async (_e, { host, port, name }) => { + return wemo.renameDevice(host, port, name); + }); + + ipcMain.handle('reset-data', async (_e, { host, port }) => { + return wemo.resetData(host, port); + }); + + ipcMain.handle('factory-reset', async (_e, { host, port }) => { + return wemo.factoryReset(host, port); + }); + + ipcMain.handle('reset-wifi', async (_e, { host, port }) => { + return wemo.resetWifi(host, port); + }); + + ipcMain.handle('get-homekit-info', async (_e, { host, port }) => { + return wemo.getHomeKitInfo(host, port); + }); + + // Saved device list management + ipcMain.handle('get-saved-devices', () => store.getDevices()); + ipcMain.handle('save-devices', (_e, list) => { + store.saveDevices(list); + // Keep service device list in sync so it picks up new/renamed devices + try { require('../service-manager-sync').syncDevices(list); } catch { /* service sync optional */ } + }); + ipcMain.handle('get-device-order', () => store.getDeviceOrder()); + ipcMain.handle('save-device-order', (_e, order) => store.saveDeviceOrder(order)); + ipcMain.handle('get-device-groups', () => store.getDeviceGroups()); + ipcMain.handle('save-device-groups', (_e, groups) => store.saveDeviceGroups(groups)); +}; diff --git a/apps/desktop/src/main/ipc/rules.ipc.js b/apps/desktop/src/main/ipc/rules.ipc.js new file mode 100644 index 0000000..dc7e633 --- /dev/null +++ b/apps/desktop/src/main/ipc/rules.ipc.js @@ -0,0 +1,84 @@ +'use strict'; + +const { ipcMain } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const wemo = require('../wemo'); +const store = require('../store'); +const scheduler = require('../scheduler'); + +// Write DWM rules to ProgramData so the standalone service can read them +const DWM_SHARED = path.join('C:\\ProgramData', 'DibbyWemoManager', 'dwm-rules.json'); +function syncDwmRulesToService() { + try { + fs.mkdirSync(path.dirname(DWM_SHARED), { recursive: true }); + fs.writeFileSync(DWM_SHARED, JSON.stringify(store.getDwmRules(), null, 2), 'utf8'); + } catch { /* non-critical */ } +} + +module.exports = function registerRulesIpc() { + + // ── Wemo device rules (read from device, used by Wemo Rules tab) ─────────── + + ipcMain.handle('get-rules', async (_e, { host, port }) => { + return wemo.getRules(host, port); + }); + + // Write a native rule directly to a Wemo device (Wemo Rules tab, not DWM). + ipcMain.handle('create-rule', async (_e, { host, port, input }) => { + return wemo.createRule(host, port, input); + }); + + ipcMain.handle('update-rule', async (_e, { host, port, ruleId, input }) => { + return wemo.updateRule(host, port, ruleId, input); + }); + + ipcMain.handle('delete-rule', async (_e, { host, port, ruleId }) => { + return wemo.deleteRule(host, port, ruleId); + }); + + ipcMain.handle('dump-db', async (_e, { host, port }) => { + return wemo.dumpDb(host, port); + }); + + ipcMain.handle('reboot-device', async (_e, { host, port }) => { + return wemo.rebootDevice(host, port); + }); + + // ── DWM Rules — stored locally, scheduler reads these ───────────────────── + + ipcMain.handle('get-dwm-rules', () => { + return store.getDwmRules(); + }); + + ipcMain.handle('create-dwm-rule', (_e, rule) => { + const result = store.createDwmRule(rule); + syncDwmRulesToService(); + scheduler.reload(); + return result; + }); + + ipcMain.handle('update-dwm-rule', (_e, { id, updates }) => { + const result = store.updateDwmRule(id, updates); + syncDwmRulesToService(); + scheduler.reload(); + return result; + }); + + ipcMain.handle('delete-dwm-rule', (_e, { id }) => { + store.deleteDwmRule(id); + syncDwmRulesToService(); + scheduler.reload(); + }); + + // ── Legacy disabled-rule backups (no longer used by DWM tab) ────────────── + ipcMain.handle('get-disabled-rules', () => store.getDisabledRules()); + + ipcMain.handle('set-disabled-rule', (_e, { key, ruleDevicesRows }) => { + store.setDisabledRule(key, ruleDevicesRows); + }); + + ipcMain.handle('clear-disabled-rule', (_e, { key }) => { + store.clearDisabledRule(key); + }); +}; diff --git a/apps/desktop/src/main/ipc/scheduler.ipc.js b/apps/desktop/src/main/ipc/scheduler.ipc.js new file mode 100644 index 0000000..e918cfd --- /dev/null +++ b/apps/desktop/src/main/ipc/scheduler.ipc.js @@ -0,0 +1,43 @@ +'use strict'; + +const { ipcMain, BrowserWindow } = require('electron'); +const scheduler = require('../scheduler'); + +module.exports = function registerSchedulerIpc() { + // Push fire events to all renderer windows + scheduler.onFire((event) => { + BrowserWindow.getAllWindows().forEach((w) => + w.webContents.send('scheduler-fired', event) + ); + }); + + // Push status updates to all renderer windows + scheduler.onStatus((status) => { + BrowserWindow.getAllWindows().forEach((w) => + w.webContents.send('scheduler-status', status) + ); + }); + + // Push device health events to all renderer windows + scheduler.onHealth((event) => { + BrowserWindow.getAllWindows().forEach((w) => + w.webContents.send('scheduler-health', event) + ); + }); + + ipcMain.handle('scheduler-start', async (_e, { devices }) => { + return scheduler.start(devices); + }); + + ipcMain.handle('scheduler-stop', () => { + return scheduler.stop(); + }); + + ipcMain.handle('scheduler-status', () => { + return scheduler.getStatus(); + }); + + ipcMain.handle('scheduler-health', () => { + return scheduler.getHealthStatus(); + }); +}; diff --git a/apps/desktop/src/main/ipc/system.ipc.js b/apps/desktop/src/main/ipc/system.ipc.js new file mode 100644 index 0000000..f39bd02 --- /dev/null +++ b/apps/desktop/src/main/ipc/system.ipc.js @@ -0,0 +1,104 @@ +'use strict'; + +const { ipcMain, dialog, shell } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const axios = require('axios'); +const store = require('../store'); +const wemo = require('../wemo'); +// Service management is Windows-only +const isWindows = process.platform === 'win32'; +const svcMgr = isWindows ? require('../service-manager') : null; +const svcSync = isWindows ? require('../service-manager-sync') : null; + +module.exports = function registerSystemIpc() { + // Theme + ipcMain.handle('get-theme', () => store.getTheme()); + ipcMain.handle('set-theme', (_e, theme) => store.setTheme(theme)); + + // Location + ipcMain.handle('get-location', () => store.getLocation()); + ipcMain.handle('set-location', (_e, loc) => { + store.setLocation(loc); + wemo.setLocation(loc); + }); + + // Geocoding via Nominatim (OpenStreetMap, no API key required) + ipcMain.handle('search-location', async (_e, query) => { + try { + const res = await axios.get('https://nominatim.openstreetmap.org/search', { + params: { q: query, format: 'json', limit: 8, addressdetails: 1 }, + headers: { 'User-Agent': 'WemoManager/2.0' }, + timeout: 8000, + }); + return (res.data || []).map((r) => ({ + lat: parseFloat(r.lat), + lng: parseFloat(r.lon), + label: r.display_name, + city: r.address?.city || r.address?.town || r.address?.village || r.address?.county || '', + country: r.address?.country || '', + countryCode: (r.address?.country_code || '').toUpperCase(), + region: r.address?.state || '', + })); + } catch { return []; } + }); + + ipcMain.handle('reverse-geocode', async (_e, { lat, lng }) => { + try { + const res = await axios.get('https://nominatim.openstreetmap.org/reverse', { + params: { lat, lon: lng, format: 'json', addressdetails: 1 }, + headers: { 'User-Agent': 'WemoManager/2.0' }, + timeout: 8000, + }); + const a = res.data.address || {}; + return { + lat, lng, + label: res.data.display_name || `${lat}, ${lng}`, + city: a.city || a.town || a.village || a.county || '', + country: a.country || '', + countryCode: (a.country_code || '').toUpperCase(), + region: a.state || '', + }; + } catch { return { lat, lng, label: `${lat}, ${lng}`, city: '', country: '', countryCode: '', region: '' }; } + }); + + // Sun times + ipcMain.handle('get-sun-times', (_e, { lat, lng }) => { + const { sunTimes } = require('../core/sun'); + return sunTimes(lat, lng); + }); + + // File I/O + ipcMain.handle('show-save-dialog', async (_e, opts) => { + return dialog.showSaveDialog(opts); + }); + + ipcMain.handle('show-open-dialog', async (_e, opts) => { + return dialog.showOpenDialog(opts); + }); + + ipcMain.handle('write-file', async (_e, { filePath, content }) => { + fs.writeFileSync(filePath, content, 'utf8'); + }); + + ipcMain.handle('read-file', async (_e, { filePath }) => { + return fs.readFileSync(filePath, 'utf8'); + }); + + ipcMain.handle('open-external', async (_e, url) => { + await shell.openExternal(url); + }); + + // ── Windows Service management (Windows only) ──────────────────────────── + const notSupported = () => ({ installed: false, running: false, status: 'Not supported on this platform' }); + ipcMain.handle('service-status', () => isWindows ? svcMgr.getServiceStatus() : notSupported()); + ipcMain.handle('service-install', () => isWindows ? svcMgr.installService() : { ok: false, msg: 'Windows only' }); + ipcMain.handle('service-uninstall', () => isWindows ? svcMgr.uninstallService() : { ok: false, msg: 'Windows only' }); + ipcMain.handle('service-start', () => isWindows ? svcMgr.startService() : { ok: false, msg: 'Windows only' }); + ipcMain.handle('service-stop', () => isWindows ? svcMgr.stopService() : { ok: false, msg: 'Windows only' }); + + // ── Device sync to ProgramData (for service) ────────────────────────────── + ipcMain.handle('sync-devices-to-service', (_e, devices) => { + if (isWindows && svcSync) svcSync.syncDevices(devices); + }); +}; diff --git a/apps/desktop/src/main/ipc/wifi.ipc.js b/apps/desktop/src/main/ipc/wifi.ipc.js new file mode 100644 index 0000000..6707c68 --- /dev/null +++ b/apps/desktop/src/main/ipc/wifi.ipc.js @@ -0,0 +1,22 @@ +'use strict'; + +const { ipcMain } = require('electron'); +const wemo = require('../wemo'); + +module.exports = function registerWifiIpc() { + ipcMain.handle('get-ap-list', async (_e, { host, port }) => { + return wemo.getApList(host, port); + }); + + ipcMain.handle('connect-home-network', async (_e, { host, port, ssid, auth, password, encrypt, channel }) => { + return wemo.connectHomeNetwork(host, port, { ssid, auth, password, encrypt, channel }); + }); + + ipcMain.handle('get-network-status', async (_e, { host, port }) => { + return wemo.getNetworkStatus(host, port); + }); + + ipcMain.handle('close-setup', async (_e, { host, port }) => { + return wemo.closeSetup(host, port); + }); +}; diff --git a/apps/desktop/src/main/scheduler-standalone.js b/apps/desktop/src/main/scheduler-standalone.js new file mode 100644 index 0000000..dea3922 --- /dev/null +++ b/apps/desktop/src/main/scheduler-standalone.js @@ -0,0 +1,412 @@ +'use strict'; + +/** + * Dibby Wemo Scheduler — standalone Windows Service entry point + * + * Runs under SYSTEM account via node-windows. + * Reads device list from: C:\ProgramData\DibbyWemoManager\devices.json + * Reads DWM rules from: C:\ProgramData\DibbyWemoManager\dwm-rules.json + * (both written by the Electron app after each save/discovery) + * + * Fires SetBinaryState SOAP commands at scheduled times, replacing the + * dead Belkin cloud rule engine. + * + * Rule types handled: + * - Schedule / Countdown / Away → from Wemo device firmware (RULEDEVICES table) + * - AlwaysOn → health monitor enforces ON every 10 s + * - Trigger → if device A changes state, fire action on device B + */ + +const path = require('path'); +const fs = require('fs'); +const wemo = require('./wemo'); + +const DATA_DIR = path.join('C:\\ProgramData', 'DibbyWemoManager'); +const DEVICES_FILE = path.join(DATA_DIR, 'devices.json'); +const DWM_FILE = path.join(DATA_DIR, 'dwm-rules.json'); +const LOG_FILE = path.join(DATA_DIR, 'scheduler.log'); +const MAX_LOG_BYTES = 2 * 1024 * 1024; // 2 MB cap + +const HEALTH_POLL_MS = 10_000; // poll devices every 10 s +const CATCHUP_WINDOW_S = 10 * 60; // catch-up missed rules from last 10 min + +// ── Logging ────────────────────────────────────────────────────────────────── + +function log(msg) { + const line = `[${new Date().toISOString()}] ${msg}`; + console.log(line); + try { + try { + if (fs.statSync(LOG_FILE).size > MAX_LOG_BYTES) { + const old = fs.readFileSync(LOG_FILE, 'utf8'); + fs.writeFileSync(LOG_FILE, old.slice(-512 * 1024) + '\n', 'utf8'); + } + } catch { /* first run */ } + fs.appendFileSync(LOG_FILE, line + '\n'); + } catch { /* ignore write errors */ } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function jsToWemoDayId(jsDay) { return jsDay === 0 ? 7 : jsDay; } +function secondsFromMidnight(d) { return d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); } +function secsToHHMM(s) { + const h = Math.floor(s / 3600) % 24, m = Math.floor((s % 3600) / 60); + return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; +} + +// ── State ───────────────────────────────────────────────────────────────────── + +let deviceMap = new Map(); // udn → {host, port} +let schedule = []; // pre-computed schedule entries from Wemo firmware +let firedToday = new Set(); +let lastDate = null; +let tickTimer = null; +let healthTimer = null; +let dwmRules = []; // DWM rules from shared JSON file + +// Health monitor state +let deviceHealth = new Map(); // 'host:port' → true|false +let triggerStates = new Map(); // 'host:port' → last boolean + +// ── Device list ─────────────────────────────────────────────────────────────── + +function loadDevices() { + try { + const raw = JSON.parse(fs.readFileSync(DEVICES_FILE, 'utf8')); + deviceMap.clear(); + for (const d of (raw.devices || raw)) { + if (d.udn && d.host && d.port) deviceMap.set(d.udn, { host: d.host, port: d.port, name: d.friendlyName ?? d.host }); + } + log(`Loaded ${deviceMap.size} device(s) from ${DEVICES_FILE}`); + } catch (e) { + log(`Could not load devices file: ${e.message} — will retry`); + } +} + +// ── DWM rules ───────────────────────────────────────────────────────────────── + +function loadDwmRules() { + try { + const raw = JSON.parse(fs.readFileSync(DWM_FILE, 'utf8')); + dwmRules = Array.isArray(raw) ? raw : (raw.rules ?? []); + log(`Loaded ${dwmRules.length} DWM rule(s) from ${DWM_FILE}`); + } catch (e) { + dwmRules = []; + if (e.code !== 'ENOENT') log(`Could not load DWM rules: ${e.message}`); + } +} + +// ── Schedule loading from Wemo firmware ────────────────────────────────────── + +async function loadSchedule() { + if (deviceMap.size === 0) { log('No devices — skipping schedule load'); return; } + + const entries = []; + for (const [udn, { host, port }] of deviceMap) { + try { + const db = await wemo.dumpDb(host, port); + const rdRows = db.data['RULEDEVICES'] || []; + const tdRows = db.data['TARGETDEVICES'] || []; + const ruleRows = db.data['RULES'] || []; + + const ruleNames = {}; + for (const r of ruleRows) ruleNames[String(r.RuleID ?? r.ruleid)] = String(r.Name ?? r.name ?? ''); + + for (const rd of rdRows) { + const ruleId = Number(rd.RuleID ?? rd.ruleid ?? 0); + const deviceId = String(rd.DeviceID ?? rd.deviceid ?? ''); + const dayId = Number(rd.DayID ?? rd.dayid ?? 0); + const startSecs = Number(rd.StartTime ?? rd.starttime ?? -1); + const endSecs = Number(rd.EndTime ?? rd.endtime ?? -1); + const startAction = Number(rd.StartAction ?? rd.startaction ?? 1); + const endAction = Number(rd.EndAction ?? rd.endaction ?? -1); + const ruleName = ruleNames[String(ruleId)] || `Rule ${ruleId}`; + + if (startSecs < 0) continue; + + const target = deviceMap.get(deviceId) || { host, port }; + const dedup = `${ruleId}-${dayId}-${startSecs}-${deviceId}`; + + entries.push({ ruleId, ruleName, dayId, targetSecs: startSecs, action: startAction, + host: target.host, port: target.port, dedup: `${dedup}-start` }); + + if (endSecs >= 0 && endAction !== -1) { + entries.push({ ruleId, ruleName, dayId, targetSecs: endSecs, action: endAction, + host: target.host, port: target.port, dedup: `${dedup}-end` }); + } + + // Also add TARGETDEVICES rows + const targets = tdRows + .filter((t) => Number(t.RuleID ?? t.ruleid) === ruleId) + .map((t) => String(t.DeviceID ?? t.deviceid)) + .filter(Boolean); + for (const tid of targets) { + const tdTarget = deviceMap.get(tid); + if (!tdTarget) continue; + const tdedup = `${ruleId}-${dayId}-${startSecs}-${tid}`; + entries.push({ ruleId, ruleName, dayId, targetSecs: startSecs, action: startAction, + host: tdTarget.host, port: tdTarget.port, dedup: `${tdedup}-start` }); + if (endSecs >= 0 && endAction !== -1) { + entries.push({ ruleId, ruleName, dayId, targetSecs: endSecs, action: endAction, + host: tdTarget.host, port: tdTarget.port, dedup: `${tdedup}-end` }); + } + } + } + } catch (e) { + log(`Failed to load rules from ${host}:${port}: ${e.message}`); + } + } + + const seen = new Set(); + schedule = entries.filter((e) => { if (seen.has(e.dedup)) return false; seen.add(e.dedup); return true; }); + log(`Firmware schedule loaded: ${schedule.length} entries across ${deviceMap.size} device(s)`); +} + +// ── Catch-up missed rules ───────────────────────────────────────────────────── + +function catchUpMissedRules() { + if (!schedule.length) return; + const now = new Date(); + const nowSecs = secondsFromMidnight(now); + const todayId = jsToWemoDayId(now.getDay()); + let count = 0; + + for (const entry of schedule) { + if (entry.dayId !== todayId) continue; + const age = nowSecs - entry.targetSecs; + if (age <= 0 || age > CATCHUP_WINDOW_S) continue; + if (firedToday.has(entry.dedup)) continue; + firedToday.add(entry.dedup); + count++; + fire(entry); // don't await — fire-and-forget on startup + } + if (count) log(`Catch-up: fired ${count} missed entries`); +} + +// ── Fire ────────────────────────────────────────────────────────────────────── + +async function fire(entry) { + const on = entry.action === 1; + try { + await wemo.setBinaryState(entry.host, entry.port, on); + log(`✅ Fired: "${entry.ruleName}" → ${on ? 'ON' : 'OFF'} (${entry.host})`); + } catch (e) { + log(`❌ Fire failed: "${entry.ruleName}" → ${e.message}`); + } +} + +// ── Tick ────────────────────────────────────────────────────────────────────── + +const TICK_MS = 30_000; +const WINDOW_SEC = 35; + +async function tick() { + const now = new Date(); + const dateStr = now.toDateString(); + const nowSecs = secondsFromMidnight(now); + const dayId = jsToWemoDayId(now.getDay()); + + if (dateStr !== lastDate) { + log(`New day (${dateStr}), resetting fired set`); + firedToday.clear(); + lastDate = dateStr; + loadDevices(); + loadDwmRules(); + await loadSchedule(); + } + + const due = schedule.filter((e) => + e.dayId === dayId && + e.targetSecs >= nowSecs && + e.targetSecs < nowSecs + WINDOW_SEC && + !firedToday.has(e.dedup) + ); + + for (const e of due) { + firedToday.add(e.dedup); + await fire(e); + } +} + +// ── Health monitor ──────────────────────────────────────────────────────────── + +async function pollDeviceHealth() { + // Build device map from DWM rules + const healthMap = new Map(); // 'host:port' → { host, port, name } + const alwaysOnSet = new Set(); + const triggerSrcSet = new Set(); + + const addDev = (td) => { + if (!td?.host || !td?.port) return; + const key = `${td.host}:${td.port}`; + if (!healthMap.has(key)) + healthMap.set(key, { host: td.host, port: Number(td.port), name: td.name ?? td.host }); + return key; + }; + + for (const rule of dwmRules) { + if (!rule.enabled) continue; + if (rule.type === 'Trigger') { + const k = addDev(rule.triggerDevice); + if (k) triggerSrcSet.add(k); + for (const td of (rule.actionDevices ?? [])) addDev(td); + continue; + } + for (const td of (rule.targetDevices ?? [])) { + const k = addDev(td); + if (k && rule.type === 'AlwaysOn') alwaysOnSet.add(k); + } + } + + for (const [key, dev] of healthMap) { + const wasOnline = deviceHealth.get(key); + try { + const isOn = await wemo.getBinaryState(dev.host, dev.port); + deviceHealth.set(key, true); + + if (wasOnline === false) { + log(`🟢 ${dev.name} came back online`); + // Enforce most recent schedule state + await enforceCurrentState(dev); + } else if (wasOnline === undefined) { + log(`🟢 ${dev.name} online`); + } + + // AlwaysOn enforcement + if (alwaysOnSet.has(key) && !isOn) { + try { + await wemo.setBinaryState(dev.host, dev.port, true); + log(`🔒 [always-on] ${dev.name} was OFF — turned ON ✓`); + } catch (e) { + log(`❌ [always-on] ${dev.name} turn-ON failed: ${e.message}`); + } + } + + // Trigger detection + if (triggerSrcSet.has(key)) { + const prevState = triggerStates.get(key); + triggerStates.set(key, isOn); + if (prevState !== undefined && prevState !== isOn) { + await fireTriggerRules(key, isOn); + } + } + + } catch (e) { + deviceHealth.set(key, false); + if (wasOnline !== false) { + log(`🔴 ${dev.name} unreachable: ${e.message}`); + } + } + } + + healthTimer = setTimeout(pollDeviceHealth, HEALTH_POLL_MS); +} + +async function enforceCurrentState(dev) { + const now = new Date(); + const nowSecs = secondsFromMidnight(now); + const todayId = jsToWemoDayId(now.getDay()); + + let best = null; + for (const entry of schedule) { + if (entry.host !== dev.host) continue; + if (entry.dayId !== todayId) continue; + if (entry.targetSecs > nowSecs) continue; + if (!best || entry.targetSecs > best.targetSecs) best = entry; + } + + if (!best) return; + const wantOn = best.action === 1; + try { + await wemo.setBinaryState(dev.host, dev.port, wantOn); + log(`🔄 [enforce] "${best.ruleName}" → ${wantOn ? 'ON' : 'OFF'} restored on ${dev.name} ✓`); + } catch (e) { + log(`❌ [enforce] "${best.ruleName}" restore FAILED on ${dev.name}: ${e.message}`); + } +} + +async function fireTriggerRules(sourceKey, isOn) { + const rules = dwmRules.filter((r) => + r.enabled && + r.type === 'Trigger' && + r.triggerDevice?.host && + `${r.triggerDevice.host}:${r.triggerDevice.port}` === sourceKey + ); + + for (const rule of rules) { + const matches = + rule.triggerEvent === 'any' || + (rule.triggerEvent === 'on' && isOn) || + (rule.triggerEvent === 'off' && !isOn); + if (!matches) continue; + + let targetOn; + if (rule.action === 'on') targetOn = true; + else if (rule.action === 'off') targetOn = false; + else if (rule.action === 'mirror') targetOn = isOn; + else if (rule.action === 'opposite') targetOn = !isOn; + else continue; + + for (const dev of (rule.actionDevices ?? [])) { + if (!dev.host || !dev.port) continue; + try { + await wemo.setBinaryState(dev.host, Number(dev.port), targetOn); + log(`⚡ [trigger] "${rule.name}" → ${dev.name ?? dev.host} ${targetOn ? 'ON' : 'OFF'} ✓`); + } catch (e) { + log(`❌ [trigger] "${rule.name}" → ${dev.name ?? dev.host} FAILED: ${e.message}`); + } + } + } +} + +// ── Boot ────────────────────────────────────────────────────────────────────── + +async function start() { + log('=== Dibby Wemo Scheduler service starting ==='); + try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch { /* exists */ } + + loadDevices(); + loadDwmRules(); + await loadSchedule(); + catchUpMissedRules(); + + // Watch devices file for changes (new discoveries) + try { + fs.watch(DEVICES_FILE, { persistent: false }, () => { + log('Devices file changed — reloading'); + setTimeout(async () => { loadDevices(); await loadSchedule(); }, 2000); + }); + } catch { /* file may not exist yet */ } + + // Watch DWM rules file for changes (rules edited in Electron app) + try { + fs.watch(DWM_FILE, { persistent: false }, () => { + log('DWM rules file changed — reloading'); + setTimeout(() => { loadDwmRules(); }, 1000); + }); + } catch { /* file may not exist yet */ } + + await tick(); + tickTimer = setInterval(async () => { try { await tick(); } catch (e) { log(`Tick error: ${e.message}`); } }, TICK_MS); + + // Start health monitor after 15 s so startup can complete first + healthTimer = setTimeout(pollDeviceHealth, 15_000); + + log(`Scheduler running. Tick interval: ${TICK_MS / 1000}s, Health poll: ${HEALTH_POLL_MS / 1000}s`); +} + +start().catch((e) => { log(`FATAL: ${e.message}`); process.exit(1); }); + +process.on('SIGINT', () => { + log('Stopping (SIGINT).'); + clearInterval(tickTimer); + if (healthTimer) clearTimeout(healthTimer); + process.exit(0); +}); +process.on('SIGTERM', () => { + log('Stopping (SIGTERM).'); + clearInterval(tickTimer); + if (healthTimer) clearTimeout(healthTimer); + process.exit(0); +}); diff --git a/apps/desktop/src/main/scheduler.js b/apps/desktop/src/main/scheduler.js new file mode 100644 index 0000000..a3f98fc --- /dev/null +++ b/apps/desktop/src/main/scheduler.js @@ -0,0 +1,723 @@ +'use strict'; + +/** + * Local App Scheduler + * + * Reads DWM rules from the local app database (store.js) and fires + * SetBinaryState SOAP commands at the correct wall-clock times. + * + * Rule types handled: + * - Schedule / Away (fixed times) → pre-computed {dayId, targetSecs, action} entries + * - Countdown with active window → ON at windowStart, OFF at windowEnd (cross-midnight aware) + * - Away Mode → randomisation loop: ON 30–90 min, OFF 1–15 min within window + * - AlwaysOn → health monitor enforces ON every 10 s; no schedule entry + * - Trigger → if device A changes state, fire action on device B + * + * Sun-based times (startTime < 0) are skipped for now. + */ + +const wemo = require('./wemo'); +const store = require('./store'); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Wemo DayID: 1=Mon … 7=Sun. JS getDay(): 0=Sun … 6=Sat. */ +function jsToWemoDayId(jsDay) { return jsDay === 0 ? 7 : jsDay; } + +function secondsFromMidnight(date) { + return date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds(); +} + +function secsToHHMM(secs) { + const h = Math.floor(secs / 3600) % 24; + const m = Math.floor((secs % 3600) / 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +function actionLabel(a) { + return a === 1 ? 'ON' : a === 0 ? 'OFF' : `action(${a})`; +} + +function randBetween(min, max) { + return min + Math.floor(Math.random() * (max - min + 1)); +} + +// ── Scheduler class ────────────────────────────────────────────────────────── + +const HEALTH_POLL_MS = 10_000; // poll devices every 10 seconds +const CATCHUP_WINDOW_S = 10 * 60; // catch up rules missed within last 10 minutes + +class LocalScheduler { + constructor() { + this._schedule = []; // pre-computed time entries for Schedule/Countdown rules + this._awayLoops = new Map(); // ruleId → away-loop state for active Away Mode rules + this._firedToday = new Set(); // prevent double-firing within a tick window + this._timers = []; + this._tickTimer = null; + this._running = false; + this._lastDate = null; + this._onFire = null; // ({success, msg, entry}) notification callback + this._onStatus = null; // (statusObj) status callback + this._onHealth = null; // ({host, port, name, online, msg}) health event callback + this._deviceHealth = new Map(); // 'host:port' → true | false + this._triggerStates = new Map(); // 'host:port' → last known boolean state (for Trigger rules) + this._healthTimer = null; + this._startedAt = null; + } + + // ── Public API ──────────────────────────────────────────────────────────── + + setDevices(_devices) { /* no-op — DWM rules store target host/port directly */ } + onFire(cb) { this._onFire = cb; } + onStatus(cb) { this._onStatus = cb; } + onHealth(cb) { this._onHealth = cb; } + + getHealthStatus() { + const out = {}; + for (const [key, online] of this._deviceHealth) out[key] = online; + return out; + } + + async start(_devices) { + if (this._running) this._clearTimers(); + this._running = true; + this._startedAt = new Date(); + this._firedToday = new Set(); + + this._loadSchedule(); + this._resumeAwayLoops(); // re-arm any Away windows already in progress + this._catchUpMissedRules(); + this._tick(); + this._startHealthMonitor(); + + const status = this._buildStatus(); + this._onStatus?.(status); + return status; + } + + stop() { + this._running = false; + this._clearTimers(); + this._stopAllAwayLoops(false); + this._stopHealthMonitor(); + this._schedule = []; + this._firedToday = new Set(); + this._lastDate = null; + this._deviceHealth = new Map(); + this._triggerStates = new Map(); + return { running: false }; + } + + reload() { + if (!this._running) return; + this._stopAllAwayLoops(false); + this._loadSchedule(); + this._resumeAwayLoops(); + const status = this._buildStatus(); + this._onStatus?.(status); + return status; + } + + getStatus() { return this._buildStatus(); } + + // ── Schedule loading ───────────────────────────────────────────────────── + + _loadSchedule() { + const schedule = []; + const rules = store.getDwmRules(); + + for (const rule of rules) { + if (!rule.enabled) continue; + + // ── AlwaysOn / Trigger — handled entirely by the health-monitor poll ── + if (rule.type === 'AlwaysOn' || rule.type === 'Trigger') continue; + + // ── Away Mode — handled by the randomisation loop, not pre-computed entries ── + if (rule.type === 'Away') { + const startSecs = Number(rule.startTime ?? -1); + const endSecs = Number(rule.endTime ?? -1); + if (startSecs < 0) continue; // sun-based — skip for now + + for (const dayId of (rule.days ?? [])) { + const td0 = rule.targetDevices?.[0]; // for status display only + // Window-start entry: fires the away loop + schedule.push({ + ruleId: rule.id, + ruleName: rule.name, + targetHost: td0?.host ?? '', + targetPort: td0?.port ?? 0, + dayId: Number(dayId), + targetSecs: startSecs, + action: 1, + isAwayStart: true, + }); + // Window-end entry: stops the away loop + if (endSecs >= 0) { + schedule.push({ + ruleId: rule.id + '-away-end', + ruleName: rule.name, + targetHost: td0?.host ?? '', + targetPort: td0?.port ?? 0, + dayId: Number(dayId), + targetSecs: endSecs, + action: 0, + isAwayEnd: true, + awayRuleId: rule.id, + }); + } + } + continue; + } + + // ── Countdown with active window ───────────────────────────────────── + if (rule.type === 'Countdown') { + const windowStart = Number(rule.windowStart ?? -1); + const windowEnd = Number(rule.windowEnd ?? -1); + if (windowStart < 0 || !(rule.windowDays?.length)) continue; + + const crossesMidnight = windowEnd >= 0 && windowEnd < windowStart; + + for (const dayId of rule.windowDays) { + for (const td of (rule.targetDevices ?? [])) { + if (!td.host || !td.port) continue; + schedule.push({ ruleId: rule.id, ruleName: rule.name, + targetHost: td.host, targetPort: td.port, + dayId: Number(dayId), targetSecs: windowStart, action: 1 }); + + if (windowEnd >= 0) { + const offDayId = crossesMidnight ? (Number(dayId) % 7) + 1 : Number(dayId); + schedule.push({ ruleId: rule.id + '-wend', ruleName: rule.name, + targetHost: td.host, targetPort: td.port, + dayId: offDayId, targetSecs: windowEnd, action: 0 }); + } + } + } + continue; + } + + // ── Schedule / other time-based rules ──────────────────────────────── + const startSecs = Number(rule.startTime ?? -1); + const endSecs = Number(rule.endTime ?? -1); + const startAction = Number(rule.startAction ?? 1); + const endAction = Number(rule.endAction ?? -1); + if (startSecs < 0) continue; + + for (const dayId of (rule.days ?? [])) { + for (const td of (rule.targetDevices ?? [])) { + if (!td.host || !td.port) continue; + if (startAction >= 0) { + schedule.push({ ruleId: rule.id, ruleName: rule.name, + targetHost: td.host, targetPort: td.port, + dayId: Number(dayId), targetSecs: startSecs, action: startAction }); + } + if (endSecs > 0 && endAction >= 0) { + schedule.push({ ruleId: rule.id, ruleName: rule.name, + targetHost: td.host, targetPort: td.port, + dayId: Number(dayId), targetSecs: endSecs, action: endAction }); + } + } + } + } + + this._schedule = schedule; + this._lastDate = new Date().toDateString(); + } + + // ── Away Mode loop ─────────────────────────────────────────────────────── + + /** + * After load/reload, check if any Away Mode rule's window is currently active + * and start its randomisation loop if it isn't already running. + */ + _resumeAwayLoops() { + if (!this._running) return; + const now = new Date(); + const nowSecs = secondsFromMidnight(now); + const todayId = jsToWemoDayId(now.getDay()); + const rules = store.getDwmRules(); + + for (const rule of rules) { + if (!rule.enabled || rule.type !== 'Away') continue; + if (this._awayLoops.has(rule.id)) continue; // already running + + const startSecs = Number(rule.startTime ?? -1); + const endSecs = Number(rule.endTime ?? -1); + if (startSecs < 0) continue; + if (!(rule.days ?? []).includes(todayId)) continue; + + const inWindow = endSecs >= 0 + ? (startSecs <= endSecs ? (nowSecs >= startSecs && nowSecs < endSecs) + : (nowSecs >= startSecs || nowSecs < endSecs)) + : nowSecs >= startSecs; + + if (inWindow) { + this._startAwayLoop(rule); + } + } + } + + _startAwayLoop(rule) { + // Stop any existing loop for this rule first + const existing = this._awayLoops.get(rule.id); + if (existing?.timer) clearTimeout(existing.timer); + + const devices = (rule.targetDevices ?? []).filter(td => td.host && td.port); + if (!devices.length) return; + + const endSecs = Number(rule.endTime ?? -1); + const loop = { rule, devices, endSecs, timer: null, isOn: false }; + this._awayLoops.set(rule.id, loop); + + // Immediately turn ON to begin the cycle + this._awayStep(rule.id, true); + } + + _awayStep(ruleId, turnOn) { + if (!this._running) return; + + const loop = this._awayLoops.get(ruleId); + if (!loop) return; + + // Check we're still within the window + const nowSecs = secondsFromMidnight(new Date()); + if (loop.endSecs >= 0 && nowSecs >= loop.endSecs) { + this._stopAwayLoop(ruleId, true); + return; + } + + loop.isOn = turnOn; + + // Fire on all target devices + for (const td of loop.devices) { + wemo.setBinaryState(td.host, td.port, turnOn) + .then(() => { + this._onFire?.({ + success: true, + msg: `"${loop.rule.name}" Away Mode → ${turnOn ? 'ON' : 'OFF'} (${td.host}) ✓`, + entry: { action: turnOn ? 1 : 0 }, + }); + }) + .catch((e) => { + this._onFire?.({ + success: false, + msg: `"${loop.rule.name}" Away Mode → ${turnOn ? 'ON' : 'OFF'} FAILED (${td.host}): ${e.message}`, + entry: { action: turnOn ? 1 : 0 }, + }); + }); + } + + // ON for 30–90 minutes, OFF for 1–15 minutes (matches Wemo firmware behaviour) + const delaySecs = turnOn + ? randBetween(30, 90) * 60 + : randBetween(1, 15) * 60; + + // Don't schedule a next step past the window end + if (loop.endSecs >= 0) { + const remaining = loop.endSecs - nowSecs; + if (delaySecs >= remaining) { + // Window will end before the next toggle — let window-end entry handle OFF + return; + } + } + + loop.timer = setTimeout(() => this._awayStep(ruleId, !turnOn), delaySecs * 1000); + } + + _stopAwayLoop(ruleId, forceOff) { + const loop = this._awayLoops.get(ruleId); + if (!loop) return; + if (loop.timer) clearTimeout(loop.timer); + this._awayLoops.delete(ruleId); + + if (forceOff) { + for (const td of loop.devices) { + wemo.setBinaryState(td.host, td.port, false).catch(() => {}); + } + this._onFire?.({ + success: true, + msg: `"${loop.rule.name}" Away Mode window ended — all devices OFF`, + entry: { action: 0 }, + }); + } + } + + _stopAllAwayLoops(forceOff) { + for (const [id] of this._awayLoops) this._stopAwayLoop(id, forceOff); + } + + // ── Tick / scheduling ──────────────────────────────────────────────────── + + _tick() { + if (!this._running) return; + const now = new Date(); + const today = now.toDateString(); + + if (today !== this._lastDate) { + this._firedToday = new Set(); + this._stopAllAwayLoops(false); + this._loadSchedule(); + this._scheduleUpcoming(); + this._resumeAwayLoops(); + this._onStatus?.(this._buildStatus()); + } else { + this._scheduleUpcoming(); + } + + this._tickTimer = setTimeout(() => this._tick(), 30_000); + } + + // ── Missed-rule catch-up ───────────────────────────────────────────────── + + /** + * On start, fire any Schedule/Countdown entries whose time fell within the + * last CATCHUP_WINDOW_S seconds (i.e. the app/service was down when they + * were supposed to run). Away Mode windows are handled by _resumeAwayLoops. + */ + _catchUpMissedRules() { + if (!this._schedule.length) return; + + const now = new Date(); + const nowSecs = secondsFromMidnight(now); + const todayId = jsToWemoDayId(now.getDay()); + const missed = []; + + for (const entry of this._schedule) { + if (entry.isAwayStart || entry.isAwayEnd) continue; + if (entry.dayId !== todayId) continue; + + const age = nowSecs - entry.targetSecs; + if (age <= 0 || age > CATCHUP_WINDOW_S) continue; + + const key = `${entry.ruleId}-${entry.dayId}-${entry.targetSecs}-${entry.targetHost}`; + if (this._firedToday.has(key)) continue; + + missed.push({ entry, key }); + } + + for (const { entry, key } of missed) { + this._firedToday.add(key); + this._onFire?.({ success: true, msg: `[catch-up] "${entry.ruleName}" → ${actionLabel(entry.action)} (${entry.targetHost})`, entry }); + this._fire(entry); + } + + if (missed.length) { + this._onStatus?.(this._buildStatus()); + } + } + + // ── Health monitor ──────────────────────────────────────────────────────── + + _startHealthMonitor() { + if (this._healthTimer) return; + // Small initial delay so start() returns quickly before first poll + this._healthTimer = setTimeout(() => this._pollDeviceHealth(), 15_000); + } + + _stopHealthMonitor() { + if (this._healthTimer) { clearTimeout(this._healthTimer); this._healthTimer = null; } + } + + /** + * Collect every unique host:port referenced in enabled DWM rules, + * probe each one, track online/offline state, and emit _onHealth events + * on transitions. When a device comes back online, enforce the state + * it should currently be in according to the active schedule. + */ + async _pollDeviceHealth() { + if (!this._running) return; + + // Build device map: all targets + trigger source devices + const deviceMap = new Map(); // 'host:port' → { host, port, name } + const allRules = store.getDwmRules(); + const alwaysOnSet = new Set(); // keys with an active AlwaysOn rule + const triggerSrcSet = new Set(); // keys that are trigger source devices + + const addDev = (td) => { + if (!td?.host || !td?.port) return; + const key = `${td.host}:${td.port}`; + if (!deviceMap.has(key)) + deviceMap.set(key, { host: td.host, port: Number(td.port), name: td.name ?? td.host }); + return key; + }; + + for (const rule of allRules) { + if (!rule.enabled) continue; + if (rule.type === 'Trigger') { + const k = addDev(rule.triggerDevice); + if (k) triggerSrcSet.add(k); + for (const td of (rule.actionDevices ?? [])) addDev(td); + continue; + } + for (const td of (rule.targetDevices ?? [])) { + const k = addDev(td); + if (k && rule.type === 'AlwaysOn') alwaysOnSet.add(k); + } + } + + for (const [key, dev] of deviceMap) { + const wasOnline = this._deviceHealth.get(key); // undefined = first check + + try { + const isOn = await wemo.getBinaryState(dev.host, dev.port); + + if (wasOnline === false) { + // ── Just came back online ────────────────────────────────────── + this._deviceHealth.set(key, true); + this._onHealth?.({ ...dev, online: true, + msg: `${dev.name} came back online` }); + await this._enforceCurrentState(dev); + } else { + this._deviceHealth.set(key, true); + if (wasOnline === undefined) { + this._onHealth?.({ ...dev, online: true, msg: `${dev.name} online` }); + } + } + + // ── AlwaysOn enforcement ────────────────────────────────────────── + if (alwaysOnSet.has(key) && !isOn) { + try { + await wemo.setBinaryState(dev.host, dev.port, true); + this._onFire?.({ success: true, + msg: `[always-on] ${dev.name} was OFF — turned ON ✓` }); + } catch (e) { + this._onFire?.({ success: false, + msg: `[always-on] ${dev.name} turn-ON failed: ${e.message}` }); + } + } + + // ── Trigger detection — fire rules if this device changed state ── + if (triggerSrcSet.has(key)) { + const prevState = this._triggerStates.get(key); + this._triggerStates.set(key, isOn); + if (prevState !== undefined && prevState !== isOn) { + await this._fireTriggerRules(key, isOn); + } + } + + } catch (e) { + this._deviceHealth.set(key, false); + if (wasOnline !== false) { + this._onHealth?.({ ...dev, online: false, + msg: `${dev.name} unreachable: ${e.message}` }); + } + } + } + + this._onStatus?.(this._buildStatus()); + + // Schedule next poll + if (this._running) { + this._healthTimer = setTimeout(() => this._pollDeviceHealth(), HEALTH_POLL_MS); + } + } + + /** + * For a device that just came back online, find the most recent Schedule + * entry that should have fired today and push that state to the device. + */ + async _enforceCurrentState(dev) { + const now = new Date(); + const nowSecs = secondsFromMidnight(now); + const todayId = jsToWemoDayId(now.getDay()); + + // Find the most-recently-past entry for this device today + let best = null; + for (const entry of this._schedule) { + if (entry.isAwayStart || entry.isAwayEnd) continue; + if (entry.targetHost !== dev.host) continue; + if (entry.dayId !== todayId) continue; + if (entry.targetSecs > nowSecs) continue; // hasn't fired yet today + + if (!best || entry.targetSecs > best.targetSecs) best = entry; + } + + if (!best) return; // no rule has fired today for this device + + const wantOn = best.action === 1; + try { + await wemo.setBinaryState(dev.host, dev.port, wantOn); + this._onFire?.({ + success: true, + msg: `[enforce] "${best.ruleName}" → ${actionLabel(best.action)} restored on ${dev.name} ✓`, + entry: best, + }); + } catch (e) { + this._onFire?.({ + success: false, + msg: `[enforce] "${best.ruleName}" → ${actionLabel(best.action)} FAILED on ${dev.name}: ${e.message}`, + entry: best, + }); + } + } + + // ── Trigger rules ───────────────────────────────────────────────────────── + + /** + * A trigger device changed state. Find every enabled Trigger rule whose + * triggerDevice matches sourceKey and whose triggerEvent matches, then + * fire the action on each actionDevice. + * + * triggerEvent: 'on' | 'off' | 'any' + * action: 'on' | 'off' | 'mirror' | 'opposite' + */ + async _fireTriggerRules(sourceKey, isOn) { + const rules = store.getDwmRules().filter((r) => + r.enabled && + r.type === 'Trigger' && + r.triggerDevice?.host && + `${r.triggerDevice.host}:${r.triggerDevice.port}` === sourceKey + ); + + for (const rule of rules) { + const matches = + rule.triggerEvent === 'any' || + (rule.triggerEvent === 'on' && isOn) || + (rule.triggerEvent === 'off' && !isOn); + if (!matches) continue; + + let targetOn; + if (rule.action === 'on') targetOn = true; + else if (rule.action === 'off') targetOn = false; + else if (rule.action === 'mirror') targetOn = isOn; + else if (rule.action === 'opposite') targetOn = !isOn; + else continue; + + for (const dev of (rule.actionDevices ?? [])) { + if (!dev.host || !dev.port) continue; + try { + await wemo.setBinaryState(dev.host, Number(dev.port), targetOn); + this._onFire?.({ success: true, + msg: `[trigger] "${rule.name}" → ${dev.name ?? dev.host} ${targetOn ? 'ON' : 'OFF'} ✓` }); + } catch (e) { + this._onFire?.({ success: false, + msg: `[trigger] "${rule.name}" → ${dev.name ?? dev.host} FAILED: ${e.message}` }); + } + } + } + } + + // ── Timers ──────────────────────────────────────────────────────────────── + + _clearTimers() { + for (const t of this._timers) clearTimeout(t); + this._timers = []; + if (this._tickTimer) { clearTimeout(this._tickTimer); this._tickTimer = null; } + } + + _scheduleUpcoming() { + if (!this._running) return; + const now = new Date(); + const nowSecs = secondsFromMidnight(now); + const todayId = jsToWemoDayId(now.getDay()); + const dayStart = new Date(now); dayStart.setHours(0, 0, 0, 0); + const windowEnd = nowSecs + 65; + + for (const entry of this._schedule) { + if (entry.dayId !== todayId) continue; + if (entry.targetSecs < nowSecs - 5) continue; + if (entry.targetSecs > windowEnd) continue; + + const key = `${entry.ruleId}-${entry.dayId}-${entry.targetSecs}-${entry.targetHost}`; + if (this._firedToday.has(key)) continue; + this._firedToday.add(key); + + const fireAt = dayStart.getTime() + entry.targetSecs * 1000; + const delay = Math.max(0, fireAt - Date.now()); + const t = setTimeout(() => this._fire(entry), delay); + this._timers.push(t); + } + } + + async _fire(entry) { + // Away Mode start — kick off the randomisation loop + if (entry.isAwayStart) { + const rule = store.getDwmRules().find(r => r.id === entry.ruleId); + if (rule && rule.enabled) { + this._startAwayLoop(rule); + this._onFire?.({ success: true, msg: `"${entry.ruleName}" Away Mode started`, entry }); + } + return; + } + + // Away Mode end — stop the loop and force all devices OFF + if (entry.isAwayEnd) { + this._stopAwayLoop(entry.awayRuleId, true); + return; + } + + // Normal on/off command + const label = actionLabel(entry.action); + const wantOn = entry.action === 1; + try { + await wemo.setBinaryState(entry.targetHost, entry.targetPort, wantOn); + + // Verify 3 s later + await new Promise((r) => setTimeout(r, 3000)); + let confirmed = true; + try { + const state = await wemo.getBinaryState(entry.targetHost, entry.targetPort); + confirmed = (!!state) === wantOn; + } catch { confirmed = null; } + + const suffix = confirmed === null ? ' (unverified)' : confirmed ? ' ✓' : ' ⚠ retrying'; + this._onFire?.({ success: true, msg: `"${entry.ruleName}" → ${label} (${entry.targetHost})${suffix}`, entry }); + + if (confirmed === false) { + await new Promise((r) => setTimeout(r, 5000)); + try { + await wemo.setBinaryState(entry.targetHost, entry.targetPort, wantOn); + this._onFire?.({ success: true, msg: `"${entry.ruleName}" → ${label} retry OK`, entry }); + } catch { /* silent */ } + } + } catch (e) { + this._onFire?.({ success: false, msg: `"${entry.ruleName}" → ${label} FAILED: ${e.message}`, entry }); + } + } + + // ── Status ─────────────────────────────────────────────────────────────── + + _buildStatus() { + const now = new Date(); + const nowSecs = secondsFromMidnight(now); + const todayId = jsToWemoDayId(now.getDay()); + + // Active away loops → show as "Away Mode active" + const awayActive = []; + for (const [, loop] of this._awayLoops) { + awayActive.push({ + ruleName: loop.rule.name, + action: loop.isOn ? 'ON (Away)' : 'OFF (Away)', + at: 'now', + }); + } + + const seen = new Set(); + const upcoming = this._schedule + .filter(e => e.dayId === todayId && e.targetSecs > nowSecs && !e.isAwayEnd) + .sort((a, b) => a.targetSecs - b.targetSecs) + .reduce((acc, e) => { + const key = `${e.ruleId}|${e.targetSecs}|${e.action}|${e.targetHost}`; + if (!seen.has(key)) { + seen.add(key); + acc.push({ + ruleName: e.ruleName, + targetHost: e.targetHost, + action: e.isAwayStart ? 'Away Mode start' : actionLabel(e.action), + at: secsToHHMM(e.targetSecs), + }); + } + return acc; + }, []) + .slice(0, 8); + + return { + running: this._running, + totalEntries: this._schedule.length, + awayActive, + upcoming: [...awayActive, ...upcoming].slice(0, 8), + }; + } +} + +const scheduler = new LocalScheduler(); +module.exports = scheduler; diff --git a/apps/desktop/src/main/service-manager-sync.js b/apps/desktop/src/main/service-manager-sync.js new file mode 100644 index 0000000..3660d29 --- /dev/null +++ b/apps/desktop/src/main/service-manager-sync.js @@ -0,0 +1,28 @@ +'use strict'; + +/** + * Thin helper: writes the device list to the shared ProgramData file so the + * Windows service always has up-to-date host/port/udn mappings. + * Kept separate so it can be required without pulling in node-windows. + */ + +const path = require('path'); +const fs = require('fs'); + +const PROGRAMDATA_DIR = path.join('C:\\ProgramData', 'DibbyWemoManager'); +const PROGRAMDATA_DEVICES = path.join(PROGRAMDATA_DIR, 'devices.json'); + +function syncDevices(devices) { + try { + fs.mkdirSync(PROGRAMDATA_DIR, { recursive: true }); + fs.writeFileSync( + PROGRAMDATA_DEVICES, + JSON.stringify({ devices, updatedAt: new Date().toISOString() }, null, 2), + 'utf8', + ); + } catch (e) { + console.warn('[service-sync] Could not write devices.json:', e.message); + } +} + +module.exports = { syncDevices }; diff --git a/apps/desktop/src/main/service-manager.js b/apps/desktop/src/main/service-manager.js new file mode 100644 index 0000000..454210b --- /dev/null +++ b/apps/desktop/src/main/service-manager.js @@ -0,0 +1,127 @@ +'use strict'; + +/** + * Windows Service manager for the Dibby Wemo Scheduler. + * + * Uses node-windows to register/unregister a Windows service that: + * - Starts automatically at boot (no user login required) + * - Runs under LocalSystem account + * - Reads devices from C:\ProgramData\DibbyWemoManager\devices.json + */ + +const path = require('path'); +const fs = require('fs'); + +const SERVICE_NAME = 'DibbyWemoScheduler'; +const SERVICE_DESC = 'Dibby Wemo Scheduler — fires Wemo device rules on schedule (local, no cloud)'; + +// Path to the standalone scheduler script (works in both dev and packaged) +function getScriptPath() { + // In packaged app: resources/app.asar.unpacked/... or alongside the exe + // In dev: src/main/scheduler-standalone.js + const devPath = path.join(__dirname, 'scheduler-standalone.js'); + if (fs.existsSync(devPath)) return devPath; + // Packaged (electron-builder extraResources / asarUnpack) + return path.join(process.resourcesPath, 'scheduler-standalone.js'); +} + +// Path to node.exe: use the bundled Electron node, or fall back to system node +function getNodePath() { + // Electron exposes its own node as process.execPath only when running as node + // For a service we need a real node.exe, not electron.exe + // Check for bundled node next to the app executable + const candidates = [ + path.join(path.dirname(process.execPath), 'node.exe'), + path.join(process.resourcesPath || '', '..', 'node.exe'), + 'node', // system PATH + ]; + for (const c of candidates) { + try { if (c === 'node' || fs.existsSync(c)) return c; } catch { /* skip */ } + } + return 'node'; +} + +function makeService() { + const { Service } = require('node-windows'); + return new Service({ + name: SERVICE_NAME, + description: SERVICE_DESC, + script: getScriptPath(), + nodeOptions: [], + execPath: getNodePath(), + }); +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** Install and start the Windows service. Returns a Promise. */ +function installService() { + return new Promise((resolve, reject) => { + const svc = makeService(); + svc.on('install', () => { + svc.start(); + resolve({ ok: true, msg: 'Service installed and started.' }); + }); + svc.on('alreadyinstalled', () => { + svc.start(); + resolve({ ok: true, msg: 'Service was already installed — started.' }); + }); + svc.on('error', (e) => reject(new Error(e?.message || String(e)))); + svc.install(); + }); +} + +/** Stop and uninstall the Windows service. Returns a Promise. */ +function uninstallService() { + return new Promise((resolve, reject) => { + const svc = makeService(); + svc.on('uninstall', () => resolve({ ok: true, msg: 'Service uninstalled.' })); + svc.on('error', (e) => reject(new Error(e?.message || String(e)))); + svc.stop(); + setTimeout(() => svc.uninstall(), 2000); + }); +} + +/** Start the service (if already installed). */ +function startService() { + return new Promise((resolve, reject) => { + const svc = makeService(); + svc.on('start', () => resolve({ ok: true, msg: 'Service started.' })); + svc.on('error', (e) => reject(new Error(e?.message || String(e)))); + svc.start(); + }); +} + +/** Stop the service. */ +function stopService() { + return new Promise((resolve, reject) => { + const svc = makeService(); + svc.on('stop', () => resolve({ ok: true, msg: 'Service stopped.' })); + svc.on('error', (e) => reject(new Error(e?.message || String(e)))); + svc.stop(); + }); +} + +/** + * Check if the service is installed and running using sc.exe (no node-windows needed). + * Returns { installed, running, status } + */ +function getServiceStatus() { + return new Promise((resolve) => { + const { exec } = require('child_process'); + exec(`sc query "${SERVICE_NAME}"`, (err, stdout) => { + if (err || stdout.includes('FAILED') || stdout.includes('does not exist')) { + return resolve({ installed: false, running: false, status: 'Not installed' }); + } + const running = stdout.includes('RUNNING'); + const stopped = stdout.includes('STOPPED'); + resolve({ + installed: true, + running, + status: running ? 'Running' : stopped ? 'Stopped' : 'Unknown', + }); + }); + }); +} + +module.exports = { installService, uninstallService, startService, stopService, getServiceStatus }; diff --git a/apps/desktop/src/main/store.js b/apps/desktop/src/main/store.js new file mode 100644 index 0000000..71366c9 --- /dev/null +++ b/apps/desktop/src/main/store.js @@ -0,0 +1,94 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const { app } = require('electron'); + +const DEFAULTS = { + location: null, + theme: 'dark', + devices: [], + deviceGroups: [], + deviceOrder: [], + disabledRules: {}, +}; + +function storePath() { + return path.join(app.getPath('userData'), 'wemo-manager.json'); +} + +function load() { + try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(storePath(), 'utf8')) }; } + catch { return { ...DEFAULTS }; } +} + +function save(data) { + fs.writeFileSync(storePath(), JSON.stringify(data, null, 2), 'utf8'); +} + +// Location +function getLocation() { return load().location; } +function setLocation(loc) { const d = load(); d.location = loc; save(d); } + +// Theme +function getTheme() { return load().theme ?? 'dark'; } +function setTheme(theme) { const d = load(); d.theme = theme; save(d); } + +// Devices +function getDevices() { return load().devices ?? []; } +function saveDevices(list) { const d = load(); d.devices = list; save(d); } +function getDeviceOrder() { return load().deviceOrder ?? []; } +function saveDeviceOrder(order) { const d = load(); d.deviceOrder = order; save(d); } +function getDeviceGroups() { return load().deviceGroups ?? []; } +function saveDeviceGroups(groups) { const d = load(); d.deviceGroups = groups; save(d); } + +// Disabled-rule backups +function getDisabledRules() { return load().disabledRules ?? {}; } +function setDisabledRule(key, ruleDevicesRows) { const d = load(); if (!d.disabledRules) d.disabledRules = {}; d.disabledRules[key] = ruleDevicesRows; save(d); } +function clearDisabledRule(key) { const d = load(); if (!d.disabledRules) return; delete d.disabledRules[key]; save(d); } + +// ── DWM Rules — local app database ───────────────────────────────────────── +// Rules are stored entirely on disk (not on the Wemo device). +// Schema per rule: { id, name, type, enabled, days[], startTime, endTime, +// startAction, endAction, startType, endType, startOffset, endOffset, +// countdownTime, targetDevices[{udn,host,port,name}], createdAt, updatedAt } + +function getDwmRules() { + return load().dwmRules ?? []; +} + +function createDwmRule(rule) { + const d = load(); + if (!d.dwmRules) d.dwmRules = []; + const id = `dwm-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const now = new Date().toISOString(); + const newRule = { ...rule, id, createdAt: now, updatedAt: now }; + d.dwmRules.push(newRule); + save(d); + return newRule; +} + +function updateDwmRule(id, updates) { + const d = load(); + if (!d.dwmRules) d.dwmRules = []; + const idx = d.dwmRules.findIndex((r) => r.id === id); + if (idx === -1) throw new Error(`DWM rule not found: ${id}`); + d.dwmRules[idx] = { ...d.dwmRules[idx], ...updates, id, updatedAt: new Date().toISOString() }; + save(d); + return d.dwmRules[idx]; +} + +function deleteDwmRule(id) { + const d = load(); + if (!d.dwmRules) return; + d.dwmRules = d.dwmRules.filter((r) => r.id !== id); + save(d); +} + +module.exports = { + getLocation, setLocation, + getTheme, setTheme, + getDevices, saveDevices, getDeviceOrder, saveDeviceOrder, getDeviceGroups, saveDeviceGroups, + getDisabledRules, setDisabledRule, clearDisabledRule, + getDwmRules, createDwmRule, updateDwmRule, deleteDwmRule, +}; diff --git a/apps/desktop/src/main/web-server.js b/apps/desktop/src/main/web-server.js new file mode 100644 index 0000000..27cb616 --- /dev/null +++ b/apps/desktop/src/main/web-server.js @@ -0,0 +1,321 @@ +'use strict'; + +const http = require('http'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const WEB_PORT_BASE = 3456; + +let _server = null; +let _wss = null; +let _scheduler = null; +let _activePort = WEB_PORT_BASE; +let _ready = false; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function getLocalIP() { + const ifaces = os.networkInterfaces(); + for (const iface of Object.values(ifaces)) { + for (const addr of iface) { + if (addr.family === 'IPv4' && !addr.internal) return addr.address; + } + } + return 'localhost'; +} + +function json(res, data, status = 200) { + const body = JSON.stringify(data); + res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); + res.end(body); +} + +function err(res, msg, status = 500) { + json(res, { error: msg }, status); +} + +function broadcast(type, data) { + if (!_wss) return; + const msg = JSON.stringify({ type, data }); + for (const client of _wss.clients) { + if (client.readyState === 1 /* OPEN */) client.send(msg); + } +} + +// ── Static file serving ─────────────────────────────────────────────────────── + +function getWebDir() { + try { + const { app } = require('electron'); + return app.isPackaged + ? path.join(process.resourcesPath, 'web') + : path.join(__dirname, '..', '..', 'resources', 'web'); + } catch { + return path.join(__dirname, '..', '..', 'resources', 'web'); + } +} + +function getResourcesDir() { + try { + const { app } = require('electron'); + return app.isPackaged + ? process.resourcesPath + : path.join(__dirname, '..', '..', 'resources'); + } catch { + return path.join(__dirname, '..', '..', 'resources'); + } +} + +function serveResourceFile(res, filename) { + const file = path.join(getResourcesDir(), filename); + fs.readFile(file, (readErr, data) => { + if (readErr) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(data); + }); +} + +function serveStatic(req, res) { + const webDir = getWebDir(); + const file = path.join(webDir, 'index.html'); + fs.readFile(file, (readErr, data) => { + if (readErr) { + res.writeHead(404); res.end('Not found'); + } else { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(data); + } + }); +} + +// ── Request router ──────────────────────────────────────────────────────────── + +async function handleRequest(req, res, store, wemo) { + const url = req.url.split('?')[0]; + const method = req.method.toUpperCase(); + + // CORS preflight + if (method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE', + 'Access-Control-Allow-Headers': 'Content-Type', + }); + res.end(); + return; + } + + // Parse JSON body for POST/PUT + const body = await new Promise((resolve) => { + if (method !== 'POST' && method !== 'PUT') return resolve({}); + let raw = ''; + req.on('data', (chunk) => { raw += chunk; }); + req.on('end', () => { + try { resolve(JSON.parse(raw)); } catch { resolve({}); } + }); + }); + + try { + // ── Devices ──────────────────────────────────────────────────────────── + + if (url === '/api/devices' && method === 'GET') { + return json(res, store.getDevices()); + } + + if (url === '/api/devices/discover' && method === 'POST') { + const saved = store.getDevices(); + const manual = saved.map((d) => ({ host: d.host, port: d.port })); + const devices = await wemo.discoverDevices(8000, manual); + store.saveDevices(devices); + return json(res, devices); + } + + // GET /api/devices/:host/:port/state + const stateMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/state$/); + if (stateMatch) { + const [, host, port] = stateMatch; + if (method === 'GET') { + const on = await wemo.getBinaryState(host, Number(port)); + return json(res, { on }); + } + if (method === 'POST') { + const { on } = body; + await wemo.setBinaryState(host, Number(port), !!on); + return json(res, { ok: true }); + } + } + + // ── DWM Rules ────────────────────────────────────────────────────────── + + if (url === '/api/dwm-rules') { + if (method === 'GET') return json(res, store.getDwmRules()); + if (method === 'POST') { + const rule = store.createDwmRule(body); + _scheduler?.reload?.(); + return json(res, rule, 201); + } + } + + // PUT /api/dwm-rules/:id DELETE /api/dwm-rules/:id + const ruleMatch = url.match(/^\/api\/dwm-rules\/(.+)$/); + if (ruleMatch) { + const id = ruleMatch[1]; + if (method === 'PUT') { + const updated = store.updateDwmRule(id, body); + _scheduler?.reload?.(); + return json(res, updated); + } + if (method === 'DELETE') { + store.deleteDwmRule(id); + _scheduler?.reload?.(); + return json(res, { ok: true }); + } + } + + // ── Wemo Device Rules ────────────────────────────────────────────────── + + // GET /api/devices/:host/:port/rules + const wemoRulesMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/rules$/); + if (wemoRulesMatch && method === 'GET') { + const [, host, port] = wemoRulesMatch; + const rules = await wemo.getRules(host, Number(port)); + return json(res, rules); + } + + // PUT /api/devices/:host/:port/rules/:ruleId + const wemoRuleMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/rules\/(\d+)$/); + if (wemoRuleMatch && method === 'PUT') { + const [, host, port, ruleId] = wemoRuleMatch; + await wemo.updateRule(host, Number(port), Number(ruleId), body); + return json(res, { ok: true }); + } + + // ── Scheduler ────────────────────────────────────────────────────────── + + if (url === '/api/scheduler/status' && method === 'GET') { + const status = _scheduler?.getStatus?.() ?? { running: false, entries: [] }; + return json(res, status); + } + + // ── QR code page ─────────────────────────────────────────────────────── + + if (url === '/qr' && method === 'GET') { + let qrcode; + try { qrcode = require('qrcode'); } catch { return err(res, 'qrcode package not installed', 503); } + const remoteURL = getURL(); + const svgStr = await qrcode.toString(remoteURL, { type: 'svg', margin: 1, width: 260 }); + const html = ` + +DWM Web Remote — QR Code + +

📱 DWM Web Remote

+
${svgStr}
+
${remoteURL}
+
Scan with your phone camera to open the remote control
+`; + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(html); + return; + } + + // ── Help & About pages ───────────────────────────────────────────────── + + if (url === '/help' && method === 'GET') return serveResourceFile(res, 'help.html'); + if (url === '/about' && method === 'GET') return serveResourceFile(res, 'about.html'); + if (url === '/icon.png' && method === 'GET') { + const file = path.join(getResourcesDir(), 'icon.png'); + fs.readFile(file, (readErr, data) => { + if (readErr) { res.writeHead(404); res.end(); return; } + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(data); + }); + return; + } + + // ── Fallback → serve web UI ──────────────────────────────────────────── + + serveStatic(req, res); + + } catch (e) { + err(res, e.message); + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +function start(scheduler, store, wemo) { + if (_server) return getLocalIP(); + + _scheduler = scheduler; + + // HTTP server + _server = http.createServer((req, res) => handleRequest(req, res, store, wemo)); + + // WebSocket — attach to same HTTP server + let WebSocketServer; + try { + WebSocketServer = require('ws').WebSocketServer || require('ws').Server; + } catch { + WebSocketServer = null; + } + + if (WebSocketServer) { + _wss = new WebSocketServer({ server: _server }); + _wss.on('connection', (ws) => { + const status = _scheduler?.getStatus?.() ?? { running: false }; + ws.send(JSON.stringify({ type: 'scheduler-status', data: status })); + }); + } + + // Wire scheduler events → WebSocket broadcast + if (scheduler) { + scheduler.onFire = (event) => broadcast('scheduler-fired', event); + scheduler.onStatus = (status) => broadcast('scheduler-status', status); + } + + // Try ports WEB_PORT_BASE … WEB_PORT_BASE+9, skip any that are in use + _server.on('error', (e) => { + if (e.code === 'EADDRINUSE' && _activePort < WEB_PORT_BASE + 9) { + _activePort++; + _server.listen(_activePort, '0.0.0.0'); + } else { + console.error(`[DWM Web Remote] Could not bind to any port (last tried ${_activePort}):`, e.message); + _server = null; + _wss = null; + } + }); + + _ready = false; + _activePort = WEB_PORT_BASE; + _server.listen(_activePort, '0.0.0.0', () => { + _ready = true; + console.log(`[DWM Web Remote] http://${getLocalIP()}:${_activePort}`); + }); + + return getLocalIP(); +} + +function stop() { + _wss?.close?.(); + _server?.close?.(); + _server = null; + _wss = null; +} + +function getURL() { + return _ready ? `http://${getLocalIP()}:${_activePort}` : `http://${getLocalIP()}:${WEB_PORT_BASE}`; +} + +function isReady() { return _ready; } + +module.exports = { start, stop, getURL, isReady, WEB_PORT: WEB_PORT_BASE }; diff --git a/apps/desktop/src/main/wemo.js b/apps/desktop/src/main/wemo.js new file mode 100644 index 0000000..faef44c --- /dev/null +++ b/apps/desktop/src/main/wemo.js @@ -0,0 +1,1017 @@ +'use strict'; + +/** + * Wemo SOAP client + discovery + rules CRUD. + * Runs in the Electron main process (Node.js). + */ + +const dgram = require('dgram'); +const path = require('path'); +const http = require('http'); +const axios = require('axios'); +const sun = require('./core/sun'); +const AdmZip = require('adm-zip'); +const { parseStringPromise } = require('xml2js'); +const { create } = require('xmlbuilder2'); +const { namesToDayNumbers, timeToSecs } = require('./core/types'); + +// Wemo devices close the socket immediately after each response. +const NO_KEEPALIVE = new http.Agent({ keepAlive: false }); + +// Map our UI rule types → firmware-expected type strings (confirmed from real device DB) +const RULE_TYPE_TO_DEVICE = { + 'Schedule': 'Time Interval', + 'Countdown': 'Countdown Rule', + 'Away': 'Away Mode', +}; + +// --------------------------------------------------------------------------- +// Location (for LOCATIONINFO population) +// --------------------------------------------------------------------------- + +let _location = null; +function setLocation(loc) { _location = loc; } +exports.setLocation = setLocation; + +// --------------------------------------------------------------------------- +// Product model resolution +// --------------------------------------------------------------------------- + +function resolveProductModel(udn, deviceType, firmwareSuffix) { + const udnBase = String(udn || '').replace(/^uuid:/i, ''); + const parts = udnBase.split('-'); + const udnPrefix = parts.slice(0, 2).join('-').toLowerCase(); + const udnType = parts[0].toLowerCase(); + const fwSuffix = String(firmwareSuffix || '').toUpperCase(); + const dt = String(deviceType || '').toLowerCase(); + + if (udnPrefix === 'lightswitch-3_0') return 'Wemo 3-Way Smart Switch (WLS0403)'; + if (udnPrefix === 'lightswitch-2_0') return 'Wemo Light Switch (WLS040)'; + if (udnPrefix === 'lightswitch-1_0') { + if (fwSuffix.includes('OWRT-LS')) return 'Wemo Light Switch (F7C030)'; + return 'Wemo Light Switch (WLS040)'; + } + if (udnType === 'dimmer' || dt.includes('dimmer') || fwSuffix.includes('WDS')) + return 'Wemo WiFi Smart Dimmer (WDS060)'; + if (udnType === 'insight' || dt.includes('insight')) return 'Wemo Insight Smart Plug (F7C029)'; + if (udnPrefix === 'socket-2_0') return 'Wemo Mini Smart Plug (F7C063)'; + if (udnPrefix === 'socket-1_0') { + if (fwSuffix.includes('OWRT-SNS')) return 'Wemo Switch (F7C027)'; + return 'Wemo Smart Plug'; + } + if (udnType === 'socket') return 'Wemo Smart Plug'; + if (udnType === 'wsp' || fwSuffix.includes('WSP100')) return 'Wemo Smart Plug with Thread (WSP100)'; + if (fwSuffix.includes('WSP080')) return 'Wemo WiFi Smart Plug (WSP080)'; + if (udnType === 'scene' || dt.includes('scene')) return 'Wemo Stage Scene Controller (WSC010)'; + if (udnType === 'sensor' || dt.includes('sensor')) return 'Wemo Switch + Motion (F5Z0340)'; + if (udnType === 'bridge' || dt.includes('bridge')) return 'Wemo Bridge (F7C074)'; + if (udnType === 'doorbell' || dt.includes('doorbell')) return 'Wemo Smart Video Doorbell (WDC010)'; + return null; +} + +// --------------------------------------------------------------------------- +// sql.js (WASM SQLite) +// --------------------------------------------------------------------------- + +let SQL = null; +async function getSql() { + if (!SQL) { + const fs = require('fs'); + const initSqlJs = require('sql.js'); + + // Resolve the WASM binary directly with fs.readFileSync so Emscripten never + // falls back to fetch() (which hangs in Electron's main process on bad paths). + // Monorepo: sql.js lives 4 levels above out/main/ in the workspace root. + // Try several candidate paths so dev and packaged builds both work. + const candidates = [ + // standalone service bundle: sql-wasm.wasm copied next to the script in resources/ + path.join(__dirname, 'sql-wasm.wasm'), + // monorepo workspace root (dev + npm run build) + path.join(__dirname, '..', '..', '..', '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'), + // local node_modules (if hoisted differently) + path.join(__dirname, '..', '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'), + // packaged asar.unpacked + path.join(process.resourcesPath || '', 'app.asar.unpacked', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'), + ]; + + let wasmBinary = null; + for (const p of candidates) { + try { wasmBinary = fs.readFileSync(p); break; } catch { /* try next */ } + } + if (!wasmBinary) throw new Error(`sql-wasm.wasm not found. Tried:\n${candidates.join('\n')}`); + + SQL = await initSqlJs({ wasmBinary }); + } + return SQL; +} + +// --------------------------------------------------------------------------- +// SOAP helpers +// --------------------------------------------------------------------------- + +const WEMO_PORTS = [49153, 49152, 49154, 49155, 49156]; + +async function soapRequest(host, port, controlURL, serviceType, action, args = {}, timeoutMs = 10_000) { + const url = `http://${host}:${port}${controlURL}`; + const root = create({ version: '1.0', encoding: 'utf-8' }) + .ele('s:Envelope', { 'xmlns:s': 'http://schemas.xmlsoap.org/soap/envelope/', 's:encodingStyle': 'http://schemas.xmlsoap.org/soap/encoding/' }) + .ele('s:Body') + .ele(`u:${action}`, { [`xmlns:u`]: serviceType }); + for (const [k, v] of Object.entries(args)) root.ele(k).txt(v); + const xml = root.doc().end({ headless: false }); + + const res = await axios.post(url, xml, { + headers: { + 'Content-Type': 'text/xml; charset="utf-8"', + 'SOAPACTION': `"${serviceType}#${action}"`, + 'Connection': 'close', + }, + httpAgent: NO_KEEPALIVE, + timeout: timeoutMs, + }); + const parsed = await parseStringPromise(res.data, { explicitArray: false, ignoreAttrs: true }); + const body = parsed['s:Envelope']['s:Body']; + return body[`u:${action}Response`] ?? body; +} + +async function soapWithFallback(host, port, controlURL, serviceType, action, args = {}) { + const portsToTry = [port, ...WEMO_PORTS.filter((p) => p !== port)]; + let lastErr = null; + for (const tryPort of portsToTry) { + try { + return await soapRequest(host, tryPort, controlURL, serviceType, action, args); + } catch (err) { + lastErr = err; + const isConn = err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT'; + if (!isConn) throw err; + } + } + throw lastErr || new Error(`${host}: all ports failed for ${action}`); +} + +// --------------------------------------------------------------------------- +// Device control +// --------------------------------------------------------------------------- + +const BE_SVC = 'urn:Belkin:service:basicevent:1'; +const BE_URL = '/upnp/control/basicevent1'; + +async function getBinaryState(host, port) { + const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetBinaryState'); + const raw = String(res['BinaryState'] ?? '0'); + return raw === '1' || raw === '8'; +} +exports.getBinaryState = getBinaryState; + +async function setBinaryState(host, port, on) { + await soapWithFallback(host, port, BE_URL, BE_SVC, 'SetBinaryState', { BinaryState: on ? '1' : '0' }); +} +exports.setBinaryState = setBinaryState; + +// --------------------------------------------------------------------------- +// Device info & management +// --------------------------------------------------------------------------- + +async function getDeviceInfo(host, port) { + const results = {}; + try { + const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetMacAddr'); + results.macAddress = String(res['MacAddr'] ?? '').trim(); + results.serialNumber = String(res['SerialNo'] ?? '').trim(); + } catch { results.macAddress = null; results.serialNumber = null; } + + try { + const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetSignalStrength'); + results.signalStrength = String(res['SignalStrength'] ?? '').trim(); + } catch { results.signalStrength = null; } + + try { + const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetFriendlyName'); + results.friendlyName = String(res['FriendlyName'] ?? '').trim(); + } catch { results.friendlyName = null; } + + try { + const sx = await axios.get(`http://${host}:${port}/setup.xml`, { timeout: 5000, httpAgent: NO_KEEPALIVE }); + const fwMatch = sx.data.match(/([^<]+)<\/firmwareVersion>/i); + const hwMatch = sx.data.match(/([^<]+)<\/hwVersion>/i); + const udnMatch = sx.data.match(/([^<]+)<\/UDN>/i); + const dtMatch = sx.data.match(/([^<]+)<\/deviceType>/i); + const mdMatch = sx.data.match(/([^<]+)<\/modelDescription>/i); + results.firmwareVersion = fwMatch ? fwMatch[1].trim() : null; + results.hwVersion = hwMatch ? hwMatch[1].trim() : null; + results.modelDescription = mdMatch ? mdMatch[1].trim() : null; + if (udnMatch) { + const fw = results.firmwareVersion || ''; + const fwSuffix = fw.split('PVT-').pop() || ''; + results.productModel = resolveProductModel(udnMatch[1].trim(), dtMatch ? dtMatch[1] : '', fwSuffix); + } + } catch { + try { + const res = await soapWithFallback(host, port, '/upnp/control/firmwareupdate1', 'urn:Belkin:service:firmwareupdate:1', 'GetFirmwareVersion'); + results.firmwareVersion = String(res['FirmwareVersion'] ?? '').trim(); + } catch { results.firmwareVersion = null; } + } + return results; +} +exports.getDeviceInfo = getDeviceInfo; + +const TS_SVC = 'urn:Belkin:service:timesync:1'; +const TS_URL = '/upnp/control/timesync1'; + +async function setDeviceTime(host, port) { + const now = Math.floor(Date.now() / 1000); + const d = new Date(); + // Standard offset = worst-case (no DST) — Jan or Jul whichever is larger + const stdOffset = Math.max( + new Date(d.getFullYear(), 0, 1).getTimezoneOffset(), + new Date(d.getFullYear(), 6, 1).getTimezoneOffset(), + ); + const isDst = d.getTimezoneOffset() < stdOffset; + const tzOffsetMin = -(d.getTimezoneOffset()); + const localNow = now + tzOffsetMin * 60; + await soapWithFallback(host, port, TS_URL, TS_SVC, 'TimeSync', { + UTC: String(now), + TimeZone: String(tzOffsetMin * 60), + dst: isDst ? '1' : '0', + DstSupported: '1', + }); + const localISO = new Date(localNow * 1000).toISOString().replace('T', ' ').slice(0, 19); + return { timestamp: now, localISO }; +} +exports.setDeviceTime = setDeviceTime; + +async function renameDevice(host, port, newName) { + await soapWithFallback(host, port, BE_URL, BE_SVC, 'ChangeFriendlyName', { FriendlyName: newName }); +} +exports.renameDevice = renameDevice; + +async function resetData(host, port) { await soapWithFallback(host, port, BE_URL, BE_SVC, 'ReSetup', { Reset: '1' }); } +async function factoryReset(host, port) { await soapWithFallback(host, port, BE_URL, BE_SVC, 'ReSetup', { Reset: '2' }); } +async function resetWifi(host, port) { await soapWithFallback(host, port, BE_URL, BE_SVC, 'ReSetup', { Reset: '5' }); } + +// Fetch a device SCPD XML file and return a list of action names. +async function fetchScpdActions(host, port, scpdPath) { + try { + const res = await axios.get(`http://${host}:${port}${scpdPath}`, { + timeout: 5000, httpAgent: NO_KEEPALIVE, headers: { 'Connection': 'close' }, + }); + const parsed = await parseStringPromise(res.data, { explicitArray: false, ignoreAttrs: false }); + const al = parsed?.scpd?.actionList?.action; + if (!al) return []; + const actions = Array.isArray(al) ? al : [al]; + return actions.map((a) => String(a.name ?? '')).filter(Boolean); + } catch { return []; } +} + +async function rebootDevice(host, port) { + // 1. Collect all candidate (controlURL, serviceType, action) triples. + // Start with known patterns, then scan every service SCPD for a Reboot action. + const isConnDrop = (e) => + e.code === 'ECONNRESET' || e.code === 'ETIMEDOUT' || e.code === 'ECONNABORTED'; + + const candidates = [ + { url: BE_URL, svc: BE_SVC, action: 'Reboot', args: {} }, + { url: '/upnp/control/deviceevent1', svc: 'urn:Belkin:service:deviceevent:1', action: 'Reboot', args: {} }, + { url: '/upnp/control/manufacture1', svc: 'urn:Belkin:service:manufacture:1', action: 'Reboot', args: {} }, + { url: BE_URL, svc: BE_SVC, action: 'ReSetup', args: { Reset: '0' } }, + ]; + + // Dynamically discover any Reboot/Restart action from setup.xml SCPDs + try { + const setup = await fetchSetupXml(host, port); + if (setup?.services) { + for (const { serviceType, controlURL, scpdURL } of Object.values(setup.services)) { + if (!scpdURL) continue; + const actions = await fetchScpdActions(host, port, scpdURL); + for (const action of actions) { + if (/reboot|restart/i.test(action)) { + // Prepend discovered action so it is tried before the fallbacks + candidates.unshift({ url: controlURL, svc: serviceType, action, args: {} }); + } + } + } + } + } catch { /* ignore SCPD lookup errors */ } + + const portsToTry = [port, ...WEMO_PORTS.filter((p) => p !== port)]; + const errors = []; + + for (const { url, svc, action, args } of candidates) { + for (const tryPort of portsToTry) { + try { + await soapRequest(host, tryPort, url, svc, action, args, 5_000); + return; + } catch (err) { + if (isConnDrop(err)) return; // connection dropped — reboot in progress + if (err.code !== 'ECONNREFUSED') { + errors.push(`${action}@${tryPort}`); + break; + } + } + } + } + + throw new Error( + 'REBOOT_UNSUPPORTED: This device does not support remote reboot via SOAP. ' + + 'To activate new rules cut power at the circuit breaker briefly.' + ); +} +exports.resetData = resetData; +exports.factoryReset = factoryReset; +exports.resetWifi = resetWifi; +exports.rebootDevice = rebootDevice; + +// --------------------------------------------------------------------------- +// Wi-Fi setup +// --------------------------------------------------------------------------- + +const WIFI_SVC = 'urn:Belkin:service:WiFiSetup:1'; +const WIFI_URL = '/upnp/control/WiFiSetup1'; + +async function getApList(host, port) { + const res = await soapWithFallback(host, port, WIFI_URL, WIFI_SVC, 'GetApList'); + const raw = String(res['ApList'] ?? ''); + if (!raw.trim()) return []; + return raw.split('\n').filter(Boolean).map((line) => { + const parts = line.split('|'); + return { ssid: parts[0] || '', channel: parts[1] || '', auth: parts[2] || '', encrypt: parts[3] || '', signal: parseInt(parts[4] || '0', 10) || 0 }; + }).sort((a, b) => b.signal - a.signal); +} +exports.getApList = getApList; + +async function connectHomeNetwork(host, port, { ssid, auth, password, encrypt, channel }) { + await soapWithFallback(host, port, WIFI_URL, WIFI_SVC, 'ConnectHomeNetwork', { + ssid, auth: auth || 'WPA2PSK', password: password || '', encrypt: encrypt || 'AES', channel: channel || '0', + }); +} +exports.connectHomeNetwork = connectHomeNetwork; + +async function getNetworkStatus(host, port) { + const res = await soapWithFallback(host, port, WIFI_URL, WIFI_SVC, 'GetNetworkStatus'); + return String(res['NetworkStatus'] ?? '').trim(); +} +exports.getNetworkStatus = getNetworkStatus; + +async function closeSetup(host, port) { + await soapWithFallback(host, port, WIFI_URL, WIFI_SVC, 'CloseSetup'); +} +exports.closeSetup = closeSetup; + +// --------------------------------------------------------------------------- +// HomeKit +// --------------------------------------------------------------------------- + +async function getHomeKitInfo(host, port) { + const result = { setupDone: null, setupCode: null }; + try { + const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'getHKSetupState'); + result.setupDone = String(res['HKSetupDone'] ?? '').trim(); + } catch { /* not supported */ } + try { + const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetHKSetupInfo'); + result.setupCode = String(res['HKSetupCode'] ?? '').trim(); + } catch { /* not supported */ } + return result; +} +exports.getHomeKitInfo = getHomeKitInfo; + +// --------------------------------------------------------------------------- +// Setup XML parsing +// --------------------------------------------------------------------------- + +async function fetchSetupXml(host, port, timeoutMs = 7000) { + try { + const response = await axios.get(`http://${host}:${port}/setup.xml`, { + timeout: timeoutMs, httpAgent: NO_KEEPALIVE, headers: { 'Connection': 'close' }, + }); + const parsed = await parseStringPromise(response.data, { explicitArray: false, ignoreAttrs: false }); + const root = parsed['root']; + if (!root) return null; + const device = root['device']; + if (!device) return null; + + const friendlyName = String(device['friendlyName'] ?? 'Wemo Device'); + const serialNumber = String(device['serialNumber'] ?? ''); + const udn = String(device['UDN'] ?? `uuid:${host}-${port}`); + const modelName = String(device['modelName'] ?? ''); + const modelDescription = String(device['modelDescription'] ?? ''); + const hwVersion = String(device['hwVersion'] ?? ''); + const firmwareVersion = String(device['firmwareVersion'] ?? '') + || (response.data.match(/([^<]+)<\/firmwareVersion>/i)?.[1]?.trim() ?? ''); + const fwSuffix = firmwareVersion.split('PVT-').pop() || ''; + const deviceType = String(device['deviceType'] ?? ''); + const productModel = resolveProductModel(udn, deviceType, fwSuffix); + + const services = {}; + const rawList = device['serviceList']; + if (rawList) { + let arr = rawList['service']; + if (arr && !Array.isArray(arr)) arr = [arr]; + if (Array.isArray(arr)) { + for (const svc of arr) { + const st = String(svc['serviceType'] ?? ''); + if (st) services[st] = { + serviceType: st, + controlURL: String(svc['controlURL'] ?? ''), + scpdURL: String(svc['SCPDURL'] ?? ''), + }; + } + } + } + + return { + friendlyName, serialNumber, udn, modelName, modelDescription, hwVersion, + firmwareVersion, productModel, host, port, services, + supportsRules: 'urn:Belkin:service:rules:1' in services, + }; + } catch { + return null; + } +} +exports.fetchSetupXml = fetchSetupXml; + +// --------------------------------------------------------------------------- +// SSDP discovery +// --------------------------------------------------------------------------- + +let _discoverySocket = null; + +function stopDiscovery() { + try { _discoverySocket?.close(); } catch { /* ok */ } + _discoverySocket = null; +} +exports.stopDiscovery = stopDiscovery; + +async function discoverDevices(timeoutMs = 10_000, manualEntries = []) { + const found = new Map(); + + await new Promise((resolve) => { + const SSDP_ADDR = '239.255.255.250'; + const SSDP_PORT = 1900; + const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); + _discoverySocket = socket; + const pending = new Set(); + + const handleLocation = async (location) => { + if (pending.has(location)) return; + pending.add(location); + try { + const url = new URL(location); + const device = await fetchSetupXml(url.hostname, parseInt(url.port, 10) || 49153); + if (device && !found.has(device.udn)) found.set(device.udn, device); + } catch { /* ignore */ } + }; + + socket.on('message', (msg) => { + const text = msg.toString(); + if (!text.includes('HTTP/1.1') && !text.includes('NOTIFY')) return; + for (const line of text.split('\r\n')) { + if (line.toLowerCase().startsWith('location:')) { + handleLocation(line.slice(9).trim()).catch(() => {}); + } + } + }); + socket.on('error', () => { try { socket.close(); } catch { /* ok */ } resolve(); }); + + socket.bind(0, () => { + try { socket.addMembership(SSDP_ADDR); } catch { /* ok */ } + const msg = Buffer.from(`M-SEARCH * HTTP/1.1\r\nHOST: ${SSDP_ADDR}:${SSDP_PORT}\r\nMAN: "ssdp:discover"\r\nMX: 3\r\nST: urn:Belkin:device:**\r\n\r\n`); + socket.send(msg, SSDP_PORT, SSDP_ADDR); + setTimeout(() => { try { socket.send(msg, SSDP_PORT, SSDP_ADDR); } catch { /* ok */ } }, 2000); + }); + + setTimeout(() => { try { socket.close(); } catch { /* ok */ } resolve(); }, timeoutMs); + }); + + // Probe manual entries + for (const entry of manualEntries) { + const portsToTry = entry.port ? [entry.port] : WEMO_PORTS; + for (const p of portsToTry) { + const device = await fetchSetupXml(entry.host, p); + if (device) { found.set(device.udn, device); break; } + } + } + + // Subnet probe fallback (when SSDP blocked by firewall) + if (found.size === 0 && manualEntries.length === 0) { + try { + const { networkInterfaces } = require('os'); + const ifaces = networkInterfaces(); + let localSubnet = null; + for (const list of Object.values(ifaces)) { + for (const iface of list) { + if (iface.family === 'IPv4' && !iface.internal && iface.address.startsWith('192.168.')) { + const parts = iface.address.split('.'); + localSubnet = `${parts[0]}.${parts[1]}.${parts[2]}`; + break; + } + } + if (localSubnet) break; + } + if (localSubnet) { + console.log(`[wemo] SSDP returned 0 devices — probing subnet ${localSubnet}.0/24`); + const probePromises = []; + for (let i = 1; i <= 254; i++) { + const host = `${localSubnet}.${i}`; + probePromises.push((async () => { + for (const p of [49153, 49152, 49154, 49155]) { + const device = await fetchSetupXml(host, p, 800); + if (device) { found.set(device.udn, device); return; } + } + })()); + } + await Promise.allSettled(probePromises); + } + } catch (err) { + console.warn('[wemo] subnet probe failed:', err.message); + } + } + + return [...found.values()]; +} +exports.discoverDevices = discoverDevices; + +// --------------------------------------------------------------------------- +// Rules DB helpers +// --------------------------------------------------------------------------- + +async function loadDb(host, port) { + const portsToTry = [port, ...WEMO_PORTS.filter((p) => p !== port)]; + const MAX_ROUNDS = 2; + const RETRY_DELAY = 4000; + let lastErr = null; + + // Discover the actual rules controlURL from setup.xml. + // Some firmware versions use a non-standard path; never assume /upnp/control/rules1. + const RULES_SVC = 'urn:Belkin:service:rules:1'; + const FALLBACK_URLS = ['/upnp/control/rules1', '/upnp/control/rulesrules1']; + let rulesControlURL = FALLBACK_URLS[0]; + try { + const sx = await axios.get(`http://${host}:${port}/setup.xml`, { + timeout: 5000, httpAgent: NO_KEEPALIVE, headers: { 'Connection': 'close' }, + }); + const parsed = await parseStringPromise(sx.data, { explicitArray: false, ignoreAttrs: false }); + const svcList = parsed?.root?.device?.serviceList?.service; + const svcs = Array.isArray(svcList) ? svcList : (svcList ? [svcList] : []); + const rulesSvc = svcs.find((s) => String(s.serviceType ?? s['_'] ?? '').includes('rules')); + if (rulesSvc) { + const cu = String(rulesSvc.controlURL ?? rulesSvc['controlURL'] ?? '').trim(); + if (cu) rulesControlURL = cu; + } + } catch { /* use fallback URL */ } + + for (let round = 0; round < MAX_ROUNDS; round++) { + if (round > 0) await new Promise((r) => setTimeout(r, RETRY_DELAY)); + + for (const tryPort of portsToTry) { + // Try the discovered controlURL first, then fallbacks + const urlsToTry = [rulesControlURL, ...FALLBACK_URLS.filter((u) => u !== rulesControlURL)]; + for (const ctrlURL of urlsToTry) { + try { + const info = await soapRequest(host, tryPort, ctrlURL, RULES_SVC, 'FetchRules', {}, 10_000); + + const version = parseInt(String(info['ruleDbVersion'] ?? '1'), 10); + let dbPath = String(info['ruleDbPath'] ?? ''); + if (dbPath && !dbPath.startsWith('http')) dbPath = `http://${host}:${tryPort}${dbPath}`; + + // WASM is loaded AFTER FetchRules succeeds (cached on subsequent calls) + const SqlLib = await getSql(); + let db; + + if (version === 0 || !dbPath) { + db = new SqlLib.Database(); + } else { + const zipRes = await axios.get(dbPath, { + responseType: 'arraybuffer', + timeout: 15_000, + httpAgent: NO_KEEPALIVE, + headers: { 'Connection': 'close' }, + }); + const zip = new AdmZip(Buffer.from(zipRes.data)); + const dbEntry = zip.getEntries().find((e) => e.entryName.endsWith('.db')); + if (!dbEntry) throw new Error('No .db file in rules ZIP'); + db = new SqlLib.Database(dbEntry.getData()); + // Preserve original ZIP entry name so storeDb sends it back unchanged + db._zipEntryName = dbEntry.entryName; + } + + ensureTables(db); + return { db, version, resolvedPort: tryPort, zipEntryName: db._zipEntryName || 'temppluginRules.db' }; + } catch (err) { + lastErr = err; + const isConnErr = err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' + || err.code === 'ETIMEDOUT' || err.code === 'ECONNABORTED' + || (err.response?.status >= 400); + if (!isConnErr) throw err; // parse/data error — don't retry + } + } + } + } + + throw new Error(`${host}: FetchRules unavailable — ${lastErr?.message || 'no response'}`); +} + +function ensureTables(db) { + db.run(`CREATE TABLE IF NOT EXISTS RULES ( + RuleID INTEGER PRIMARY KEY, Name TEXT NOT NULL, Type TEXT NOT NULL, + RuleOrder INTEGER DEFAULT 0, StartDate TEXT DEFAULT '12201982', + EndDate TEXT DEFAULT '07301982', State TEXT DEFAULT '1', Sync TEXT DEFAULT 'NOSYNC')`); + db.run(`CREATE TABLE IF NOT EXISTS RULEDEVICES ( + RuleDevicePK INTEGER PRIMARY KEY AUTOINCREMENT, RuleID INTEGER, + DeviceID TEXT, GroupID INTEGER DEFAULT 0, DayID INTEGER DEFAULT 127, + StartTime INTEGER DEFAULT 0, RuleDuration INTEGER DEFAULT 0, + StartAction REAL DEFAULT 1.0, EndAction REAL DEFAULT -1.0, + SensorDuration INTEGER DEFAULT -1, Type INTEGER DEFAULT -1, + Value INTEGER DEFAULT -1, Level INTEGER DEFAULT -1, + ZBCapabilityStart TEXT DEFAULT '', ZBCapabilityEnd TEXT DEFAULT '', + OnModeOffset INTEGER DEFAULT -1, OffModeOffset INTEGER DEFAULT -1, + CountdownTime INTEGER DEFAULT -1, EndTime INTEGER DEFAULT -1)`); + db.run(`CREATE TABLE IF NOT EXISTS TARGETDEVICES ( + TargetDevicesPK INTEGER PRIMARY KEY AUTOINCREMENT, + RuleID INTEGER, DeviceID TEXT, DeviceIndex INTEGER DEFAULT 0)`); + db.run(`CREATE TABLE IF NOT EXISTS LOCATIONINFO ( + LocationPk INTEGER PRIMARY KEY AUTOINCREMENT, + cityName TEXT, countryName TEXT, latitude TEXT, longitude TEXT, + countryCode TEXT, region TEXT)`); + + // Sync LOCATIONINFO from stored location + if (_location) { + let existingCode = '', existingRegion = ''; + try { + const r = db.exec('SELECT countryCode, region FROM LOCATIONINFO LIMIT 1'); + if (r[0]?.values?.[0]) { existingCode = r[0].values[0][0] || ''; existingRegion = r[0].values[0][1] || ''; } + } catch { /* ok */ } + db.run('DELETE FROM LOCATIONINFO'); + db.run('INSERT INTO LOCATIONINFO (cityName,countryName,latitude,longitude,countryCode,region) VALUES (?,?,?,?,?,?)', + [ _location.city || _location.label || '', _location.country || '', + String(_location.lat ?? ''), String(_location.lng ?? ''), + _location.countryCode || existingCode, _location.region || existingRegion ]); + } + + db.run('UPDATE RULEDEVICES SET GroupID = 0 WHERE GroupID IS NULL'); +} + +async function fetchCurrentVersion(host, port) { + const portsToTry = [port, ...WEMO_PORTS.filter((p) => p !== port)]; + let lastErr = null; + for (let round = 0; round < 3; round++) { + if (round > 0) await new Promise((r) => setTimeout(r, 5000)); + for (const tryPort of portsToTry) { + try { + const info = await soapRequest(host, tryPort, '/upnp/control/rules1', 'urn:Belkin:service:rules:1', 'FetchRules'); + return { version: parseInt(String(info['ruleDbVersion'] ?? '1'), 10), resolvedPort: tryPort }; + } catch (err) { + lastErr = err; + const isConn = err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT'; + if (!isConn) throw err; + } + } + } + throw lastErr || new Error(`${host}: all ports failed for FetchRules`); +} + +function buildStoreXml(serviceType, version, base64Body) { + return `` + + `` + + `` + + `${version}1` + + `<![CDATA[${base64Body}]]>` + + ``; +} + +async function saveAndUpload(db, host, port, version, zipEntryName) { + const exported = db.export(); + db.close(); + const newZip = new AdmZip(); + newZip.addFile(zipEntryName || 'temppluginRules.db', Buffer.from(exported)); + const base64Body = newZip.toBuffer().toString('base64'); + const svcType = 'urn:Belkin:service:rules:1'; + + let freshVersion = version, activePort = port; + try { + const fresh = await fetchCurrentVersion(host, port); + freshVersion = fresh.version; + activePort = fresh.resolvedPort; + } catch { /* use loadDb version */ } + + const newVersion = freshVersion + 2; + const xml = buildStoreXml(svcType, newVersion, base64Body); + + const postResult = async (h, p, x) => { + const res = await axios.post(`http://${h}:${p}/upnp/control/rules1`, x, { + headers: { 'Content-Type': 'text/xml; charset="utf-8"', 'SOAPACTION': `"${svcType}#StoreRules"`, 'Connection': 'close' }, + httpAgent: NO_KEEPALIVE, timeout: 30_000, + }); + const parsed = await parseStringPromise(res.data, { explicitArray: false, ignoreAttrs: true }); + return String(parsed['s:Envelope']['s:Body']['u:StoreRulesResponse']?.['errorInfo'] ?? '').trim(); + }; + + let errorInfo; + const portsToTry = [activePort, ...WEMO_PORTS.filter((p) => p !== activePort)]; + for (let round = 0; round < 3; round++) { + if (round > 0) await new Promise((r) => setTimeout(r, 5000)); + for (const tryPort of portsToTry) { + try { + errorInfo = await postResult(host, tryPort, xml); + if (errorInfo.toLowerCase().includes('successful')) { + setDeviceTime(host, tryPort).catch(() => {}); + return; + } + // Version mismatch — retry with fresh version + await new Promise((r) => setTimeout(r, 2000)); + const f2 = await fetchCurrentVersion(host, tryPort); + const retryXml = buildStoreXml(svcType, f2.version + 2, base64Body); + const ri = await postResult(host, f2.resolvedPort, retryXml); + if (!ri.toLowerCase().includes('successful')) throw new Error(`StoreRules failed: ${ri}`); + setDeviceTime(host, f2.resolvedPort).catch(() => {}); + return; + } catch (err) { + const isConn = err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT'; + if (!isConn) throw err; + } + } + } + throw new Error(`StoreRules failed: ${errorInfo || 'all ports failed'}`); +} + +// --------------------------------------------------------------------------- +// Rules CRUD +// --------------------------------------------------------------------------- + +function resolveSunTimes(input) { + const clamp = (v) => Math.max(0, Math.min(86399, v)); + const resolveOne = (type, offsetMin, fixedHHMM) => { + // Always emit native Wemo sun codes (-2=sunrise, -3=sunset) regardless of + // whether _location is configured. The device uses its own LOCATIONINFO + // table to calculate the actual times; we populate that table separately + // (in ensureTables) when the user has set a location. + // Positive offset = after the sun event, negative = before. + // Device formula: fireTime = calculatedSunTime + OnModeOffset + // (confirmed from real Wemo iOS app: OnModeOffset=1800 → 30 min AFTER sunset) + if (type === 'sunrise') return { secs: -2, modeOffset: (offsetMin ?? 0) * 60 }; + if (type === 'sunset') return { secs: -3, modeOffset: (offsetMin ?? 0) * 60 }; + // null / empty string = no time set; device uses -1 as "no end time" sentinel + // Fixed-time rules use -1 for OnModeOffset (no sun offset), matching iOS app default + if (!fixedHHMM) return { secs: -1, modeOffset: 0 }; + return { secs: clamp(timeToSecs(fixedHHMM)), modeOffset: 0 }; + }; + const start = resolveOne(input.startType, input.startOffset, input.startTime); + const end = resolveOne(input.endType, input.endOffset, input.endTime); + return { startSecs: start.secs, endSecs: end.secs, onModeOffset: start.modeOffset, offModeOffset: end.modeOffset }; +} + +async function getRules(host, port) { + // Hard 30-second deadline: FetchRules (12s) + ZIP download (12s) + getSql + margin + const fence = new Promise((_, reject) => + setTimeout(() => reject(new Error(`RULES_TIMEOUT`)), 30_000) + ); + const { db } = await Promise.race([loadDb(host, port), fence]); + try { + const rulesRes = db.exec('SELECT RuleID,Name,Type,RuleOrder,State FROM RULES ORDER BY RuleOrder'); + const DEVICE_TYPE_TO_UI = { + 'time interval': 'Schedule', 'simple switch': 'Schedule', + 'countdown rule': 'Countdown', + 'away mode': 'Away', + }; + const rules = (rulesRes[0]?.values ?? []).map(([RuleID, Name, Type, RuleOrder, State]) => { + const rawType = String(Type ?? ''); + const uiType = DEVICE_TYPE_TO_UI[rawType.toLowerCase()] || rawType; + return { + ruleId: Number(RuleID), + name: String(Name ?? ''), + type: uiType, + ruleOrder: Number(RuleOrder ?? 0), + enabled: String(State) === '1', + }; + }); + + const rdRes = db.exec('SELECT * FROM RULEDEVICES ORDER BY rowid'); + const tdRes = db.exec('SELECT * FROM TARGETDEVICES ORDER BY rowid'); + + const rdCols = (rdRes[0]?.columns ?? []).map((c) => c.toLowerCase()); + const rdRows = (rdRes[0]?.values ?? []).map((row) => { + const obj = {}; + rdCols.forEach((c, i) => { obj[c] = row[i]; }); + return obj; + }); + const tdCols = (tdRes[0]?.columns ?? []).map((c) => c.toLowerCase()); + const tdRows = (tdRes[0]?.values ?? []).map((row) => { + const obj = {}; + tdCols.forEach((c, i) => { obj[c] = row[i]; }); + return obj; + }); + + let locationInfo = null; + try { + const locRes = db.exec('SELECT cityName,countryName,latitude,longitude,countryCode,region FROM LOCATIONINFO LIMIT 1'); + if (locRes[0]?.values?.[0]) { + const [cityName, countryName, latitude, longitude, countryCode, region] = locRes[0].values[0]; + locationInfo = { cityName, countryName, latitude, longitude, countryCode, region }; + } + } catch { /* ok */ } + + const mappedRules = rules.map((rule) => { + const allRds = rdRows.filter((r) => Number(r.ruleid) === rule.ruleId); + const deviceMap = new Map(); + for (const rd of allRds) { + const key = rd.deviceid; + if (!deviceMap.has(key)) deviceMap.set(key, { ...rd, days: [] }); + const dayNum = Number(rd.dayid); + if (dayNum > 0 && dayNum <= 7) deviceMap.get(key).days.push(dayNum); + } + return { + ...rule, + ruleDevices: [...deviceMap.values()], + targetDevices: tdRows.filter((t) => Number(t.ruleid) === rule.ruleId).map((t) => t.deviceid), + }; + }); + return { rules: mappedRules, locationInfo }; + } finally { + db.close(); + } +} +exports.getRules = getRules; + +async function createRule(host, port, input) { + const { db, version, resolvedPort, zipEntryName } = await loadDb(host, port); + const maxId = db.exec('SELECT COALESCE(MAX(CAST(RuleID AS INTEGER)),0) FROM RULES')[0]?.values?.[0]?.[0] ?? 0; + const ruleId = Number(maxId) + 1; + const ruleOrder = 2; // Wemo iOS app always uses RuleOrder=2 for all rules + + const { startSecs, endSecs, onModeOffset, offModeOffset } = resolveSunTimes(input); + // iOS app always stores a real EndTime — use 86340 (23:59) when no end time is set. + // -2 (sunrise) and -3 (sunset) are valid sun codes and must be preserved. + const storedEndSecs = endSecs === -1 ? 86340 : endSecs; + // Duration only meaningful for fixed times; sun-based rules (startSecs < 0) use 0 + const duration = startSecs >= 0 && storedEndSecs >= 0 && storedEndSecs > startSecs ? storedEndSecs - startSecs : 0; + const dayNumbers = namesToDayNumbers(input.days || []); + const deviceIds = input.deviceIds || (input.deviceId ? [input.deviceId] : []); + const isAway = (input.type || '').toLowerCase().includes('away'); + const isCountdown = (input.type || '').toLowerCase().includes('countdown'); + + const deviceType = RULE_TYPE_TO_DEVICE[input.type] || input.type || 'Time Interval'; + db.run(`INSERT INTO RULES (RuleID,Name,Type,RuleOrder,StartDate,EndDate,State,Sync) VALUES (?,?,?,?,'12201982','07301982','1','NOSYNC')`, + [ruleId, input.name, deviceType, ruleOrder]); + + const insertDays = isCountdown ? [-1] : (dayNumbers.length ? dayNumbers : [1,2,3,4,5,6,7]); + for (const deviceId of deviceIds) { + for (const dayNum of insertDays) { + db.run(`INSERT INTO RULEDEVICES (RuleID,DeviceID,GroupID,DayID,StartTime,RuleDuration,StartAction,EndAction,SensorDuration,CountdownTime,EndTime,OnModeOffset,OffModeOffset) VALUES (?,?,0,?,?,?,?,?,?,?,?,?,?)`, + [ruleId, deviceId, dayNum, startSecs, + isAway ? duration : 0, + input.startAction ?? 1, input.endAction ?? -1, + 2, isCountdown ? (input.countdownTime || 3600) : 0, + storedEndSecs, onModeOffset, offModeOffset]); + } + } + + // TARGETDEVICES: Wemo iOS app populates this for multi-device rules (all types). + // Without it, some firmware versions only fire the rule on the hosting device. + if (isAway) { + (input.targetDeviceIds || []).forEach((tid, idx) => { + db.run('INSERT INTO TARGETDEVICES (RuleID,DeviceID,DeviceIndex) VALUES (?,?,?)', [ruleId, tid, idx]); + }); + } else if (deviceIds.length > 1) { + deviceIds.forEach((did, idx) => { + db.run('INSERT INTO TARGETDEVICES (RuleID,DeviceID,DeviceIndex) VALUES (?,?,?)', [ruleId, did, idx]); + }); + } + + await saveAndUpload(db, host, resolvedPort || port, version, zipEntryName); + return ruleId; +} +exports.createRule = createRule; + +async function updateRule(host, port, ruleId, input) { + const { db, version, resolvedPort, zipEntryName } = await loadDb(host, port); + + // Always normalise RuleOrder=2 — Wemo iOS app sets every rule to 2 and + // firmware may skip rules with other values. + db.run('UPDATE RULES SET RuleOrder=2 WHERE RuleID=?', [ruleId]); + if (input.name !== undefined) db.run('UPDATE RULES SET Name=? WHERE RuleID=?', [input.name, ruleId]); + if (input.enabled !== undefined) db.run('UPDATE RULES SET State=? WHERE RuleID=?', [input.enabled ? '1' : '0', ruleId]); + if (input.type !== undefined) db.run('UPDATE RULES SET Type=? WHERE RuleID=?', [RULE_TYPE_TO_DEVICE[input.type] || input.type, ruleId]); + + const hasSchedule = ['days','startTime','endTime','startAction','endAction','countdownTime','deviceIds','startType','endType'].some((f) => f in input); + // State='0'/'1' in the RULES table is sufficient to disable/enable — RULEDEVICES are preserved. + + if (hasSchedule) { + let existingTimes = null; + if (!input.startTime && !input.startType) { + const r = db.exec('SELECT StartTime,EndTime,RuleDuration FROM RULEDEVICES WHERE RuleID=? LIMIT 1', [ruleId]); + if (r[0]?.values?.[0]) existingTimes = { startSecs: r[0].values[0][0], endSecs: r[0].values[0][1], duration: r[0].values[0][2] }; + } + + let startSecs, endSecs, onModeOffset = 0, offModeOffset = 0; + if (input.startType || input.endType) { + const resolved = resolveSunTimes(input); + startSecs = resolved.startSecs; endSecs = resolved.endSecs; + onModeOffset = resolved.onModeOffset; offModeOffset = resolved.offModeOffset; + } else { + startSecs = input.startTime ? timeToSecs(input.startTime) : (existingTimes?.startSecs ?? 0); + endSecs = input.endTime ? timeToSecs(input.endTime) : (existingTimes?.endSecs ?? -1); + } + + const ruleTypeRes = db.exec('SELECT Type FROM RULES WHERE RuleID=?', [ruleId]); + const ruleType = (input.type || ruleTypeRes[0]?.values?.[0]?.[0] || '').toString().toLowerCase(); + const isAway = ruleType.includes('away'); + const isCountdown = ruleType.includes('countdown'); + // iOS app always stores a real EndTime — use 86340 (23:59) when no end time is set. + // -2 (sunrise) and -3 (sunset) are valid sun codes and must be preserved. + const storedEndSecs = endSecs === -1 ? 86340 : endSecs; + // Duration only meaningful for fixed times; sun-based rules (startSecs < 0) use 0 + const duration = startSecs >= 0 && storedEndSecs >= 0 && storedEndSecs > startSecs ? storedEndSecs - startSecs : 0; + + let deviceIds; + if (input.deviceIds?.length > 0) { + deviceIds = input.deviceIds; + } else { + const r = db.exec('SELECT DISTINCT DeviceID FROM RULEDEVICES WHERE RuleID=?', [ruleId]); + deviceIds = r[0]?.values?.map((v) => v[0]) ?? []; + } + + const dayNumbers = input.days?.length > 0 ? namesToDayNumbers(input.days) : null; + let existingDaysByDevice = null; + if (!dayNumbers) { + existingDaysByDevice = new Map(); + const r = db.exec('SELECT DeviceID,DayID FROM RULEDEVICES WHERE RuleID=?', [ruleId]); + if (r[0]) for (const [did, day] of r[0].values) { + if (!existingDaysByDevice.has(did)) existingDaysByDevice.set(did, []); + existingDaysByDevice.get(did).push(Number(day)); + } + } + + let existingActions = null; + const actR = db.exec('SELECT StartAction,EndAction,CountdownTime FROM RULEDEVICES WHERE RuleID=? LIMIT 1', [ruleId]); + if (actR[0]?.values?.[0]) existingActions = { startAction: actR[0].values[0][0], endAction: actR[0].values[0][1], countdownTime: actR[0].values[0][2] }; + + const sa = input.startAction ?? existingActions?.startAction ?? 1; + const ea = input.endAction ?? existingActions?.endAction ?? -1; + + db.run('DELETE FROM RULEDEVICES WHERE RuleID=?', [ruleId]); + const insertDays = isCountdown ? [-1] : (dayNumbers || (existingDaysByDevice?.values().next().value) || [1,2,3,4,5,6,7]); + + for (const deviceId of deviceIds) { + const days = Array.isArray(insertDays) && !isCountdown + ? (dayNumbers || existingDaysByDevice?.get(deviceId) || insertDays) + : insertDays; + for (const dayNum of days) { + db.run(`INSERT INTO RULEDEVICES (RuleID,DeviceID,GroupID,DayID,StartTime,RuleDuration,StartAction,EndAction,SensorDuration,CountdownTime,EndTime,OnModeOffset,OffModeOffset) VALUES (?,?,0,?,?,?,?,?,?,?,?,?,?)`, + [ruleId, deviceId, dayNum, startSecs, + isAway ? duration : 0, + sa, ea, 2, + isCountdown ? (input.countdownTime ?? existingActions?.countdownTime ?? 3600) : 0, + storedEndSecs, onModeOffset, offModeOffset]); + } + } + } + + const tdDeviceIds = input.deviceIds || []; + if (input.targetDeviceIds !== undefined || tdDeviceIds.length > 1) { + db.run('DELETE FROM TARGETDEVICES WHERE RuleID=?', [ruleId]); + const ruleTypeStr = (input.type || '').toString().toLowerCase(); + if (ruleTypeStr.includes('away')) { + (input.targetDeviceIds || []).forEach((tid, idx) => { + db.run('INSERT INTO TARGETDEVICES (RuleID,DeviceID,DeviceIndex) VALUES (?,?,?)', [ruleId, tid, idx]); + }); + } else if (tdDeviceIds.length > 1) { + tdDeviceIds.forEach((did, idx) => { + db.run('INSERT INTO TARGETDEVICES (RuleID,DeviceID,DeviceIndex) VALUES (?,?,?)', [ruleId, did, idx]); + }); + } + } + + await saveAndUpload(db, host, resolvedPort || port, version, zipEntryName); +} +exports.updateRule = updateRule; + +async function deleteRule(host, port, ruleId) { + const { db, version, resolvedPort, zipEntryName } = await loadDb(host, port); + db.run('DELETE FROM RULES WHERE RuleID=?', [ruleId]); + db.run('DELETE FROM RULEDEVICES WHERE RuleID=?', [ruleId]); + db.run('DELETE FROM TARGETDEVICES WHERE RuleID=?', [ruleId]); + await saveAndUpload(db, host, resolvedPort || port, version, zipEntryName); +} +exports.deleteRule = deleteRule; + +async function dumpDb(host, port) { + const { db } = await loadDb(host, port); + try { + const tablesRes = db.exec(`SELECT name,sql FROM sqlite_master WHERE type='table' ORDER BY name`); + const tables = (tablesRes[0]?.values ?? []).map(([name, sql]) => ({ name, sql })); + const data = {}; + for (const { name } of tables) { + try { + const res = db.exec(`SELECT * FROM [${name}]`); + if (!res[0]) { data[name] = []; continue; } + data[name] = res[0].values.map((row) => { + const obj = {}; + res[0].columns.forEach((c, i) => { obj[c] = row[i]; }); + return obj; + }); + } catch (e) { data[name] = `ERROR: ${e.message}`; } + } + return { tables, data }; + } finally { db.close(); } +} +exports.dumpDb = dumpDb; diff --git a/apps/desktop/src/preload/index.js b/apps/desktop/src/preload/index.js new file mode 100644 index 0000000..6e15374 --- /dev/null +++ b/apps/desktop/src/preload/index.js @@ -0,0 +1,101 @@ +'use strict'; + +const { contextBridge, ipcRenderer } = require('electron'); + +const invoke = (channel, args) => ipcRenderer.invoke(channel, args); + +contextBridge.exposeInMainWorld('wemoAPI', { + // Device discovery & control + discoverDevices: (opts) => invoke('discover-devices', opts), + getDeviceState: (args) => invoke('get-device-state', args), + setDeviceState: (args) => invoke('set-device-state', args), + getDeviceInfo: (args) => invoke('get-device-info', args), + checkOnline: (args) => invoke('check-online', args), + setDeviceTime: (args) => invoke('set-device-time', args), + renameDevice: (args) => invoke('rename-device', args), + resetData: (args) => invoke('reset-data', args), + factoryReset: (args) => invoke('factory-reset', args), + resetWifi: (args) => invoke('reset-wifi', args), + getHomekitInfo: (args) => invoke('get-homekit-info', args), + + // Saved device management + getSavedDevices: () => invoke('get-saved-devices'), + saveDevices: (list) => invoke('save-devices', list), + getDeviceOrder: () => invoke('get-device-order'), + saveDeviceOrder: (order) => invoke('save-device-order', order), + getDeviceGroups: () => invoke('get-device-groups'), + saveDeviceGroups: (groups) => invoke('save-device-groups', groups), + + // Wemo device rules (read-only source, Wemo Rules tab) + getRules: (args) => invoke('get-rules', args), + createRule: (args) => invoke('create-rule', args), + updateRule: (args) => invoke('update-rule', args), + deleteRule: (args) => invoke('delete-rule', args), + dumpDb: (args) => invoke('dump-db', args), + rebootDevice: (args) => invoke('reboot-device', args), + getDisabledRules: () => invoke('get-disabled-rules'), + setDisabledRule: (args) => invoke('set-disabled-rule', args), + clearDisabledRule: (args) => invoke('clear-disabled-rule', args), + + // DWM Rules — local app database (scheduler reads these) + getDwmRules: () => invoke('get-dwm-rules'), + createDwmRule: (rule) => invoke('create-dwm-rule', rule), + updateDwmRule: (args) => invoke('update-dwm-rule', args), + deleteDwmRule: (args) => invoke('delete-dwm-rule', args), + + // WiFi + getApList: (args) => invoke('get-ap-list', args), + connectHomeNetwork: (args) => invoke('connect-home-network', args), + getNetworkStatus: (args) => invoke('get-network-status', args), + closeSetup: (args) => invoke('close-setup', args), + + // System + getTheme: () => invoke('get-theme'), + setTheme: (theme) => invoke('set-theme', theme), + getLocation: () => invoke('get-location'), + setLocation: (loc) => invoke('set-location', loc), + searchLocation: (query) => invoke('search-location', query), + reverseGeocode: (args) => invoke('reverse-geocode', args), + getSunTimes: (args) => invoke('get-sun-times', args), + showSaveDialog: (opts) => invoke('show-save-dialog', opts), + showOpenDialog: (opts) => invoke('show-open-dialog', opts), + writeFile: (args) => invoke('write-file', args), + readFile: (args) => invoke('read-file', args), + openExternal: (url) => invoke('open-external', url), + + // Local Scheduler (in-process) + schedulerStart: (args) => invoke('scheduler-start', args), + schedulerStop: () => invoke('scheduler-stop'), + schedulerStatus: () => invoke('scheduler-status'), + schedulerHealth: () => invoke('scheduler-health'), + + // Windows Service + serviceStatus: () => invoke('service-status'), + serviceInstall: () => invoke('service-install'), + serviceUninstall: () => invoke('service-uninstall'), + serviceStart: () => invoke('service-start'), + serviceStop: () => invoke('service-stop'), + syncDevicesToService: (devices) => invoke('sync-devices-to-service', devices), + + onSchedulerFired: (cb) => { + const handler = (_e, data) => cb(data); + ipcRenderer.on('scheduler-fired', handler); + return () => ipcRenderer.removeListener('scheduler-fired', handler); + }, + onSchedulerStatus: (cb) => { + const handler = (_e, data) => cb(data); + ipcRenderer.on('scheduler-status', handler); + return () => ipcRenderer.removeListener('scheduler-status', handler); + }, + onSchedulerHealth: (cb) => { + const handler = (_e, data) => cb(data); + ipcRenderer.on('scheduler-health', handler); + return () => ipcRenderer.removeListener('scheduler-health', handler); + }, + + // Main-process events + onTriggerDiscovery: (cb) => { + ipcRenderer.on('trigger-discovery', cb); + return () => ipcRenderer.removeListener('trigger-discovery', cb); + }, +}); diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html new file mode 100644 index 0000000..0bdd910 --- /dev/null +++ b/apps/desktop/src/renderer/index.html @@ -0,0 +1,14 @@ + + + + + + + Dibby Wemo Manager + + +
+ + + diff --git a/apps/desktop/src/renderer/src/App.jsx b/apps/desktop/src/renderer/src/App.jsx new file mode 100644 index 0000000..fa6344c --- /dev/null +++ b/apps/desktop/src/renderer/src/App.jsx @@ -0,0 +1,151 @@ +import React, { useEffect, useState } from 'react'; +import Sidebar from './components/layout/Sidebar'; +import DetailPanel from './components/layout/DetailPanel'; +import Toast from './components/shared/Toast'; +import Modal from './components/shared/Modal'; +import useSettingsStore from './store/settings'; +import useDeviceStore from './store/devices'; + +/* ── Settings Panel ─────────────────────────────────────────────────────── */ +function SettingsPanel({ onClose }) { + const { theme, setTheme, location, setLocation } = useSettingsStore(); + const addToast = useSettingsStore((s) => s.addToast); + + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const [saveMsg, setSaveMsg] = useState(''); + + const searchLocation = async () => { + if (!query.trim()) return; + setSearching(true); + setResults([]); + try { + const res = await window.wemoAPI.searchLocation(query.trim()); + setResults(res || []); + if (!res?.length) setSaveMsg('No results found.'); + } catch (e) { + addToast(`Location search failed: ${e.message}`, 'error'); + } finally { + setSearching(false); + } + }; + + const pick = (r) => { + setLocation({ lat: r.lat, lng: r.lng, label: r.label }); + window.wemoAPI.setLocation({ lat: r.lat, lng: r.lng, label: r.label }); + setSaveMsg(`Location set: ${r.label}`); + setResults([]); + setQuery(''); + }; + + const clearLocation = () => { + setLocation(null); + window.wemoAPI.setLocation(null); + setSaveMsg('Location cleared.'); + }; + + return ( + + {/* Theme */} +
+ +
+ {['dark', 'light'].map((t) => ( + + ))} +
+
+ + {/* Location for sun rules */} +
+ + {location && ( +
+ + 📍 {location.label || `${location.lat}, ${location.lng}`} + + +
+ )} +
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && searchLocation()} + style={{ flex: 1 }} + /> + +
+ + {results.length > 0 && ( +
+ {results.slice(0, 5).map((r, i) => ( +
pick(r)} + style={{ + padding: '8px 12px', cursor: 'pointer', fontSize: 12, + borderBottom: i < results.length - 1 ? '1px solid var(--border)' : 'none', + background: 'var(--card2)', + }} + onMouseEnter={(e) => e.currentTarget.style.background = 'var(--hover)'} + onMouseLeave={(e) => e.currentTarget.style.background = 'var(--card2)'} + > + {r.label} +
+ ))} +
+ )} + + {saveMsg &&
{saveMsg}
} +
+ Location is used to populate Sunrise/Sunset data on the device. The device calculates sun times independently once the location is set. +
+
+
+ ); +} + +/* ── App Root ────────────────────────────────────────────────────────────── */ +export default function App() { + const { setTheme, setLocation } = useSettingsStore(); + const { mergeDevice } = useDeviceStore(); + const [showSettings, setShowSettings] = useState(false); + + // Load persisted theme, location, and saved devices on startup + useEffect(() => { + window.wemoAPI.getTheme().then((t) => { if (t) setTheme(t); }).catch(() => {}); + window.wemoAPI.getLocation().then((l) => { if (l) setLocation(l); }).catch(() => {}); + window.wemoAPI.getSavedDevices().then((devs) => { + if (devs?.length) devs.forEach((d) => mergeDevice(d)); + }).catch(() => {}); + }, []); + + // Listen for discovery trigger from Electron menu + useEffect(() => { + const off = window.wemoAPI.onTriggerDiscovery(() => { + // Sidebar handles discovery internally — just trigger it via a custom event + window.dispatchEvent(new CustomEvent('wemo:discover')); + }); + return () => { if (off) off(); }; + }, []); + + return ( +
+ setShowSettings(true)} /> + + + {showSettings && setShowSettings(false)} />} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/device/DeviceCard.jsx b/apps/desktop/src/renderer/src/components/device/DeviceCard.jsx new file mode 100644 index 0000000..16a7c6a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/device/DeviceCard.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import PowerButton from './PowerButton'; +import useDeviceStore from '../../store/devices'; + +const ICONS = { + lightswitch: '💡', + dimmer: '🔆', + insight: '⚡', + socket: '🔌', + sensor: '👁', + bridge: '🌉', + default: '🔌', +}; + +function deviceIcon(udn = '') { + const t = udn.replace(/^uuid:/i, '').split('-')[0].toLowerCase(); + return ICONS[t] ?? ICONS.default; +} + +export default function DeviceCard({ device }) { + const { selectedUdn, selectDevice, updateDevice } = useDeviceStore(); + const isSelected = selectedUdn === device.udn; + + const handleToggle = async (on) => { + await window.wemoAPI.setDeviceState({ host: device.host, port: device.port, on }); + updateDevice(device.udn, { on }); + }; + + const onlineBadge = device.online === true + ? ● Online + : device.online === false + ? ● Offline + : null; + + return ( +
selectDevice(device.udn)} + style={{ + display: 'flex', alignItems: 'center', gap: 10, + padding: '10px 12px', cursor: 'pointer', borderRadius: 8, + border: '1px solid ' + (isSelected ? 'var(--accent)' : 'transparent'), + background: isSelected ? 'rgba(0,169,213,.08)' : 'transparent', + transition: 'all .15s', + marginBottom: 2, + }} + > + {deviceIcon(device.udn)} +
+
+ {device.friendlyName || 'Unknown Device'} +
+
+ {device.host}:{device.port} + {onlineBadge && {onlineBadge}} +
+
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/device/DeviceInfoTab.jsx b/apps/desktop/src/renderer/src/components/device/DeviceInfoTab.jsx new file mode 100644 index 0000000..3de75a8 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/device/DeviceInfoTab.jsx @@ -0,0 +1,229 @@ +import React, { useState, useEffect } from 'react'; +import CopyField from '../shared/CopyField'; +import SignalMeter from './SignalMeter'; +import ConfirmDialog from '../shared/ConfirmDialog'; +import Modal from '../shared/Modal'; +import useDeviceStore from '../../store/devices'; +import useSettingsStore from '../../store/settings'; + +export default function DeviceInfoTab({ device }) { + const { updateDevice } = useDeviceStore(); + const addToast = useSettingsStore((s) => s.addToast); + + const [info, setInfo] = useState(null); + const [hkInfo, setHkInfo] = useState(null); + const [loading, setLoading] = useState(false); + const [syncing, setSyncing] = useState(false); + const [checking, setChecking] = useState(false); + + const [renameModal, setRenameModal] = useState(false); + const [newName, setNewName] = useState(''); + const [confirm, setConfirm] = useState(null); // { action, label, code } + + useEffect(() => { + if (!device) return; + setInfo(null); setHkInfo(null); + loadInfo(); + }, [device?.udn]); + + const loadInfo = async () => { + setLoading(true); + try { + const [infoRes, hkRes] = await Promise.allSettled([ + window.wemoAPI.getDeviceInfo({ host: device.host, port: device.port }), + window.wemoAPI.getHomekitInfo({ host: device.host, port: device.port }), + ]); + if (infoRes.status === 'fulfilled') setInfo(infoRes.value); + if (hkRes.status === 'fulfilled') setHkInfo(hkRes.value); + } finally { + setLoading(false); + } + }; + + const syncTime = async () => { + setSyncing(true); + try { + const res = await window.wemoAPI.setDeviceTime({ host: device.host, port: device.port }); + addToast(`Clock synced: ${res.localISO}`, 'success'); + } catch (err) { + addToast(`Sync failed: ${err.message}`, 'error'); + } finally { + setSyncing(false); + } + }; + + const checkOnline = async () => { + setChecking(true); + try { + const online = await window.wemoAPI.checkOnline({ host: device.host, port: device.port }); + updateDevice(device.udn, { online }); + addToast(online ? 'Device is online' : 'Device is offline', online ? 'success' : 'error'); + } finally { + setChecking(false); + } + }; + + const doRename = async () => { + if (!newName.trim()) return; + try { + await window.wemoAPI.renameDevice({ host: device.host, port: device.port, name: newName.trim() }); + updateDevice(device.udn, { friendlyName: newName.trim() }); + addToast('Device renamed', 'success'); + setRenameModal(false); + } catch (err) { + addToast(`Rename failed: ${err.message}`, 'error'); + } + }; + + const doReset = async () => { + if (!confirm) return; + try { + if (confirm.code === 1) await window.wemoAPI.resetData({ host: device.host, port: device.port }); + else if (confirm.code === 2) await window.wemoAPI.factoryReset({ host: device.host, port: device.port }); + else if (confirm.code === 5) await window.wemoAPI.resetWifi({ host: device.host, port: device.port }); + addToast(`${confirm.label} complete`, 'success'); + } catch (err) { + addToast(`${confirm.label} failed: ${err.message}`, 'error'); + } finally { + setConfirm(null); + } + }; + + const combined = { ...device, ...info }; + + return ( +
+ {/* Status */} +
+ {device.online === true && ● Online} + {device.online === false && ● Offline} + + {loading && } +
+ + {/* Device information */} +
+
Device Information
+ + + + + + + + + + + +
+ Signal Strength + + {info?.signalStrength + ? + : } + +
+
+ + {/* Device Clock */} +
+
Device Clock
+

+ Press Sync to push host time to the device. Required for schedule rules to fire at the correct local time. +

+ +
+ + {/* HomeKit */} +
+
HomeKit
+ {hkInfo?.setupCode + ? <> +
+ Setup Code + {hkInfo.setupCode} +
+
+ Status + {hkInfo.setupDone === '1' ? '✅ Paired' : '⏳ Not paired'} +
+ + :

HomeKit not supported on this device.

+ } +
+ + {/* Rename */} +
+
Rename Device
+

Change this device's friendly name.

+ +
+ + {/* Reset Options */} +
+
Reset Options
+
+ These actions cannot be undone. +
+ Clear Data = name, rules, icon.   + Clear Wi-Fi = Wi-Fi settings only.   + Factory Reset = everything. +
+
+ + + +
+
+ + {/* Rename modal */} + {renameModal && ( + setRenameModal(false)} + footer={ + <> + + + + } + > +
+ + setNewName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') doRename(); }} + maxLength={32} + /> +
+
+ )} + + {/* Reset confirm */} + {confirm && ( + setConfirm(null)} + /> + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/device/PowerButton.jsx b/apps/desktop/src/renderer/src/components/device/PowerButton.jsx new file mode 100644 index 0000000..d98701f --- /dev/null +++ b/apps/desktop/src/renderer/src/components/device/PowerButton.jsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react'; +import useSettingsStore from '../../store/settings'; + +export default function PowerButton({ device, onToggle }) { + const [busy, setBusy] = useState(false); + const addToast = useSettingsStore((s) => s.addToast); + const on = !!device?.on; + + const toggle = async (e) => { + e.stopPropagation(); + if (busy || !device) return; + setBusy(true); + try { + await onToggle(!on); + } catch (err) { + addToast(`Toggle failed: ${err.message}`, 'error'); + } finally { + setBusy(false); + } + }; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/src/components/device/SignalMeter.jsx b/apps/desktop/src/renderer/src/components/device/SignalMeter.jsx new file mode 100644 index 0000000..82a8485 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/device/SignalMeter.jsx @@ -0,0 +1,37 @@ +import React from 'react'; + +/** + * Renders 4 signal bars. dBm values: > -50 excellent, > -65 good, > -75 fair, else poor. + */ +export default function SignalMeter({ dBm }) { + const val = parseInt(dBm, 10); + const bars = isNaN(val) ? 0 + : val > -50 ? 4 + : val > -65 ? 3 + : val > -75 ? 2 + : 1; + + const label = isNaN(val) ? 'Unknown' + : val > -50 ? 'Excellent' + : val > -65 ? 'Good' + : val > -75 ? 'Fair' + : 'Poor'; + + return ( + + + {[1, 2, 3, 4].map((b) => ( + + ))} + + + {isNaN(val) ? '—' : `${val} dBm`} + + ({label}) + + ); +} diff --git a/apps/desktop/src/renderer/src/components/layout/DetailPanel.jsx b/apps/desktop/src/renderer/src/components/layout/DetailPanel.jsx new file mode 100644 index 0000000..813f4c7 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/layout/DetailPanel.jsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import DeviceInfoTab from '../device/DeviceInfoTab'; +import RulesTab from '../rules/RulesTab'; +import WiFiTab from '../wifi/WiFiTab'; +import useDeviceStore from '../../store/devices'; +import useRulesStore from '../../store/rules'; + +const TABS = [ + { id: 'info', label: '📋 Info' }, + { id: 'rules', label: '📅 Rules' }, + { id: 'wifi', label: '📶 Wi-Fi' }, +]; + +export default function DetailPanel() { + const [activeTab, setActiveTab] = useState('info'); + const { devices, selectedUdn } = useDeviceStore(); + const clearRules = useRulesStore((s) => s.clear); + + const device = devices.find((d) => d.udn === selectedUdn); + + // Reset rules when device changes + React.useEffect(() => { + clearRules(); + setActiveTab('info'); + }, [selectedUdn]); + + if (!device) { + return ( +
+ +

Select a device from the sidebar

+
+ ); + } + + return ( +
+ {/* Device header */} +
+
+
+
{device.friendlyName}
+
+ {device.productModel || device.modelName || 'Wemo Device'} +
+
+ {device.online === true && ● Online} + {device.online === false && ● Offline} +
+
+ {TABS.map((t) => ( + + ))} +
+
+ + {/* Tab content */} +
+ {activeTab === 'info' && } + {activeTab === 'rules' && } + {activeTab === 'wifi' && } +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/layout/Sidebar.jsx b/apps/desktop/src/renderer/src/components/layout/Sidebar.jsx new file mode 100644 index 0000000..1bd9b91 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/layout/Sidebar.jsx @@ -0,0 +1,370 @@ +import React, { useState, useEffect } from 'react'; +import DeviceCard from '../device/DeviceCard'; +import Modal from '../shared/Modal'; +import useDeviceStore from '../../store/devices'; +import useSettingsStore from '../../store/settings'; + +export default function Sidebar({ onOpenSettings }) { + const { devices, discovering, deviceOrder, deviceGroups, setDiscovering, mergeDevice } = useDeviceStore(); + const addToast = useSettingsStore((s) => s.addToast); + + // ── In-process scheduler (legacy — kept for upcoming-fires panel) ───────── + const [schedulerRunning, setSchedulerRunning] = useState(false); + const [schedulerStarting, setSchedulerStarting] = useState(false); + const [schedulerUpcoming, setSchedulerUpcoming] = useState([]); + const [showSchedulePanel, setShowSchedulePanel] = useState(false); + + useEffect(() => { + const offFired = window.wemoAPI.onSchedulerFired((ev) => { + addToast(`⏱ ${ev.msg}`, ev.success ? 'success' : 'error', 6000); + }); + const offStatus = window.wemoAPI.onSchedulerStatus((s) => { + setSchedulerRunning(s.running); + setSchedulerUpcoming(s.upcoming ?? []); + }); + window.wemoAPI.schedulerStatus().then((s) => { + setSchedulerRunning(s.running); + setSchedulerUpcoming(s.upcoming ?? []); + }).catch(() => {}); + return () => { offFired(); offStatus(); }; + }, []); + + const toggleScheduler = async () => { + if (schedulerRunning) { + await window.wemoAPI.schedulerStop(); + setSchedulerRunning(false); + setSchedulerUpcoming([]); + addToast('Scheduler stopped', 'info'); + return; + } + if (devices.length === 0) { addToast('Discover devices first', 'warn'); return; } + setSchedulerStarting(true); + try { + const status = await window.wemoAPI.schedulerStart({ devices }); + setSchedulerRunning(true); + setSchedulerUpcoming(status.upcoming ?? []); + addToast(`Scheduler running — ${status.totalEntries} actions loaded`, 'success', 6000); + } catch (e) { + addToast(`Scheduler failed: ${e.message}`, 'error'); + } finally { + setSchedulerStarting(false); + } + }; + + // ── Windows Service ─────────────────────────────────────────────────────── + const [svcStatus, setSvcStatus] = useState(null); // null | {installed,running,status} + const [svcWorking, setSvcWorking] = useState(false); + const [showSvcPanel, setShowSvcPanel] = useState(false); + + const refreshSvcStatus = () => { + window.wemoAPI.serviceStatus().then(setSvcStatus).catch(() => setSvcStatus(null)); + }; + + useEffect(() => { refreshSvcStatus(); }, []); + + const svcInstall = async () => { + if (devices.length === 0) { addToast('Discover devices first so the service knows what to control', 'warn'); return; } + setSvcWorking(true); + try { + // Push device list to ProgramData before installing + await window.wemoAPI.syncDevicesToService(devices); + const res = await window.wemoAPI.serviceInstall(); + addToast(`✅ ${res.msg}`, 'success', 8000); + setTimeout(refreshSvcStatus, 3000); + } catch (e) { + addToast(`Service install failed: ${e.message}`, 'error', 10000); + } finally { setSvcWorking(false); } + }; + + const svcUninstall = async () => { + setSvcWorking(true); + try { + const res = await window.wemoAPI.serviceUninstall(); + addToast(res.msg, 'info'); + setTimeout(refreshSvcStatus, 3000); + } catch (e) { + addToast(`Service uninstall failed: ${e.message}`, 'error'); + } finally { setSvcWorking(false); } + }; + + const svcStartStop = async () => { + setSvcWorking(true); + try { + const res = svcStatus?.running + ? await window.wemoAPI.serviceStop() + : await window.wemoAPI.serviceStart(); + addToast(res.msg, 'info'); + setTimeout(refreshSvcStatus, 3000); + } catch (e) { + addToast(`Service error: ${e.message}`, 'error'); + } finally { setSvcWorking(false); } + }; + + const discover = async () => { + setDiscovering(true); + try { + const found = await window.wemoAPI.discoverDevices({ timeout: 6000 }); + found.forEach((d) => mergeDevice(d)); + await window.wemoAPI.saveDevices(useDeviceStore.getState().devices); + if (found.length === 0) addToast('No new devices found', 'info'); + else addToast(`Found ${found.length} device${found.length > 1 ? 's' : ''}`, 'success'); + } catch (e) { + addToast(`Discovery failed: ${e.message}`, 'error'); + } finally { + setDiscovering(false); + } + }; + + // Listen for discovery trigger from Electron menu or App + useEffect(() => { + const handler = () => discover(); + window.addEventListener('wemo:discover', handler); + return () => window.removeEventListener('wemo:discover', handler); + }, []); + const [manualModal, setManualModal] = useState(false); + const [manualHost, setManualHost] = useState(''); + const [manualPort, setManualPort] = useState('49153'); + + // Sort devices by custom order + const sortedDevices = [...devices].sort((a, b) => { + const ia = deviceOrder.indexOf(a.udn); + const ib = deviceOrder.indexOf(b.udn); + if (ia === -1 && ib === -1) return (a.friendlyName || '').localeCompare(b.friendlyName || ''); + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + }); + + // Group devices + const grouped = new Map(); + grouped.set('__ungrouped__', []); + for (const g of deviceGroups) grouped.set(g.name, []); + + for (const device of sortedDevices) { + const group = deviceGroups.find((g) => g.udns?.includes(device.udn)); + grouped.get(group ? group.name : '__ungrouped__').push(device); + } + + const addManual = async () => { + if (!manualHost.trim()) return; + try { + const result = await window.wemoAPI.discoverDevices({ + manualEntries: [{ host: manualHost.trim(), port: parseInt(manualPort, 10) || 49153 }], + timeout: 5000, + }); + if (result.length === 0) { + addToast('No device found at that address', 'error'); + return; + } + const { mergeDevice, saveDevices, devices: devs } = useDeviceStore.getState(); + result.forEach((d) => mergeDevice({ ...d, manual: true })); + const allDevs = useDeviceStore.getState().devices; + await window.wemoAPI.saveDevices(allDevs); + addToast(`Added ${result[0].friendlyName}`, 'success'); + setManualModal(false); + } catch (err) { + addToast(`Failed: ${err.message}`, 'error'); + } + }; + + const renderGroup = (name, devs) => { + if (devs.length === 0) return null; + return ( +
+ {name !== '__ungrouped__' && ( +
+ {name} +
+ )} + {devs.map((d) => )} +
+ ); + }; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/src/components/rules/AllRulesTab.jsx b/apps/desktop/src/renderer/src/components/rules/AllRulesTab.jsx new file mode 100644 index 0000000..0d2681a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/rules/AllRulesTab.jsx @@ -0,0 +1,446 @@ +'use strict'; +import React, { useState, useCallback } from 'react'; +import RuleEditor from './RuleEditor'; +import useDeviceStore from '../../store/devices'; +import useSettingsStore from '../../store/settings'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const DWM_PREFIX = 'DWM:'; +function isDwm(name) { return String(name ?? '').startsWith(DWM_PREFIX); } +function stripDwm(name) { return String(name ?? '').replace(/^DWM:/i, ''); } + +// ── Shared display helpers ──────────────────────────────────────────────────── + +const DAY_SHORT = { 1:'Mon', 2:'Tue', 3:'Wed', 4:'Thu', 5:'Fri', 6:'Sat', 7:'Sun' }; +const RULE_ICONS = { Schedule: '📅', Away: '🏠', Countdown: '⏱', 'Long Press': '👆' }; +const DISPLAY_TYPE_MAP = { + 'time interval': 'Schedule', 'simple switch': 'Schedule', + 'countdown rule': 'Countdown', + 'away mode': 'Away', + 'long press': 'Long Press', +}; + +function normaliseType(raw) { + if (!raw) return 'Schedule'; + const key = String(raw).toLowerCase().trim().replace(/\s+/g, ' '); + return DISPLAY_TYPE_MAP[key] || raw; +} + +function secsToHHMM(secs) { + if (secs === -2) return '🌅 Sunrise'; + if (secs === -3) return '🌇 Sunset'; + if (!secs && secs !== 0) return '—'; + const h = Math.floor(Math.abs(secs) / 3600) % 24; + const m = Math.floor((Math.abs(secs) % 3600) / 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +const ACTION_LABEL = { 1: '→ ON', 0: '→ OFF', 2: '↔ Toggle', '-1': '' }; +function actionLabel(val) { + if (val === null || val === undefined) return ''; + return ACTION_LABEL[String(Math.round(Number(val)))] ?? ''; +} + +function ruleSummary(rule) { + const rd = rule.ruleDevices?.[0]; + const typeKey = normaliseType(rule.type); + + if (typeKey === 'Countdown') { + const mins = rd?.countdowntime ? Math.round(rd.countdowntime / 60) : null; + return mins ? `${mins} min ${actionLabel(rd?.startaction)}` : '—'; + } + if (typeKey === 'Away') { + const days = rd?.days?.map((d) => DAY_SHORT[d]).join(' ') || '—'; + return `${days} · ${secsToHHMM(rd?.starttime)}–${secsToHHMM(rd?.endtime)}`; + } + const days = rd?.days?.map((d) => DAY_SHORT[d]).join(' ') || '—'; + const start = secsToHHMM(rd?.starttime); + const sa = actionLabel(rd?.startaction); + const et = rd?.endtime; + const endTime = (et > 0 || et === -2 || et === -3) ? secsToHHMM(et) : null; + const ea = endTime ? actionLabel(rd?.endaction) : ''; + return endTime + ? `${days} · ${start} ${sa} → ${endTime} ${ea}`.trim() + : `${days} · ${start}${sa ? ' ' + sa : ''}`; +} + + +/** + * Deduplication key: normalised name (prefix stripped) + canonical type + sorted days + startTime. + */ +function dedupKey(rule) { + const rd = rule.ruleDevices?.[0]; + const days = (rd?.days ?? []).slice().sort((a, b) => a - b).join(','); + return `${stripDwm(rule.name).toLowerCase().trim()}|${normaliseType(rule.type)}|${days}|${rd?.starttime ?? ''}`; +} + +// ── Copy-to-DWM button ──────────────────────────────────────────────────────── + +/** + * Converts a Wemo device rule into a DWM local-store rule and saves it. + * Does NOT write anything to the Wemo device. + */ +function CopyToDwmButton({ rule, onDone }) { + const [copying, setCopying] = useState(false); + const addToast = useSettingsStore((s) => s.addToast); + const { devices } = useDeviceStore(); + + const handle = async () => { + setCopying(true); + try { + // Resolve full device info (host+port) for each source device + const targetDevices = (rule.sourceDevices ?? []).map((sd) => { + const dev = devices.find((d) => d.udn === sd.udn); + return dev + ? { udn: dev.udn, host: dev.host, port: dev.port, name: dev.friendlyName || dev.name } + : null; + }).filter(Boolean); + + if (!targetDevices.length) { + addToast('No discoverable devices found for this rule — run a scan first', 'warn'); + setCopying(false); + return; + } + + // Convert Wemo rule schema to DWM local schema + const rd = rule.ruleDevices?.[0]; + const startSecs = Number(rd?.starttime ?? 0); + const endSecs = Number(rd?.endtime ?? -1); + + let startType = 'fixed', startOffset = 0; + let endType = 'fixed', endOffset = 0; + let startTime = startSecs, endTime = endSecs; + + if (startSecs === -2) { startType = 'sunrise'; startOffset = Math.round((rd?.onmodeoffset ?? 0) / 60); startTime = -2; } + else if (startSecs === -3) { startType = 'sunset'; startOffset = Math.round((rd?.onmodeoffset ?? 0) / 60); startTime = -3; } + if (endSecs === -2) { endType = 'sunrise'; endOffset = Math.round((rd?.offmodeoffset ?? 0) / 60); endTime = -2; } + else if (endSecs === -3) { endType = 'sunset'; endOffset = Math.round((rd?.offmodeoffset ?? 0) / 60); endTime = -3; } + + const dwmRule = { + name: stripDwm(rule.name), + type: normaliseType(rule.type), + enabled: rule.enabled ?? true, + days: rd?.days ?? [1,2,3,4,5,6,7], + startTime, + endTime, + startAction: Number(rd?.startaction ?? 1), + endAction: Number(rd?.endaction ?? -1), + startType, startOffset, + endType, endOffset, + countdownTime: Number(rd?.countdowntime ?? 0), + targetDevices, + }; + + await window.wemoAPI.createDwmRule(dwmRule); + addToast(`✅ "${dwmRule.name}" copied to DWM Rules`, 'success', 6000); + onDone?.(); + } catch (e) { + addToast(`Copy failed: ${e.message}`, 'error'); + } finally { + setCopying(false); + } + }; + + return ( + + ); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function AllRulesTab() { + const { devices } = useDeviceStore(); + const addToast = useSettingsStore((s) => s.addToast); + + const [allRules, setAllRules] = useState(null); + const [loading, setLoading] = useState(false); + const [deviceErrors, setDeviceErrors] = useState({}); + const [editingRule, setEditingRule] = useState(null); + const [editingDevice, setEditingDevice] = useState(null); + + const loadAll = useCallback(async () => { + setLoading(true); + setDeviceErrors({}); + + const capable = devices.filter((d) => d.supportsRules !== false); + if (!capable.length) { setAllRules([]); setLoading(false); return; } + + const errors = {}; + const grouped = new Map(); + + await Promise.allSettled( + capable.map(async (dev) => { + try { + const res = await window.wemoAPI.getRules({ host: dev.host, port: dev.port }); + const devLabel = dev.friendlyName || dev.name || dev.host; + + for (const rule of (res.rules ?? [])) { + const key = dedupKey(rule); + if (grouped.has(key)) { + const existing = grouped.get(key); + if (!existing.sourceDevices.some((s) => s.udn === dev.udn)) { + existing.sourceDevices.push({ udn: dev.udn, name: devLabel }); + } + // Prefer DWM version of the rule (has more info) + if (isDwm(rule.name) && !isDwm(existing.name)) { + grouped.set(key, { ...rule, _key: key, sourceDevices: existing.sourceDevices }); + } + } else { + grouped.set(key, { ...rule, _key: key, sourceDevices: [{ udn: dev.udn, name: devLabel }] }); + } + } + } catch (e) { + errors[dev.udn] = e.message || 'Failed to connect'; + } + }) + ); + + setDeviceErrors(errors); + + // Sort: DWM rules first, then enabled, then alphabetically + const list = [...grouped.values()].sort((a, b) => { + const aDwm = isDwm(a.name) ? 0 : 1; + const bDwm = isDwm(b.name) ? 0 : 1; + if (aDwm !== bDwm) return aDwm - bDwm; + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; + return stripDwm(a.name).localeCompare(stripDwm(b.name)); + }); + setAllRules(list); + setLoading(false); + }, [devices]); + + const handleEdit = (rule) => { + const sd = rule.sourceDevices?.[0]; + const dev = sd ? devices.find((d) => d.udn === sd.udn) : null; + if (!dev) { + addToast('Device not found — run a scan first', 'warn'); + return; + } + setEditingRule(rule); + setEditingDevice(dev); + }; + + const handleToggle = async (rule, enabled) => { + // Update rule on every source device it lives on + const targets = (rule.sourceDevices ?? []) + .map((sd) => devices.find((d) => d.udn === sd.udn)) + .filter(Boolean); + + if (!targets.length) { + addToast('Device not found — run a scan first', 'warn'); + return; + } + + // Optimistic UI update + setAllRules((prev) => + prev.map((r) => r._key === rule._key ? { ...r, enabled } : r) + ); + + const results = await Promise.allSettled( + targets.map((dev) => + window.wemoAPI.updateRule({ host: dev.host, port: dev.port, ruleId: rule.ruleId, input: { enabled } }) + ) + ); + const failed = results.filter((r) => r.status === 'rejected').length; + if (failed) { + addToast(`Updated ${targets.length - failed}/${targets.length} device(s) — ${failed} unreachable`, 'warn'); + // Revert optimistic update on failure + setAllRules((prev) => + prev.map((r) => r._key === rule._key ? { ...r, enabled: !enabled } : r) + ); + } else { + addToast(`Rule ${enabled ? 'enabled' : 'disabled'}`, 'info'); + } + }; + + const errorEntries = Object.entries(deviceErrors); + const capableCount = devices.filter((d) => d.supportsRules !== false).length; + + const dwmCount = allRules ? allRules.filter((r) => isDwm(r.name)).length : 0; + const wemoCount = allRules ? allRules.filter((r) => !isDwm(r.name)).length : 0; + const dedupSaved = allRules + ? allRules.reduce((acc, r) => acc + r.sourceDevices.length, 0) - allRules.length + : 0; + + return ( +
+ + {/* Toolbar */} +
+ + {allRules !== null && !loading && ( + + {capableCount} device{capableCount !== 1 ? 's' : ''} + {' · '} + {dwmCount} DWM + {wemoCount > 0 && · {wemoCount} Wemo} + {dedupSaved > 0 && ( + + ({dedupSaved} duplicate{dedupSaved !== 1 ? 's' : ''} collapsed) + + )} + + )} + + 📥 Copy adds rules to local DWM database + +
+ + {/* Device errors */} + {errorEntries.length > 0 && ( +
+ ⚠️ Could not reach {errorEntries.length} device{errorEntries.length !== 1 ? 's' : ''}: + {errorEntries.map(([udn, msg]) => { + const dev = devices.find((d) => d.udn === udn); + return ( +
+ · {dev?.friendlyName || dev?.name || udn}: {msg} +
+ ); + })} +
+ )} + + {/* Not loaded */} + {allRules === null && !loading && ( +
+ 🌐 +

+ Click Load All Rules to fetch rules from all
+ your Wemo devices in one deduplicated list.
+ + 🔵 DWM = managed by this app  ·  Wemo = native device rules + +

+
+ )} + + {/* Loading */} + {loading && ( +
+ +

Fetching rules from {capableCount} device{capableCount !== 1 ? 's' : ''}…

+
+ )} + + {/* Empty */} + {allRules !== null && !loading && allRules.length === 0 && ( +
+ 📅 +

No rules found across any devices.

+
+ )} + + {/* Rule list */} + {allRules !== null && !loading && allRules.length > 0 && ( +
+ {allRules.map((rule) => { + const typeKey = normaliseType(rule.type); + const icon = RULE_ICONS[typeKey] || '📅'; + const isAway = typeKey === 'Away'; + const managed = isDwm(rule.name); + return ( +
+ {icon} +
+
+
{stripDwm(rule.name)}
+ {managed && ( + + DWM + + )} +
+
+ + {rule.type} + + {ruleSummary(rule)} + {!rule.enabled && ( + Disabled + )} +
+ {/* Source device chips + action buttons */} +
+ {rule.sourceDevices.map((sd) => ( + + 📍 {sd.name} + + ))} + {/* Copy button for non-DWM rules */} + {!managed && ( + + )} + {managed && ( + + ✓ Managed by DWM scheduler + + )} +
+
+ {/* Toggle + Edit — writes back to the Wemo device */} +
+ + +
+
+ ); + })} +
+ )} + {/* Edit rule modal — writes back to the Wemo device directly */} + {editingRule && editingDevice && ( + { + setEditingRule(null); + setEditingDevice(null); + addToast('✅ Rule updated on device', 'success'); + loadAll(); + }} + onClose={() => { + setEditingRule(null); + setEditingDevice(null); + }} + /> + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/rules/DayPicker.jsx b/apps/desktop/src/renderer/src/components/rules/DayPicker.jsx new file mode 100644 index 0000000..3f9f437 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/rules/DayPicker.jsx @@ -0,0 +1,33 @@ +import React from 'react'; + +const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; +const SHORT = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +export default function DayPicker({ selected, onChange }) { + const toggle = (day) => { + if (selected.includes(day)) onChange(selected.filter((d) => d !== day)); + else onChange([...selected, day]); + }; + + return ( +
+
+ {DAYS.map((day, i) => ( + toggle(day)} + > + {SHORT[i]} + + ))} +
+
+ onChange([...DAYS])}>All + onChange(['Monday','Tuesday','Wednesday','Thursday','Friday'])}>Weekdays + onChange(['Saturday','Sunday'])}>Weekend + onChange([])}>None +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/rules/RuleEditor.jsx b/apps/desktop/src/renderer/src/components/rules/RuleEditor.jsx new file mode 100644 index 0000000..49d4fc3 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/rules/RuleEditor.jsx @@ -0,0 +1,611 @@ +import React, { useState, useEffect } from 'react'; +import Modal from '../shared/Modal'; +import ScheduleEditor from './editors/ScheduleEditor'; +import CountdownEditor from './editors/CountdownEditor'; +import AwayModeEditor from './editors/AwayModeEditor'; +import useDeviceStore from '../../store/devices'; +import useSettingsStore from '../../store/settings'; + +const RULE_TYPES = [ + { value: 'Schedule', label: '📅 Schedule', desc: 'Turn on/off at a specific time' }, + { value: 'Countdown', label: '⏱ Countdown', desc: 'Auto-off after a set duration' }, + { value: 'Away', label: '🏠 Away Mode', desc: 'Random on/off to simulate occupancy' }, + { value: 'AlwaysOn', label: '🔒 Always On', desc: 'Keep device on — re-enables if turned off' }, + { value: 'Trigger', label: '⚡ Trigger', desc: 'If a device changes state, act on another' }, +]; + +// ── Day name ↔ number conversion ───────────────────────────────────────────── + +const DAY_NAMES = { 1:'Monday', 2:'Tuesday', 3:'Wednesday', 4:'Thursday', 5:'Friday', 6:'Saturday', 7:'Sunday' }; +const DAY_NUMS = { Monday:1, Tuesday:2, Wednesday:3, Thursday:4, Friday:5, Saturday:6, Sunday:7 }; + +function dayNumsToNames(nums) { + return (nums ?? []).map((n) => DAY_NAMES[n]).filter(Boolean); +} + +function dayNamesToNums(names) { + return (names ?? []).map((n) => DAY_NUMS[n]).filter(Boolean); +} + +function secsToHHMM(secs) { + if (secs === null || secs === undefined || secs < 0) return ''; + const h = Math.floor(secs / 3600) % 24; + const m = Math.floor((secs % 3600) / 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +function hhmmToSecs(t) { + if (!t) return -1; + const [h, m] = t.split(':').map(Number); + return (h || 0) * 3600 + (m || 0) * 60; +} + +// ── Normalise Wemo firmware type strings ───────────────────────────────────── + +const TYPE_MAP = { + 'time interval': 'Schedule', timeinterval: 'Schedule', + 'simple switch': 'Schedule', simpleswitch: 'Schedule', + 'countdown rule': 'Countdown', countdownrule: 'Countdown', + 'away mode': 'Away', awaymode: 'Away', awaymoderule: 'Away', + schedule: 'Schedule', timer: 'Schedule', timerrule: 'Schedule', + automation: 'Schedule', automated: 'Schedule', time: 'Schedule', + countdown: 'Countdown', timer2: 'Countdown', + away: 'Away', + 'long press': 'Long Press', longpress: 'Long Press', +}; + +function normaliseType(raw) { + if (!raw) return 'Schedule'; + const key = String(raw).toLowerCase().trim().replace(/\s+/g, ' '); + return TYPE_MAP[key] || TYPE_MAP[key.replace(/\s/g, '')] || raw; +} + +// ── Form init helpers ───────────────────────────────────────────────────────── + +/** Build form state from a DWM rule (local store schema). */ +function dwmRuleToForm(rule, defaultDeviceUdn) { + return { + type: normaliseType(rule.type) || 'Schedule', + name: rule.name || '', + days: dayNumsToNames(rule.days), + startType: rule.startType || 'fixed', + startTime: rule.startType === 'fixed' && rule.startTime >= 0 ? secsToHHMM(rule.startTime) : '', + startOffset: rule.startOffset ?? 0, + startAction: rule.startAction ?? 1, + endType: rule.endType || 'fixed', + endTime: rule.endType === 'fixed' && rule.endTime > 0 ? secsToHHMM(rule.endTime) : '', + endOffset: rule.endOffset ?? 0, + endAction: rule.endAction ?? -1, + countdownMins: rule.countdownTime ? Math.round(rule.countdownTime / 60) : 60, + countdownTime: rule.countdownTime ?? 3600, + // Countdown active window + windowEnabled: rule.windowStart >= 0 && rule.windowStart != null, + windowStartTime: rule.windowStart >= 0 ? secsToHHMM(rule.windowStart) : '', + windowEndTime: rule.windowEnd >= 0 ? secsToHHMM(rule.windowEnd) : '', + windowDays: dayNumsToNames(rule.windowDays ?? []), + deviceIds: (rule.targetDevices ?? []).map((d) => d.udn).filter(Boolean), + targetDeviceIds: [], + // Trigger-specific + triggerDeviceId: rule.triggerDevice?.udn ?? '', + triggerEvent: rule.triggerEvent ?? 'any', + triggerAction: rule.action ?? 'on', + actionDeviceIds: (rule.actionDevices ?? []).map((d) => d.udn).filter(Boolean), + }; +} + +/** Build form state from a Wemo device rule (ruleDevices schema). */ +function wemoRuleToForm(rule, currentUdn) { + if (!rule) return { + type: 'Schedule', name: '', + days: ['Monday','Tuesday','Wednesday','Thursday','Friday'], + startType: 'fixed', startTime: '07:00', startOffset: 0, + startAction: 1, + endType: 'fixed', endTime: '', endOffset: 0, endAction: -1, + countdownMins: 60, countdownTime: 3600, + deviceIds: currentUdn ? [currentUdn] : [], + targetDeviceIds: [], + }; + + const rd = rule.ruleDevices?.[0]; + const type = normaliseType(rule.type); + const startSecs = rd?.starttime; + const endSecs = rd?.endtime; + const onOff = rd?.onmodeoffset || 0; + const offOff = rd?.offmodeoffset || 0; + + let startType = 'fixed', startTime = secsToHHMM(startSecs), startOffset = 0; + let endType = 'fixed', endTime = secsToHHMM(endSecs), endOffset = 0; + + if (startSecs === -2) { startType = 'sunrise'; startOffset = Math.round(onOff / 60); startTime = ''; } + else if (startSecs === -3) { startType = 'sunset'; startOffset = Math.round(onOff / 60); startTime = ''; } + if (endSecs === -2) { endType = 'sunrise'; endOffset = Math.round(offOff / 60); endTime = ''; } + else if (endSecs === -3) { endType = 'sunset'; endOffset = Math.round(offOff / 60); endTime = ''; } + + const days = rd?.days?.length + ? rd.days.map((n) => ['','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'][n]).filter(Boolean) + : []; + const deviceIds = [...new Set((rule.ruleDevices ?? []).map((r) => r.deviceid).filter(Boolean))]; + if (currentUdn && !deviceIds.includes(currentUdn)) deviceIds.unshift(currentUdn); + + return { + type, name: rule.name.replace(/^DWM:/i, ''), + days, startType, startTime, startOffset, + endType, endTime, endOffset, + startAction: rd?.startaction ?? 1, + endAction: rd?.endaction ?? -1, + countdownMins: rd?.countdowntime ? Math.round(rd.countdowntime / 60) : 60, + countdownTime: rd?.countdowntime ?? 3600, + deviceIds, targetDeviceIds: rule.targetDevices || [], + }; +} + +// ── Trigger rule editor ─────────────────────────────────────────────────────── + +function TriggerEditor({ form, onChange, allDevices }) { + const selStyle = { background: 'var(--input-bg)', color: 'var(--text)', border: '1px solid var(--border)', borderRadius: 6, padding: '7px 10px', width: '100%' }; + + const toggleActionDev = (udn) => { + const ids = form.actionDeviceIds ?? []; + onChange({ ...form, actionDeviceIds: ids.includes(udn) ? ids.filter((x) => x !== udn) : [...ids, udn] }); + }; + + return ( +
+ {/* Trigger source device */} +
+ +

+ Which device's state change should trigger the action? +

+ +
+ + {/* When */} +
+
+ + +
+
+ + +
+
+ + {/* Action devices */} +
+ +

+ Which devices should be controlled when the trigger fires? +

+ {allDevices.length === 0 ? ( +

No devices found. Scan for devices first.

+ ) : ( +
+ {allDevices.map((d) => ( + toggleActionDev(d.udn)}> + {d.friendlyName || d.name} + + ))} +
+ )} + {!(form.actionDeviceIds ?? []).length && ( +

+ Select at least one action device. +

+ )} +
+
+ ); +} + +// ── Device picker ───────────────────────────────────────────────────────────── + +/** + * For DWM rules: any combination of devices may be selected (none mandatory). + * For Wemo device rules: the current device is always included. + */ +function DevicePicker({ isDwm, currentUdn, selected, allDevices, onChange }) { + if (allDevices.length === 0) return null; + + const toggle = (udn) => { + if (!isDwm && udn === currentUdn) return; // current device locked in Wemo mode + const next = selected.includes(udn) + ? selected.filter((x) => x !== udn) + : [...selected, udn]; + if (!isDwm && currentUdn) { + onChange([currentUdn, ...next.filter((x) => x !== currentUdn)]); + } else { + onChange(next); + } + }; + + const selectAll = () => { + const allUdns = allDevices.map((d) => d.udn); + onChange(allUdns); + }; + + const selectNone = () => { + if (!isDwm && currentUdn) { + // In Wemo mode the current device is always kept + onChange([currentUdn]); + } else { + onChange([]); + } + }; + + return ( +
+
+ + + +
+

+ {isDwm + ? 'Select which devices this rule will control.' + : 'This rule will be stored on the current device and run on all selected devices.'} +

+
+ {allDevices.map((d) => { + const locked = !isDwm && d.udn === currentUdn; + return ( + !locked && toggle(d.udn)} + > + {locked ? '🏠 ' : ''}{d.friendlyName || d.name} + + ); + })} +
+ {selected.length === 0 && isDwm && ( +

+ Select at least one device. +

+ )} +
+ ); +} + +// ── Main editor ─────────────────────────────────────────────────────────────── + +/** + * RuleEditor — works in two modes: + * isDwm=true → saves to local DWM store via createDwmRule / updateDwmRule + * isDwm=false → saves to Wemo device via createRule / updateRule (Wemo Rules tab) + */ +export default function RuleEditor({ rule, device, isDwm = false, onSave, onClose }) { + const { devices } = useDeviceStore(); + const { location } = useSettingsStore(); + + const [form, setForm] = useState(() => + isDwm + ? (rule ? dwmRuleToForm(rule) : { + type: 'Schedule', name: '', + days: ['Monday','Tuesday','Wednesday','Thursday','Friday'], + startType: 'fixed', startTime: '07:00', startOffset: 0, + startAction: 1, + endType: 'fixed', endTime: '', endOffset: 0, endAction: -1, + countdownMins: 60, countdownTime: 3600, + windowEnabled: false, windowStartTime: '', windowEndTime: '', + windowDays: ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'], + // Pre-select the sidebar device if one is selected + deviceIds: device?.udn ? [device.udn] : [], + targetDeviceIds: [], + // Trigger-specific defaults + triggerDeviceId: '', triggerEvent: 'any', triggerAction: 'on', actionDeviceIds: [], + }) + : wemoRuleToForm(rule, device?.udn) + ); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [sunTimes, setSunTimes] = useState(null); + + const isEdit = !!rule; + + useEffect(() => { + if (location) { + window.wemoAPI.getSunTimes({ lat: location.lat, lng: location.lng }) + .then(setSunTimes).catch(() => {}); + } + }, [location?.lat, location?.lng]); + + const validate = () => { + if (!form.name.trim()) return 'Rule name is required.'; + if (form.type === 'Schedule' && !form.days?.length) return 'Select at least one day.'; + if (form.type === 'Schedule' && form.startType === 'fixed' && !form.startTime) return 'Start time is required.'; + if (form.type === 'Away' && !form.days?.length) return 'Select at least one day.'; + if (form.type === 'Away' && (form.startType || 'fixed') === 'fixed' && !form.startTime) return 'Window Start time is required for Away Mode.'; + if (form.type === 'Away' && (form.endType || 'fixed') === 'fixed' && !form.endTime) return 'Window End time is required for Away Mode.'; + if (isDwm && form.type === 'AlwaysOn' && !form.deviceIds?.length) return 'Select at least one device to keep on.'; + if (isDwm && form.type === 'Trigger' && !form.triggerDeviceId) return 'Select a trigger (source) device.'; + if (isDwm && form.type === 'Trigger' && !form.actionDeviceIds?.length) return 'Select at least one action device.'; + if (isDwm && form.type !== 'AlwaysOn' && form.type !== 'Trigger' && !form.deviceIds?.length) return 'Select at least one target device.'; + const usesSun = form.startType === 'sunrise' || form.startType === 'sunset' + || form.endType === 'sunrise' || form.endType === 'sunset'; + if (usesSun && !location) { + return 'A location is required for Sunrise/Sunset rules. Open ⚙️ Settings and search for your city.'; + } + return null; + }; + + const save = async () => { + const err = validate(); + if (err) { setError(err); return; } + setSaving(true); + setError(''); + try { + if (isDwm) { + await saveDwm(); + } else { + await saveWemo(); + } + onSave(); + } catch (e) { + setError(e.message); + } finally { + setSaving(false); + } + }; + + /** Save DWM rule to local store. */ + const saveDwm = async () => { + const devById = (udn) => { + const dev = devices.find((d) => d.udn === udn); + return dev ? { udn: dev.udn, host: dev.host, port: dev.port, name: dev.friendlyName || dev.name } : null; + }; + + // ── AlwaysOn ────────────────────────────────────────────────────────── + if (form.type === 'AlwaysOn') { + const targetDevices = (form.deviceIds ?? []).map(devById).filter(Boolean); + const payload = { + name: form.name.trim(), type: 'AlwaysOn', enabled: rule?.enabled ?? true, targetDevices, + }; + if (isEdit) await window.wemoAPI.updateDwmRule({ id: rule.id, updates: payload }); + else await window.wemoAPI.createDwmRule(payload); + return; + } + + // ── Trigger ─────────────────────────────────────────────────────────── + if (form.type === 'Trigger') { + const triggerDevice = devById(form.triggerDeviceId); + const actionDevices = (form.actionDeviceIds ?? []).map(devById).filter(Boolean); + const payload = { + name: form.name.trim(), type: 'Trigger', enabled: rule?.enabled ?? true, + triggerDevice, triggerEvent: form.triggerEvent ?? 'any', + action: form.triggerAction ?? 'on', actionDevices, + }; + if (isEdit) await window.wemoAPI.updateDwmRule({ id: rule.id, updates: payload }); + else await window.wemoAPI.createDwmRule(payload); + return; + } + + // ── Schedule / Countdown / Away ─────────────────────────────────────── + const targetDevices = (form.deviceIds ?? []).map(devById).filter(Boolean); + + const startTime = form.startType === 'sunrise' ? -2 + : form.startType === 'sunset' ? -3 + : hhmmToSecs(form.startTime); + const endTime = form.endType === 'sunrise' ? -2 + : form.endType === 'sunset' ? -3 + : (form.endTime ? hhmmToSecs(form.endTime) : -1); + + const windowStart = (form.type === 'Countdown' && form.windowEnabled && form.windowStartTime) + ? hhmmToSecs(form.windowStartTime) : -1; + const windowEnd = (form.type === 'Countdown' && form.windowEnabled && form.windowEndTime) + ? hhmmToSecs(form.windowEndTime) : -1; + const windowDays = (form.type === 'Countdown' && form.windowEnabled) + ? dayNamesToNums(form.windowDays ?? []) : []; + + const payload = { + name: form.name.trim(), + type: form.type, + enabled: rule?.enabled ?? true, + days: dayNamesToNums(form.days), + startTime, + endTime, + startAction: form.startAction ?? 1, + endAction: form.endAction ?? -1, + startType: form.startType || 'fixed', + endType: form.endType || 'fixed', + startOffset: form.startOffset ?? 0, + endOffset: form.endOffset ?? 0, + countdownTime: form.countdownTime ?? 3600, + windowStart, + windowEnd, + windowDays, + targetDevices, + }; + + if (isEdit) { + await window.wemoAPI.updateDwmRule({ id: rule.id, updates: payload }); + } else { + await window.wemoAPI.createDwmRule(payload); + } + }; + + /** Save native Wemo device rule (Wemo Rules tab / legacy). */ + const saveWemo = async () => { + const input = { + name: form.name.trim(), + type: form.type, + days: form.days, + startTime: form.startType === 'fixed' ? form.startTime : null, + startType: form.startType !== 'fixed' ? form.startType : null, + startOffset: form.startOffset || 0, + endTime: form.endType === 'fixed' ? form.endTime : null, + endType: form.endType !== 'fixed' ? form.endType : null, + endOffset: form.endOffset || 0, + startAction: form.startAction ?? 1, + endAction: form.endAction ?? -1, + countdownTime: form.countdownTime, + deviceIds: form.deviceIds?.length ? form.deviceIds : [device.udn], + targetDeviceIds: form.targetDeviceIds || [], + }; + if (isEdit) { + const isDwmRule = rule.name.startsWith('DWM:'); + await window.wemoAPI.updateRule({ host: device.host, port: device.port, ruleId: rule.ruleId, input: { ...input, isDwm: isDwmRule } }); + } else { + await window.wemoAPI.createRule({ host: device.host, port: device.port, input }); + } + }; + + // All devices eligible as targets — sorted alphabetically by display name + const allRuleDevices = devices + .filter((d) => d.supportsRules !== false) + .sort((a, b) => (a.friendlyName || a.name || '').localeCompare(b.friendlyName || b.name || '')); + const otherDevices = allRuleDevices.filter((d) => d.udn !== device?.udn); + + return ( + + + + + } + > + {error && ( +
+ ⚠️ {error} +
+ )} + + {/* Rule name */} +
+ + { setError(''); setForm({ ...form, name: e.target.value }); }} + style={error && !form.name.trim() ? { borderColor: 'var(--danger, #e55)' } : {}} + /> +
+ + {/* Rule type */} + {form.type === 'Long Press' ? ( +
+ 👆 Long Press rules are managed by the device firmware and cannot be edited here. +
+ ) : ( +
+ +
+ {RULE_TYPES.map((rt) => ( +
setForm({ ...form, type: rt.value })} + style={{ + padding: '10px 14px', borderRadius: 8, cursor: 'pointer', flex: 1, minWidth: 140, + border: `1px solid ${form.type === rt.value ? 'var(--accent)' : 'var(--border)'}`, + background: form.type === rt.value ? 'rgba(0,169,213,.1)' : 'var(--card2)', + transition: 'all .15s', + }} + > +
{rt.label}
+
{rt.desc}
+
+ ))} +
+
+ )} + + {/* Device picker — for Schedule / Countdown / Away / AlwaysOn */} + {(form.type === 'Schedule' || form.type === 'Countdown' || form.type === 'Away' || form.type === 'AlwaysOn') && ( + isDwm + ? allRuleDevices.length > 0 && ( + setForm({ ...form, deviceIds: ids })} + /> + ) + : allRuleDevices.length > 1 && ( + setForm({ ...form, deviceIds: ids })} + /> + ) + )} + + {/* Trigger rule pickers */} + {form.type === 'Trigger' && isDwm && ( + + )} + + {/* Rule-type specific editors */} + {form.type !== 'Long Press' && ( +
+ {form.type === 'Schedule' && } + {form.type === 'Countdown' && } + {form.type === 'Away' && } + {form.type === 'AlwaysOn' && ( +
+ 🔒 The scheduler polls this device every 10 seconds. If it's found OFF it will be turned back ON automatically. No schedule needed. +
+ )} + {form.type === 'Trigger' && !isDwm && ( +
+ Trigger rules are only available as DWM rules. +
+ )} + {form.type !== 'Schedule' && form.type !== 'Countdown' && form.type !== 'Away' + && form.type !== 'AlwaysOn' && form.type !== 'Trigger' && ( +
+ Unknown rule type: {form.type}. Select a type above to edit. +
+ )} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/rules/RulesTab.jsx b/apps/desktop/src/renderer/src/components/rules/RulesTab.jsx new file mode 100644 index 0000000..21d583f --- /dev/null +++ b/apps/desktop/src/renderer/src/components/rules/RulesTab.jsx @@ -0,0 +1,412 @@ +'use strict'; +import React, { useEffect, useState, useCallback } from 'react'; +import RuleEditor from './RuleEditor'; +import AllRulesTab from './AllRulesTab'; +import ConfirmDialog from '../shared/ConfirmDialog'; +import useSettingsStore from '../../store/settings'; +import useDeviceStore from '../../store/devices'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const DAY_SHORT = { 1:'Mon', 2:'Tue', 3:'Wed', 4:'Thu', 5:'Fri', 6:'Sat', 7:'Sun' }; +const RULE_ICONS = { Schedule: '📅', Away: '🏠', Countdown: '⏱', AlwaysOn: '🔒', Trigger: '⚡', 'Long Press': '👆' }; +const ACTION_LABEL = { 1: '→ ON', 0: '→ OFF', 2: '↔ Toggle', '-1': '' }; + +function actionLabel(val) { + if (val === null || val === undefined) return ''; + return ACTION_LABEL[String(Math.round(Number(val)))] ?? ''; +} + +function secsToHHMM(secs) { + if (secs === -2) return '🌅 Sunrise'; + if (secs === -3) return '🌇 Sunset'; + if (!secs && secs !== 0) return '—'; + const h = Math.floor(Math.abs(secs) / 3600) % 24; + const m = Math.floor((Math.abs(secs) % 3600) / 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +function ruleSummary(rule) { + const days = (rule.days ?? []).map((d) => DAY_SHORT[d]).join(' ') || '—'; + if (rule.type === 'AlwaysOn') return 'Enforced ON every 10 s'; + if (rule.type === 'Trigger') { + const src = rule.triggerDevice?.name || rule.triggerDevice?.host || '?'; + const when = rule.triggerEvent === 'on' ? 'ON' : rule.triggerEvent === 'off' ? 'OFF' : 'ON/OFF'; + const action = rule.action === 'mirror' ? 'mirror' : rule.action === 'opposite' ? 'opposite' : (rule.action ?? 'on').toUpperCase(); + return `If ${src} → ${when}, then ${action}`; + } + if (rule.type === 'Countdown') { + const mins = rule.countdownTime ? Math.round(rule.countdownTime / 60) : null; + const base = mins + ? `${mins >= 60 ? `${Math.floor(mins / 60)}h${mins % 60 > 0 ? `${mins % 60}m` : ''}` : `${mins}m`} auto-off` + : '—'; + if (rule.windowStart >= 0 && rule.windowEnd >= 0) { + const windowDays = (rule.windowDays ?? []).map((d) => DAY_SHORT[d]).join(' ') || '—'; + return `${base} · window ${secsToHHMM(rule.windowStart)}–${secsToHHMM(rule.windowEnd)} ${windowDays}`; + } + return base; + } + if (rule.type === 'Away') { + return `${days} · ${secsToHHMM(rule.startTime)}–${secsToHHMM(rule.endTime)}`; + } + const start = secsToHHMM(rule.startTime); + const sa = actionLabel(rule.startAction); + const et = rule.endTime; + const endTime = (et > 0 || et === -2 || et === -3) ? secsToHHMM(et) : null; + const ea = endTime ? actionLabel(rule.endAction) : ''; + return endTime + ? `${days} · ${start} ${sa} → ${endTime} ${ea}`.trim() + : `${days} · ${start}${sa ? ' ' + sa : ''}`; +} + +// ── DWM Rule Row ────────────────────────────────────────────────────────────── + +function RuleRow({ rule, onEdit, onDelete, onToggle, onTest }) { + const [toggling, setToggling] = useState(false); + const [testing, setTesting] = useState(false); + const icon = RULE_ICONS[rule.type] || '📅'; + const badgeType = rule.type === 'Away' ? 'away' : rule.type === 'AlwaysOn' ? 'alwayson' : rule.type === 'Trigger' ? 'trigger' : 'schedule'; + + return ( +
+ {icon} +
onEdit(rule)} style={{ cursor: 'pointer' }}> +
{rule.name}
+
+ + {rule.type} + + {ruleSummary(rule)} + {!rule.enabled && Disabled} +
+ {/* Target / action devices */} + {rule.type === 'Trigger' ? ( +
+ {rule.triggerDevice && ( + + ⚡ {rule.triggerDevice.name || rule.triggerDevice.host} + + )} + {(rule.actionDevices ?? []).map((td) => ( + + 🎯 {td.name || td.host} + + ))} +
+ ) : rule.targetDevices?.length > 0 && ( +
+ {rule.targetDevices.map((td) => ( + + 📍 {td.name || td.host} + + ))} +
+ )} +
+
+ + + + +
+
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export default function RulesTab({ device }) { + const addToast = useSettingsStore((s) => s.addToast); + const { devices } = useDeviceStore(); + + const [subTab, setSubTab] = useState('dwm'); + const [dwmRules, setDwmRules] = useState([]); + const [loading, setLoading] = useState(true); + const [editingRule, setEditingRule] = useState(null); + const [creating, setCreating] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [importing, setImporting] = useState(false); + + // ── Load DWM rules from local store ────────────────────────────────────── + + const loadDwmRules = useCallback(async () => { + setLoading(true); + try { + const rules = await window.wemoAPI.getDwmRules(); + setDwmRules(rules ?? []); + } catch (e) { + addToast(`Failed to load DWM rules: ${e.message}`, 'error'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadDwmRules(); + }, []); + + // ── DWM CRUD ───────────────────────────────────────────────────────────── + + const handleSaved = async () => { + setEditingRule(null); + setCreating(false); + await loadDwmRules(); + addToast('✅ Rule saved', 'success'); + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + try { + await window.wemoAPI.deleteDwmRule({ id: deleteTarget.id }); + setDwmRules((prev) => prev.filter((r) => r.id !== deleteTarget.id)); + addToast('Rule deleted', 'success'); + } catch (e) { + addToast(`Delete failed: ${e.message}`, 'error'); + } finally { + setDeleteTarget(null); + } + }; + + const handleToggle = async (rule, enabled) => { + try { + await window.wemoAPI.updateDwmRule({ id: rule.id, updates: { enabled } }); + setDwmRules((prev) => prev.map((r) => r.id === rule.id ? { ...r, enabled } : r)); + addToast(`Rule ${enabled ? 'enabled' : 'disabled'}`, 'info'); + } catch (e) { + addToast(`Toggle failed: ${e.message}`, 'error'); + } + }; + + const handleTest = async (rule) => { + const targets = (rule.targetDevices ?? []).filter((td) => td.host && td.port); + if (targets.length === 0) { + addToast('No devices configured for this rule', 'warn'); + return; + } + const results = await Promise.allSettled( + targets.map((t) => window.wemoAPI.setDeviceState({ host: t.host, port: t.port, on: true })) + ); + const ok = results.filter((r) => r.status === 'fulfilled').length; + addToast( + `▶ Test: turned ON ${ok}/${targets.length} device(s)` + + (results.some((r) => r.status === 'rejected') ? ` · ${results.filter((r) => r.status === 'rejected').length} unreachable` : ''), + ok > 0 ? 'success' : 'error' + ); + }; + + // ── Export / Import ─────────────────────────────────────────────────────── + + function handleExportJSON() { + if (!dwmRules.length) { addToast('No DWM rules to export', 'warn'); return; } + const payload = { + _meta: { exportedAt: new Date().toISOString(), app: 'Dibby Wemo Manager', version: '2.0' }, + rules: dwmRules, + }; + window.wemoAPI.showSaveDialog({ + title: 'Export DWM Rules as JSON', + defaultPath: 'dwm-rules.json', + filters: [{ name: 'JSON', extensions: ['json'] }], + }).then(({ filePath, canceled }) => { + if (canceled || !filePath) return null; + return window.wemoAPI.writeFile({ filePath, content: JSON.stringify(payload, null, 2) }).then(() => filePath); + }).then((fp) => { if (fp) addToast(`✅ Exported ${dwmRules.length} DWM rules`, 'success'); }) + .catch((e) => addToast(`Export failed: ${e.message}`, 'error')); + } + + function secsToCSVTime(secs, startType) { + if (startType === 'sunrise') return 'sunrise'; + if (startType === 'sunset') return 'sunset'; + if (!secs || secs < 0) return ''; + return `${String(Math.floor(secs / 3600) % 24).padStart(2,'0')}:${String(Math.floor((secs % 3600) / 60)).padStart(2,'0')}`; + } + + function handleExportCSV() { + if (!dwmRules.length) { addToast('No DWM rules to export', 'warn'); return; } + const escape = (v) => { const s = String(v ?? ''); return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g,'""')}"` : s; }; + const ACTION_LABEL_CSV = { 1: 'ON', 0: 'OFF', 2: 'Toggle', '-1': 'None' }; + const encodeAction = (val) => ACTION_LABEL_CSV[String(Math.round(Number(val ?? -1)))] ?? 'None'; + const header = ['name','type','enabled','days','startTime','endTime','startAction','endAction','countdownTime','targetDevices']; + const rows = [header.join(',')]; + for (const rule of dwmRules) { + rows.push([ + escape(rule.name), escape(rule.type), rule.enabled ? '1' : '0', + escape((rule.days ?? []).map((n) => DAY_SHORT[n]).join('|')), + escape(secsToCSVTime(rule.startTime, rule.startType)), + escape(secsToCSVTime(rule.endTime, rule.endType)), + encodeAction(rule.startAction), encodeAction(rule.endAction), + String(rule.countdownTime ?? 0), + escape((rule.targetDevices ?? []).map((td) => td.udn || td.host).join(';')), + ].join(',')); + } + window.wemoAPI.showSaveDialog({ + title: 'Export DWM Rules as CSV', + defaultPath: 'dwm-rules.csv', + filters: [{ name: 'CSV', extensions: ['csv'] }], + }).then(({ filePath, canceled }) => { + if (canceled || !filePath) return null; + return window.wemoAPI.writeFile({ filePath, content: rows.join('\r\n') }).then(() => filePath); + }).then((fp) => { if (fp) addToast('✅ DWM rules exported to CSV', 'success'); }) + .catch((e) => addToast(`Export failed: ${e.message}`, 'error')); + } + + async function handleImport() { + let filePath; + try { + const result = await window.wemoAPI.showOpenDialog({ + title: 'Import DWM Rules', filters: [{ name: 'Rules files', extensions: ['json'] }], properties: ['openFile'], + }); + if (result.canceled || !result.filePaths?.length) return; + filePath = result.filePaths[0]; + } catch (e) { addToast(`Import failed: ${e.message}`, 'error'); return; } + + let rawText; + try { rawText = await window.wemoAPI.readFile({ filePath }); } + catch (e) { addToast(`Could not read file: ${e.message}`, 'error'); return; } + + let rulesList = []; + try { + const parsed = JSON.parse(rawText); + rulesList = Array.isArray(parsed) ? parsed : (parsed.rules ?? []); + } catch (e) { addToast(`Failed to parse JSON: ${e.message}`, 'error'); return; } + + if (!rulesList.length) { addToast('No rules found in file', 'warn'); return; } + + setImporting(true); + let ok = 0, fail = 0; + for (const rule of rulesList) { + // Strip the id so a new one is generated — prevents ID collisions on import + const { id: _id, createdAt: _ca, updatedAt: _ua, ...rest } = rule; + try { await window.wemoAPI.createDwmRule(rest); ok++; } + catch (e) { fail++; console.error('Import rule failed:', e.message, rule); } + } + setImporting(false); + await loadDwmRules(); + if (fail === 0) addToast(`✅ Imported ${ok} rule${ok !== 1 ? 's' : ''} successfully`, 'success', 8000); + else addToast(`Imported ${ok} rule${ok !== 1 ? 's' : ''}, ${fail} failed`, 'warn', 8000); + } + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( +
+ + {/* ── Sub-tab bar ─────────────────────────────────────────────────── */} +
+ + +
+ + {/* ── DWM Rules ───────────────────────────────────────────────────── */} + {subTab === 'dwm' && ( +
+ + {loading ? ( +

Loading rules…

+ ) : ( +
+ + {/* Toolbar */} +
+ + +
+ + + +
+
+ + {/* Rule list */} + {dwmRules.length === 0 ? ( +
+ 📅 +

+ No DWM rules yet.
+ Click + New Rule to create one, or switch to
+ Wemo Rules tab to copy existing device rules here. +

+
+ ) : ( +
+ {dwmRules.map((rule) => ( + + ))} +
+ )} + + {/* Info notice */} +
+ 💾 DWM rules are stored locally on this computer — not on the Wemo device.
+ The app scheduler fires them while this app is running. +
+ +
+ )} + + {/* Create / Edit modal */} + {(creating || editingRule) && ( + { setCreating(false); setEditingRule(null); }} + /> + )} + + {/* Delete confirm */} + {deleteTarget && ( + setDeleteTarget(null)} /> + )} +
+ )} + + {/* ── Wemo Rules ──────────────────────────────────────────────────── */} + {subTab === 'wemo' && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/rules/editors/AwayModeEditor.jsx b/apps/desktop/src/renderer/src/components/rules/editors/AwayModeEditor.jsx new file mode 100644 index 0000000..bae30d7 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/rules/editors/AwayModeEditor.jsx @@ -0,0 +1,171 @@ +import React, { useState, useEffect, useRef } from 'react'; +import DayPicker from '../DayPicker'; + +// ── Inline SunTimeField (same as ScheduleEditor) ────────────────────────────── + +function secsToHHMM(secs) { + if (!secs && secs !== 0) return ''; + const totalMins = Math.floor(Math.abs(secs) / 60); + const h24 = Math.floor(totalMins / 60) % 24; + const m = totalMins % 60; + const ampm = h24 < 12 ? 'AM' : 'PM'; + const h12 = h24 % 12 || 12; + return `${h12}:${String(m).padStart(2, '0')} ${ampm}`; +} + +function SunTimeField({ label, type, offset, time, onTypeChange, onOffsetChange, onTimeChange, sunTimes }) { + const isSun = type === 'sunrise' || type === 'sunset'; + const previewSecs = isSun && sunTimes + ? (type === 'sunrise' ? sunTimes.sunrise : sunTimes.sunset) + : null; + const previewWithOffset = previewSecs !== null + ? previewSecs + (offset || 0) * 60 + : null; + + const [rawOffset, setRawOffset] = useState(() => String(offset ?? 0)); + const prevOffsetRef = useRef(offset); + useEffect(() => { + if (offset !== prevOffsetRef.current) { + prevOffsetRef.current = offset; + setRawOffset(String(offset ?? 0)); + } + }, [offset]); + + const handleOffsetChange = (e) => { + const raw = e.target.value; + setRawOffset(raw); + if (raw === '' || raw === '-') return; + const n = parseInt(raw, 10); + if (!isNaN(n)) { + prevOffsetRef.current = n; + onOffsetChange(n); + } + }; + + const handleOffsetBlur = () => { + const n = parseInt(rawOffset, 10); + const final = isNaN(n) ? 0 : n; + setRawOffset(String(final)); + prevOffsetRef.current = final; + onOffsetChange(final); + }; + + return ( +
+ +
+ + {isSun ? ( +
+ + + min (+ after, − before) + +
+ ) : ( + onTimeChange(e.target.value)} /> + )} +
+ {isSun && previewWithOffset !== null && ( +
+ + Today's {type}: + {secsToHHMM(previewSecs)} + + + Window {label.includes('Start') ? 'opens' : 'closes'}: + {secsToHHMM(previewWithOffset)} + {offset !== 0 && ( + + {' '}({offset > 0 ? '+' : ''}{offset} min) + + )} + + + 📍 Coordinates synced to device on save + +
+ )} + {isSun && !sunTimes && ( +
+ ⚠️ No location set — open ⚙️ Settings and search for your city. + The device needs your coordinates to calculate {type} times. +
+ )} +
+ ); +} + +// ── AwayModeEditor ──────────────────────────────────────────────────────────── + +export default function AwayModeEditor({ form, onChange, sunTimes }) { + // Cross-midnight detection (fixed times only) + function crossesMidnight() { + if (!form.startTime || !form.endTime) return false; + if ((form.startType || 'fixed') !== 'fixed' || (form.endType || 'fixed') !== 'fixed') return false; + const [sh, sm] = form.startTime.split(':').map(Number); + const [eh, em] = form.endTime.split(':').map(Number); + return eh * 60 + em < sh * 60 + sm; + } + + return ( + <> +
+ Away Mode — simulates occupancy by randomly turning devices{' '} + on (30–90 min) then off (1–15 min) within your + configured window. The DWM scheduler handles all randomisation while the app is running. +
+ + {/* Active days */} +
+ + onChange({ ...form, days })} + /> +
+ + {/* Window start */} + onChange({ ...form, startType: v })} + onOffsetChange={(v) => onChange({ ...form, startOffset: v })} + onTimeChange={(v) => onChange({ ...form, startTime: v })} + sunTimes={sunTimes} + /> + + {/* Window end */} + onChange({ ...form, endType: v })} + onOffsetChange={(v) => onChange({ ...form, endOffset: v })} + onTimeChange={(v) => onChange({ ...form, endTime: v })} + sunTimes={sunTimes} + /> + + {/* Cross-midnight hint */} + {crossesMidnight() && ( +
+ 🌙 Window crosses midnight — ends at {form.endTime} the{' '} + next day. +
+ )} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/rules/editors/CountdownEditor.jsx b/apps/desktop/src/renderer/src/components/rules/editors/CountdownEditor.jsx new file mode 100644 index 0000000..18f7e52 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/rules/editors/CountdownEditor.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import DayPicker from '../DayPicker'; + +export default function CountdownEditor({ form, onChange }) { + const mins = form.countdownMins ?? 60; + const windowEnabled = form.windowEnabled ?? false; + const windowStartTime = form.windowStartTime ?? ''; + const windowEndTime = form.windowEndTime ?? ''; + const windowDays = form.windowDays ?? ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday']; + + // Determine if window crosses midnight (end time earlier in the day than start time) + function crossesMidnight() { + if (!windowStartTime || !windowEndTime) return false; + const [sh, sm] = windowStartTime.split(':').map(Number); + const [eh, em] = windowEndTime.split(':').map(Number); + return eh * 60 + em < sh * 60 + sm; + } + + return ( + <> +
+ The device automatically turns off after the countdown completes. + The countdown starts when the device is manually turned on (or at window start, if an active window is set below). +
+ + {/* Countdown duration */} +
+ + { + const v = parseInt(e.target.value, 10) || 60; + onChange({ ...form, countdownMins: v, countdownTime: v * 60 }); + }} + /> +
+ {mins >= 60 + ? `${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''}` + : `${mins} minutes`} +
+
+ + {/* Active window toggle */} +
+
+ + Active Window + + — restrict this rule to specific hours + +
+ + {windowEnabled && ( + <> +

+ The scheduler will turn the device ON at the window start and + OFF at the window end. The countdown auto-off fires in between. + Use this to prevent the timer rule conflicting with other rules outside these hours. +

+ + {/* Window times */} +
+
+ + onChange({ ...form, windowStartTime: e.target.value })} + /> +
+
+ + onChange({ ...form, windowEndTime: e.target.value })} + /> +
+
+ + {/* Cross-midnight hint */} + {windowStartTime && windowEndTime && crossesMidnight() && ( +
+ 🌙 Window crosses midnight — ends at {windowEndTime} the next day. + The OFF command fires on the following calendar day. +
+ )} + + {/* Window days */} +
+ + onChange({ ...form, windowDays: days })} + /> +
+ + )} +
+ + ); +} diff --git a/apps/desktop/src/renderer/src/components/rules/editors/ScheduleEditor.jsx b/apps/desktop/src/renderer/src/components/rules/editors/ScheduleEditor.jsx new file mode 100644 index 0000000..647cca2 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/rules/editors/ScheduleEditor.jsx @@ -0,0 +1,148 @@ +import React, { useState, useEffect, useRef } from 'react'; +import DayPicker from '../DayPicker'; + +function secsToHHMM(secs) { + if (!secs && secs !== 0) return ''; + const totalMins = Math.floor(Math.abs(secs) / 60); + const h24 = Math.floor(totalMins / 60) % 24; + const m = totalMins % 60; + const ampm = h24 < 12 ? 'AM' : 'PM'; + const h12 = h24 % 12 || 12; + return `${h12}:${String(m).padStart(2, '0')} ${ampm}`; +} + +function SunTimeField({ label, type, offset, time, onTypeChange, onOffsetChange, onTimeChange, sunTimes }) { + const isSun = type === 'sunrise' || type === 'sunset'; + const previewSecs = isSun && sunTimes + ? (type === 'sunrise' ? sunTimes.sunrise : sunTimes.sunset) + : null; + const previewWithOffset = previewSecs !== null + ? previewSecs + (offset || 0) * 60 + : null; + + // Local string state so the user can type '-' without it snapping back to 0 + const [rawOffset, setRawOffset] = useState(() => String(offset ?? 0)); + const prevOffsetRef = useRef(offset); + useEffect(() => { + // Only sync from parent when parent actually changed the numeric value + // (e.g. loading a saved rule), not on every keystroke + if (offset !== prevOffsetRef.current) { + prevOffsetRef.current = offset; + setRawOffset(String(offset ?? 0)); + } + }, [offset]); + + const handleOffsetChange = (e) => { + const raw = e.target.value; + setRawOffset(raw); + if (raw === '' || raw === '-') return; // incomplete — wait for more input + const n = parseInt(raw, 10); + if (!isNaN(n)) { + prevOffsetRef.current = n; + onOffsetChange(n); + } + }; + + const handleOffsetBlur = () => { + const n = parseInt(rawOffset, 10); + const final = isNaN(n) ? 0 : n; + setRawOffset(String(final)); + prevOffsetRef.current = final; + onOffsetChange(final); + }; + + return ( +
+ +
+ + {isSun ? ( +
+ + + min (+ after, − before) + +
+ ) : ( + onTimeChange(e.target.value)} /> + )} +
+ {isSun && previewWithOffset !== null && ( +
+ Today's {type}: {secsToHHMM(previewSecs)} + + Fires at: + {secsToHHMM(previewWithOffset)} + {offset !== 0 && ({offset > 0 ? '+' : ''}{offset} min)} + + 📍 Coordinates synced to device on save +
+ )} + {isSun && !sunTimes && ( +
+ ⚠️ No location set — open ⚙️ Settings and search for your city. + The device needs your coordinates to calculate {type} times. +
+ )} +
+ ); +} + +export default function ScheduleEditor({ form, onChange, sunTimes }) { + return ( + <> +
+ + onChange({ ...form, days })} /> +
+ + onChange({ ...form, startType: v })} + onOffsetChange={(v) => onChange({ ...form, startOffset: v })} + onTimeChange={(v) => onChange({ ...form, startTime: v })} + sunTimes={sunTimes} + /> + +
+ + +
+ + onChange({ ...form, endType: v })} + onOffsetChange={(v) => onChange({ ...form, endOffset: v })} + onTimeChange={(v) => onChange({ ...form, endTime: v })} + sunTimes={sunTimes} + /> + + {(form.endTime || form.endType === 'sunrise' || form.endType === 'sunset') && ( +
+ + +
+ )} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/shared/ConfirmDialog.jsx b/apps/desktop/src/renderer/src/components/shared/ConfirmDialog.jsx new file mode 100644 index 0000000..f6a8a20 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/shared/ConfirmDialog.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from './Modal'; + +export default function ConfirmDialog({ title, message, confirmLabel = 'Confirm', danger, onConfirm, onCancel }) { + return ( + + + + + } + > +

{message}

+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/shared/CopyField.jsx b/apps/desktop/src/renderer/src/components/shared/CopyField.jsx new file mode 100644 index 0000000..37d2ce5 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/shared/CopyField.jsx @@ -0,0 +1,30 @@ +import React, { useState } from 'react'; + +export default function CopyField({ label, value, mono }) { + const [copied, setCopied] = useState(false); + + const doCopy = () => { + if (!value) return; + navigator.clipboard.writeText(value).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + return ( +
+ {label} + {value || '—'} + {value && ( + + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/shared/Modal.jsx b/apps/desktop/src/renderer/src/components/shared/Modal.jsx new file mode 100644 index 0000000..275c645 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/shared/Modal.jsx @@ -0,0 +1,19 @@ +import React, { useEffect } from 'react'; + +export default function Modal({ title, onClose, footer, children, wide }) { + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') onClose?.(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
{ if (e.target === e.currentTarget) onClose?.(); }}> +
+ {title &&
{title}
} +
{children}
+ {footer &&
{footer}
} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/shared/Toast.jsx b/apps/desktop/src/renderer/src/components/shared/Toast.jsx new file mode 100644 index 0000000..9234ea5 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/shared/Toast.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import useSettingsStore from '../../store/settings'; + +export default function Toast() { + const { toasts, removeToast } = useSettingsStore(); + return ( +
+ {toasts.map((t) => ( +
removeToast(t.id)} + style={{ cursor: 'pointer' }} + > + {t.msg} +
+ ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/wifi/ApList.jsx b/apps/desktop/src/renderer/src/components/wifi/ApList.jsx new file mode 100644 index 0000000..ce8537b --- /dev/null +++ b/apps/desktop/src/renderer/src/components/wifi/ApList.jsx @@ -0,0 +1,56 @@ +import React from 'react'; + +function signalBars(rssi) { + const n = rssi >= -50 ? 4 : rssi >= -65 ? 3 : rssi >= -75 ? 2 : 1; + return ( + + {[1, 2, 3, 4].map((i) => ( + + ))} + + ); +} + +function securityIcon(auth) { + if (!auth || auth === 'OPEN') return 🔓; + return 🔒; +} + +export default function ApList({ networks, selected, onSelect }) { + if (!networks || networks.length === 0) { + return
No networks found.
; + } + + const sorted = [...networks].sort((a, b) => (b.rssi ?? -100) - (a.rssi ?? -100)); + + return ( +
+ {sorted.map((ap) => ( +
onSelect(ap.ssid)} + > +
+ {signalBars(ap.rssi ?? -90)} + {ap.ssid || '(hidden)'} +
+
+ {securityIcon(ap.auth)} + {ap.rssi !== undefined && ( + {ap.rssi} dBm + )} +
+
+ ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/wifi/NetworkStatus.jsx b/apps/desktop/src/renderer/src/components/wifi/NetworkStatus.jsx new file mode 100644 index 0000000..f0e66a3 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/wifi/NetworkStatus.jsx @@ -0,0 +1,37 @@ +import React, { useState } from 'react'; + +export default function NetworkStatus({ device }) { + const [status, setStatus] = useState(null); // null | 'checking' | 'connected' | 'disconnected' + + const check = async () => { + setStatus('checking'); + try { + const online = await window.wemoAPI.checkOnline({ host: device.host, port: device.port }); + setStatus(online ? 'connected' : 'disconnected'); + } catch { + setStatus('disconnected'); + } + }; + + return ( +
+ {status === null && ( + Network status unknown + )} + {status === 'checking' && ( + + Checking… + + )} + {status === 'connected' && ( + ● Connected + )} + {status === 'disconnected' && ( + ● Disconnected + )} + +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/wifi/WiFiTab.jsx b/apps/desktop/src/renderer/src/components/wifi/WiFiTab.jsx new file mode 100644 index 0000000..a238399 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/wifi/WiFiTab.jsx @@ -0,0 +1,168 @@ +import React, { useState, useEffect } from 'react'; +import ApList from './ApList'; +import NetworkStatus from './NetworkStatus'; +import useSettingsStore from '../../store/settings'; + +const AUTH_TYPES = [ + { value: 'OPEN', label: 'Open (no password)' }, + { value: 'WPA-PSK', label: 'WPA Personal' }, + { value: 'WPA2-PSK', label: 'WPA2 Personal' }, +]; + +export default function WiFiTab({ device }) { + const addToast = useSettingsStore((s) => s.addToast); + + const [networks, setNetworks] = useState([]); + const [scanning, setScanning] = useState(false); + const [ssid, setSsid] = useState(''); + const [password, setPassword] = useState(''); + const [auth, setAuth] = useState('WPA2-PSK'); + const [showPass, setShowPass] = useState(false); + const [connecting, setConnecting] = useState(false); + const [connectResult, setConnectResult] = useState(null); // null | 'connecting' | 'success' | 'failed' | 'badpass' + + const scan = async () => { + setScanning(true); + setNetworks([]); + try { + const res = await window.wemoAPI.getApList({ host: device.host, port: device.port }); + setNetworks(res || []); + } catch (e) { + addToast(`Scan failed: ${e.message}`, 'error'); + } finally { + setScanning(false); + } + }; + + const connect = async () => { + if (!ssid.trim()) { addToast('Enter an SSID', 'error'); return; } + if (auth !== 'OPEN' && !password) { addToast('Enter a password', 'error'); return; } + + setConnecting(true); + setConnectResult('connecting'); + try { + await window.wemoAPI.connectHomeNetwork({ + host: device.host, port: device.port, + ssid: ssid.trim(), password, auth, + }); + // Poll network status + let attempts = 0; + const poll = async () => { + try { + const status = await window.wemoAPI.getNetworkStatus({ host: device.host, port: device.port }); + // 0=failed, 1=success, 2=badpass, 3=connecting + if (status === 1) { setConnectResult('success'); return; } + if (status === 2) { setConnectResult('badpass'); return; } + if (status === 0) { setConnectResult('failed'); return; } + if (attempts++ < 12) setTimeout(poll, 2500); + else setConnectResult('failed'); + } catch { + if (attempts++ < 12) setTimeout(poll, 2500); + else setConnectResult('failed'); + } + }; + await poll(); + } catch (e) { + addToast(`Connect failed: ${e.message}`, 'error'); + setConnectResult('failed'); + } finally { + setConnecting(false); + } + }; + + return ( +
+ {/* Network status header */} +
+
Network Status
+ +
+ + {/* AP Scanner */} +
+
+
Available Networks
+ +
+ {networks.length > 0 || scanning ? ( + + ) : ( +
Click Scan to discover nearby networks.
+ )} +
+ + {/* Connection form */} +
+
Connect to Network
+ +
+ + setSsid(e.target.value)} + /> +
+ +
+ + +
+ + {auth !== 'OPEN' && ( +
+ +
+ setPassword(e.target.value)} + style={{ flex: 1 }} + /> + +
+
+ )} + + {connectResult && ( +
+ {connectResult === 'connecting' && '⏳ Connecting to network…'} + {connectResult === 'success' && '✅ Connected successfully!'} + {connectResult === 'failed' && '❌ Connection failed. Check the device and try again.'} + {connectResult === 'badpass' && '❌ Incorrect password.'} +
+ )} + + + +
+ Note: After connecting to a new network, the device will reboot and may appear offline briefly. Rediscover it after ~30 seconds. +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/main.jsx b/apps/desktop/src/renderer/src/main.jsx new file mode 100644 index 0000000..5dfcc0d --- /dev/null +++ b/apps/desktop/src/renderer/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './styles/app.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); diff --git a/apps/desktop/src/renderer/src/store/devices.js b/apps/desktop/src/renderer/src/store/devices.js new file mode 100644 index 0000000..bf867c2 --- /dev/null +++ b/apps/desktop/src/renderer/src/store/devices.js @@ -0,0 +1,37 @@ +import { create } from 'zustand'; + +const useDeviceStore = create((set, get) => ({ + devices: [], // all discovered/saved devices + selectedUdn: null, // currently selected device UDN + deviceGroups: [], + deviceOrder: [], + discovering: false, + + get selectedDevice() { + return get().devices.find((d) => d.udn === get().selectedUdn) ?? null; + }, + + setDevices: (devices) => set({ devices }), + + mergeDevice: (device) => set((s) => { + const idx = s.devices.findIndex((d) => d.udn === device.udn); + if (idx >= 0) { + const updated = [...s.devices]; + updated[idx] = { ...updated[idx], ...device }; + return { devices: updated }; + } + return { devices: [...s.devices, device] }; + }), + + updateDevice: (udn, patch) => set((s) => ({ + devices: s.devices.map((d) => d.udn === udn ? { ...d, ...patch } : d), + })), + + selectDevice: (udn) => set({ selectedUdn: udn }), + + setDiscovering: (v) => set({ discovering: v }), + setDeviceGroups: (g) => set({ deviceGroups: g }), + setDeviceOrder: (o) => set({ deviceOrder: o }), +})); + +export default useDeviceStore; diff --git a/apps/desktop/src/renderer/src/store/rules.js b/apps/desktop/src/renderer/src/store/rules.js new file mode 100644 index 0000000..d738072 --- /dev/null +++ b/apps/desktop/src/renderer/src/store/rules.js @@ -0,0 +1,24 @@ +import { create } from 'zustand'; + +const useRulesStore = create((set) => ({ + rules: [], + locationInfo: null, + loading: false, + error: null, + + setRules: (rules, locationInfo) => set({ rules, locationInfo, error: null }), + setLoading: (loading) => set({ loading }), + setError: (error) => set({ error }), + + updateRule: (ruleId, patch) => set((s) => ({ + rules: s.rules.map((r) => r.ruleId === ruleId ? { ...r, ...patch } : r), + })), + + removeRule: (ruleId) => set((s) => ({ + rules: s.rules.filter((r) => r.ruleId !== ruleId), + })), + + clear: () => set({ rules: [], locationInfo: null, error: null }), +})); + +export default useRulesStore; diff --git a/apps/desktop/src/renderer/src/store/settings.js b/apps/desktop/src/renderer/src/store/settings.js new file mode 100644 index 0000000..3fcc828 --- /dev/null +++ b/apps/desktop/src/renderer/src/store/settings.js @@ -0,0 +1,26 @@ +import { create } from 'zustand'; + +const useSettingsStore = create((set) => ({ + theme: 'dark', + location: null, // { lat, lng, label, city, country, countryCode, region } + toasts: [], + + setTheme: (theme) => { + document.documentElement.classList.toggle('light', theme === 'light'); + set({ theme }); + }, + + setLocation: (location) => set({ location }), + + addToast: (msg, type = 'info', duration = 3500) => { + const id = Date.now(); + set((s) => ({ toasts: [...s.toasts, { id, msg, type }] })); + setTimeout(() => { + set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })); + }, duration); + }, + + removeToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), +})); + +export default useSettingsStore; diff --git a/apps/desktop/src/renderer/src/styles/app.css b/apps/desktop/src/renderer/src/styles/app.css new file mode 100644 index 0000000..e3d4bda --- /dev/null +++ b/apps/desktop/src/renderer/src/styles/app.css @@ -0,0 +1,316 @@ +/* ── Design tokens ───────────────────────────────── */ +:root { + --bg: #0d1b27; + --sidebar: #182d3d; + --card: #1e3448; + --card2: #253f58; + --border: #2c5070; + --accent: #00a9d5; + --accent2: #0088b0; + --orange: #ff6900; + --danger: #ff453a; + --success: #30d158; + --warning: #ffd60a; + --text: #eef2f8; + --text2: #88b4cc; + --text3: #4d7c99; + --radius: 10px; + --radius-sm: 6px; + --sidebar-w: 290px; + --shadow: 0 4px 20px rgba(0,0,0,.35); +} + +:root.light { + --bg: #f0f4f0; + --sidebar: #e0eae0; + --card: #ffffff; + --card2: #eef4ee; + --border: #bccfbc; + --accent: #2a8452; + --accent2: #1f6640; + --orange: #d85e00; + --danger: #c62828; + --success: #2e7d32; + --warning: #e68a00; + --text: #1a2e1a; + --text2: #4a6a4a; + --text3: #7a9a7a; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body, #root { + height: 100%; width: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; -webkit-font-smoothing: antialiased; + background: var(--bg); color: var(--text); + overflow: hidden; +} + +/* ── Scrollbars ──────────────────────────────────── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text3); } + +/* ── Buttons ─────────────────────────────────────── */ +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 14px; border-radius: var(--radius-sm); + font-size: 13px; font-weight: 500; cursor: pointer; + border: 1px solid transparent; transition: all .15s; white-space: nowrap; +} +.btn:disabled { opacity: .45; cursor: not-allowed; } +.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent2); } +.btn-primary:hover:not(:disabled) { filter: brightness(1.1); } +.btn-secondary { background: var(--card2); color: var(--text); border-color: var(--border); } +.btn-secondary:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:hover:not(:disabled) { filter: brightness(1.1); } +.btn-ghost { background: transparent; color: var(--text2); border-color: transparent; } +.btn-ghost:hover:not(:disabled) { color: var(--text); background: rgba(255,255,255,.06); } +.btn-sm { padding: 4px 10px; font-size: 12px; } +.btn-icon { padding: 6px; border-radius: var(--radius-sm); } + +/* ── Form elements ───────────────────────────────── */ +input, select, textarea { + background: var(--card2); color: var(--text); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 7px 10px; font-size: 13px; width: 100%; + outline: none; transition: border-color .15s; +} +input:focus, select:focus, textarea:focus { border-color: var(--accent); } +input::placeholder { color: var(--text3); } +label { font-size: 12px; color: var(--text2); display: block; margin-bottom: 4px; } +.form-group { margin-bottom: 14px; } +.form-row { display: flex; gap: 10px; } +.form-row > * { flex: 1; } +.form-error { font-size: 12px; color: var(--danger); margin-top: 4px; } + +/* ── Tags/badges ─────────────────────────────────── */ +.badge { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 600; +} +.badge-online { background: rgba(48,209,88,.15); color: var(--success); } +.badge-offline { background: rgba(255,69,58,.15); color: var(--danger); } +.badge-checking { background: rgba(255,214,10,.12); color: var(--warning); } +.badge-schedule { background: rgba(0,169,213,.15); color: var(--accent); } +.badge-away { background: rgba(255,105,0,.15); color: var(--orange); } +.badge-alwayson { background: rgba(48,209,88,.15); color: var(--success); } +.badge-trigger { background: rgba(255,214,10,.15); color: var(--warning); } +.badge-countdown { background: rgba(255,214,10,.12); color: var(--warning); } +.badge-longpress { background: rgba(142,68,200,.15); color: #a259e6; } +.badge-enabled { background: rgba(48,209,88,.12); color: var(--success); } +.badge-disabled { background: rgba(255,69,58,.12); color: var(--danger); } +.badge-sun { background: rgba(255,214,10,.12); color: var(--warning); } + +/* ── Section headers ─────────────────────────────── */ +.section-header { + font-size: 11px; font-weight: 700; letter-spacing: .08em; + text-transform: uppercase; color: var(--text3); + padding: 0 0 8px; margin-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +/* ── Cards ───────────────────────────────────────── */ +.card { + background: var(--card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 14px; +} +.card + .card { margin-top: 10px; } + +/* ── Info row (label + value + copy) ─────────────── */ +.info-row { + display: flex; align-items: flex-start; gap: 8px; + padding: 9px 0; border-bottom: 1px solid rgba(255,255,255,.04); +} +.info-row:last-child { border-bottom: none; } +.info-label { min-width: 130px; font-size: 12px; color: var(--text2); flex-shrink: 0; padding-top: 1px; } +.info-value { flex: 1; font-size: 13px; word-break: break-all; } +.info-value.mono { font-family: 'Consolas', 'Courier New', monospace; font-size: 12px; } + +/* ── Tabs ────────────────────────────────────────── */ +.tab-bar { + display: flex; gap: 2px; + border-bottom: 1px solid var(--border); + padding: 0 16px; background: var(--sidebar); + flex-shrink: 0; +} +.tab-btn { + padding: 10px 16px; font-size: 13px; font-weight: 500; + color: var(--text2); background: transparent; border: none; + border-bottom: 2px solid transparent; cursor: pointer; + transition: all .15s; margin-bottom: -1px; +} +.tab-btn:hover { color: var(--text); } +.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); } + +/* ── Toast notifications ─────────────────────────── */ +.toast-container { + position: fixed; bottom: 20px; right: 20px; z-index: 1000; + display: flex; flex-direction: column; gap: 8px; pointer-events: none; +} +.toast { + padding: 10px 16px; border-radius: var(--radius-sm); + font-size: 13px; background: var(--card); border: 1px solid var(--border); + box-shadow: var(--shadow); pointer-events: all; + animation: slideIn .2s ease; +} +.toast-success { border-color: var(--success); color: var(--success); } +.toast-error { border-color: var(--danger); color: var(--danger); } +.toast-info { border-color: var(--accent); color: var(--accent); } +@keyframes slideIn { from { transform: translateX(40px); opacity: 0; } to { transform: none; opacity: 1; } } + +/* ── Modal ───────────────────────────────────────── */ +.overlay { + position: fixed; inset: 0; background: rgba(0,0,0,.55); + z-index: 200; display: flex; align-items: center; justify-content: center; +} +.modal { + background: var(--card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 24px; + min-width: 360px; max-width: 600px; width: 90%; + max-height: 90vh; overflow-y: auto; + box-shadow: var(--shadow); +} +.modal-title { + font-size: 16px; font-weight: 600; margin-bottom: 20px; + padding-bottom: 12px; border-bottom: 1px solid var(--border); +} +.modal-footer { + display: flex; justify-content: flex-end; gap: 8px; + margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border); +} + +/* ── Day chips ───────────────────────────────────── */ +.day-chips { display: flex; gap: 5px; flex-wrap: wrap; } +.day-chip { + padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; + cursor: pointer; border: 1px solid var(--border); color: var(--text2); + transition: all .12s; user-select: none; +} +.day-chip.on { background: rgba(0,169,213,.18); border-color: var(--accent); color: var(--accent); } +.day-chip-quick { display: flex; gap: 5px; margin-top: 8px; } + +/* ── Power button ────────────────────────────────── */ +.power-btn { + width: 40px; height: 40px; border-radius: 50%; border: none; + cursor: pointer; display: flex; align-items: center; justify-content: center; + transition: all .15s; flex-shrink: 0; +} +.power-btn.on { background: rgba(48,209,88,.18); } +.power-btn.off { background: rgba(255,255,255,.07); } +.power-btn:hover { transform: scale(1.08); } +.power-dot { + width: 10px; height: 10px; border-radius: 50%; + background: var(--text3); transition: all .2s; +} +.power-dot.on { background: var(--success); box-shadow: 0 0 8px rgba(48,209,88,.5); } + +/* ── Signal meter ────────────────────────────────── */ +.signal-bars { display: flex; align-items: flex-end; gap: 2px; height: 16px; } +.signal-bar { width: 4px; border-radius: 1px; background: var(--border); } +.signal-bar.lit { background: var(--accent); } + +/* ── Rule card ───────────────────────────────────── */ +.rule-card { + background: var(--card2); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 12px 14px; + cursor: pointer; transition: border-color .15s; + display: flex; align-items: flex-start; gap: 10px; +} +.rule-card:hover { border-color: var(--accent); } +.rule-card.disabled { opacity: .55; } +.rule-icon { font-size: 20px; flex-shrink: 0; margin-top: 1px; } +.rule-body { flex: 1; min-width: 0; } +.rule-name { font-weight: 600; font-size: 14px; margin-bottom: 4px; } +.rule-meta { font-size: 12px; color: var(--text2); } +.rule-actions { display: flex; gap: 4px; flex-shrink: 0; } + +/* ── AP list ─────────────────────────────────────── */ +.ap-item { + display: flex; align-items: center; gap: 10px; + padding: 10px 12px; border-radius: var(--radius-sm); + cursor: pointer; border: 1px solid transparent; + transition: all .12s; +} +.ap-item:hover { background: var(--card2); } +.ap-item.selected { background: rgba(0,169,213,.1); border-color: var(--accent); } +.ap-ssid { flex: 1; font-weight: 500; } +.ap-auth { font-size: 11px; color: var(--text3); } + +/* ── Notice ──────────────────────────────────────── */ +.notice { + padding: 10px 14px; border-radius: var(--radius-sm); + font-size: 13px; margin-bottom: 14px; border: 1px solid; +} +.notice-info { background: rgba(0,169,213,.08); border-color: rgba(0,169,213,.25); color: var(--accent); } +.notice-warn { background: rgba(255,105,0,.08); border-color: rgba(255,105,0,.25); color: var(--orange); } +.notice-success { background: rgba(48,209,88,.08); border-color: rgba(48,209,88,.25); color: var(--success); } +.notice-danger { background: rgba(255,69,58,.08); border-color: rgba(255,69,58,.25); color: var(--danger); } + +/* ── Sun preview ─────────────────────────────────── */ +.sun-preview { + background: var(--card2); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 8px 12px; + font-size: 12px; display: flex; flex-wrap: wrap; gap: 6px 16px; +} +.sun-preview .lbl { color: var(--text3); } +.sun-preview .val { color: var(--warning); font-weight: 600; } + +/* ── Spinner ─────────────────────────────────────── */ +.spinner { + width: 18px; height: 18px; border-radius: 50%; + border: 2px solid var(--border); border-top-color: var(--accent); + animation: spin .7s linear infinite; display: inline-block; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Empty state ─────────────────────────────────── */ +.empty-state { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 10px; padding: 40px 20px; color: var(--text3); text-align: center; +} +.empty-state-icon { font-size: 40px; } +.empty-state p { font-size: 13px; max-width: 260px; line-height: 1.5; } + +/* ── Toggle switch ───────────────────────────────── */ +.toggle { + position: relative; width: 36px; height: 20px; cursor: pointer; + flex-shrink: 0; +} +.toggle input { opacity: 0; width: 0; height: 0; position: absolute; } +.toggle-track { + position: absolute; inset: 0; border-radius: 10px; + background: var(--border); transition: background .2s; +} +.toggle input:checked + .toggle-track { background: var(--accent); } +.toggle-thumb { + position: absolute; top: 2px; left: 2px; + width: 16px; height: 16px; border-radius: 50%; + background: #fff; transition: transform .2s; +} +.toggle input:checked ~ .toggle-thumb { transform: translateX(16px); } + +/* ── Autocomplete ────────────────────────────────── */ +.autocomplete-wrap { position: relative; } +.autocomplete-list { + position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 50; + background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-sm); + max-height: 220px; overflow-y: auto; box-shadow: var(--shadow); +} +.autocomplete-item { + padding: 9px 12px; cursor: pointer; font-size: 13px; + border-bottom: 1px solid rgba(255,255,255,.04); +} +.autocomplete-item:hover, .autocomplete-item.focused { background: rgba(0,169,213,.12); } + +/* ── Info sections (WiFi tab, etc.) ─────────────── */ +.info-section { + background: var(--card2); border: 1px solid var(--border); + border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; +} +.info-section-title { + font-size: 11px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; + color: var(--text3); margin-bottom: 10px; +} diff --git a/packages/homebridge-plugin/README.md b/packages/homebridge-plugin/README.md new file mode 100644 index 0000000..d89905b --- /dev/null +++ b/packages/homebridge-plugin/README.md @@ -0,0 +1,222 @@ +# homebridge-dibby-wemo + +**Homebridge plugin for local Belkin Wemo control — no cloud required.** + +Registers all Wemo devices on your local network as HomeKit switches and provides a full scheduling engine via a custom Homebridge UI panel. All device communication is direct local UPnP/SOAP — no Belkin account needed. + +--- + +## Installation + +### Via Homebridge UI (recommended) + +1. Open Homebridge UI → **Plugins** +2. Search for `homebridge-dibby-wemo` +3. Click **Install** +4. Restart Homebridge + +### Via npm + +```bash +npm install -g homebridge-dibby-wemo +``` + +--- + +## Configuration + +Add to your Homebridge `config.json`: + +```json +{ + "platforms": [ + { + "platform": "DibbyWemo", + "name": "DibbyWemo" + } + ] +} +``` + +Restart Homebridge. All Wemo devices on your network are discovered automatically and appear in HomeKit. + +### Optional config properties + +```json +{ + "platform": "DibbyWemo", + "name": "DibbyWemo", + "discoveryTimeout": 10000, + "pollInterval": 30, + "manualDevices": [ + { "host": "192.168.1.50", "port": 49153 } + ] +} +``` + +| Property | Type | Default | Description | +|---|---|---|---| +| `discoveryTimeout` | number | `10000` | SSDP discovery window in milliseconds | +| `pollInterval` | number | `30` | How often (seconds) to poll device state for HomeKit | +| `manualDevices` | array | `[]` | Devices to add by IP if SSDP discovery misses them | + +--- + +## Custom UI + +Once installed, open the plugin settings in Homebridge UI. The plugin provides a full custom panel with five tabs: + +### 📱 Devices Tab + +- Lists all discovered Wemo devices with their model, firmware version, and IP address +- Toggle any device on or off directly from the UI +- **Discover** button re-runs SSDP discovery and updates the device list + +### ⏰ DWM Rules Tab + +Create and manage automation rules that run inside Homebridge. + +**Scheduler status bar** — shown at the top of the tab: +- 🟢 **Green** — scheduler is running, shows total schedule entries and next upcoming rule +- 🟠 **Amber** — scheduler may have stopped (no heartbeat for 90+ seconds) — restart Homebridge +- 🔴 **Red** — scheduler is not running — check the `DibbyWemo` platform is in `config.json` + +**Rule types:** + +| Icon | Type | Description | +|---|---|---| +| 📅 | **Schedule** | Turn devices on/off at specific times on selected days | +| ⏱ | **Countdown** | Active window — on at start, off at end (cross-midnight aware) | +| 🏠 | **Away Mode** | Randomised on/off simulation during a time window | +| 🔒 | **Always On** | Device is kept ON at all times; any off-state is corrected within 10 seconds | +| ⚡ | **Trigger** | IFTTT-style: when one device changes state, control another | + +**Creating a rule:** + +1. Click **+ ADD RULE** +2. Enter a name, select the rule type +3. Select target device(s) and set times / options +4. Click **Save Rule** + +Rules take effect on the next 30-second scheduler tick — no restart needed. + +**Editing / deleting a rule:** + +- Click **EDIT** to open the inline form +- Click **DELETE** → confirm with **Yes, delete** in the inline bar that appears + +**Times use 12-hour AM/PM format.** Examples: `8:30 PM`, `6:00 AM`, `12:00 AM` (midnight), `9 PM` + +### 🔌 Device Rules Tab + +Manage rules stored directly on the Wemo device's own firmware: + +1. Select a device from the dropdown +2. Click **Load Rules** to fetch the device's rule database +3. Toggle rules on/off or delete them +4. Click **Add Rule** to create a new native firmware rule + +> Native firmware rules are separate from DWM Rules. DWM Rules are recommended as they support more features and work across multiple devices simultaneously. + +> Wemo Dimmer V2 (WDS060) with newer RTOS firmware does not support `FetchRules`/`StoreRules`. These devices show a warning in the Device Rules tab. + +### ⚙️ Settings Tab + +Set your **location** for sunrise/sunset-based scheduling: + +1. Type your city name in the search box +2. Select your city from the dropdown +3. Click **Save Location** + +Once set, you can use Sunrise and Sunset as rule start/end times. + +### ❓ Help Tab + +Built-in documentation covering all features, rule types, time format, and troubleshooting. + +--- + +## How It Works + +### Device Discovery + +At startup, the plugin broadcasts an SSDP M-SEARCH packet to `239.255.255.250:1900`. Wemo devices respond with their location URL, from which the plugin fetches device details (`/setup.xml`) and registers each device as a HomeKit switch accessory. + +Cached devices are restored immediately on the next restart so HomeKit doesn't time out waiting for SSDP to complete. + +### HomeKit Control + +All on/off commands use direct UPnP SOAP requests to the device: + +- `SetBinaryState` — set on (`1`) or off (`0`) +- `GetBinaryState` — read current state + +The plugin polls each device every `pollInterval` seconds and pushes state changes to HomeKit. + +### DWM Scheduler + +The scheduler runs inside the Homebridge process: + +- **30-second tick** — reloads rules from store, schedules upcoming events +- **65-second look-ahead window** — pre-schedules `setTimeout` callbacks for precise firing +- **10-minute catch-up** — on restart, fires any rules whose time fell within the last 10 minutes +- **Health monitor** — polls all referenced devices every 10 seconds for AlwaysOn and Trigger rule enforcement +- **Heartbeat** — writes scheduler status to the store every tick; the UI reads this to show the status bar + +Rules are stored in `/dibby-wemo.json`. The scheduler reloads this file on every tick, so rules created or edited in the UI take effect within 30 seconds without a restart. + +### Native Firmware Rules + +Wemo devices store their own rules in a SQLite database inside a ZIP archive. The plugin: + +1. Calls `FetchRules` to get the current database URL +2. Downloads and extracts the ZIP to get the SQLite file +3. Opens it with `sql.js` (WebAssembly SQLite — no native compilation) +4. Modifies the database +5. Re-ZIPs, base64-encodes, and uploads via `StoreRules` + +--- + +## Troubleshooting + +| Problem | Solution | +|---|---| +| No devices found | Ensure PC and Wemo devices are on the same network. Some routers block SSDP multicast — add devices manually via `manualDevices` in config. | +| HomeKit switch unresponsive | Restart Homebridge. The device must be discovered at least once to register. Check Homebridge logs for SOAP errors. | +| Rules not firing | Check the scheduler status bar in the DWM Rules tab. 🔴 Red = DibbyWemo platform missing from config. 🟠 Amber = restart Homebridge. | +| Settings gear icon missing | Ensure `customUi: true` is in the plugin's `package.json` and `config.schema.json`. Upgrade `homebridge-config-ui-x` to v5+. | +| Dimmer device shows warning | Wemo Dimmer V2 (WDS060) newer firmware does not support FetchRules. Power control still works. | +| Rule was created but not showing | The UI data refreshes on tab open. Switch away and back to the DWM Rules tab, or restart Homebridge and hard-refresh the browser (Ctrl+Shift+R). | + +--- + +## Data Storage + +All plugin data is stored in the Homebridge storage directory (default `~/.homebridge/`): + +**`dibby-wemo.json`** — main plugin store: +```json +{ + "location": { "lat": 0, "lng": 0, "city": "...", "country": "..." }, + "devices": [...], + "dwmRules": [...], + "schedulerHeartbeat": { "running": true, "ts": "...", "upcoming": [...] } +} +``` + +No data is sent outside your local network. + +--- + +## Requirements + +- Homebridge ≥ 1.6.0 +- Node.js ≥ 18 +- homebridge-config-ui-x ≥ 5.0.0 (for custom UI panel) +- Wemo devices on the same LAN as the Homebridge host + +--- + +## License + +MIT diff --git a/packages/homebridge-plugin/config.schema.json b/packages/homebridge-plugin/config.schema.json new file mode 100644 index 0000000..fd7f9c9 --- /dev/null +++ b/packages/homebridge-plugin/config.schema.json @@ -0,0 +1,50 @@ +{ + "pluginAlias": "DibbyWemo", + "pluginType": "platform", + "singular": true, + "customUi": true, + "headerDisplay": "**Dibby Wemo Manager** – Local Wemo control with DWM scheduling. No Belkin cloud required.", + "schema": { + "type": "object", + "properties": { + "name": { + "title": "Plugin Name", + "type": "string", + "default": "DibbyWemo" + }, + "location": { + "title": "Location (for sunrise/sunset rules)", + "type": "string", + "description": "Set your city in the plugin settings panel (click the Settings icon) for accurate sunrise/sunset times. This field is filled in automatically.", + "readOnly": true + }, + "discoveryTimeout": { + "title": "Discovery Timeout (ms)", + "type": "integer", + "default": 10000, + "minimum": 3000, + "description": "How long to wait for SSDP discovery responses" + }, + "pollInterval": { + "title": "Device Poll Interval (seconds)", + "type": "integer", + "default": 30, + "minimum": 10, + "description": "How often to poll device state for HomeKit updates" + }, + "manualDevices": { + "title": "Manual Devices", + "type": "array", + "description": "Add devices that don't respond to SSDP discovery", + "items": { + "type": "object", + "properties": { + "host": { "title": "IP Address", "type": "string" }, + "port": { "title": "Port", "type": "integer", "default": 49153 } + }, + "required": ["host"] + } + } + } + } +} diff --git a/packages/homebridge-plugin/homebridge-ui/public/index.html b/packages/homebridge-plugin/homebridge-ui/public/index.html new file mode 100644 index 0000000..8d3ca4b --- /dev/null +++ b/packages/homebridge-plugin/homebridge-ui/public/index.html @@ -0,0 +1,555 @@ + + + + + Dibby Wemo Manager + + + + +
+ + + + + +
+ + +
+
+

Wemo Devices

+
+ +
+
+
Click Discover to find Wemo devices on your network.
+
+ + +
+ + +
+
+

DWM Automation Rules

+
+ +
+ + +
+ + Checking scheduler… +
+ +
+ +
+
No DWM rules yet.
+
+ + + + +
+ + +
+
+

Native Device Rules

+

+ Manage on-device schedules stored in Wemo firmware. Select a device to view its rules. +

+
+
+ + +
+
+
+
+ + +
+

Settings

+
+

Location (for sunrise/sunset rules)

+
Not set
+
+ + + +
+ + +
+
+ + +
+

❓ Help & Guide

+

How to use Dibby Wemo Manager in Homebridge

+ + +
+

🚀 Getting Started

+
    +
  1. Go to the 📱 Devices tab and click Discover — your Wemo devices on the local network will appear.
  2. +
  3. Devices are automatically added to HomeKit as switches. Toggle them from the Home app on your iPhone/iPad.
  4. +
  5. To create automation rules, go to the ⏰ DWM Rules tab and click + Add Rule.
  6. +
  7. Rules run inside Homebridge — no internet or Belkin cloud required.
  8. +
+
+ + +
+

⏰ DWM Rules — How to Create a Rule

+

DWM (Dibby Wemo Manager) rules are stored locally and run in Homebridge.

+
    +
  1. Click the ⏰ DWM Rules tab at the top.
  2. +
  3. Click + Add Rule — the rule form opens inline on the same page (no pop-up).
  4. +
  5. Enter a Rule Name (e.g. "Evening Lights").
  6. +
  7. Choose a Rule Type (see types below).
  8. +
  9. Select target devices — which lights/switches the rule controls.
  10. +
  11. Fill in the schedule details and click Save Rule. Click Cancel or the button to go back without saving.
  12. +
  13. The rule is active immediately — the toggle switch on the card enables/disables it without deleting it.
  14. +
+ +
+

Rule Types:

+ + + + + + + + + + + + + + + + + + + + + +
📅 ScheduleTurn on/off at fixed times on selected days. Enter times in 12-hour format (e.g. 8:30 PM). Set a start time and optional end time, choose the action for each.
CountdownAuto-off after a set number of minutes. Useful for things like a bathroom fan or porch light.
🏠 Away ModeRandomly turns lights on and off within a time window to simulate occupancy while you're away.
🔒 Always OnKeeps a device permanently ON. If it's switched off by anyone, it will be turned back on within 10 seconds automatically. No time fields needed.
TriggerIFTTT-style: when one device turns on/off, automatically control another. E.g. "When the porch light turns ON, turn ON the driveway lights too."
+
+ +
+

⏰ Entering Times

+

Times use 12-hour AM/PM format. All of these are valid:

+ + + + + + + +
8:30 PM8:30 in the evening
8:30PMsame — space is optional
6:00 AM6 o'clock in the morning
12:00 AMmidnight
12:00 PMnoon
9 PM9:00 PM — minutes are optional
+
+
+ + +
+

⚡ Trigger Rules (IFTTT)

+

Trigger rules let one device control another automatically.

+
    +
  1. Click + Add Rule and select type ⚡ Trigger.
  2. +
  3. Under Trigger Device — pick the device whose state change starts the action.
  4. +
  5. Under When — choose "Turns ON", "Turns OFF", or "Turns ON or OFF".
  6. +
  7. Under Then — choose what to do to the action devices:
    + + • Turn ON — always turn action devices on
    + • Turn OFF — always turn action devices off
    + • Mirror — action devices copy the trigger (ON→ON, OFF→OFF)
    + • Opposite — action devices invert the trigger (ON→OFF, OFF→ON) +
    +
  8. +
  9. Under Action Devices — select which devices to control (hold Ctrl/Cmd for multiple).
  10. +
  11. Click Save Rule. Homebridge polls devices every 10 s and fires the trigger on state change.
  12. +
+

+ ⚠️ The scheduler must be running for Trigger rules to work. If Homebridge restarts, rules resume automatically. +

+
+ + +
+

🔌 Device Rules (Native Firmware)

+

These are rules stored directly on the Wemo device's own firmware — separate from DWM Rules.

+
    +
  • Click 🔌 Device Rules tab, then select a device from the dropdown.
  • +
  • Rules stored on the device are listed. You can enable/disable or delete them.
  • +
  • Note: Wemo Dimmer V2 devices with newer firmware do not support this feature.
  • +
  • DWM Rules are recommended over device rules as they support more features and work across multiple devices.
  • +
+
+ + +
+

⚙️ Settings — Location

+

Set your city for accurate sunrise/sunset times in Schedule rules.

+
    +
  1. Click the ⚙️ Settings tab.
  2. +
  3. Type your city name in the search box (e.g. "London" or "New York").
  4. +
  5. Pick your city from the dropdown that appears.
  6. +
  7. Click Save Location.
  8. +
  9. You can now use 🌅 Sunrise and 🌇 Sunset as start/end times in Schedule rules.
  10. +
+
+ + +
+

🔧 Troubleshooting

+ + + + + + + + + + + + + + + + + +
No devices foundMake sure your PC and Wemo devices are on the same WiFi network. Try clicking Discover again. Some routers block SSDP multicast — add a manual device entry via the Homebridge config.
HomeKit toggle not workingRestart Homebridge. Devices need to be discovered at least once before HomeKit can control them. Check the Homebridge logs for errors.
Rules not firingCheck the ⏰ DWM Rules tab status bar. 🟢 Green = scheduler running fine. 🟠 Amber = scheduler may have stopped — restart Homebridge. 🔴 Red = scheduler not running — check the DibbyWemo platform is in your Homebridge config. Times use 12-hour AM/PM (e.g. 8:30 PM).
Settings panel blankRun: npm install --prefix "%APPDATA%/npm/node_modules/homebridge-dibby-wemo" then restart Homebridge.
+
+
+ + + + + diff --git a/packages/homebridge-plugin/homebridge-ui/public/index.js b/packages/homebridge-plugin/homebridge-ui/public/index.js new file mode 100644 index 0000000..ea452f0 --- /dev/null +++ b/packages/homebridge-plugin/homebridge-ui/public/index.js @@ -0,0 +1,768 @@ +/* Dibby Wemo Manager — Homebridge custom UI */ +/* global homebridge */ +'use strict'; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let _devices = []; +let _dwmRules = []; +let _wemoRules = null; // { rules, ruleDevices, targets } for selected device +let _editingDwmId = null; // null = create, string = update +let _selectedDwmDays = new Set(); +let _pendingLocation = null; // { lat, lng, label } + +// --------------------------------------------------------------------------- +// Tabs +// --------------------------------------------------------------------------- + +document.querySelectorAll('.tab-btn').forEach((btn) => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active')); + document.querySelectorAll('.tab-panel').forEach((p) => p.classList.remove('active')); + btn.classList.add('active'); + document.getElementById(`tab-${btn.dataset.tab}`).classList.add('active'); + }); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Display seconds as 12-hour time: "8:30 AM" / "11:00 PM" +function secsToHHMM(secs) { + if (secs == null || secs < 0) return ''; + const totalMins = Math.floor(secs / 60); + let h = Math.floor(totalMins / 60) % 24; + const m = totalMins % 60; + const ampm = h < 12 ? 'AM' : 'PM'; + h = h % 12 || 12; // 0 → 12, 13 → 1, etc. + return `${h}:${String(m).padStart(2, '0')} ${ampm}`; +} + +// Accept "8:30 AM", "8:30AM", "08:30 am", "8:30" (24-hr fallback), "8 AM" +function hhmmToSecs(str) { + if (!str) return -1; + str = str.trim().toUpperCase(); + const match = str.match(/^(\d{1,2})(?::(\d{2}))?\s*(AM|PM)?$/); + if (!match) return -1; + let h = parseInt(match[1], 10); + const m = match[2] ? parseInt(match[2], 10) : 0; + const period = match[3]; + if (isNaN(h) || isNaN(m) || m > 59) return -1; + if (period) { + // 12-hour mode + if (h < 1 || h > 12) return -1; + if (period === 'AM') h = h === 12 ? 0 : h; + else h = h === 12 ? 12 : h + 12; + } else { + // 24-hour fallback + if (h > 23) return -1; + } + return h * 3600 + m * 60; +} + +const DAY_NAMES = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +function dayLabel(dayIds) { + if (!dayIds?.length) return '—'; + if (dayIds.length === 7) return 'Every day'; + return dayIds.map((d) => DAY_NAMES[d] ?? d).join(', '); +} + +function showStatus(containerId, msg, type = 'info') { + const el = document.getElementById(containerId); + if (!el) return; + el.innerHTML = msg + ? `
${msg}
` + : ''; +} + +function spinner() { return ''; } + +// --------------------------------------------------------------------------- +// Devices tab +// --------------------------------------------------------------------------- + +async function loadDevices() { + showStatus('devices-status', spinner() + ' Loading…', 'info'); + try { + _devices = await homebridge.request('/devices/list'); + renderDevices(); + showStatus('devices-status', ''); + } catch (e) { + showStatus('devices-status', 'Failed to load devices: ' + e.message, 'error'); + } +} + +async function discoverDevices() { + const btn = document.getElementById('btn-discover'); + btn.disabled = true; + showStatus('devices-status', spinner() + ' Scanning for devices (up to 10 s)…', 'info'); + try { + _devices = await homebridge.request('/devices/discover', { timeout: 10000 }); + renderDevices(); + showStatus('devices-status', `Found ${_devices.length} device(s)`, 'success'); + refreshWemoDeviceSelect(); + } catch (e) { + showStatus('devices-status', 'Discovery failed: ' + e.message, 'error'); + } finally { + btn.disabled = false; + } +} + +function renderDevices() { + const el = document.getElementById('devices-list'); + if (!_devices.length) { + el.innerHTML = '
No devices found. Click Discover to scan your network.
'; + return; + } + el.innerHTML = _devices.map((d, i) => ` +
+
+
+
${esc(d.friendlyName ?? d.host)}
+
${esc(d.host)}:${d.port} — ${esc(d.productModel ?? 'Wemo Device')}
+
+
+ + +
+
+
+ `).join(''); + + // Fetch state for each device + _devices.forEach((d, i) => fetchDeviceState(i, d)); +} + +async function fetchDeviceState(idx, device) { + try { + const on = await homebridge.request('/devices/state', { host: device.host, port: device.port }); + const toggle = document.getElementById(`dev-toggle-${idx}`); + const label = document.getElementById(`dev-state-label-${idx}`); + if (toggle) toggle.checked = !!on; + if (label) label.textContent = on ? 'ON' : 'OFF'; + } catch { /* device unreachable */ } +} + +async function setDeviceState(idx, on) { + const d = _devices[idx]; + if (!d) return; + const label = document.getElementById(`dev-state-label-${idx}`); + if (label) label.textContent = on ? 'ON' : 'OFF'; + try { + await homebridge.request('/devices/setState', { host: d.host, port: d.port, on }); + } catch (e) { + showStatus('devices-status', `Failed to set ${d.friendlyName}: ${e.message}`, 'error'); + // Revert toggle + const toggle = document.getElementById(`dev-toggle-${idx}`); + if (toggle) toggle.checked = !on; + if (label) label.textContent = !on ? 'ON' : 'OFF'; + } +} + +document.getElementById('btn-discover').addEventListener('click', discoverDevices); + +// --------------------------------------------------------------------------- +// DWM Rules tab +// --------------------------------------------------------------------------- + +async function loadDwmRules() { + try { + _dwmRules = await homebridge.request('/rules/list'); + renderDwmRules(); + } catch (e) { + showStatus('dwm-rules-status', 'Failed to load rules: ' + e.message, 'error'); + } +} + +function dwmRuleSummary(r) { + if (r.type === 'AlwaysOn') { + const devs = (r.targetDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || 'no targets'; + return `🔒 Enforced ON every 10 s · ${devs}`; + } + if (r.type === 'Trigger') { + const src = esc(r.triggerDevice?.name ?? r.triggerDevice?.host ?? '?'); + const when = r.triggerEvent === 'on' ? 'ON' : r.triggerEvent === 'off' ? 'OFF' : 'ON/OFF'; + const action = r.action === 'mirror' ? 'mirror' : r.action === 'opposite' ? 'opposite' : (r.action ?? 'on').toUpperCase(); + const targets = (r.actionDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || '—'; + return `⚡ If ${src} → ${when}, then ${action} (${targets})`; + } + if (r.type === 'Countdown') { + const mins = r.countdownTime ? Math.round(r.countdownTime / 60) : null; + return mins ? `⏱ ${mins} min auto-off` : '—'; + } + const days = dayLabel(r.days); + const devs = (r.targetDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || 'no targets'; + const start = secsToHHMM(r.startTime) || '—'; + const end = r.endTime > 0 ? ' – ' + secsToHHMM(r.endTime) : ''; + return `${days} · ${start}${end} · ${devs}`; +} + +function renderDwmRules() { + const el = document.getElementById('dwm-rules-list'); + if (!_dwmRules.length) { + el.innerHTML = '
No DWM rules yet. Click "+ Add Rule" to create one.
'; + return; + } + const typeIcon = { Schedule: '📅', Away: '🏠', Countdown: '⏱', AlwaysOn: '🔒', Trigger: '⚡' }; + el.innerHTML = _dwmRules.map((r) => ` +
+
+
+
+ ${typeIcon[r.type] || '📅'} ${esc(r.name)} + ${r.enabled ? 'enabled' : 'disabled'} + ${esc(r.type)} +
+
${dwmRuleSummary(r)}
+
+
+ + + +
+
+
+ `).join(''); +} + +async function toggleDwmRule(id, enabled) { + try { + await homebridge.request('/rules/update', { id, updates: { enabled } }); + await loadDwmRules(); + } catch (e) { + showStatus('dwm-rules-status', 'Toggle failed: ' + e.message, 'error'); + await loadDwmRules(); + } +} + +function deleteDwmRule(id) { + // confirm() is blocked in cross-origin iframes — use inline confirm row instead + const card = document.querySelector(`[data-rule-id="${id}"]`); + if (!card) return; + + // If already showing confirm, execute delete + const existing = card.querySelector('.delete-confirm-row'); + if (existing) { + existing.remove(); + homebridge.request('/rules/delete', { id }) + .then(() => loadDwmRules()) + .catch((e) => showStatus('dwm-rules-status', 'Delete failed: ' + e.message, 'error')); + return; + } + + // Show inline confirm bar + const row = document.createElement('div'); + row.className = 'delete-confirm-row'; + row.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:8px;padding:6px 10px;background:rgba(239,68,68,.12);border-radius:6px;font-size:0.8rem'; + row.innerHTML = 'Delete this rule?' + + `` + + ''; + card.appendChild(row); + + // Auto-dismiss after 5 seconds + setTimeout(() => row.remove(), 5000); +} + +document.getElementById('btn-add-dwm').addEventListener('click', () => openDwmEdit(null)); + +// ── DWM Inline Form ─────────────────────────────────────────────────────────── + +function openDwmEdit(id) { + _editingDwmId = id; + _selectedDwmDays = new Set(); + document.getElementById('dwm-form-error').style.display = 'none'; + document.getElementById('dwm-form-title').textContent = id ? 'Edit DWM Rule' : 'Add DWM Rule'; + + const devOptions = _devices.map((d) => + `` + ).join(''); + + // Populate all device selects + document.getElementById('dwm-target-devices').innerHTML = devOptions; + document.getElementById('dwm-trigger-src').innerHTML = '' + devOptions; + document.getElementById('dwm-trigger-targets').innerHTML = devOptions; + + if (id) { + const r = _dwmRules.find((x) => x.id === id); + if (!r) return; + document.getElementById('dwm-name').value = r.name ?? ''; + document.getElementById('dwm-type').value = r.type ?? 'Schedule'; + document.getElementById('dwm-enabled').checked = r.enabled !== false; + document.getElementById('dwm-start-time').value = secsToHHMM(r.startTime); + document.getElementById('dwm-end-time').value = secsToHHMM(r.endTime); + document.getElementById('dwm-start-action').value = String(r.startAction ?? 1); + document.getElementById('dwm-end-action').value = String(r.endAction ?? -1); + document.getElementById('dwm-countdown-mins').value = + r.countdownTime ? String(Math.round(r.countdownTime / 60)) : ''; + + _selectedDwmDays = new Set((r.days ?? []).map(Number)); + + // Select target devices + const targets = (r.targetDevices ?? []).map((td) => `${td.host}:${td.port}`); + Array.from(document.getElementById('dwm-target-devices').options).forEach((opt) => { + opt.selected = targets.includes(opt.value); + }); + + // Trigger-specific + if (r.type === 'Trigger') { + const srcKey = r.triggerDevice ? `${r.triggerDevice.host}:${r.triggerDevice.port}` : ''; + document.getElementById('dwm-trigger-src').value = srcKey; + document.getElementById('dwm-trigger-event').value = r.triggerEvent ?? 'any'; + document.getElementById('dwm-trigger-action').value = r.action ?? 'on'; + const actKeys = (r.actionDevices ?? []).map((td) => `${td.host}:${td.port}`); + Array.from(document.getElementById('dwm-trigger-targets').options).forEach((opt) => { + opt.selected = actKeys.includes(opt.value); + }); + } + } else { + document.getElementById('dwm-name').value = ''; + document.getElementById('dwm-type').value = 'Schedule'; + document.getElementById('dwm-enabled').checked = true; + document.getElementById('dwm-start-time').value = ''; + document.getElementById('dwm-end-time').value = ''; + document.getElementById('dwm-start-action').value = '1'; + document.getElementById('dwm-end-action').value = '-1'; + document.getElementById('dwm-countdown-mins').value = ''; + document.getElementById('dwm-trigger-src').value = ''; + document.getElementById('dwm-trigger-event').value = 'any'; + document.getElementById('dwm-trigger-action').value = 'on'; + Array.from(document.getElementById('dwm-target-devices').options).forEach((opt) => { opt.selected = false; }); + Array.from(document.getElementById('dwm-trigger-targets').options).forEach((opt) => { opt.selected = false; }); + } + + updateDwmDayButtons(); + updateDwmTypeFields(); + document.getElementById('dwm-list-view').style.display = 'none'; + document.getElementById('dwm-form-panel').style.display = ''; + window.scrollTo(0, 0); +} + +function updateDwmDayButtons() { + document.querySelectorAll('#dwm-days .day-btn').forEach((btn) => { + const d = Number(btn.dataset.day); + btn.classList.toggle('selected', _selectedDwmDays.has(d)); + }); +} + +function updateDwmTypeFields() { + const type = document.getElementById('dwm-type').value; + const isSchedule = type === 'Schedule' || type === 'Away'; + const isCountdown = type === 'Countdown'; + const isAlwaysOn = type === 'AlwaysOn'; + const isTrigger = type === 'Trigger'; + const isTimeBased = isSchedule || isCountdown; + + document.getElementById('dwm-target-group').style.display = isTrigger ? 'none' : ''; + document.getElementById('dwm-days-group').style.display = isTrigger || isAlwaysOn ? 'none' : ''; + document.getElementById('dwm-schedule-fields').style.display = isCountdown || isTrigger || isAlwaysOn ? 'none' : ''; + document.getElementById('dwm-countdown-fields').style.display = isCountdown ? '' : 'none'; + document.getElementById('dwm-trigger-fields').style.display = isTrigger ? '' : 'none'; + document.getElementById('dwm-alwayson-info').style.display = isAlwaysOn ? '' : 'none'; +} + +document.querySelectorAll('#dwm-days .day-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const d = Number(btn.dataset.day); + if (_selectedDwmDays.has(d)) _selectedDwmDays.delete(d); + else _selectedDwmDays.add(d); + updateDwmDayButtons(); + }); +}); + +document.getElementById('dwm-type').addEventListener('change', updateDwmTypeFields); + +function closeDwmModal() { + document.getElementById('dwm-form-panel').style.display = 'none'; + document.getElementById('dwm-list-view').style.display = ''; +} + +document.getElementById('btn-dwm-form-cancel').addEventListener('click', closeDwmModal); +document.getElementById('dwm-form-cancel-btn').addEventListener('click', closeDwmModal); + +document.getElementById('dwm-form-save-btn').addEventListener('click', async () => { + const errEl = document.getElementById('dwm-form-error'); + errEl.style.display = 'none'; + + const name = document.getElementById('dwm-name').value.trim(); + const type = document.getElementById('dwm-type').value; + const enabled = document.getElementById('dwm-enabled').checked; + + if (!name) { showModalError('Rule name is required'); return; } + + const devFromKey = (key) => { + const [host, port] = key.split(':'); + const dev = _devices.find((d) => d.host === host && String(d.port) === port); + return { host, port: Number(port), name: dev?.friendlyName ?? host, udn: dev?.udn }; + }; + + // ── AlwaysOn ────────────────────────────────────────────────────────────── + if (type === 'AlwaysOn') { + const selEl = document.getElementById('dwm-target-devices'); + const selectedDevs = Array.from(selEl.selectedOptions).map((opt) => devFromKey(opt.value)); + if (!selectedDevs.length) { showModalError('Select at least one device to keep on'); return; } + const rule = { name, type, enabled, targetDevices: selectedDevs }; + try { + if (_editingDwmId) await homebridge.request('/rules/update', { id: _editingDwmId, updates: rule }); + else await homebridge.request('/rules/create', rule); + closeDwmModal(); + await loadDwmRules(); + } catch (e) { showModalError('Save failed: ' + e.message); } + return; + } + + // ── Trigger ─────────────────────────────────────────────────────────────── + if (type === 'Trigger') { + const srcKey = document.getElementById('dwm-trigger-src').value; + if (!srcKey) { showModalError('Select a trigger (source) device'); return; } + const actTargets = Array.from(document.getElementById('dwm-trigger-targets').selectedOptions) + .map((opt) => devFromKey(opt.value)); + if (!actTargets.length) { showModalError('Select at least one action device'); return; } + const rule = { + name, type, enabled, + triggerDevice: devFromKey(srcKey), + triggerEvent: document.getElementById('dwm-trigger-event').value, + action: document.getElementById('dwm-trigger-action').value, + actionDevices: actTargets, + }; + try { + if (_editingDwmId) await homebridge.request('/rules/update', { id: _editingDwmId, updates: rule }); + else await homebridge.request('/rules/create', rule); + closeDwmModal(); + await loadDwmRules(); + } catch (e) { showModalError('Save failed: ' + e.message); } + return; + } + + // ── Schedule / Countdown / Away ─────────────────────────────────────────── + if (_selectedDwmDays.size === 0 && type !== 'Countdown') { + showModalError('Select at least one day'); return; + } + + const selEl = document.getElementById('dwm-target-devices'); + const selectedDevs = Array.from(selEl.selectedOptions).map((opt) => devFromKey(opt.value)); + if (!selectedDevs.length) { showModalError('Select at least one target device'); return; } + + const rule = { + name, type, enabled, + days: Array.from(_selectedDwmDays).sort(), + targetDevices: selectedDevs, + }; + + if (type === 'Countdown') { + const mins = Number(document.getElementById('dwm-countdown-mins').value); + if (!mins || mins < 1) { showModalError('Enter countdown duration in minutes'); return; } + rule.countdownTime = mins * 60; + } else { + const startSecs = hhmmToSecs(document.getElementById('dwm-start-time').value); + if (startSecs < 0) { showModalError('Enter a valid start time (HH:MM)'); return; } + rule.startTime = startSecs; + rule.endTime = hhmmToSecs(document.getElementById('dwm-end-time').value); + rule.startAction = Number(document.getElementById('dwm-start-action').value); + rule.endAction = Number(document.getElementById('dwm-end-action').value); + } + + try { + if (_editingDwmId) { + await homebridge.request('/rules/update', { id: _editingDwmId, updates: rule }); + } else { + await homebridge.request('/rules/create', rule); + } + closeDwmModal(); + await loadDwmRules(); + } catch (e) { + showModalError('Save failed: ' + e.message); + } +}); + +function showModalError(msg) { + const el = document.getElementById('dwm-form-error'); + el.textContent = msg; + el.style.display = 'block'; +} + +// --------------------------------------------------------------------------- +// Wemo Device Rules tab +// --------------------------------------------------------------------------- + +function refreshWemoDeviceSelect() { + const sel = document.getElementById('wemo-rules-device-select'); + const cur = sel.value; + sel.innerHTML = '' + + _devices.map((d) => + `` + ).join(''); + if (cur) sel.value = cur; +} + +document.getElementById('wemo-rules-device-select').addEventListener('change', async function () { + const val = this.value; + if (!val) { document.getElementById('wemo-rules-list').innerHTML = ''; return; } + const [host, portStr] = val.split(':'); + const port = Number(portStr); + + showStatus('wemo-rules-status', spinner() + ' Fetching rules from device…', 'info'); + document.getElementById('wemo-rules-list').innerHTML = ''; + + try { + _wemoRules = await homebridge.request('/rules/wemo/list', { host, port }); + showStatus('wemo-rules-status', ''); + renderWemoRules(host, port); + } catch (e) { + if (String(e.message).includes('FetchRules') || String(e.message).includes('rules1')) { + showStatus('wemo-rules-status', + '⚠️ This device does not support the Wemo Rules service (e.g. Dimmer V2 with newer firmware).', 'info'); + } else { + showStatus('wemo-rules-status', 'Failed: ' + e.message, 'error'); + } + } +}); + +function renderWemoRules(host, port) { + const el = document.getElementById('wemo-rules-list'); + if (!_wemoRules?.rules?.length) { + el.innerHTML = '
No on-device rules found.
'; + return; + } + + el.innerHTML = _wemoRules.rules.map((r) => { + const devices = (_wemoRules.ruleDevices ?? []).filter((rd) => String(rd.RuleID) === String(r.RuleID)); + const enabled = String(r.State) === '1'; + const dayList = [...new Set(devices.map((d) => d.DayID))].sort().map((d) => DAY_NAMES[d] ?? d).join(', ') || '—'; + const startTime = devices[0]?.StartTime >= 0 ? secsToHHMM(devices[0].StartTime) : '—'; + + return `
+
+
+
+ ${esc(r.Name)} + ${enabled ? 'enabled' : 'disabled'} + ${esc(r.Type)} +
+
${dayList} · ${startTime}
+
+
+ + +
+
+
`; + }).join(''); +} + +async function toggleWemoRule(host, port, ruleId, enabled) { + showStatus('wemo-rules-status', spinner() + ' Updating device…', 'info'); + try { + await homebridge.request('/rules/wemo/toggle', { host, port, ruleId, enabled }); + showStatus('wemo-rules-status', 'Rule updated ✓', 'success'); + // Refresh list + _wemoRules = await homebridge.request('/rules/wemo/list', { host, port }); + renderWemoRules(host, port); + setTimeout(() => showStatus('wemo-rules-status', ''), 2500); + } catch (e) { + showStatus('wemo-rules-status', 'Toggle failed: ' + e.message, 'error'); + _wemoRules = await homebridge.request('/rules/wemo/list', { host, port }); + renderWemoRules(host, port); + } +} + +async function deleteWemoRule(host, port, ruleId) { + if (!confirm('Delete this on-device rule? This cannot be undone.')) return; + showStatus('wemo-rules-status', spinner() + ' Deleting…', 'info'); + try { + await homebridge.request('/rules/wemo/delete', { host, port, ruleId }); + showStatus('wemo-rules-status', 'Rule deleted ✓', 'success'); + _wemoRules = await homebridge.request('/rules/wemo/list', { host, port }); + renderWemoRules(host, port); + setTimeout(() => showStatus('wemo-rules-status', ''), 2500); + } catch (e) { + showStatus('wemo-rules-status', 'Delete failed: ' + e.message, 'error'); + } +} + +// --------------------------------------------------------------------------- +// Settings — Location +// --------------------------------------------------------------------------- + +async function loadLocation() { + try { + const loc = await homebridge.request('/location/get'); + updateLocationDisplay(loc); + } catch { /* ignore */ } +} + +function updateLocationDisplay(loc) { + const el = document.getElementById('location-current'); + if (loc?.lat != null) { + el.textContent = `📍 ${loc.label ?? `${loc.lat}, ${loc.lng}`}`; + } else { + el.textContent = 'Not set'; + } +} + +let _locSearchTimer = null; +document.getElementById('location-search-input').addEventListener('input', function () { + clearTimeout(_locSearchTimer); + const q = this.value.trim(); + if (q.length < 2) { hideAutocomplete(); return; } + _locSearchTimer = setTimeout(() => searchLocation(q), 400); +}); + +async function searchLocation(query) { + try { + const results = await homebridge.request('/location/search', { query }); + showAutocomplete(results); + } catch { hideAutocomplete(); } +} + +function showAutocomplete(results) { + const el = document.getElementById('location-autocomplete'); + if (!results.length) { hideAutocomplete(); return; } + el.innerHTML = results.map((r, i) => + `
${esc(r.label)}
` + ).join(''); + el.style.display = 'block'; + el._results = results; + el.querySelectorAll('.autocomplete-item').forEach((item, i) => { + item.addEventListener('click', () => { + _pendingLocation = el._results[i]; + document.getElementById('location-search-input').value = _pendingLocation.label; + hideAutocomplete(); + document.getElementById('btn-location-save').disabled = false; + }); + }); +} + +function hideAutocomplete() { + const el = document.getElementById('location-autocomplete'); + el.style.display = 'none'; +} + +document.getElementById('btn-location-save').addEventListener('click', async () => { + if (!_pendingLocation) return; + try { + await homebridge.request('/location/set', _pendingLocation); + updateLocationDisplay(_pendingLocation); + document.getElementById('location-status').textContent = 'Saved ✓'; + document.getElementById('btn-location-save').disabled = true; + _pendingLocation = null; + setTimeout(() => { document.getElementById('location-status').textContent = ''; }, 2500); + } catch (e) { + document.getElementById('location-status').textContent = 'Failed: ' + e.message; + } +}); + +// --------------------------------------------------------------------------- +// XSS-safe text escaping +// --------------------------------------------------------------------------- + +function esc(str) { + return String(str ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// --------------------------------------------------------------------------- +// Scheduler heartbeat +// --------------------------------------------------------------------------- + +async function refreshHeartbeat() { + const dot = document.getElementById('hb-dot'); + const text = document.getElementById('hb-text'); + const next = document.getElementById('hb-next'); + if (!dot) return; + + try { + const hb = await homebridge.request('/scheduler/status'); + + if (!hb || !hb.running) { + dot.style.background = '#ef4444'; + text.style.color = '#fca5a5'; + text.textContent = hb?.ts + ? '⚠ Scheduler stopped — restart Homebridge to recover' + : '⚠ Scheduler not running — check Homebridge config has DibbyWemo platform'; + next.textContent = ''; + return; + } + + if (hb.stale) { + dot.style.background = '#f97316'; + text.style.color = '#fdba74'; + text.textContent = '⚠ Scheduler may be unresponsive (last heartbeat: ' + _relTime(hb.ts) + ')'; + next.textContent = ''; + return; + } + + // Healthy + dot.style.background = '#22c55e'; + text.style.color = '#4ade80'; + text.textContent = '✓ Scheduler running · ' + hb.totalEntries + ' schedule entr' + (hb.totalEntries === 1 ? 'y' : 'ies'); + + // Last fired + if (hb.lastFire) { + const icon = hb.lastFire.success ? '✓' : '⚠'; + next.textContent = 'Last: ' + icon + ' ' + hb.lastFire.msg.replace(/\s*[✓⚠]\s*$/, '') + ' · ' + _relTime(hb.lastFire.at); + next.style.color = hb.lastFire.success ? 'var(--muted)' : '#fca5a5'; + } else if (hb.upcoming && hb.upcoming.length) { + const u = hb.upcoming[0]; + next.textContent = 'Next: ' + u.ruleName + ' → ' + u.action + ' at ' + u.at; + next.style.color = 'var(--muted)'; + } else { + next.textContent = 'No upcoming rules today'; + next.style.color = 'var(--muted)'; + } + } catch { + dot.style.background = 'var(--muted)'; + text.style.color = 'var(--muted)'; + text.textContent = 'Scheduler status unavailable'; + next.textContent = ''; + } +} + +function _relTime(iso) { + const diff = Math.round((Date.now() - new Date(iso).getTime()) / 1000); + if (diff < 5) return 'just now'; + if (diff < 60) return diff + 's ago'; + if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; + return Math.floor(diff / 3600) + 'h ago'; +} + +// Poll heartbeat every 35 seconds while on the DWM tab +let _hbTimer = null; +function startHeartbeatPolling() { + refreshHeartbeat(); + _hbTimer = setInterval(refreshHeartbeat, 35_000); +} +function stopHeartbeatPolling() { + if (_hbTimer) { clearInterval(_hbTimer); _hbTimer = null; } +} + +// Start/stop polling when tab changes +document.querySelectorAll('.tab-btn').forEach((btn) => { + btn.addEventListener('click', () => { + if (btn.dataset.tab === 'dwm-rules') startHeartbeatPolling(); + else stopHeartbeatPolling(); + }); +}); + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- + +(async function init() { + await loadDevices(); + await loadDwmRules(); + await loadLocation(); + refreshWemoDeviceSelect(); + startHeartbeatPolling(); // start immediately (DWM tab is not default, but still useful) +})(); diff --git a/packages/homebridge-plugin/homebridge-ui/server.js b/packages/homebridge-plugin/homebridge-ui/server.js new file mode 100644 index 0000000..3a336c7 --- /dev/null +++ b/packages/homebridge-plugin/homebridge-ui/server.js @@ -0,0 +1,151 @@ +'use strict'; + +/** + * Homebridge custom UI server for homebridge-dibby-wemo. + * + * Runs as a child process managed by homebridge-config-ui-x. + * Communicates with the frontend via this.onRequest() / homebridge.request(). + * + * Provides: + * - devices.list → saved device list (from plugin store) + * - devices.discover → trigger SSDP discovery + * - devices.state → get binary state of a device + * - devices.setState → set binary state of a device + * - rules.list → DWM rules from plugin store + * - rules.create → create a DWM rule + * - rules.update → update a DWM rule + * - rules.delete → delete a DWM rule + * - rules.wemo.list → fetch native device rules from a Wemo device + * - rules.wemo.toggle → enable / disable a native Wemo device rule + * - rules.wemo.delete → delete a native Wemo device rule + * - location.get → get stored location + * - location.search → geocode query via Nominatim + * - location.set → save location + */ + +const { HomebridgePluginUiServer } = require('@homebridge/plugin-ui-utils'); +const path = require('path'); +const DwmStore = require('../lib/store'); +const wemoClient = require('../lib/wemo-client'); +const axios = require('axios'); + +class DibbyWemoUiServer extends HomebridgePluginUiServer { + constructor() { + super(); + + // Shared store instance — storagePath provided by homebridge-config-ui-x + this._store = new DwmStore(this.homebridgeStoragePath); + + // ── Devices ───────────────────────────────────────────────────────────── + this.onRequest('/devices/list', async () => { + return this._store.getDevices(); + }); + + this.onRequest('/devices/discover', async ({ timeout } = {}) => { + const ms = typeof timeout === 'number' ? timeout : 10_000; + const devices = await wemoClient.discoverDevices(ms); + // Persist updated list + this._store.saveDevices(devices.map((d) => ({ + host: d.host, + port: d.port, + udn: d.udn ?? `${d.host}:${d.port}`, + friendlyName: d.friendlyName ?? d.host, + productModel: d.productModel ?? 'Wemo Device', + firmwareVersion: d.firmwareVersion ?? null, + }))); + return devices; + }); + + this.onRequest('/devices/state', async ({ host, port }) => { + return await wemoClient.getBinaryState(host, Number(port)); + }); + + this.onRequest('/devices/setState', async ({ host, port, on }) => { + await wemoClient.setBinaryState(host, Number(port), !!on); + return { ok: true }; + }); + + // ── DWM Rules ──────────────────────────────────────────────────────────── + this.onRequest('/rules/list', async () => { + return this._store.getDwmRules(); + }); + + this.onRequest('/rules/create', async (rule) => { + return this._store.createDwmRule(rule); + }); + + this.onRequest('/rules/update', async ({ id, updates }) => { + return this._store.updateDwmRule(id, updates); + }); + + this.onRequest('/rules/delete', async ({ id }) => { + this._store.deleteDwmRule(id); + return { ok: true }; + }); + + // ── Scheduler heartbeat ─────────────────────────────────────────────────── + this.onRequest('/scheduler/status', async () => { + const hb = this._store.getHeartbeat(); + if (!hb) return { running: false, stale: false, ts: null }; + const ageMs = Date.now() - new Date(hb.ts).getTime(); + // stale if no heartbeat for > 90 seconds (3 missed ticks) + return { ...hb, stale: ageMs > 90_000 }; + }); + + // ── Native Wemo Device Rules ────────────────────────────────────────────── + this.onRequest('/rules/wemo/list', async ({ host, port }) => { + return await wemoClient.fetchRules(host, Number(port)); + }); + + this.onRequest('/rules/wemo/toggle', async ({ host, port, ruleId, enabled }) => { + await wemoClient.toggleRule(host, Number(port), ruleId, !!enabled); + return { ok: true }; + }); + + this.onRequest('/rules/wemo/delete', async ({ host, port, ruleId }) => { + await wemoClient.deleteRule(host, Number(port), ruleId); + return { ok: true }; + }); + + this.onRequest('/rules/wemo/create', async ({ host, port, ruleData }) => { + const id = await wemoClient.createRule(host, Number(port), ruleData); + return { ok: true, id }; + }); + + this.onRequest('/rules/wemo/update', async ({ host, port, ruleId, ruleData }) => { + await wemoClient.updateRule(host, Number(port), ruleId, ruleData); + return { ok: true }; + }); + + // ── Location ────────────────────────────────────────────────────────────── + this.onRequest('/location/get', async () => { + return this._store.getLocation(); + }); + + this.onRequest('/location/set', async (loc) => { + this._store.setLocation(loc); + return { ok: true }; + }); + + this.onRequest('/location/search', async ({ query }) => { + try { + const res = await axios.get('https://nominatim.openstreetmap.org/search', { + params: { q: query, format: 'json', limit: 8, addressdetails: 1 }, + headers: { 'User-Agent': 'homebrige-dibby-wemo/1.0' }, + timeout: 8000, + }); + return (res.data || []).map((r) => ({ + lat: parseFloat(r.lat), + lng: parseFloat(r.lon), + label: r.display_name, + city: r.address?.city || r.address?.town || r.address?.village || '', + country: r.address?.country || '', + })); + } catch { return []; } + }); + + this.ready(); + } +} + +(() => new DibbyWemoUiServer())(); diff --git a/packages/homebridge-plugin/index.js b/packages/homebridge-plugin/index.js new file mode 100644 index 0000000..63021ab --- /dev/null +++ b/packages/homebridge-plugin/index.js @@ -0,0 +1,20 @@ +'use strict'; + +/** + * homebridge-dibby-wemo + * + * Homebridge plugin entry point. + * + * Registers the DibbyWemo platform so Homebridge discovers Wemo devices and + * exposes them to HomeKit as Switch accessories. Also runs the DWM scheduler + * for local time-based automations — no Belkin cloud required. + */ + +const { WemoPlatform, PLUGIN_NAME, PLATFORM_NAME } = require('./lib/platform'); + +/** + * @param {object} api - The Homebridge API object + */ +module.exports = (api) => { + api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, WemoPlatform); +}; diff --git a/packages/homebridge-plugin/lib/accessory.js b/packages/homebridge-plugin/lib/accessory.js new file mode 100644 index 0000000..708632b --- /dev/null +++ b/packages/homebridge-plugin/lib/accessory.js @@ -0,0 +1,95 @@ +'use strict'; + +/** + * WemoSwitchAccessory + * + * Represents a single Wemo device as a HomeKit Switch. + * State is polled on the configured interval and pushed to HomeKit. + */ + +class WemoSwitchAccessory { + /** + * @param {object} params + * @param {object} params.platform - WemoPlatform instance + * @param {object} params.accessory - PlatformAccessory from Homebridge + * @param {object} params.device - { host, port, udn, friendlyName, ... } + * @param {object} params.wemoClient - wemo-client module + * @param {number} params.pollInterval - poll interval in seconds + */ + constructor({ platform, accessory, device, wemoClient, pollInterval = 30 }) { + this.platform = platform; + this.accessory = accessory; + this.device = device; + this.wemo = wemoClient; + this.pollInterval = pollInterval; + this.log = platform.log; + + const { Service, Characteristic } = platform.api.hap; + + // ── Accessory information ─────────────────────────────────────────────── + this.accessory.getService(Service.AccessoryInformation) + ?.setCharacteristic(Characteristic.Manufacturer, 'Belkin') + .setCharacteristic(Characteristic.Model, device.productModel ?? 'Wemo Switch') + .setCharacteristic(Characteristic.SerialNumber, device.udn ?? device.host); + + // ── Switch service ────────────────────────────────────────────────────── + this.switchService = this.accessory.getService(Service.Switch) + || this.accessory.addService(Service.Switch, device.friendlyName ?? device.host); + + this.switchService.getCharacteristic(Characteristic.On) + .onGet(this._getOn.bind(this)) + .onSet(this._setOn.bind(this)); + + // ── Initial state + poll ──────────────────────────────────────────────── + this._currentState = false; + this._pollTimer = null; + this._startPolling(); + } + + // ── HomeKit handlers ────────────────────────────────────────────────────── + + async _getOn() { + try { + this._currentState = await this.wemo.getBinaryState(this.device.host, this.device.port); + } catch (e) { + this.log.warn(`[${this.device.friendlyName}] getBinaryState failed: ${e.message}`); + } + return this._currentState; + } + + async _setOn(value) { + try { + await this.wemo.setBinaryState(this.device.host, this.device.port, !!value); + this._currentState = !!value; + } catch (e) { + this.log.error(`[${this.device.friendlyName}] setBinaryState failed: ${e.message}`); + throw new this.platform.api.hap.HapStatusError( + this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE + ); + } + } + + // ── Polling ─────────────────────────────────────────────────────────────── + + _startPolling() { + this._pollTimer = setInterval(async () => { + try { + const newState = await this.wemo.getBinaryState(this.device.host, this.device.port); + if (newState !== this._currentState) { + this._currentState = newState; + const { Characteristic } = this.platform.api.hap; + this.switchService.updateCharacteristic(Characteristic.On, newState); + } + } catch { /* device unreachable — keep last state */ } + }, this.pollInterval * 1000); + } + + stopPolling() { + if (this._pollTimer) { + clearInterval(this._pollTimer); + this._pollTimer = null; + } + } +} + +module.exports = WemoSwitchAccessory; diff --git a/packages/homebridge-plugin/lib/platform.js b/packages/homebridge-plugin/lib/platform.js new file mode 100644 index 0000000..89bbacd --- /dev/null +++ b/packages/homebridge-plugin/lib/platform.js @@ -0,0 +1,188 @@ +'use strict'; + +/** + * WemoPlatform + * + * Homebridge platform plugin. Discovers Wemo devices via SSDP (and any + * manually-configured hosts), registers each as a Switch accessory, and + * runs the DWM local scheduler for time-based automation rules. + */ + +const DwmStore = require('./store'); +const wemoClient = require('./wemo-client'); +const DwmScheduler = require('./scheduler'); +const WemoSwitchAccessory = require('./accessory'); + +const PLUGIN_NAME = 'homebridge-dibby-wemo'; +const PLATFORM_NAME = 'DibbyWemo'; + +class WemoPlatform { + /** + * @param {object} log - Homebridge logger + * @param {object} config - Platform config from config.json + * @param {object} api - Homebridge API + */ + constructor(log, config, api) { + this.log = log; + this.config = config ?? {}; + this.api = api; + + this._accessories = new Map(); // uuid → PlatformAccessory + this._handlers = new Map(); // uuid → WemoSwitchAccessory + + // Store in Homebridge's user storage directory + this._store = new DwmStore(api.user.storagePath()); + + // Location is set via the custom UI settings panel (city search) and stored + // in the plugin's DwmStore — no raw lat/lng in config.json needed. + + // DWM Scheduler + this._scheduler = new DwmScheduler({ + store: this._store, + wemoClient, + log, + }); + this._scheduler.onFire(({ success, msg }) => { + if (success) log.info('[DWM] ' + msg); + else log.warn('[DWM] ' + msg); + }); + + // Homebridge calls didFinishLaunching once the restore cache is ready + api.on('didFinishLaunching', () => { + this._discoverDevices(); + this._scheduler.start().catch((e) => log.error('[DWM Scheduler] Start failed: ' + e.message)); + }); + + log.info('DibbyWemo platform initialised'); + } + + // ── Homebridge lifecycle ────────────────────────────────────────────────── + + /** + * Called for each accessory restored from cache on startup. + * We immediately attach handlers using the device context stored in the + * accessory so HomeKit requests don't time out during the SSDP window. + */ + configureAccessory(accessory) { + this.log.info('Restoring cached accessory: ' + accessory.displayName); + this._accessories.set(accessory.UUID, accessory); + + // Re-attach handlers right away if we have saved device context + const device = accessory.context?.device; + if (device?.host && device?.port) { + const pollInterval = this.config.pollInterval ?? 30; + this._handlers.get(accessory.UUID)?.stopPolling(); + const handler = new WemoSwitchAccessory({ + platform: this, + accessory, + device, + wemoClient, + pollInterval, + }); + this._handlers.set(accessory.UUID, handler); + } + } + + // ── Discovery ───────────────────────────────────────────────────────────── + + async _discoverDevices() { + const timeout = this.config.discoveryTimeout ?? 10_000; + const pollInterval = this.config.pollInterval ?? 30; + + this.log.info('Starting Wemo device discovery…'); + let discovered = []; + try { + discovered = await wemoClient.discoverDevices(timeout); + } catch (e) { + this.log.error('SSDP discovery failed: ' + e.message); + } + + // Merge in manually-configured devices + const manual = (this.config.manualDevices ?? []).map(({ host, port }) => ({ + host, port: port ?? 49153, + })); + for (const m of manual) { + if (!discovered.find((d) => d.host === m.host && d.port === m.port)) { + try { + const info = await wemoClient.getDeviceInfo(m.host, m.port); + discovered.push({ ...m, ...info }); + } catch { + discovered.push(m); + } + } + } + + this.log.info(`Found ${discovered.length} Wemo device(s)`); + + // Save discovered device list for the custom UI + this._store.saveDevices(discovered.map((d) => ({ + host: d.host, + port: d.port, + udn: d.udn ?? `${d.host}:${d.port}`, + friendlyName: d.friendlyName ?? d.host, + productModel: d.productModel ?? 'Wemo Device', + firmwareVersion: d.firmwareVersion ?? null, + }))); + + for (const device of discovered) { + this._registerDevice(device, pollInterval); + } + + // Remove stale accessories (devices no longer discovered) + const activeUUIDs = new Set(discovered.map((d) => this._uuidForDevice(d))); + for (const [uuid, acc] of this._accessories) { + if (!activeUUIDs.has(uuid)) { + this.log.info('Removing stale accessory: ' + acc.displayName); + this._handlers.get(uuid)?.stopPolling(); + this._handlers.delete(uuid); + this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [acc]); + this._accessories.delete(uuid); + } + } + } + + _uuidForDevice(device) { + const id = device.udn ?? `${device.host}:${device.port}`; + return this.api.hap.uuid.generate(id); + } + + _registerDevice(device, pollInterval) { + const uuid = this._uuidForDevice(device); + const name = device.friendlyName ?? device.host; + + let accessory = this._accessories.get(uuid); + + if (!accessory) { + this.log.info('Adding new accessory: ' + name); + accessory = new this.api.platformAccessory(name, uuid); + this._accessories.set(uuid, accessory); + this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); + } else { + accessory.displayName = name; + } + + // Persist device connection info so configureAccessory can restore it on + // the next restart without waiting for SSDP to complete. + accessory.context.device = { + host: device.host, + port: device.port, + udn: device.udn ?? `${device.host}:${device.port}`, + friendlyName: device.friendlyName ?? device.host, + productModel: device.productModel ?? 'Wemo Device', + firmwareVersion: device.firmwareVersion ?? null, + }; + + // (Re)create handler so device info is up to date + this._handlers.get(uuid)?.stopPolling(); + const handler = new WemoSwitchAccessory({ + platform: this, + accessory, + device, + wemoClient, + pollInterval, + }); + this._handlers.set(uuid, handler); + } +} + +module.exports = { WemoPlatform, PLUGIN_NAME, PLATFORM_NAME }; diff --git a/packages/homebridge-plugin/lib/scheduler.js b/packages/homebridge-plugin/lib/scheduler.js new file mode 100644 index 0000000..75e9cb1 --- /dev/null +++ b/packages/homebridge-plugin/lib/scheduler.js @@ -0,0 +1,721 @@ +'use strict'; + +/** + * DWM Scheduler — Homebridge edition. + * + * Identical logic to the desktop LocalScheduler but takes store + wemoClient + * as constructor dependencies instead of top-level requires. + * + * Rule types handled: + * - Schedule / Away (fixed times) → pre-computed {dayId, targetSecs, action} entries + * - Countdown with active window → ON at windowStart, OFF at windowEnd (cross-midnight aware) + * - Away Mode → randomisation loop: ON 30–90 min, OFF 1–15 min within window + * - AlwaysOn → health monitor enforces ON every 10 s; no schedule entry + * - Trigger → if device A changes state, fire action on device B + * + * Usage: + * const scheduler = new DwmScheduler({ store, wemoClient, log }); + * scheduler.onFire(({ success, msg }) => log.info(msg)); + * await scheduler.start(); + */ + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Wemo DayID: 1=Mon … 7=Sun. JS getDay(): 0=Sun … 6=Sat. */ +function jsToWemoDayId(jsDay) { return jsDay === 0 ? 7 : jsDay; } + +function secondsFromMidnight(date) { + return date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds(); +} + +function secsToHHMM(secs) { + const h = Math.floor(secs / 3600) % 24; + const m = Math.floor((secs % 3600) / 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +function actionLabel(a) { + return a === 1 ? 'ON' : a === 0 ? 'OFF' : `action(${a})`; +} + +function randBetween(min, max) { + return min + Math.floor(Math.random() * (max - min + 1)); +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +const HEALTH_POLL_MS = 10_000; // poll devices every 10 seconds +const CATCHUP_WINDOW_S = 10 * 60; // catch up rules missed within last 10 minutes + +// ── DwmScheduler ───────────────────────────────────────────────────────────── + +class DwmScheduler { + /** + * @param {object} deps + * @param {import('./store')} deps.store - DwmStore instance + * @param {object} deps.wemoClient - wemo-client module + * @param {{ info, warn, error }} deps.log - Homebridge log object + */ + constructor({ store, wemoClient, log }) { + this._store = store; + this._wemo = wemoClient; + this._log = log ?? console; + + this._schedule = []; // pre-computed time entries for Schedule/Countdown rules + this._awayLoops = new Map(); // ruleId → away-loop state for active Away Mode rules + this._firedToday = new Set(); // prevent double-firing within a tick window + this._timers = []; + this._tickTimer = null; + this._running = false; + this._lastDate = null; + this._onFire = null; // ({success, msg, entry}) notification callback + this._lastFireMsg = null; // last fire event for heartbeat + this._onStatus = null; // (statusObj) status callback + this._onHealth = null; // ({host, port, name, online, msg}) health event callback + this._deviceHealth = new Map(); // 'host:port' → true | false + this._triggerStates = new Map(); // 'host:port' → last known boolean state (for Trigger rules) + this._healthTimer = null; + this._startedAt = null; + } + + // ── Public API ──────────────────────────────────────────────────────────── + + isRunning() { return this._running; } + + // Internal helper — records every fire event then forwards to caller + _emit(event) { + this._lastFireMsg = { msg: event.msg, success: event.success, at: new Date().toISOString() }; + this._onFire?.(event); + } + + onFire(cb) { this._onFire = cb; } + onStatus(cb) { this._onStatus = cb; } + onHealth(cb) { this._onHealth = cb; } + + getHealthStatus() { + const out = {}; + for (const [key, online] of this._deviceHealth) out[key] = online; + return out; + } + + async start() { + if (this._running) this._clearTimers(); + this._running = true; + this._startedAt = new Date(); + this._firedToday = new Set(); + + this._loadSchedule(); + this._resumeAwayLoops(); + this._catchUpMissedRules(); + this._tick(); + this._startHealthMonitor(); + + const status = this._buildStatus(); + this._onStatus?.(status); + this._log.info?.('[DWM Scheduler] Started — ' + this._schedule.length + ' schedule entries loaded'); + return status; + } + + stop() { + this._running = false; + this._clearTimers(); + this._stopAllAwayLoops(false); + this._stopHealthMonitor(); + this._schedule = []; + this._firedToday = new Set(); + this._lastDate = null; + this._deviceHealth = new Map(); + this._triggerStates = new Map(); + this._log.info?.('[DWM Scheduler] Stopped'); + return { running: false }; + } + + reload() { + if (!this._running) return; + this._stopAllAwayLoops(false); + this._loadSchedule(); + this._catchUpMissedRules(); + this._scheduleUpcoming(); + this._resumeAwayLoops(); + const status = this._buildStatus(); + this._onStatus?.(status); + this._log.info?.('[DWM Scheduler] Reloaded — ' + this._schedule.length + ' schedule entries'); + return status; + } + + getStatus() { return this._buildStatus(); } + + // ── Schedule loading ────────────────────────────────────────────────────── + + _loadSchedule() { + const schedule = []; + const rules = this._store.getDwmRules(); + + for (const rule of rules) { + if (!rule.enabled) continue; + + // ── AlwaysOn / Trigger — handled entirely by the health-monitor poll ── + if (rule.type === 'AlwaysOn' || rule.type === 'Trigger') continue; + + // Away Mode + if (rule.type === 'Away') { + const startSecs = Number(rule.startTime ?? -1); + const endSecs = Number(rule.endTime ?? -1); + if (startSecs < 0) continue; + + for (const dayId of (rule.days ?? [])) { + const td0 = rule.targetDevices?.[0]; + schedule.push({ + ruleId: rule.id, ruleName: rule.name, + targetHost: td0?.host ?? '', targetPort: td0?.port ?? 0, + dayId: Number(dayId), targetSecs: startSecs, + action: 1, isAwayStart: true, + }); + if (endSecs >= 0) { + schedule.push({ + ruleId: rule.id + '-away-end', ruleName: rule.name, + targetHost: td0?.host ?? '', targetPort: td0?.port ?? 0, + dayId: Number(dayId), targetSecs: endSecs, + action: 0, isAwayEnd: true, awayRuleId: rule.id, + }); + } + } + continue; + } + + // Countdown with active window + if (rule.type === 'Countdown') { + const windowStart = Number(rule.windowStart ?? -1); + const windowEnd = Number(rule.windowEnd ?? -1); + if (windowStart < 0 || !(rule.windowDays?.length)) continue; + + const crossesMidnight = windowEnd >= 0 && windowEnd < windowStart; + for (const dayId of rule.windowDays) { + for (const td of (rule.targetDevices ?? [])) { + if (!td.host || !td.port) continue; + schedule.push({ ruleId: rule.id, ruleName: rule.name, + targetHost: td.host, targetPort: td.port, + dayId: Number(dayId), targetSecs: windowStart, action: 1 }); + if (windowEnd >= 0) { + const offDayId = crossesMidnight ? (Number(dayId) % 7) + 1 : Number(dayId); + schedule.push({ ruleId: rule.id + '-wend', ruleName: rule.name, + targetHost: td.host, targetPort: td.port, + dayId: offDayId, targetSecs: windowEnd, action: 0 }); + } + } + } + continue; + } + + // Schedule / time-based + const startSecs = Number(rule.startTime ?? -1); + const endSecs = Number(rule.endTime ?? -1); + const startAction = Number(rule.startAction ?? 1); + const endAction = Number(rule.endAction ?? -1); + if (startSecs < 0) continue; + + for (const dayId of (rule.days ?? [])) { + for (const td of (rule.targetDevices ?? [])) { + if (!td.host || !td.port) continue; + if (startAction >= 0) { + schedule.push({ ruleId: rule.id, ruleName: rule.name, + targetHost: td.host, targetPort: td.port, + dayId: Number(dayId), targetSecs: startSecs, action: startAction }); + } + if (endSecs > 0 && endAction >= 0) { + schedule.push({ ruleId: rule.id, ruleName: rule.name, + targetHost: td.host, targetPort: td.port, + dayId: Number(dayId), targetSecs: endSecs, action: endAction }); + } + } + } + } + + this._schedule = schedule; + this._lastDate = new Date().toDateString(); + } + + // ── Away Mode loop ──────────────────────────────────────────────────────── + + _resumeAwayLoops() { + if (!this._running) return; + const now = new Date(); + const nowSecs = secondsFromMidnight(now); + const todayId = jsToWemoDayId(now.getDay()); + const rules = this._store.getDwmRules(); + + for (const rule of rules) { + if (!rule.enabled || rule.type !== 'Away') continue; + if (this._awayLoops.has(rule.id)) continue; + + const startSecs = Number(rule.startTime ?? -1); + const endSecs = Number(rule.endTime ?? -1); + if (startSecs < 0) continue; + if (!(rule.days ?? []).includes(todayId)) continue; + + const inWindow = endSecs >= 0 + ? (startSecs <= endSecs ? (nowSecs >= startSecs && nowSecs < endSecs) + : (nowSecs >= startSecs || nowSecs < endSecs)) + : nowSecs >= startSecs; + + if (inWindow) this._startAwayLoop(rule); + } + } + + _startAwayLoop(rule) { + const existing = this._awayLoops.get(rule.id); + if (existing?.timer) clearTimeout(existing.timer); + + const devices = (rule.targetDevices ?? []).filter(td => td.host && td.port); + if (!devices.length) return; + + const loop = { rule, devices, endSecs: Number(rule.endTime ?? -1), timer: null, isOn: false }; + this._awayLoops.set(rule.id, loop); + this._awayStep(rule.id, true); + } + + _awayStep(ruleId, turnOn) { + if (!this._running) return; + const loop = this._awayLoops.get(ruleId); + if (!loop) return; + + const nowSecs = secondsFromMidnight(new Date()); + if (loop.endSecs >= 0 && nowSecs >= loop.endSecs) { + this._stopAwayLoop(ruleId, true); + return; + } + + loop.isOn = turnOn; + for (const td of loop.devices) { + this._wemo.setBinaryState(td.host, td.port, turnOn) + .then(() => { + this._emit({ success: true, + msg: `"${loop.rule.name}" Away → ${turnOn ? 'ON' : 'OFF'} (${td.host}) ✓`, + entry: { action: turnOn ? 1 : 0 } }); + }) + .catch((e) => { + this._emit({ success: false, + msg: `"${loop.rule.name}" Away → ${turnOn ? 'ON' : 'OFF'} FAILED (${td.host}): ${e.message}`, + entry: { action: turnOn ? 1 : 0 } }); + }); + } + + const delaySecs = turnOn ? randBetween(30, 90) * 60 : randBetween(1, 15) * 60; + if (loop.endSecs >= 0) { + const remaining = loop.endSecs - nowSecs; + if (delaySecs >= remaining) return; + } + loop.timer = setTimeout(() => this._awayStep(ruleId, !turnOn), delaySecs * 1000); + } + + _stopAwayLoop(ruleId, forceOff) { + const loop = this._awayLoops.get(ruleId); + if (!loop) return; + if (loop.timer) clearTimeout(loop.timer); + this._awayLoops.delete(ruleId); + if (forceOff) { + for (const td of loop.devices) { + this._wemo.setBinaryState(td.host, td.port, false).catch(() => {}); + } + this._emit({ success: true, + msg: `"${loop.rule.name}" Away Mode window ended — all devices OFF`, + entry: { action: 0 } }); + } + } + + _stopAllAwayLoops(forceOff) { + for (const [id] of this._awayLoops) this._stopAwayLoop(id, forceOff); + } + + // ── Tick / scheduling ───────────────────────────────────────────────────── + + _tick() { + if (!this._running) return; + + // Always reschedule FIRST — even if something below throws, the next tick + // still runs. Clears any previous timer so we don't double-fire. + if (this._tickTimer) clearTimeout(this._tickTimer); + this._tickTimer = setTimeout(() => this._tick(), 30_000); + + try { + const now = new Date(); + const today = now.toDateString(); + + if (today !== this._lastDate) { + // Day rolled over — full reset + this._firedToday = new Set(); + this._stopAllAwayLoops(false); + this._loadSchedule(); + this._resumeAwayLoops(); + this._onStatus?.(this._buildStatus()); + } else { + // Reload rules on every tick so newly created/edited rules are picked up + // without requiring a Homebridge restart. _firedToday prevents double-firing. + this._loadSchedule(); + } + + this._scheduleUpcoming(); + this._writeHeartbeat(); + } catch (e) { + this._log.error?.('[DWM Scheduler] Tick error (scheduler still running): ' + e.message); + } + } + + _clearTimers() { + for (const t of this._timers) clearTimeout(t); + this._timers = []; + if (this._tickTimer) { clearTimeout(this._tickTimer); this._tickTimer = null; } + } + + _scheduleUpcoming() { + if (!this._running) return; + const now = new Date(); + const nowSecs = secondsFromMidnight(now); + const todayId = jsToWemoDayId(now.getDay()); + const dayStart = new Date(now); dayStart.setHours(0, 0, 0, 0); + const windowEnd = nowSecs + 65; + + for (const entry of this._schedule) { + if (entry.dayId !== todayId) continue; + if (entry.targetSecs < nowSecs - 5) continue; + if (entry.targetSecs > windowEnd) continue; + + const key = `${entry.ruleId}-${entry.dayId}-${entry.targetSecs}-${entry.targetHost}`; + if (this._firedToday.has(key)) continue; + this._firedToday.add(key); + + const fireAt = dayStart.getTime() + entry.targetSecs * 1000; + const delay = Math.max(0, fireAt - Date.now()); + const t = setTimeout(() => this._fire(entry), delay); + this._timers.push(t); + } + } + + async _fire(entry) { + if (entry.isAwayStart) { + const rule = this._store.getDwmRules().find(r => r.id === entry.ruleId); + if (rule && rule.enabled) { + this._startAwayLoop(rule); + this._emit({ success: true, msg: `"${entry.ruleName}" Away Mode started`, entry }); + } + return; + } + + if (entry.isAwayEnd) { + this._stopAwayLoop(entry.awayRuleId, true); + return; + } + + const label = actionLabel(entry.action); + const wantOn = entry.action === 1; + try { + await this._wemo.setBinaryState(entry.targetHost, entry.targetPort, wantOn); + + await new Promise((r) => setTimeout(r, 3000)); + let confirmed = true; + try { + const state = await this._wemo.getBinaryState(entry.targetHost, entry.targetPort); + confirmed = (!!state) === wantOn; + } catch { confirmed = null; } + + const suffix = confirmed === null ? ' (unverified)' : confirmed ? ' ✓' : ' ⚠ retrying'; + this._emit({ success: true, + msg: `"${entry.ruleName}" → ${label} (${entry.targetHost})${suffix}`, entry }); + + if (confirmed === false) { + await new Promise((r) => setTimeout(r, 5000)); + try { + await this._wemo.setBinaryState(entry.targetHost, entry.targetPort, wantOn); + this._emit({ success: true, msg: `"${entry.ruleName}" → ${label} retry OK`, entry }); + } catch { /* silent */ } + } + } catch (e) { + this._emit({ success: false, + msg: `"${entry.ruleName}" → ${label} FAILED: ${e.message}`, entry }); + } + } + + // ── Missed-rule catch-up ────────────────────────────────────────────────── + + /** + * On start, fire any Schedule/Countdown entries whose time fell within the + * last CATCHUP_WINDOW_S seconds (i.e. Homebridge was restarting when they + * were supposed to run). Away Mode windows are handled by _resumeAwayLoops. + */ + _catchUpMissedRules() { + if (!this._schedule.length) return; + + const now = new Date(); + const nowSecs = secondsFromMidnight(now); + const todayId = jsToWemoDayId(now.getDay()); + const missed = []; + + for (const entry of this._schedule) { + if (entry.isAwayStart || entry.isAwayEnd) continue; + if (entry.dayId !== todayId) continue; + + const age = nowSecs - entry.targetSecs; + if (age <= 0 || age > CATCHUP_WINDOW_S) continue; + + const key = `${entry.ruleId}-${entry.dayId}-${entry.targetSecs}-${entry.targetHost}`; + if (this._firedToday.has(key)) continue; + + missed.push({ entry, key }); + } + + for (const { entry, key } of missed) { + this._firedToday.add(key); + this._emit({ success: true, + msg: `[catch-up] "${entry.ruleName}" → ${actionLabel(entry.action)} (${entry.targetHost})`, entry }); + this._fire(entry); + } + + if (missed.length) { + this._onStatus?.(this._buildStatus()); + } + } + + // ── Health monitor ──────────────────────────────────────────────────────── + + _startHealthMonitor() { + if (this._healthTimer) return; + // Small initial delay so start() returns quickly before first poll + this._healthTimer = setTimeout(() => this._pollDeviceHealth(), 15_000); + } + + _stopHealthMonitor() { + if (this._healthTimer) { clearTimeout(this._healthTimer); this._healthTimer = null; } + } + + /** + * Collect every unique host:port referenced in enabled DWM rules, + * probe each one, track online/offline state, and emit _onHealth events + * on transitions. When a device comes back online, enforce the state + * it should currently be in according to the active schedule. + */ + async _pollDeviceHealth() { + if (!this._running) return; + + // Build device map: all targets + trigger source devices + const deviceMap = new Map(); // 'host:port' → { host, port, name } + const allRules = this._store.getDwmRules(); + const alwaysOnSet = new Set(); // keys with an active AlwaysOn rule + const triggerSrcSet = new Set(); // keys that are trigger source devices + + const addDev = (td) => { + if (!td?.host || !td?.port) return; + const key = `${td.host}:${td.port}`; + if (!deviceMap.has(key)) + deviceMap.set(key, { host: td.host, port: Number(td.port), name: td.name ?? td.host }); + return key; + }; + + for (const rule of allRules) { + if (!rule.enabled) continue; + if (rule.type === 'Trigger') { + const k = addDev(rule.triggerDevice); + if (k) triggerSrcSet.add(k); + for (const td of (rule.actionDevices ?? [])) addDev(td); + continue; + } + for (const td of (rule.targetDevices ?? [])) { + const k = addDev(td); + if (k && rule.type === 'AlwaysOn') alwaysOnSet.add(k); + } + } + + for (const [key, dev] of deviceMap) { + const wasOnline = this._deviceHealth.get(key); // undefined = first check + + try { + const isOn = await this._wemo.getBinaryState(dev.host, dev.port); + + if (wasOnline === false) { + // ── Just came back online ────────────────────────────────────── + this._deviceHealth.set(key, true); + this._onHealth?.({ ...dev, online: true, + msg: `${dev.name} came back online` }); + await this._enforceCurrentState(dev); + } else { + this._deviceHealth.set(key, true); + if (wasOnline === undefined) { + this._onHealth?.({ ...dev, online: true, msg: `${dev.name} online` }); + } + } + + // ── AlwaysOn enforcement ────────────────────────────────────────── + if (alwaysOnSet.has(key) && !isOn) { + try { + await this._wemo.setBinaryState(dev.host, dev.port, true); + this._emit({ success: true, + msg: `[always-on] ${dev.name} was OFF — turned ON ✓` }); + } catch (e) { + this._emit({ success: false, + msg: `[always-on] ${dev.name} turn-ON failed: ${e.message}` }); + } + } + + // ── Trigger detection — fire rules if this device changed state ── + if (triggerSrcSet.has(key)) { + const prevState = this._triggerStates.get(key); + this._triggerStates.set(key, isOn); + if (prevState !== undefined && prevState !== isOn) { + await this._fireTriggerRules(key, isOn); + } + } + + } catch (e) { + this._deviceHealth.set(key, false); + if (wasOnline !== false) { + this._onHealth?.({ ...dev, online: false, + msg: `${dev.name} unreachable: ${e.message}` }); + } + } + } + + // Schedule next poll + if (this._running) { + this._healthTimer = setTimeout(() => this._pollDeviceHealth(), HEALTH_POLL_MS); + } + } + + /** + * For a device that just came back online, find the most recent Schedule + * entry that should have fired today and push that state to the device. + */ + async _enforceCurrentState(dev) { + const now = new Date(); + const nowSecs = secondsFromMidnight(now); + const todayId = jsToWemoDayId(now.getDay()); + + let best = null; + for (const entry of this._schedule) { + if (entry.isAwayStart || entry.isAwayEnd) continue; + if (entry.targetHost !== dev.host) continue; + if (entry.dayId !== todayId) continue; + if (entry.targetSecs > nowSecs) continue; + + if (!best || entry.targetSecs > best.targetSecs) best = entry; + } + + if (!best) return; + + const wantOn = best.action === 1; + try { + await this._wemo.setBinaryState(dev.host, dev.port, wantOn); + this._emit({ + success: true, + msg: `[enforce] "${best.ruleName}" → ${actionLabel(best.action)} restored on ${dev.name} ✓`, + entry: best, + }); + } catch (e) { + this._emit({ + success: false, + msg: `[enforce] "${best.ruleName}" → ${actionLabel(best.action)} FAILED on ${dev.name}: ${e.message}`, + entry: best, + }); + } + } + + // ── Trigger rules ───────────────────────────────────────────────────────── + + /** + * A trigger device changed state. Find every enabled Trigger rule whose + * triggerDevice matches sourceKey and whose triggerEvent matches, then + * fire the action on each actionDevice. + * + * triggerEvent: 'on' | 'off' | 'any' + * action: 'on' | 'off' | 'mirror' | 'opposite' + */ + async _fireTriggerRules(sourceKey, isOn) { + const rules = this._store.getDwmRules().filter((r) => + r.enabled && + r.type === 'Trigger' && + r.triggerDevice?.host && + `${r.triggerDevice.host}:${r.triggerDevice.port}` === sourceKey + ); + + for (const rule of rules) { + const matches = + rule.triggerEvent === 'any' || + (rule.triggerEvent === 'on' && isOn) || + (rule.triggerEvent === 'off' && !isOn); + if (!matches) continue; + + let targetOn; + if (rule.action === 'on') targetOn = true; + else if (rule.action === 'off') targetOn = false; + else if (rule.action === 'mirror') targetOn = isOn; + else if (rule.action === 'opposite') targetOn = !isOn; + else continue; + + for (const dev of (rule.actionDevices ?? [])) { + if (!dev.host || !dev.port) continue; + try { + await this._wemo.setBinaryState(dev.host, Number(dev.port), targetOn); + this._emit({ success: true, + msg: `[trigger] "${rule.name}" → ${dev.name ?? dev.host} ${targetOn ? 'ON' : 'OFF'} ✓` }); + } catch (e) { + this._emit({ success: false, + msg: `[trigger] "${rule.name}" → ${dev.name ?? dev.host} FAILED: ${e.message}` }); + } + } + } + } + + // ── Heartbeat ───────────────────────────────────────────────────────────── + + _writeHeartbeat() { + try { + const status = this._buildStatus(); + const lastFire = this._lastFireMsg ?? null; + this._store.saveHeartbeat({ + running: true, + startedAt: this._startedAt?.toISOString() ?? null, + totalEntries: status.totalEntries, + upcoming: status.upcoming.slice(0, 3), + lastFire, + }); + } catch { /* non-critical */ } + } + + // ── Status ──────────────────────────────────────────────────────────────── + + _buildStatus() { + const now = new Date(); + const nowSecs = secondsFromMidnight(now); + const todayId = jsToWemoDayId(now.getDay()); + + const awayActive = []; + for (const [, loop] of this._awayLoops) { + awayActive.push({ ruleName: loop.rule.name, action: loop.isOn ? 'ON (Away)' : 'OFF (Away)', at: 'now' }); + } + + const seen = new Set(); + const upcoming = this._schedule + .filter(e => e.dayId === todayId && e.targetSecs > nowSecs && !e.isAwayEnd) + .sort((a, b) => a.targetSecs - b.targetSecs) + .reduce((acc, e) => { + const key = `${e.ruleId}|${e.targetSecs}|${e.action}|${e.targetHost}`; + if (!seen.has(key)) { + seen.add(key); + acc.push({ + ruleName: e.ruleName, targetHost: e.targetHost, + action: e.isAwayStart ? 'Away Mode start' : actionLabel(e.action), + at: secsToHHMM(e.targetSecs), + }); + } + return acc; + }, []) + .slice(0, 8); + + return { + running: this._running, + totalEntries: this._schedule.length, + awayActive, + upcoming: [...awayActive, ...upcoming].slice(0, 8), + }; + } +} + +module.exports = DwmScheduler; diff --git a/packages/homebridge-plugin/lib/store.js b/packages/homebridge-plugin/lib/store.js new file mode 100644 index 0000000..7e06348 --- /dev/null +++ b/packages/homebridge-plugin/lib/store.js @@ -0,0 +1,118 @@ +'use strict'; + +/** + * DWM Store — Homebridge edition. + * + * Stores devices, DWM rules, and location in a single JSON file inside + * Homebridge's storagePath (passed in at construction time, not via Electron). + * + * Schema mirrors the desktop store exactly so DWM rules created in the desktop + * app can be imported / shared. + */ + +const path = require('path'); +const fs = require('fs'); + +const DEFAULTS = { + location: null, + devices: [], + deviceGroups: [], + deviceOrder: [], + disabledRules: {}, + dwmRules: [], + schedulerHeartbeat: null, +}; + +class DwmStore { + constructor(storagePath) { + this._filePath = path.join(storagePath, 'dibby-wemo.json'); + } + + // ── Internal I/O ────────────────────────────────────────────────────────── + + _load() { + try { + return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(this._filePath, 'utf8')) }; + } catch { + return { ...DEFAULTS }; + } + } + + _save(data) { + fs.writeFileSync(this._filePath, JSON.stringify(data, null, 2), 'utf8'); + } + + // ── Location ────────────────────────────────────────────────────────────── + + getLocation() { return this._load().location; } + setLocation(loc) { const d = this._load(); d.location = loc; this._save(d); } + + // ── Devices ─────────────────────────────────────────────────────────────── + + getDevices() { return this._load().devices ?? []; } + saveDevices(list) { const d = this._load(); d.devices = list; this._save(d); } + getDeviceOrder() { return this._load().deviceOrder ?? []; } + saveDeviceOrder(order) { const d = this._load(); d.deviceOrder = order; this._save(d); } + getDeviceGroups() { return this._load().deviceGroups ?? []; } + saveDeviceGroups(groups) { const d = this._load(); d.deviceGroups = groups; this._save(d); } + + // ── Disabled-rule backups ───────────────────────────────────────────────── + + getDisabledRules() { return this._load().disabledRules ?? {}; } + setDisabledRule(key, ruleDevicesRows) { + const d = this._load(); + if (!d.disabledRules) d.disabledRules = {}; + d.disabledRules[key] = ruleDevicesRows; + this._save(d); + } + clearDisabledRule(key) { + const d = this._load(); + if (!d.disabledRules) return; + delete d.disabledRules[key]; + this._save(d); + } + + // ── DWM Rules ───────────────────────────────────────────────────────────── + + getDwmRules() { return this._load().dwmRules ?? []; } + + createDwmRule(rule) { + const d = this._load(); + if (!d.dwmRules) d.dwmRules = []; + const id = `dwm-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const now = new Date().toISOString(); + const newRule = { ...rule, id, createdAt: now, updatedAt: now }; + d.dwmRules.push(newRule); + this._save(d); + return newRule; + } + + updateDwmRule(id, updates) { + const d = this._load(); + if (!d.dwmRules) d.dwmRules = []; + const idx = d.dwmRules.findIndex((r) => r.id === id); + if (idx === -1) throw new Error(`DWM rule not found: ${id}`); + d.dwmRules[idx] = { ...d.dwmRules[idx], ...updates, id, updatedAt: new Date().toISOString() }; + this._save(d); + return d.dwmRules[idx]; + } + + deleteDwmRule(id) { + const d = this._load(); + if (!d.dwmRules) return; + d.dwmRules = d.dwmRules.filter((r) => r.id !== id); + this._save(d); + } + + // ── Scheduler heartbeat ─────────────────────────────────────────────────── + + getHeartbeat() { return this._load().schedulerHeartbeat ?? null; } + + saveHeartbeat(hb) { + const d = this._load(); + d.schedulerHeartbeat = { ...hb, ts: new Date().toISOString() }; + this._save(d); + } +} + +module.exports = DwmStore; diff --git a/packages/homebridge-plugin/lib/sun.js b/packages/homebridge-plugin/lib/sun.js new file mode 100644 index 0000000..feaa5f1 --- /dev/null +++ b/packages/homebridge-plugin/lib/sun.js @@ -0,0 +1,81 @@ +'use strict'; + +/** + * Calculate sunrise and sunset times for a given location and date. + * Pure JS – no external dependencies. + * + * Algorithm: NOAA Solar Calculator (Jean Meeus, Astronomical Algorithms). + * + * @param {number} lat Latitude in decimal degrees (positive = North) + * @param {number} lng Longitude in decimal degrees (positive = East) + * @param {Date} date Date to calculate for (default: today) + * @returns {{ sunrise: number|null, sunset: number|null }} + * Times as integer seconds from LOCAL midnight. + * null for each value if polar day or polar night. + */ +function sunTimes(lat, lng, date = new Date()) { + const D2R = Math.PI / 180; + const R2D = 180 / Math.PI; + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const A = Math.floor((14 - month) / 12); + const Y = year + 4800 - A; + const M = month + 12 * A - 3; + const JDN = day + Math.floor((153 * M + 2) / 5) + 365 * Y + + Math.floor(Y / 4) - Math.floor(Y / 100) + Math.floor(Y / 400) - 32045; + const JD = JDN - 0.5; + + const T = (JD - 2451545.0) / 36525.0; + + let L0 = 280.46646 + T * (36000.76983 + T * 0.0003032); + L0 = ((L0 % 360) + 360) % 360; + + let Mdeg = 357.52911 + T * (35999.05029 - 0.0001537 * T); + Mdeg = ((Mdeg % 360) + 360) % 360; + const Mrad = Mdeg * D2R; + + const C = (1.914602 - T * (0.004817 + 0.000014 * T)) * Math.sin(Mrad) + + (0.019993 - 0.000101 * T) * Math.sin(2 * Mrad) + + 0.000289 * Math.sin(3 * Mrad); + + const omega = 125.04 - 1934.136 * T; + const lambda = (L0 + C) - 0.00569 - 0.00478 * Math.sin(omega * D2R); + + const eps0 = 23.0 + + (26.0 + (21.448 - T * (46.8150 + T * (0.00059 - T * 0.001813))) / 60.0) / 60.0; + const eps = (eps0 + 0.00256 * Math.cos(omega * D2R)) * D2R; + + const sinDec = Math.sin(eps) * Math.sin(lambda * D2R); + const decl = Math.asin(sinDec); + + const e = 0.016708634 - T * (0.000042037 + 0.0000001267 * T); + const y = Math.pow(Math.tan(eps / 2), 2); + const EqT = 4 * R2D * ( + y * Math.sin(2 * L0 * D2R) + - 2 * e * Math.sin(Mrad) + + 4 * e * y * Math.sin(Mrad) * Math.cos(2 * L0 * D2R) + - 0.5 * y * y * Math.sin(4 * L0 * D2R) + - 1.25 * e * e * Math.sin(2 * Mrad) + ); + + const cosHA = (Math.cos(90.833 * D2R) - Math.sin(lat * D2R) * sinDec) + / (Math.cos(lat * D2R) * Math.cos(decl)); + + if (cosHA < -1 || cosHA > 1) { + return { sunrise: null, sunset: null }; + } + + const HA = Math.acos(cosHA) * R2D; + const tzOffsetMin = -date.getTimezoneOffset(); + const solarNoon = 720.0 - 4.0 * lng - EqT + tzOffsetMin; + + return { + sunrise: Math.round((solarNoon - HA * 4.0) * 60), + sunset: Math.round((solarNoon + HA * 4.0) * 60), + }; +} + +module.exports = { sunTimes }; diff --git a/packages/homebridge-plugin/lib/types.js b/packages/homebridge-plugin/lib/types.js new file mode 100644 index 0000000..9c4691f --- /dev/null +++ b/packages/homebridge-plugin/lib/types.js @@ -0,0 +1,76 @@ +'use strict'; + +/** Day numbers: 1=Monday ... 7=Sunday (Wemo convention) */ +const DAY_NUMBERS = { Monday:1, Tuesday:2, Wednesday:3, Thursday:4, Friday:5, Saturday:6, Sunday:7 }; +const DAY_NAMES = { 1:'Monday', 2:'Tuesday', 3:'Wednesday', 4:'Thursday', 5:'Friday', 6:'Saturday', 7:'Sunday' }; +const DAY_SHORT = { 1:'Mon', 2:'Tue', 3:'Wed', 4:'Thu', 5:'Fri', 6:'Sat', 7:'Sun' }; + +/** Rule types stored in RULES.Type */ +const RULE_TYPES = { + SCHEDULE: 'Schedule', + AWAY: 'Away', + COUNTDOWN: 'Countdown', + LONG_PRESS: 'Long Press', +}; + +/** Start/End action values */ +const ACTIONS = { ON: 1.0, OFF: 0.0, TOGGLE: 2.0, NONE: -1.0 }; + +/** Network status codes returned by GetNetworkStatus */ +const NETWORK_STATUS = { FAILED: '0', SUCCESS: '1', WRONG_PASSWORD: '2', CONNECTING: '3' }; + +/** Wemo device reset codes for ReSetup action */ +const RESET_CODES = { CLEAR_DATA: 1, FACTORY_RESET: 2, CLEAR_WIFI: 5 }; + +/** Default RULEDEVICES field values */ +const RD_DEFAULTS = { + GroupID: 0, + RuleDuration: 0, + StartAction: 1.0, + EndAction: -1.0, + SensorDuration: 2, + Type: -1, + Value: -1, + Level: -1, + ZBCapabilityStart: '', + ZBCapabilityEnd: '', + OnModeOffset: -1, + OffModeOffset: -1, + CountdownTime: 0, + EndTime: -1, +}; + +/** Sun time sentinel codes stored in RULEDEVICES.StartTime / EndTime */ +const SUN_CODES = { SUNRISE: -2, SUNSET: -3 }; + +function namesToDayNumbers(names) { + return names.map((n) => DAY_NUMBERS[n]).filter(Boolean).sort((a, b) => a - b); +} + +function dayNumbersToNames(numbers) { + return numbers.map((n) => DAY_NAMES[n]).filter(Boolean); +} + +function dayNumbersToShort(numbers) { + return numbers.map((n) => DAY_SHORT[n]).filter(Boolean); +} + +function timeToSecs(hhmm) { + if (!hhmm || !hhmm.includes(':')) return 0; + const [h, m] = hhmm.split(':').map(Number); + return h * 3600 + m * 60; +} + +function secsToHHMM(secs) { + if (secs === undefined || secs === null || secs < 0) return '00:00'; + const h = Math.floor(secs / 3600) % 24; + const m = Math.floor((secs % 3600) / 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +module.exports = { + DAY_NUMBERS, DAY_NAMES, DAY_SHORT, + RULE_TYPES, ACTIONS, NETWORK_STATUS, RESET_CODES, RD_DEFAULTS, SUN_CODES, + namesToDayNumbers, dayNumbersToNames, dayNumbersToShort, + timeToSecs, secsToHHMM, +}; diff --git a/packages/homebridge-plugin/lib/wemo-client.js b/packages/homebridge-plugin/lib/wemo-client.js new file mode 100644 index 0000000..89a403e --- /dev/null +++ b/packages/homebridge-plugin/lib/wemo-client.js @@ -0,0 +1,489 @@ +'use strict'; + +/** + * Wemo SOAP client + SSDP discovery + rules CRUD. + * + * Self-contained: no Electron, no store dependency. + * Adapted from apps/desktop/src/main/wemo.js — same protocol, same SQL schema. + */ + +const dgram = require('dgram'); +const path = require('path'); +const http = require('http'); +const axios = require('axios'); +const AdmZip = require('adm-zip'); +const { parseStringPromise } = require('xml2js'); +const { create } = require('xmlbuilder2'); + +// Core helpers — bundled locally so the plugin is self-contained +const { namesToDayNumbers, timeToSecs } = require('./types'); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const NO_KEEPALIVE = new http.Agent({ keepAlive: false }); +const WEMO_PORTS = [49153, 49152, 49154, 49155, 49156]; +const BE_SVC = 'urn:Belkin:service:basicevent:1'; +const BE_URL = '/upnp/control/basicevent1'; +const TS_SVC = 'urn:Belkin:service:timesync:1'; +const TS_URL = '/upnp/control/timesync1'; +const RULES_SVC = 'urn:Belkin:service:rules:1'; +const RULES_URL = '/upnp/control/rules1'; + +const RULE_TYPE_TO_DEVICE = { + 'Schedule': 'Time Interval', + 'Countdown': 'Countdown Rule', + 'Away': 'Away Mode', +}; + +// --------------------------------------------------------------------------- +// sql.js (WASM SQLite) +// --------------------------------------------------------------------------- + +let SQL = null; + +async function getSql(log) { + if (!SQL) { + const fs = require('fs'); + const initSqlJs = require('sql.js'); + + const candidates = [ + path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'), + path.join(__dirname, '..', '..', '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'), + path.join(__dirname, 'sql-wasm.wasm'), + ]; + + let wasmBinary = null; + for (const p of candidates) { + try { wasmBinary = fs.readFileSync(p); break; } catch { /* try next */ } + } + if (!wasmBinary) { + throw new Error(`sql-wasm.wasm not found. Tried:\n${candidates.join('\n')}`); + } + SQL = await initSqlJs({ wasmBinary }); + } + return SQL; +} + +// --------------------------------------------------------------------------- +// SOAP helpers +// --------------------------------------------------------------------------- + +async function soapRequest(host, port, controlURL, serviceType, action, args = {}, timeoutMs = 10_000) { + const url = `http://${host}:${port}${controlURL}`; + const root = create({ version: '1.0', encoding: 'utf-8' }) + .ele('s:Envelope', { 'xmlns:s': 'http://schemas.xmlsoap.org/soap/envelope/', 's:encodingStyle': 'http://schemas.xmlsoap.org/soap/encoding/' }) + .ele('s:Body') + .ele(`u:${action}`, { [`xmlns:u`]: serviceType }); + for (const [k, v] of Object.entries(args)) root.ele(k).txt(v); + const xml = root.doc().end({ headless: false }); + + const res = await axios.post(url, xml, { + headers: { + 'Content-Type': 'text/xml; charset="utf-8"', + 'SOAPACTION': `"${serviceType}#${action}"`, + 'Connection': 'close', + }, + httpAgent: NO_KEEPALIVE, + timeout: timeoutMs, + }); + const parsed = await parseStringPromise(res.data, { explicitArray: false, ignoreAttrs: true }); + const body = parsed['s:Envelope']['s:Body']; + return body[`u:${action}Response`] ?? body; +} + +async function soapWithFallback(host, port, controlURL, serviceType, action, args = {}) { + const portsToTry = [port, ...WEMO_PORTS.filter((p) => p !== port)]; + let lastErr = null; + for (const tryPort of portsToTry) { + try { + return await soapRequest(host, tryPort, controlURL, serviceType, action, args); + } catch (err) { + lastErr = err; + const isConn = err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT'; + if (!isConn) throw err; + } + } + throw lastErr || new Error(`${host}: all ports failed for ${action}`); +} + +// --------------------------------------------------------------------------- +// Device control +// --------------------------------------------------------------------------- + +async function getBinaryState(host, port) { + const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetBinaryState'); + const raw = String(res['BinaryState'] ?? '0'); + return raw === '1' || raw === '8'; +} + +async function setBinaryState(host, port, on) { + await soapWithFallback(host, port, BE_URL, BE_SVC, 'SetBinaryState', { BinaryState: on ? '1' : '0' }); +} + +// --------------------------------------------------------------------------- +// Device info +// --------------------------------------------------------------------------- + +function resolveProductModel(udn, deviceType, firmwareSuffix) { + const udnBase = String(udn || '').replace(/^uuid:/i, ''); + const parts = udnBase.split('-'); + const udnPrefix = parts.slice(0, 2).join('-').toLowerCase(); + const udnType = parts[0].toLowerCase(); + const fwSuffix = String(firmwareSuffix || '').toUpperCase(); + const dt = String(deviceType || '').toLowerCase(); + + if (udnPrefix === 'lightswitch-3_0') return 'Wemo 3-Way Smart Switch (WLS0403)'; + if (udnPrefix === 'lightswitch-2_0') return 'Wemo Light Switch (WLS040)'; + if (udnPrefix === 'lightswitch-1_0') { + if (fwSuffix.includes('OWRT-LS')) return 'Wemo Light Switch (F7C030)'; + return 'Wemo Light Switch (WLS040)'; + } + if (udnType === 'dimmer' || dt.includes('dimmer') || fwSuffix.includes('WDS')) + return 'Wemo WiFi Smart Dimmer (WDS060)'; + if (udnType === 'insight' || dt.includes('insight')) return 'Wemo Insight Smart Plug (F7C029)'; + if (udnPrefix === 'socket-2_0') return 'Wemo Mini Smart Plug (F7C063)'; + if (udnPrefix === 'socket-1_0') { + if (fwSuffix.includes('OWRT-SNS')) return 'Wemo Switch (F7C027)'; + return 'Wemo Smart Plug'; + } + if (udnType === 'socket') return 'Wemo Smart Plug'; + return null; +} + +async function getDeviceInfo(host, port) { + const results = {}; + try { + const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetFriendlyName'); + results.friendlyName = String(res['FriendlyName'] ?? '').trim(); + } catch { results.friendlyName = null; } + + try { + const sx = await axios.get(`http://${host}:${port}/setup.xml`, { timeout: 5000, httpAgent: NO_KEEPALIVE }); + const fwMatch = sx.data.match(/([^<]+)<\/firmwareVersion>/i); + const udnMatch = sx.data.match(/([^<]+)<\/UDN>/i); + const dtMatch = sx.data.match(/([^<]+)<\/deviceType>/i); + const mdMatch = sx.data.match(/([^<]+)<\/modelDescription>/i); + results.firmwareVersion = fwMatch ? fwMatch[1].trim() : null; + results.modelDescription = mdMatch ? mdMatch[1].trim() : null; + if (udnMatch) { + results.udn = udnMatch[1].trim(); + const fw = results.firmwareVersion || ''; + const fwSuffix = fw.split('PVT-').pop() || ''; + results.productModel = resolveProductModel(results.udn, dtMatch ? dtMatch[1] : '', fwSuffix); + } + } catch { /* non-fatal */ } + return results; +} + +// --------------------------------------------------------------------------- +// SSDP Discovery +// --------------------------------------------------------------------------- + +function discoverDevices(timeoutMs = 10_000) { + return new Promise((resolve) => { + const SSDP_ADDR = '239.255.255.250'; + const SSDP_PORT = 1900; + const M_SEARCH = [ + 'M-SEARCH * HTTP/1.1', + `HOST: ${SSDP_ADDR}:${SSDP_PORT}`, + 'MAN: "ssdp:discover"', + 'MX: 3', + 'ST: urn:Belkin:device:**', + '', '', + ].join('\r\n'); + + const found = new Map(); + const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true }); + + sock.on('message', async (msg) => { + const text = msg.toString(); + const locMatch = text.match(/LOCATION:\s*(http:\/\/([^:]+):(\d+)\/setup\.xml)/i); + if (!locMatch) return; + const [, , ip, portStr] = locMatch; + const port = parseInt(portStr, 10); + const key = `${ip}:${port}`; + if (found.has(key)) return; + found.set(key, { host: ip, port, discovering: true }); + try { + const info = await getDeviceInfo(ip, port); + found.set(key, { host: ip, port, ...info }); + } catch { /* keep partial entry */ } + }); + + sock.bind(() => { + const buf = Buffer.from(M_SEARCH); + sock.send(buf, 0, buf.length, SSDP_PORT, SSDP_ADDR); + }); + + setTimeout(() => { + try { sock.close(); } catch { /* ignore */ } + resolve(Array.from(found.values())); + }, timeoutMs); + }); +} + +// --------------------------------------------------------------------------- +// Rules — fetch (ZIP + SQLite) +// --------------------------------------------------------------------------- + +async function fetchRules(host, port) { + const res = await soapWithFallback(host, port, RULES_URL, RULES_SVC, 'FetchRules'); + const version = String(res['ruleDbVersion'] ?? '0'); + const dbUrl = String(res['ruleDbPath'] ?? ''); + if (!dbUrl) throw new Error('FetchRules returned no ruleDbPath'); + + const dlRes = await axios.get(dbUrl, { responseType: 'arraybuffer', timeout: 15_000 }); + const zip = new AdmZip(Buffer.from(dlRes.data)); + const entry = zip.getEntries().find((e) => e.entryName.endsWith('.db')); + if (!entry) throw new Error('No .db file in rules ZIP'); + + const SQL = await getSql(); + const db = new SQL.Database(entry.getData()); + + const rules = _dbQuery(db, 'SELECT * FROM RULES'); + const ruleDevices = _dbQuery(db, 'SELECT * FROM RULEDEVICES'); + const targets = _dbQuery(db, 'SELECT * FROM TARGETDEVICES'); + db.close(); + + return { version, rules, ruleDevices, targets }; +} + +function _dbQuery(db, sql) { + const rows = []; + const stmt = db.prepare(sql); + while (stmt.step()) rows.push(stmt.getAsObject()); + stmt.free(); + return rows; +} + +// --------------------------------------------------------------------------- +// Rules — store (ZIP + CDATA encode) +// --------------------------------------------------------------------------- + +async function storeRules(host, port, version, dbBuffer) { + const zip = new AdmZip(); + zip.addFile('temppluginRules.db', dbBuffer); + const b64 = zip.toBuffer().toString('base64'); + + // CRITICAL: body must be entity-encoded CDATA — hand-crafted XML only + const soapXml = ` + + + + ${version} + NOSYNC + <![CDATA[${b64}]]> + + +`; + + const url = `http://${host}:${port}${RULES_URL}`; + const res = await axios.post(url, soapXml, { + headers: { + 'Content-Type': 'text/xml; charset="utf-8"', + 'SOAPACTION': `"${RULES_SVC}#StoreRules"`, + 'Connection': 'close', + }, + httpAgent: NO_KEEPALIVE, + timeout: 20_000, + }); + if (String(res.data).includes('failed')) throw new Error('StoreRules: device returned failure'); +} + +// --------------------------------------------------------------------------- +// Rules — create / update / delete / toggle +// --------------------------------------------------------------------------- + +async function createRule(host, port, ruleData) { + const SQL = await getSql(); + const { version, rules, ruleDevices, targets } = await fetchRules(host, port); + const db = new SQL.Database(); + _createSchema(db); + for (const r of rules) _insertRule(db, r); + for (const r of ruleDevices) _insertRuleDevice(db, r); + for (const r of targets) _insertTargetDevice(db, r); + + const newId = _nextRuleId(db); + _insertNewRule(db, newId, ruleData); + + const buf = Buffer.from(db.export()); + db.close(); + await storeRules(host, port, String(parseInt(version, 10) + 2), buf); + return newId; +} + +async function updateRule(host, port, ruleId, ruleData) { + const SQL = await getSql(); + const { version, rules, ruleDevices, targets } = await fetchRules(host, port); + const db = new SQL.Database(); + _createSchema(db); + for (const r of rules) _insertRule(db, r); + for (const r of ruleDevices) _insertRuleDevice(db, r); + for (const r of targets) _insertTargetDevice(db, r); + + db.run('DELETE FROM RULES WHERE RuleID = ?', [String(ruleId)]); + db.run('DELETE FROM RULEDEVICES WHERE RuleID = ?', [String(ruleId)]); + db.run('DELETE FROM TARGETDEVICES WHERE RuleID = ?', [String(ruleId)]); + _insertNewRule(db, ruleId, ruleData); + + const buf = Buffer.from(db.export()); + db.close(); + await storeRules(host, port, String(parseInt(version, 10) + 2), buf); +} + +async function deleteRule(host, port, ruleId) { + const SQL = await getSql(); + const { version, rules, ruleDevices, targets } = await fetchRules(host, port); + const db = new SQL.Database(); + _createSchema(db); + for (const r of rules) _insertRule(db, r); + for (const r of ruleDevices) _insertRuleDevice(db, r); + for (const r of targets) _insertTargetDevice(db, r); + + db.run('DELETE FROM RULES WHERE RuleID = ?', [String(ruleId)]); + db.run('DELETE FROM RULEDEVICES WHERE RuleID = ?', [String(ruleId)]); + db.run('DELETE FROM TARGETDEVICES WHERE RuleID = ?', [String(ruleId)]); + + const buf = Buffer.from(db.export()); + db.close(); + await storeRules(host, port, String(parseInt(version, 10) + 2), buf); +} + +async function toggleRule(host, port, ruleId, enabled) { + const SQL = await getSql(); + const { version, rules, ruleDevices, targets } = await fetchRules(host, port); + const db = new SQL.Database(); + _createSchema(db); + for (const r of rules) _insertRule(db, r); + for (const r of ruleDevices) _insertRuleDevice(db, r); + for (const r of targets) _insertTargetDevice(db, r); + + db.run('UPDATE RULES SET State = ? WHERE RuleID = ?', [enabled ? '1' : '0', String(ruleId)]); + + const buf = Buffer.from(db.export()); + db.close(); + await storeRules(host, port, String(parseInt(version, 10) + 2), buf); +} + +// --------------------------------------------------------------------------- +// SQLite helpers (schema + insert helpers) +// --------------------------------------------------------------------------- + +function _createSchema(db) { + db.run(`CREATE TABLE IF NOT EXISTS RULES ( + RuleID TEXT, Name TEXT, Type TEXT, RuleOrder INTEGER, + StartDate TEXT DEFAULT '12201982', EndDate TEXT DEFAULT '07301982', + State TEXT DEFAULT '1', Sync TEXT DEFAULT 'NOSYNC' + )`); + db.run(`CREATE TABLE IF NOT EXISTS RULEDEVICES ( + RuleDevicePK INTEGER PRIMARY KEY AUTOINCREMENT, + RuleID TEXT, DeviceID TEXT, GroupID INTEGER, DayID INTEGER, + StartTime INTEGER, RuleDuration INTEGER, StartAction INTEGER, EndAction INTEGER, + SensorDuration INTEGER, Type INTEGER, Value INTEGER, Level INTEGER, + ZBCapabilityStart TEXT, ZBCapabilityEnd TEXT, + OnModeOffset INTEGER, OffModeOffset INTEGER, CountdownTime INTEGER, EndTime INTEGER + )`); + db.run(`CREATE TABLE IF NOT EXISTS TARGETDEVICES ( + TargetDevicesPK INTEGER PRIMARY KEY AUTOINCREMENT, + RuleID TEXT, DeviceID TEXT, DeviceIndex INTEGER + )`); +} + +function _insertRule(db, r) { + db.run( + 'INSERT INTO RULES (RuleID,Name,Type,RuleOrder,StartDate,EndDate,State,Sync) VALUES (?,?,?,?,?,?,?,?)', + [r.RuleID, r.Name, r.Type, r.RuleOrder, r.StartDate ?? '12201982', r.EndDate ?? '07301982', r.State ?? '1', r.Sync ?? 'NOSYNC'] + ); +} + +function _insertRuleDevice(db, r) { + db.run( + `INSERT INTO RULEDEVICES (RuleID,DeviceID,GroupID,DayID,StartTime,RuleDuration,StartAction,EndAction, + SensorDuration,Type,Value,Level,ZBCapabilityStart,ZBCapabilityEnd, + OnModeOffset,OffModeOffset,CountdownTime,EndTime) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [r.RuleID, r.DeviceID, r.GroupID ?? 0, r.DayID, r.StartTime, r.RuleDuration ?? 0, + r.StartAction, r.EndAction ?? -1, r.SensorDuration ?? 0, r.Type ?? 0, r.Value ?? 0, + r.Level ?? 0, r.ZBCapabilityStart ?? '', r.ZBCapabilityEnd ?? '', + r.OnModeOffset ?? 0, r.OffModeOffset ?? 0, r.CountdownTime ?? 0, r.EndTime ?? -1] + ); +} + +function _insertTargetDevice(db, r) { + db.run( + 'INSERT INTO TARGETDEVICES (RuleID,DeviceID,DeviceIndex) VALUES (?,?,?)', + [r.RuleID, r.DeviceID, r.DeviceIndex ?? 0] + ); +} + +function _nextRuleId(db) { + const stmt = db.prepare('SELECT CAST(MAX(CAST(RuleID AS INTEGER)) AS INTEGER) AS mx FROM RULES'); + let mx = 0; + if (stmt.step()) { mx = stmt.getAsObject().mx ?? 0; } + stmt.free(); + return mx + 1; +} + +function _insertNewRule(db, ruleId, ruleData) { + // namesToDayNumbers + timeToSecs already required at top of file + const days = ruleData.days ?? []; + const dayNums = typeof days[0] === 'string' ? namesToDayNumbers(days) : days.map(Number); + const devId = ruleData.deviceId ?? ruleData.udn ?? ''; + const ruleType = RULE_TYPE_TO_DEVICE[ruleData.type] ?? ruleData.type ?? 'Time Interval'; + + let startSecs, endSecs; + if (ruleData.startTime != null) { + startSecs = typeof ruleData.startTime === 'string' + ? timeToSecs(ruleData.startTime) : Number(ruleData.startTime); + } else startSecs = 0; + + if (ruleData.endTime != null && ruleData.endTime !== '') { + endSecs = typeof ruleData.endTime === 'string' + ? timeToSecs(ruleData.endTime) : Number(ruleData.endTime); + } else endSecs = -1; + + const startAction = ruleData.startAction ?? 1; + const endAction = ruleData.endAction ?? -1; + + db.run( + 'INSERT INTO RULES (RuleID,Name,Type,RuleOrder,StartDate,EndDate,State,Sync) VALUES (?,?,?,?,?,?,?,?)', + [String(ruleId), ruleData.name ?? 'Rule', ruleType, ruleId, + '12201982', '07301982', ruleData.enabled !== false ? '1' : '0', 'NOSYNC'] + ); + + for (const dayId of dayNums) { + db.run( + `INSERT INTO RULEDEVICES (RuleID,DeviceID,GroupID,DayID,StartTime,RuleDuration,StartAction,EndAction, + SensorDuration,Type,Value,Level,ZBCapabilityStart,ZBCapabilityEnd, + OnModeOffset,OffModeOffset,CountdownTime,EndTime) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [String(ruleId), devId, 0, dayId, startSecs, 0, + startAction, endAction, 0, 0, 0, 0, '', '', + 0, 0, ruleData.countdownTime ?? 0, endSecs] + ); + } + + db.run( + 'INSERT INTO TARGETDEVICES (RuleID,DeviceID,DeviceIndex) VALUES (?,?,?)', + [String(ruleId), devId, 0] + ); +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +module.exports = { + getBinaryState, + setBinaryState, + getDeviceInfo, + discoverDevices, + fetchRules, + storeRules, + createRule, + updateRule, + deleteRule, + toggleRule, +}; diff --git a/packages/homebridge-plugin/package.json b/packages/homebridge-plugin/package.json new file mode 100644 index 0000000..52a0bfb --- /dev/null +++ b/packages/homebridge-plugin/package.json @@ -0,0 +1,34 @@ +{ + "name": "homebridge-dibby-wemo", + "version": "1.0.0", + "description": "Dibby Wemo Manager – Homebridge plugin for local Wemo control with DWM scheduling. No Belkin cloud required.", + "main": "index.js", + "customUi": true, + "scripts": { + "install-plugin": "npm install -g . && npm install --prefix \"%APPDATA%/npm/node_modules/homebridge-dibby-wemo\"" + }, + "keywords": [ + "homebridge-plugin", + "wemo", + "belkin", + "homekit", + "smart-home" + ], + "engines": { + "homebridge": ">=1.6.0", + "node": ">=18" + }, + "author": "SRS IT", + "license": "MIT", + "dependencies": { + "adm-zip": "^0.5.14", + "axios": "^1.7.0", + "sql.js": "^1.12.0", + "xml2js": "^0.6.2", + "xmlbuilder2": "^4.0.3", + "@homebridge/plugin-ui-utils": "^2.2.0" + }, + "devDependencies": { + "homebridge": "^1.8.0" + } +} diff --git a/packages/wemo-core/package.json b/packages/wemo-core/package.json new file mode 100644 index 0000000..85c9784 --- /dev/null +++ b/packages/wemo-core/package.json @@ -0,0 +1,6 @@ +{ + "name": "@wemo-manager/core", + "version": "2.0.0", + "private": true, + "main": "src/index.js" +} diff --git a/packages/wemo-core/src/index.js b/packages/wemo-core/src/index.js new file mode 100644 index 0000000..08c7f17 --- /dev/null +++ b/packages/wemo-core/src/index.js @@ -0,0 +1,15 @@ +'use strict'; + +/** + * @wemo-manager/core + * + * Shared utilities for both the Electron desktop app and the Homebridge plugin. + */ + +const sun = require('./sun'); +const types = require('./types'); + +module.exports = { + ...sun, + ...types, +}; diff --git a/packages/wemo-core/src/sun.js b/packages/wemo-core/src/sun.js new file mode 100644 index 0000000..feaa5f1 --- /dev/null +++ b/packages/wemo-core/src/sun.js @@ -0,0 +1,81 @@ +'use strict'; + +/** + * Calculate sunrise and sunset times for a given location and date. + * Pure JS – no external dependencies. + * + * Algorithm: NOAA Solar Calculator (Jean Meeus, Astronomical Algorithms). + * + * @param {number} lat Latitude in decimal degrees (positive = North) + * @param {number} lng Longitude in decimal degrees (positive = East) + * @param {Date} date Date to calculate for (default: today) + * @returns {{ sunrise: number|null, sunset: number|null }} + * Times as integer seconds from LOCAL midnight. + * null for each value if polar day or polar night. + */ +function sunTimes(lat, lng, date = new Date()) { + const D2R = Math.PI / 180; + const R2D = 180 / Math.PI; + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const A = Math.floor((14 - month) / 12); + const Y = year + 4800 - A; + const M = month + 12 * A - 3; + const JDN = day + Math.floor((153 * M + 2) / 5) + 365 * Y + + Math.floor(Y / 4) - Math.floor(Y / 100) + Math.floor(Y / 400) - 32045; + const JD = JDN - 0.5; + + const T = (JD - 2451545.0) / 36525.0; + + let L0 = 280.46646 + T * (36000.76983 + T * 0.0003032); + L0 = ((L0 % 360) + 360) % 360; + + let Mdeg = 357.52911 + T * (35999.05029 - 0.0001537 * T); + Mdeg = ((Mdeg % 360) + 360) % 360; + const Mrad = Mdeg * D2R; + + const C = (1.914602 - T * (0.004817 + 0.000014 * T)) * Math.sin(Mrad) + + (0.019993 - 0.000101 * T) * Math.sin(2 * Mrad) + + 0.000289 * Math.sin(3 * Mrad); + + const omega = 125.04 - 1934.136 * T; + const lambda = (L0 + C) - 0.00569 - 0.00478 * Math.sin(omega * D2R); + + const eps0 = 23.0 + + (26.0 + (21.448 - T * (46.8150 + T * (0.00059 - T * 0.001813))) / 60.0) / 60.0; + const eps = (eps0 + 0.00256 * Math.cos(omega * D2R)) * D2R; + + const sinDec = Math.sin(eps) * Math.sin(lambda * D2R); + const decl = Math.asin(sinDec); + + const e = 0.016708634 - T * (0.000042037 + 0.0000001267 * T); + const y = Math.pow(Math.tan(eps / 2), 2); + const EqT = 4 * R2D * ( + y * Math.sin(2 * L0 * D2R) + - 2 * e * Math.sin(Mrad) + + 4 * e * y * Math.sin(Mrad) * Math.cos(2 * L0 * D2R) + - 0.5 * y * y * Math.sin(4 * L0 * D2R) + - 1.25 * e * e * Math.sin(2 * Mrad) + ); + + const cosHA = (Math.cos(90.833 * D2R) - Math.sin(lat * D2R) * sinDec) + / (Math.cos(lat * D2R) * Math.cos(decl)); + + if (cosHA < -1 || cosHA > 1) { + return { sunrise: null, sunset: null }; + } + + const HA = Math.acos(cosHA) * R2D; + const tzOffsetMin = -date.getTimezoneOffset(); + const solarNoon = 720.0 - 4.0 * lng - EqT + tzOffsetMin; + + return { + sunrise: Math.round((solarNoon - HA * 4.0) * 60), + sunset: Math.round((solarNoon + HA * 4.0) * 60), + }; +} + +module.exports = { sunTimes }; diff --git a/packages/wemo-core/src/types.js b/packages/wemo-core/src/types.js new file mode 100644 index 0000000..9c4691f --- /dev/null +++ b/packages/wemo-core/src/types.js @@ -0,0 +1,76 @@ +'use strict'; + +/** Day numbers: 1=Monday ... 7=Sunday (Wemo convention) */ +const DAY_NUMBERS = { Monday:1, Tuesday:2, Wednesday:3, Thursday:4, Friday:5, Saturday:6, Sunday:7 }; +const DAY_NAMES = { 1:'Monday', 2:'Tuesday', 3:'Wednesday', 4:'Thursday', 5:'Friday', 6:'Saturday', 7:'Sunday' }; +const DAY_SHORT = { 1:'Mon', 2:'Tue', 3:'Wed', 4:'Thu', 5:'Fri', 6:'Sat', 7:'Sun' }; + +/** Rule types stored in RULES.Type */ +const RULE_TYPES = { + SCHEDULE: 'Schedule', + AWAY: 'Away', + COUNTDOWN: 'Countdown', + LONG_PRESS: 'Long Press', +}; + +/** Start/End action values */ +const ACTIONS = { ON: 1.0, OFF: 0.0, TOGGLE: 2.0, NONE: -1.0 }; + +/** Network status codes returned by GetNetworkStatus */ +const NETWORK_STATUS = { FAILED: '0', SUCCESS: '1', WRONG_PASSWORD: '2', CONNECTING: '3' }; + +/** Wemo device reset codes for ReSetup action */ +const RESET_CODES = { CLEAR_DATA: 1, FACTORY_RESET: 2, CLEAR_WIFI: 5 }; + +/** Default RULEDEVICES field values */ +const RD_DEFAULTS = { + GroupID: 0, + RuleDuration: 0, + StartAction: 1.0, + EndAction: -1.0, + SensorDuration: 2, + Type: -1, + Value: -1, + Level: -1, + ZBCapabilityStart: '', + ZBCapabilityEnd: '', + OnModeOffset: -1, + OffModeOffset: -1, + CountdownTime: 0, + EndTime: -1, +}; + +/** Sun time sentinel codes stored in RULEDEVICES.StartTime / EndTime */ +const SUN_CODES = { SUNRISE: -2, SUNSET: -3 }; + +function namesToDayNumbers(names) { + return names.map((n) => DAY_NUMBERS[n]).filter(Boolean).sort((a, b) => a - b); +} + +function dayNumbersToNames(numbers) { + return numbers.map((n) => DAY_NAMES[n]).filter(Boolean); +} + +function dayNumbersToShort(numbers) { + return numbers.map((n) => DAY_SHORT[n]).filter(Boolean); +} + +function timeToSecs(hhmm) { + if (!hhmm || !hhmm.includes(':')) return 0; + const [h, m] = hhmm.split(':').map(Number); + return h * 3600 + m * 60; +} + +function secsToHHMM(secs) { + if (secs === undefined || secs === null || secs < 0) return '00:00'; + const h = Math.floor(secs / 3600) % 24; + const m = Math.floor((secs % 3600) / 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +module.exports = { + DAY_NUMBERS, DAY_NAMES, DAY_SHORT, + RULE_TYPES, ACTIONS, NETWORK_STATUS, RESET_CODES, RD_DEFAULTS, SUN_CODES, + namesToDayNumbers, dayNumbersToNames, dayNumbersToShort, + timeToSecs, secsToHHMM, +};