Initial release — Dibby Wemo Manager v2.0.0
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 <noreply@anthropic.com>
This commit is contained in:
+54
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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://<ip>:<port>/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.*
|
||||||
@@ -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\<you>\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
|
||||||
@@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>About Dibby Wemo Manager</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #0d1b27;
|
||||||
|
color: #d0dde8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 32px;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top row: logo left, text block right */
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
/* 2 cm × 2 cm at 96 DPI ≈ 76 px */
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #7ecfff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #8ba5be;
|
||||||
|
line-height: 1.7;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4a6a84;
|
||||||
|
border-top: 1px solid #1e3048;
|
||||||
|
padding-top: 16px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart { color: #e05; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 8px 32px;
|
||||||
|
background: #00a9d5;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
button:hover { background: #0092ba; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-row">
|
||||||
|
<img src="icon.png" alt="Dibby Wemo Manager" />
|
||||||
|
<div class="header-text">
|
||||||
|
<h1>Dibby Wemo Manager</h1>
|
||||||
|
<div class="version">Version 2.0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="desc">
|
||||||
|
Manage Belkin Wemo device rules — schedules, timers, and automations.<br />
|
||||||
|
Local control only. No cloud required.<br />
|
||||||
|
Scheduler runs in the background even when this window is closed.
|
||||||
|
</p>
|
||||||
|
<div class="tagline">
|
||||||
|
Developed by SRS IT · Dedicated to Dibby <span class="heart">❤️</span>
|
||||||
|
</div>
|
||||||
|
<button onclick="window.close()">Close</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 = `<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>DWM Web Remote — QR Code</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||||
|
background:#0d1b27;color:#e2eaf2;display:flex;flex-direction:column;
|
||||||
|
align-items:center;justify-content:center;min-height:100vh;padding:24px;gap:16px;margin:0}
|
||||||
|
.qr-wrap{background:#fff;border-radius:16px;padding:16px;display:inline-block}
|
||||||
|
.qr-wrap svg{display:block}
|
||||||
|
h2{font-size:18px;font-weight:700;margin:0;text-align:center}
|
||||||
|
.url{font-size:13px;color:#7fa8c8;word-break:break-all;text-align:center;max-width:280px}
|
||||||
|
.hint{font-size:12px;color:#546878;text-align:center}
|
||||||
|
</style></head><body>
|
||||||
|
<h2>📱 DWM Web Remote</h2>
|
||||||
|
<div class="qr-wrap">${svgStr}</div>
|
||||||
|
<div class="url">${remoteURL}</div>
|
||||||
|
<div class="hint">Scan with your phone camera to open the remote control</div>
|
||||||
|
</body></html>`;
|
||||||
|
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 };
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dibby Wemo Manager</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 (
|
||||||
|
<Modal title="Settings" onClose={onClose}>
|
||||||
|
{/* Theme */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Theme</label>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{['dark', 'light'].map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
className={`btn btn-sm ${theme === t ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => { setTheme(t); window.wemoAPI.setTheme(t); }}
|
||||||
|
>
|
||||||
|
{t === 'dark' ? '🌙 Dark' : '☀️ Light'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location for sun rules */}
|
||||||
|
<div className="form-group" style={{ marginTop: 16 }}>
|
||||||
|
<label>Location <span style={{ fontWeight: 400, color: 'var(--text3)', fontSize: 11 }}>(for Sunrise/Sunset rules)</span></label>
|
||||||
|
{location && (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<span className="badge badge-online" style={{ fontSize: 11 }}>
|
||||||
|
📍 {location.label || `${location.lat}, ${location.lng}`}
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-ghost btn-sm" style={{ marginLeft: 8 }} onClick={clearLocation}>Clear</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<input
|
||||||
|
placeholder="City or address…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && searchLocation()}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={searchLocation} disabled={searching || !query.trim()}>
|
||||||
|
{searching ? <span className="spinner" style={{ width: 11, height: 11, borderWidth: 2 }} /> : '🔍 Search'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div style={{ marginTop: 6, border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||||
|
{results.slice(0, 5).map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
onClick={() => 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}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{saveMsg && <div style={{ fontSize: 12, color: 'var(--accent)', marginTop: 6 }}>{saveMsg}</div>}
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 6 }}>
|
||||||
|
Location is used to populate Sunrise/Sunset data on the device. The device calculates sun times independently once the location is set.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 (
|
||||||
|
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden', position: 'relative' }}>
|
||||||
|
<Sidebar onOpenSettings={() => setShowSettings(true)} />
|
||||||
|
<DetailPanel />
|
||||||
|
<Toast />
|
||||||
|
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
? <span className="badge badge-online">● Online</span>
|
||||||
|
: device.online === false
|
||||||
|
? <span className="badge badge-offline">● Offline</span>
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`device-card${isSelected ? ' selected' : ''}`}
|
||||||
|
onClick={() => 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 22, flexShrink: 0 }}>{deviceIcon(device.udn)}</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{device.friendlyName || 'Unknown Device'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
|
||||||
|
{device.host}:{device.port}
|
||||||
|
{onlineBadge && <span style={{ marginLeft: 6 }}>{onlineBadge}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PowerButton device={device} onToggle={handleToggle} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ padding: '16px 20px', overflowY: 'auto', height: '100%' }}>
|
||||||
|
{/* Status */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||||
|
{device.online === true && <span className="badge badge-online">● Online</span>}
|
||||||
|
{device.online === false && <span className="badge badge-offline">● Offline</span>}
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={checkOnline} disabled={checking}>
|
||||||
|
{checking ? <span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} /> : '⟳'} Check Network
|
||||||
|
</button>
|
||||||
|
{loading && <span className="spinner" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device information */}
|
||||||
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="section-header">Device Information</div>
|
||||||
|
|
||||||
|
<CopyField label="Name" value={combined.friendlyName} />
|
||||||
|
<CopyField label="Family" value={combined.productModel || combined.modelDescription || combined.modelName} />
|
||||||
|
<CopyField label="Model" value={[combined.modelDescription, combined.modelName].filter(Boolean).join(' — ') || null} />
|
||||||
|
<CopyField label="IP Address" value={`${device.host}:${device.port}`} mono />
|
||||||
|
<CopyField label="UDN" value={combined.udn} mono />
|
||||||
|
<CopyField label="MAC Address" value={info?.macAddress} mono />
|
||||||
|
<CopyField label="Serial Number" value={combined.serialNumber} mono />
|
||||||
|
<CopyField label="Firmware" value={combined.firmwareVersion} mono />
|
||||||
|
<CopyField label="Hardware" value={combined.hwVersion || info?.hwVersion || '—'} mono />
|
||||||
|
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="info-label">Signal Strength</span>
|
||||||
|
<span className="info-value">
|
||||||
|
{info?.signalStrength
|
||||||
|
? <SignalMeter dBm={info.signalStrength} />
|
||||||
|
: <span style={{ color: 'var(--text3)' }}>—</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device Clock */}
|
||||||
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="section-header">Device Clock</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 10 }}>
|
||||||
|
Press Sync to push host time to the device. Required for schedule rules to fire at the correct local time.
|
||||||
|
</p>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={syncTime} disabled={syncing}>
|
||||||
|
{syncing ? <><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} /> Syncing…</> : '🕐 Sync Clock'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HomeKit */}
|
||||||
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="section-header">HomeKit</div>
|
||||||
|
{hkInfo?.setupCode
|
||||||
|
? <>
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="info-label">Setup Code</span>
|
||||||
|
<span className="info-value mono">{hkInfo.setupCode}</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="info-label">Status</span>
|
||||||
|
<span className="info-value">{hkInfo.setupDone === '1' ? '✅ Paired' : '⏳ Not paired'}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
: <p style={{ fontSize: 13, color: 'var(--text3)' }}>HomeKit not supported on this device.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rename */}
|
||||||
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="section-header">Rename Device</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 10 }}>Change this device's friendly name.</p>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => { setNewName(device.friendlyName || ''); setRenameModal(true); }}>
|
||||||
|
✏️ Rename…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Options */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-header">Reset Options</div>
|
||||||
|
<div className="notice notice-warn" style={{ marginBottom: 12 }}>
|
||||||
|
These actions cannot be undone.
|
||||||
|
<br />
|
||||||
|
<strong>Clear Data</strong> = name, rules, icon.
|
||||||
|
<strong>Clear Wi-Fi</strong> = Wi-Fi settings only.
|
||||||
|
<strong>Factory Reset</strong> = everything.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => setConfirm({ code: 1, label: 'Clear Data', msg: 'This will erase the device name, rules, and icon. Wi-Fi settings will be kept.' })}>
|
||||||
|
Clear Data
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => setConfirm({ code: 5, label: 'Clear Wi-Fi', msg: 'This will reset the device Wi-Fi settings only. The device will enter setup mode.' })}>
|
||||||
|
Clear Wi-Fi
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => setConfirm({ code: 2, label: 'Factory Reset', msg: 'This will completely restore the device to factory defaults. All settings, rules, and Wi-Fi configuration will be erased.' })}>
|
||||||
|
Factory Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rename modal */}
|
||||||
|
{renameModal && (
|
||||||
|
<Modal
|
||||||
|
title="Rename Device"
|
||||||
|
onClose={() => setRenameModal(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setRenameModal(false)}>Cancel</button>
|
||||||
|
<button className="btn btn-primary" onClick={doRename}>Rename</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>New Name</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') doRename(); }}
|
||||||
|
maxLength={32}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset confirm */}
|
||||||
|
{confirm && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={confirm.label}
|
||||||
|
message={confirm.msg}
|
||||||
|
confirmLabel={confirm.label}
|
||||||
|
danger
|
||||||
|
onConfirm={doReset}
|
||||||
|
onCancel={() => setConfirm(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
className={`power-btn ${on ? 'on' : 'off'}`}
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={busy}
|
||||||
|
title={on ? 'Turn Off' : 'Turn On'}
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? <span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />
|
||||||
|
: <span className={`power-dot ${on ? 'on' : ''}`} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span className="signal-bars">
|
||||||
|
{[1, 2, 3, 4].map((b) => (
|
||||||
|
<span
|
||||||
|
key={b}
|
||||||
|
className={`signal-bar${b <= bars ? ' lit' : ''}`}
|
||||||
|
style={{ height: `${b * 4}px` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text2)' }}>
|
||||||
|
{isNaN(val) ? '—' : `${val} dBm`}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text3)' }}>({label})</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
flexDirection: 'column', gap: 14, color: 'var(--text3)' }}>
|
||||||
|
<span style={{ fontSize: 48 }}>⚡</span>
|
||||||
|
<p style={{ fontSize: 14 }}>Select a device from the sidebar</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
{/* Device header */}
|
||||||
|
<div style={{ padding: '12px 20px 0', background: 'var(--sidebar)', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 16 }}>{device.friendlyName}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
{device.productModel || device.modelName || 'Wemo Device'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{device.online === true && <span className="badge badge-online" style={{ marginLeft: 'auto' }}>● Online</span>}
|
||||||
|
{device.online === false && <span className="badge badge-offline" style={{ marginLeft: 'auto' }}>● Offline</span>}
|
||||||
|
</div>
|
||||||
|
<div className="tab-bar" style={{ padding: 0, background: 'transparent', borderBottom: 'none' }}>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={`tab-btn${activeTab === t.id ? ' active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{activeTab === 'info' && <DeviceInfoTab key={device.udn} device={device} />}
|
||||||
|
{activeTab === 'rules' && <RulesTab device={device} />}
|
||||||
|
{activeTab === 'wifi' && <WiFiTab key={device.udn} device={device} />}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div key={name} style={{ marginBottom: 8 }}>
|
||||||
|
{name !== '__ungrouped__' && (
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '.07em', textTransform: 'uppercase',
|
||||||
|
color: 'var(--text3)', padding: '8px 12px 4px' }}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{devs.map((d) => <DeviceCard key={d.udn} device={d} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside style={{ width: 'var(--sidebar-w)', minWidth: 'var(--sidebar-w)', background: 'var(--sidebar)',
|
||||||
|
borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||||
|
<span style={{ fontSize: 20 }}>⚡</span>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 15 }}>Dibby Wemo Manager</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={discover}
|
||||||
|
disabled={discovering}
|
||||||
|
>
|
||||||
|
{discovering
|
||||||
|
? <><span className="spinner" style={{ width: 11, height: 11, borderWidth: 2 }} /> Scanning…</>
|
||||||
|
: '🔍 Discover'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
title="Add device manually"
|
||||||
|
onClick={() => setManualModal(true)}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
title="Settings"
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
>
|
||||||
|
⚙️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scheduler section */}
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
|
||||||
|
{/* Windows Service row */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||||
|
{/* Status dot + label */}
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 5, flex: 1, cursor: 'pointer',
|
||||||
|
fontSize: 12, color: 'var(--text2)' }}
|
||||||
|
onClick={() => setShowSvcPanel((v) => !v)}
|
||||||
|
title="Windows Service — runs at boot, no login required"
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
width: 8, height: 8, borderRadius: '50%', flexShrink: 0, display: 'inline-block',
|
||||||
|
background: svcStatus?.running ? '#4caf50' : svcStatus?.installed ? '#ff9800' : '#666',
|
||||||
|
boxShadow: svcStatus?.running ? '0 0 5px #4caf50' : 'none',
|
||||||
|
}} />
|
||||||
|
<span>
|
||||||
|
{svcStatus === null ? 'Service: checking…'
|
||||||
|
: svcStatus.running ? 'Service: running'
|
||||||
|
: svcStatus.installed ? 'Service: stopped'
|
||||||
|
: 'Service: not installed'}
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 10 }}>{showSvcPanel ? '▲' : '▼'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service management panel */}
|
||||||
|
{showSvcPanel && (
|
||||||
|
<div style={{ background: 'var(--bg2)', borderRadius: 6, padding: '8px 10px',
|
||||||
|
marginBottom: 6, fontSize: 12 }}>
|
||||||
|
<div style={{ color: 'var(--text3)', marginBottom: 6, lineHeight: 1.4 }}>
|
||||||
|
{svcStatus?.installed
|
||||||
|
? 'Runs at boot under SYSTEM — no user login needed.'
|
||||||
|
: 'Install once to run rules at boot — no login required.'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
|
||||||
|
{!svcStatus?.installed ? (
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={svcInstall} disabled={svcWorking}
|
||||||
|
style={{ flex: 1 }}>
|
||||||
|
{svcWorking ? <><span className="spinner" style={{ width: 9, height: 9, borderWidth: 2 }} /> Working…</> : '⬆ Install Service'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={svcStartStop} disabled={svcWorking}
|
||||||
|
style={{ flex: 1 }}>
|
||||||
|
{svcWorking ? <><span className="spinner" style={{ width: 9, height: 9, borderWidth: 2 }} /> …</>
|
||||||
|
: svcStatus?.running ? '⏹ Stop' : '▶ Start'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={svcUninstall} disabled={svcWorking}>
|
||||||
|
🗑 Remove
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={refreshSvcStatus} title="Refresh status">↻</button>
|
||||||
|
</div>
|
||||||
|
{/* Sync device list to service */}
|
||||||
|
{svcStatus?.installed && (
|
||||||
|
<button className="btn btn-ghost btn-sm" style={{ marginTop: 5, width: '100%', fontSize: 11 }}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!devices.length) { addToast('No devices to sync', 'warn'); return; }
|
||||||
|
await window.wemoAPI.syncDevicesToService(devices);
|
||||||
|
addToast(`Synced ${devices.length} device(s) to service`, 'success');
|
||||||
|
}}>
|
||||||
|
🔄 Sync device list to service
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* In-process scheduler bar (fallback / testing) */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${schedulerRunning ? 'btn-success' : 'btn-secondary'}`}
|
||||||
|
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 5, fontSize: 11 }}
|
||||||
|
onClick={toggleScheduler}
|
||||||
|
disabled={schedulerStarting}
|
||||||
|
title="In-process scheduler — only runs while this window is open"
|
||||||
|
>
|
||||||
|
{schedulerStarting
|
||||||
|
? <><span className="spinner" style={{ width: 9, height: 9, borderWidth: 2 }} /> Starting…</>
|
||||||
|
: schedulerRunning
|
||||||
|
? <><span style={{ width: 7, height: 7, borderRadius: '50%', background: '#4caf50',
|
||||||
|
display: 'inline-block', flexShrink: 0 }} /> In-app sched. ON</>
|
||||||
|
: '⏱ In-app sched. OFF'}
|
||||||
|
</button>
|
||||||
|
{schedulerRunning && schedulerUpcoming.length > 0 && (
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setShowSchedulePanel((v) => !v)}
|
||||||
|
style={{ padding: '3px 7px' }} title="View upcoming fires">
|
||||||
|
{showSchedulePanel ? '▲' : '▼'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming fires */}
|
||||||
|
{showSchedulePanel && schedulerRunning && schedulerUpcoming.length > 0 && (
|
||||||
|
<div style={{ marginTop: 5, background: 'var(--bg2)', borderRadius: 6,
|
||||||
|
padding: '6px 8px', fontSize: 11, color: 'var(--text2)' }}>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 4, color: 'var(--text1)' }}>Next fires today:</div>
|
||||||
|
{schedulerUpcoming.map((f, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '1px 0' }}>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
maxWidth: '65%', color: 'var(--text1)' }}>{f.ruleName}</span>
|
||||||
|
<span style={{ color: f.action === 'ON' ? '#4caf50' : '#ff7043', flexShrink: 0 }}>
|
||||||
|
{f.at} {f.action}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device list */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 8px' }}>
|
||||||
|
{devices.length === 0 && !discovering && (
|
||||||
|
<div className="empty-state" style={{ padding: '30px 16px' }}>
|
||||||
|
<span className="empty-state-icon">📡</span>
|
||||||
|
<p>No devices found.<br />Click Discover to scan your network.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{[...grouped.entries()].map(([name, devs]) => renderGroup(name, devs))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual add modal */}
|
||||||
|
{manualModal && (
|
||||||
|
<Modal
|
||||||
|
title="Add Device Manually"
|
||||||
|
onClose={() => setManualModal(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setManualModal(false)}>Cancel</button>
|
||||||
|
<button className="btn btn-primary" onClick={addManual}>Add</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>IP Address</label>
|
||||||
|
<input autoFocus placeholder="192.168.1.100" value={manualHost} onChange={(e) => setManualHost(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Port</label>
|
||||||
|
<input type="number" placeholder="49153" value={manualPort} onChange={(e) => setManualPort(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
style={{ fontSize: 11, padding: '2px 8px', color: 'var(--accent)' }}
|
||||||
|
disabled={copying}
|
||||||
|
title="Copy to DWM Rules (saved locally — not written to device)"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handle(); }}
|
||||||
|
>
|
||||||
|
{copying
|
||||||
|
? <span className="spinner" style={{ width: 9, height: 9, borderWidth: 2 }} />
|
||||||
|
: '📥 Add to DWM'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px' }}>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 14, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={loadAll} disabled={loading}>
|
||||||
|
{loading
|
||||||
|
? <><span className="spinner" style={{ width: 10, height: 10, borderWidth: 2, marginRight: 4 }} />Loading…</>
|
||||||
|
: allRules === null ? '⟳ Load All Rules' : '⟳ Refresh'}
|
||||||
|
</button>
|
||||||
|
{allRules !== null && !loading && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
{capableCount} device{capableCount !== 1 ? 's' : ''}
|
||||||
|
{' · '}
|
||||||
|
<span style={{ color: 'var(--accent)' }}>{dwmCount} DWM</span>
|
||||||
|
{wemoCount > 0 && <span> · {wemoCount} Wemo</span>}
|
||||||
|
{dedupSaved > 0 && (
|
||||||
|
<span style={{ marginLeft: 6, color: 'var(--success)', fontSize: 11 }}>
|
||||||
|
({dedupSaved} duplicate{dedupSaved !== 1 ? 's' : ''} collapsed)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text3)' }}>
|
||||||
|
📥 Copy adds rules to local DWM database
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device errors */}
|
||||||
|
{errorEntries.length > 0 && (
|
||||||
|
<div className="notice notice-warn" style={{ marginBottom: 12, fontSize: 12 }}>
|
||||||
|
⚠️ Could not reach {errorEntries.length} device{errorEntries.length !== 1 ? 's' : ''}:
|
||||||
|
{errorEntries.map(([udn, msg]) => {
|
||||||
|
const dev = devices.find((d) => d.udn === udn);
|
||||||
|
return (
|
||||||
|
<div key={udn} style={{ marginTop: 2 }}>
|
||||||
|
· {dev?.friendlyName || dev?.name || udn}: {msg}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Not loaded */}
|
||||||
|
{allRules === null && !loading && (
|
||||||
|
<div className="empty-state">
|
||||||
|
<span className="empty-state-icon">🌐</span>
|
||||||
|
<p>
|
||||||
|
Click <strong>Load All Rules</strong> to fetch rules from all<br />
|
||||||
|
your Wemo devices in one deduplicated list.<br />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
🔵 DWM = managed by this app · Wemo = native device rules
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && (
|
||||||
|
<div className="empty-state">
|
||||||
|
<span className="spinner" />
|
||||||
|
<p>Fetching rules from {capableCount} device{capableCount !== 1 ? 's' : ''}…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty */}
|
||||||
|
{allRules !== null && !loading && allRules.length === 0 && (
|
||||||
|
<div className="empty-state">
|
||||||
|
<span className="empty-state-icon">📅</span>
|
||||||
|
<p>No rules found across any devices.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rule list */}
|
||||||
|
{allRules !== null && !loading && allRules.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{allRules.map((rule) => {
|
||||||
|
const typeKey = normaliseType(rule.type);
|
||||||
|
const icon = RULE_ICONS[typeKey] || '📅';
|
||||||
|
const isAway = typeKey === 'Away';
|
||||||
|
const managed = isDwm(rule.name);
|
||||||
|
return (
|
||||||
|
<div key={rule._key} className={`rule-card${rule.enabled ? '' : ' disabled'}`}
|
||||||
|
style={managed ? { borderLeft: '3px solid var(--accent)' } : {}}>
|
||||||
|
<span className="rule-icon">{icon}</span>
|
||||||
|
<div className="rule-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<div className="rule-name">{stripDwm(rule.name)}</div>
|
||||||
|
{managed && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 700, letterSpacing: '0.05em',
|
||||||
|
background: 'var(--accent)', color: '#fff',
|
||||||
|
borderRadius: 3, padding: '1px 5px',
|
||||||
|
}}>
|
||||||
|
DWM
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rule-meta">
|
||||||
|
<span className={`badge badge-${isAway ? 'away' : 'schedule'}`} style={{ marginRight: 6 }}>
|
||||||
|
{rule.type}
|
||||||
|
</span>
|
||||||
|
{ruleSummary(rule)}
|
||||||
|
{!rule.enabled && (
|
||||||
|
<span className="badge badge-disabled" style={{ marginLeft: 6 }}>Disabled</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Source device chips + action buttons */}
|
||||||
|
<div style={{ marginTop: 5, display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
{rule.sourceDevices.map((sd) => (
|
||||||
|
<span key={sd.udn} style={{
|
||||||
|
fontSize: 11, background: 'var(--card2)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 4, padding: '1px 7px', color: 'var(--text2)',
|
||||||
|
}}>
|
||||||
|
📍 {sd.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{/* Copy button for non-DWM rules */}
|
||||||
|
{!managed && (
|
||||||
|
<CopyToDwmButton rule={rule} onDone={loadAll} />
|
||||||
|
)}
|
||||||
|
{managed && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--accent)', marginLeft: 2 }}>
|
||||||
|
✓ Managed by DWM scheduler
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Toggle + Edit — writes back to the Wemo device */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, paddingLeft: 8 }}>
|
||||||
|
<label className="toggle" title={rule.enabled ? 'Disable rule on device' : 'Enable rule on device'}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!rule.enabled}
|
||||||
|
onChange={(e) => handleToggle(rule, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" />
|
||||||
|
<span className="toggle-thumb" />
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-icon btn-sm"
|
||||||
|
title="Edit this rule on the device"
|
||||||
|
onClick={() => handleEdit(rule)}
|
||||||
|
>✏️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Edit rule modal — writes back to the Wemo device directly */}
|
||||||
|
{editingRule && editingDevice && (
|
||||||
|
<RuleEditor
|
||||||
|
rule={editingRule}
|
||||||
|
device={editingDevice}
|
||||||
|
isDwm={false}
|
||||||
|
onSave={() => {
|
||||||
|
setEditingRule(null);
|
||||||
|
setEditingDevice(null);
|
||||||
|
addToast('✅ Rule updated on device', 'success');
|
||||||
|
loadAll();
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setEditingRule(null);
|
||||||
|
setEditingDevice(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="day-chips">
|
||||||
|
{DAYS.map((day, i) => (
|
||||||
|
<span
|
||||||
|
key={day}
|
||||||
|
className={`day-chip${selected.includes(day) ? ' on' : ''}`}
|
||||||
|
onClick={() => toggle(day)}
|
||||||
|
>
|
||||||
|
{SHORT[i]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="day-chip-quick">
|
||||||
|
<span className="day-chip" style={{ fontSize: 11 }} onClick={() => onChange([...DAYS])}>All</span>
|
||||||
|
<span className="day-chip" style={{ fontSize: 11 }} onClick={() => onChange(['Monday','Tuesday','Wednesday','Thursday','Friday'])}>Weekdays</span>
|
||||||
|
<span className="day-chip" style={{ fontSize: 11 }} onClick={() => onChange(['Saturday','Sunday'])}>Weekend</span>
|
||||||
|
<span className="day-chip" style={{ fontSize: 11 }} onClick={() => onChange([])}>None</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
{/* Trigger source device */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Trigger Device <span style={{ color: 'var(--text3)', fontSize: 12 }}>(source)</span></label>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '2px 0 6px' }}>
|
||||||
|
Which device's state change should trigger the action?
|
||||||
|
</p>
|
||||||
|
<select value={form.triggerDeviceId ?? ''} style={selStyle}
|
||||||
|
onChange={(e) => onChange({ ...form, triggerDeviceId: e.target.value })}>
|
||||||
|
<option value="">— select device —</option>
|
||||||
|
{allDevices.map((d) => (
|
||||||
|
<option key={d.udn} value={d.udn}>{d.friendlyName || d.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* When */}
|
||||||
|
<div className="form-group" style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 140 }}>
|
||||||
|
<label>When</label>
|
||||||
|
<select value={form.triggerEvent ?? 'any'} style={selStyle}
|
||||||
|
onChange={(e) => onChange({ ...form, triggerEvent: e.target.value })}>
|
||||||
|
<option value="any">Turns ON or OFF</option>
|
||||||
|
<option value="on">Turns ON</option>
|
||||||
|
<option value="off">Turns OFF</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 140 }}>
|
||||||
|
<label>Then</label>
|
||||||
|
<select value={form.triggerAction ?? 'on'} style={selStyle}
|
||||||
|
onChange={(e) => onChange({ ...form, triggerAction: e.target.value })}>
|
||||||
|
<option value="on">Turn ON action devices</option>
|
||||||
|
<option value="off">Turn OFF action devices</option>
|
||||||
|
<option value="mirror">Mirror (same as trigger)</option>
|
||||||
|
<option value="opposite">Opposite (invert trigger)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action devices */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Action Devices <span style={{ color: 'var(--text3)', fontSize: 12 }}>(targets)</span></label>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '2px 0 6px' }}>
|
||||||
|
Which devices should be controlled when the trigger fires?
|
||||||
|
</p>
|
||||||
|
{allDevices.length === 0 ? (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)' }}>No devices found. Scan for devices first.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{allDevices.map((d) => (
|
||||||
|
<span key={d.udn}
|
||||||
|
className={`day-chip${(form.actionDeviceIds ?? []).includes(d.udn) ? ' on' : ''}`}
|
||||||
|
style={{ padding: '5px 12px', cursor: 'pointer' }}
|
||||||
|
onClick={() => toggleActionDev(d.udn)}>
|
||||||
|
{d.friendlyName || d.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!(form.actionDeviceIds ?? []).length && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--danger, #e55)', marginTop: 4 }}>
|
||||||
|
Select at least one action device.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className="form-group">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<label style={{ margin: 0 }}>Target Devices</label>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm"
|
||||||
|
style={{ fontSize: 11, padding: '1px 8px' }}
|
||||||
|
onClick={selectAll}>All</button>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm"
|
||||||
|
style={{ fontSize: 11, padding: '1px 8px' }}
|
||||||
|
onClick={selectNone}>None</button>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '2px 0 8px' }}>
|
||||||
|
{isDwm
|
||||||
|
? 'Select which devices this rule will control.'
|
||||||
|
: 'This rule will be stored on the current device and run on all selected devices.'}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{allDevices.map((d) => {
|
||||||
|
const locked = !isDwm && d.udn === currentUdn;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={d.udn}
|
||||||
|
className={`day-chip${selected.includes(d.udn) ? ' on' : ''}`}
|
||||||
|
style={{
|
||||||
|
padding: '5px 12px',
|
||||||
|
opacity: locked ? 0.7 : 1,
|
||||||
|
cursor: locked ? 'default' : 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
|
}}
|
||||||
|
title={locked ? 'Current device (always included)' : undefined}
|
||||||
|
onClick={() => !locked && toggle(d.udn)}
|
||||||
|
>
|
||||||
|
{locked ? '🏠 ' : ''}{d.friendlyName || d.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{selected.length === 0 && isDwm && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--danger, #e55)', marginTop: 4 }}>
|
||||||
|
Select at least one device.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<Modal
|
||||||
|
title={isEdit
|
||||||
|
? `Edit Rule: ${(rule.name || '').replace(/^DWM:/i, '')}`
|
||||||
|
: isDwm ? 'New DWM Rule' : 'New Wemo Rule'}
|
||||||
|
onClose={onClose}
|
||||||
|
wide
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn btn-secondary" onClick={onClose} disabled={saving}>Cancel</button>
|
||||||
|
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||||
|
{saving
|
||||||
|
? <><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} /> Saving…</>
|
||||||
|
: '💾 Save Rule'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<div className="notice notice-danger" style={{ marginBottom: 12 }}>
|
||||||
|
⚠️ {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rule name */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Rule Name</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
placeholder="e.g. Evening Lights"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => { setError(''); setForm({ ...form, name: e.target.value }); }}
|
||||||
|
style={error && !form.name.trim() ? { borderColor: 'var(--danger, #e55)' } : {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rule type */}
|
||||||
|
{form.type === 'Long Press' ? (
|
||||||
|
<div className="notice notice-info" style={{ marginBottom: 10 }}>
|
||||||
|
👆 Long Press rules are managed by the device firmware and cannot be edited here.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Rule Type</label>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{RULE_TYPES.map((rt) => (
|
||||||
|
<div
|
||||||
|
key={rt.value}
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13 }}>{rt.label}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>{rt.desc}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Device picker — for Schedule / Countdown / Away / AlwaysOn */}
|
||||||
|
{(form.type === 'Schedule' || form.type === 'Countdown' || form.type === 'Away' || form.type === 'AlwaysOn') && (
|
||||||
|
isDwm
|
||||||
|
? allRuleDevices.length > 0 && (
|
||||||
|
<DevicePicker
|
||||||
|
isDwm
|
||||||
|
currentUdn={device?.udn}
|
||||||
|
selected={form.deviceIds || []}
|
||||||
|
allDevices={allRuleDevices}
|
||||||
|
onChange={(ids) => setForm({ ...form, deviceIds: ids })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: allRuleDevices.length > 1 && (
|
||||||
|
<DevicePicker
|
||||||
|
isDwm={false}
|
||||||
|
currentUdn={device?.udn}
|
||||||
|
selected={form.deviceIds || (device?.udn ? [device.udn] : [])}
|
||||||
|
allDevices={allRuleDevices}
|
||||||
|
onChange={(ids) => setForm({ ...form, deviceIds: ids })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trigger rule pickers */}
|
||||||
|
{form.type === 'Trigger' && isDwm && (
|
||||||
|
<TriggerEditor form={form} onChange={setForm} allDevices={allRuleDevices} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rule-type specific editors */}
|
||||||
|
{form.type !== 'Long Press' && (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14, marginTop: 4 }}>
|
||||||
|
{form.type === 'Schedule' && <ScheduleEditor form={form} onChange={setForm} sunTimes={sunTimes} />}
|
||||||
|
{form.type === 'Countdown' && <CountdownEditor form={form} onChange={setForm} />}
|
||||||
|
{form.type === 'Away' && <AwayModeEditor form={form} onChange={setForm} sunTimes={sunTimes} />}
|
||||||
|
{form.type === 'AlwaysOn' && (
|
||||||
|
<div className="notice notice-info" style={{ marginBottom: 0 }}>
|
||||||
|
🔒 The scheduler polls this device every 10 seconds. If it's found OFF it will be turned back ON automatically. No schedule needed.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form.type === 'Trigger' && !isDwm && (
|
||||||
|
<div className="notice notice-warn">
|
||||||
|
Trigger rules are only available as DWM rules.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form.type !== 'Schedule' && form.type !== 'Countdown' && form.type !== 'Away'
|
||||||
|
&& form.type !== 'AlwaysOn' && form.type !== 'Trigger' && (
|
||||||
|
<div className="notice notice-warn">
|
||||||
|
Unknown rule type: <strong>{form.type}</strong>. Select a type above to edit.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={`rule-card${rule.enabled ? '' : ' disabled'}`}>
|
||||||
|
<span className="rule-icon">{icon}</span>
|
||||||
|
<div className="rule-body" onClick={() => onEdit(rule)} style={{ cursor: 'pointer' }}>
|
||||||
|
<div className="rule-name">{rule.name}</div>
|
||||||
|
<div className="rule-meta">
|
||||||
|
<span className={`badge badge-${badgeType}`} style={{ marginRight: 6 }}>
|
||||||
|
{rule.type}
|
||||||
|
</span>
|
||||||
|
{ruleSummary(rule)}
|
||||||
|
{!rule.enabled && <span className="badge badge-disabled" style={{ marginLeft: 6 }}>Disabled</span>}
|
||||||
|
</div>
|
||||||
|
{/* Target / action devices */}
|
||||||
|
{rule.type === 'Trigger' ? (
|
||||||
|
<div style={{ marginTop: 4, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{rule.triggerDevice && (
|
||||||
|
<span style={{ fontSize: 11, background: 'rgba(255,200,0,.12)', border: '1px solid var(--border)', borderRadius: 4, padding: '1px 7px', color: 'var(--text2)' }}>
|
||||||
|
⚡ {rule.triggerDevice.name || rule.triggerDevice.host}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(rule.actionDevices ?? []).map((td) => (
|
||||||
|
<span key={td.udn || td.host} style={{ fontSize: 11, background: 'var(--card2)', border: '1px solid var(--border)', borderRadius: 4, padding: '1px 7px', color: 'var(--text2)' }}>
|
||||||
|
🎯 {td.name || td.host}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : rule.targetDevices?.length > 0 && (
|
||||||
|
<div style={{ marginTop: 4, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{rule.targetDevices.map((td) => (
|
||||||
|
<span key={td.udn || td.host} style={{
|
||||||
|
fontSize: 11, background: 'var(--card2)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 4, padding: '1px 7px', color: 'var(--text2)',
|
||||||
|
}}>
|
||||||
|
📍 {td.name || td.host}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rule-actions">
|
||||||
|
<label className="toggle" title={rule.enabled ? 'Disable' : 'Enable'}>
|
||||||
|
<input type="checkbox" checked={!!rule.enabled}
|
||||||
|
onChange={async () => {
|
||||||
|
setToggling(true);
|
||||||
|
try { await onToggle(rule, !rule.enabled); } finally { setToggling(false); }
|
||||||
|
}}
|
||||||
|
disabled={toggling} />
|
||||||
|
<span className="toggle-track" />
|
||||||
|
<span className="toggle-thumb" />
|
||||||
|
</label>
|
||||||
|
<button className="btn btn-ghost btn-icon btn-sm" title="Test — turn ON all target devices right now"
|
||||||
|
disabled={testing}
|
||||||
|
onClick={async () => { setTesting(true); try { await onTest(rule); } finally { setTesting(false); } }}>
|
||||||
|
{testing ? <span className="spinner" style={{ width: 10, height: 10, borderWidth: 2 }} /> : '▶'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-icon btn-sm" title="Edit" onClick={() => onEdit(rule)}>✏️</button>
|
||||||
|
<button className="btn btn-ghost btn-icon btn-sm" title="Delete" onClick={() => onDelete(rule)}>🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* ── Sub-tab bar ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="tab-bar" style={{ padding: '0 20px', background: 'var(--bg)', flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
className={`tab-btn${subTab === 'dwm' ? ' active' : ''}`}
|
||||||
|
style={{ fontSize: 12, padding: '8px 16px' }}
|
||||||
|
onClick={() => setSubTab('dwm')}
|
||||||
|
title="Rules stored locally by this app — these are what the scheduler fires"
|
||||||
|
>
|
||||||
|
DWM Rules
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab-btn${subTab === 'wemo' ? ' active' : ''}`}
|
||||||
|
style={{ fontSize: 12, padding: '8px 16px' }}
|
||||||
|
onClick={() => setSubTab('wemo')}
|
||||||
|
title="All rules from all Wemo devices — read-only, duplicates removed"
|
||||||
|
>
|
||||||
|
Wemo Rules
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── DWM Rules ───────────────────────────────────────────────────── */}
|
||||||
|
{subTab === 'dwm' && (
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty-state"><span className="spinner" /><p>Loading rules…</p></div>
|
||||||
|
) : (
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px' }}>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 14, flexWrap: 'wrap' }}>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => setCreating(true)}>+ New Rule</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={loadDwmRules}>⟳ Refresh</button>
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginLeft: 'auto' }}>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={handleExportJSON} disabled={importing}>↓ JSON</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={handleExportCSV} disabled={importing}>↓ CSV</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={handleImport} disabled={importing}>
|
||||||
|
{importing
|
||||||
|
? <><span className="spinner" style={{ width: 10, height: 10, borderWidth: 2, marginRight: 4 }} />Importing…</>
|
||||||
|
: '↑ Import'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rule list */}
|
||||||
|
{dwmRules.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<span className="empty-state-icon">📅</span>
|
||||||
|
<p>
|
||||||
|
No DWM rules yet.<br />
|
||||||
|
Click <strong>+ New Rule</strong> to create one, or switch to<br />
|
||||||
|
<strong>Wemo Rules</strong> tab to copy existing device rules here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{dwmRules.map((rule) => (
|
||||||
|
<RuleRow key={rule.id} rule={rule}
|
||||||
|
onEdit={setEditingRule} onDelete={setDeleteTarget}
|
||||||
|
onToggle={handleToggle} onTest={handleTest} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info notice */}
|
||||||
|
<div className="notice notice-info" style={{ marginTop: 16, fontSize: 12 }}>
|
||||||
|
💾 DWM rules are stored locally on this computer — not on the Wemo device.<br />
|
||||||
|
The app scheduler fires them while this app is running.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create / Edit modal */}
|
||||||
|
{(creating || editingRule) && (
|
||||||
|
<RuleEditor
|
||||||
|
rule={editingRule}
|
||||||
|
device={device}
|
||||||
|
isDwm
|
||||||
|
onSave={handleSaved}
|
||||||
|
onClose={() => { setCreating(false); setEditingRule(null); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirm */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<ConfirmDialog title="Delete Rule"
|
||||||
|
message={`Delete rule "${deleteTarget.name}"? This cannot be undone.`}
|
||||||
|
confirmLabel="Delete" danger
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setDeleteTarget(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Wemo Rules ──────────────────────────────────────────────────── */}
|
||||||
|
{subTab === 'wemo' && (
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<AllRulesTab />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{label}</label>
|
||||||
|
<div className="form-row" style={{ marginBottom: 6 }}>
|
||||||
|
<select value={type || 'fixed'} onChange={(e) => onTypeChange(e.target.value)}>
|
||||||
|
<option value="fixed">Fixed Time</option>
|
||||||
|
<option value="sunrise">Sunrise</option>
|
||||||
|
<option value="sunset">Sunset</option>
|
||||||
|
</select>
|
||||||
|
{isSun ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<input
|
||||||
|
type="number" placeholder="0"
|
||||||
|
value={rawOffset}
|
||||||
|
onChange={handleOffsetChange}
|
||||||
|
onBlur={handleOffsetBlur}
|
||||||
|
style={{ width: 70 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text2)', whiteSpace: 'nowrap' }}>
|
||||||
|
min (+ after, − before)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input type="time" value={time || ''} onChange={(e) => onTimeChange(e.target.value)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isSun && previewWithOffset !== null && (
|
||||||
|
<div className="sun-preview">
|
||||||
|
<span>
|
||||||
|
<span className="lbl">Today's {type}: </span>
|
||||||
|
<span className="val">{secsToHHMM(previewSecs)}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="lbl">Window {label.includes('Start') ? 'opens' : 'closes'}: </span>
|
||||||
|
<span className="val">{secsToHHMM(previewWithOffset)}</span>
|
||||||
|
{offset !== 0 && (
|
||||||
|
<span style={{ color: 'var(--text3)', fontSize: 11 }}>
|
||||||
|
{' '}({offset > 0 ? '+' : ''}{offset} min)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||||
|
📍 Coordinates synced to device on save
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSun && !sunTimes && (
|
||||||
|
<div className="notice notice-warn" style={{ marginTop: 6, marginBottom: 0 }}>
|
||||||
|
⚠️ No location set — open <strong>⚙️ Settings</strong> and search for your city.
|
||||||
|
The device needs your coordinates to calculate {type} times.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<>
|
||||||
|
<div className="notice notice-info" style={{ marginBottom: 14 }}>
|
||||||
|
<strong>Away Mode</strong> — simulates occupancy by randomly turning devices{' '}
|
||||||
|
<strong>on</strong> (30–90 min) then <strong>off</strong> (1–15 min) within your
|
||||||
|
configured window. The DWM scheduler handles all randomisation while the app is running.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active days */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Active Days</label>
|
||||||
|
<DayPicker
|
||||||
|
selected={form.days || []}
|
||||||
|
onChange={(days) => onChange({ ...form, days })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Window start */}
|
||||||
|
<SunTimeField
|
||||||
|
label="Window Start"
|
||||||
|
type={form.startType || 'fixed'}
|
||||||
|
offset={form.startOffset ?? 0}
|
||||||
|
time={form.startTime || ''}
|
||||||
|
onTypeChange={(v) => onChange({ ...form, startType: v })}
|
||||||
|
onOffsetChange={(v) => onChange({ ...form, startOffset: v })}
|
||||||
|
onTimeChange={(v) => onChange({ ...form, startTime: v })}
|
||||||
|
sunTimes={sunTimes}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Window end */}
|
||||||
|
<SunTimeField
|
||||||
|
label="Window End"
|
||||||
|
type={form.endType || 'fixed'}
|
||||||
|
offset={form.endOffset ?? 0}
|
||||||
|
time={form.endTime || ''}
|
||||||
|
onTypeChange={(v) => onChange({ ...form, endType: v })}
|
||||||
|
onOffsetChange={(v) => onChange({ ...form, endOffset: v })}
|
||||||
|
onTimeChange={(v) => onChange({ ...form, endTime: v })}
|
||||||
|
sunTimes={sunTimes}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cross-midnight hint */}
|
||||||
|
{crossesMidnight() && (
|
||||||
|
<div className="notice notice-info" style={{ marginBottom: 12, fontSize: 12 }}>
|
||||||
|
🌙 Window crosses midnight — ends at <strong>{form.endTime}</strong> the{' '}
|
||||||
|
<strong>next day</strong>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="notice notice-info">
|
||||||
|
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).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Countdown duration */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Turn off after (minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number" min="1" max="1440"
|
||||||
|
value={mins}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = parseInt(e.target.value, 10) || 60;
|
||||||
|
onChange({ ...form, countdownMins: v, countdownTime: v * 60 });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>
|
||||||
|
{mins >= 60
|
||||||
|
? `${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''}`
|
||||||
|
: `${mins} minutes`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active window toggle */}
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', marginTop: 8, paddingTop: 14 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: windowEnabled ? 12 : 0 }}>
|
||||||
|
<label className="toggle" title={windowEnabled ? 'Disable active window' : 'Enable active window'}>
|
||||||
|
<input type="checkbox" checked={windowEnabled}
|
||||||
|
onChange={(e) => onChange({ ...form, windowEnabled: e.target.checked })} />
|
||||||
|
<span className="toggle-track" />
|
||||||
|
<span className="toggle-thumb" />
|
||||||
|
</label>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13 }}>Active Window</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
— restrict this rule to specific hours
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{windowEnabled && (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12 }}>
|
||||||
|
The scheduler will turn the device <strong>ON</strong> at the window start and
|
||||||
|
<strong> OFF</strong> 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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Window times */}
|
||||||
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 14 }}>
|
||||||
|
<div className="form-group" style={{ flex: 1, minWidth: 140, margin: 0 }}>
|
||||||
|
<label>Window Start</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={windowStartTime}
|
||||||
|
onChange={(e) => onChange({ ...form, windowStartTime: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1, minWidth: 140, margin: 0 }}>
|
||||||
|
<label>Window End</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={windowEndTime}
|
||||||
|
onChange={(e) => onChange({ ...form, windowEndTime: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cross-midnight hint */}
|
||||||
|
{windowStartTime && windowEndTime && crossesMidnight() && (
|
||||||
|
<div className="notice notice-info" style={{ marginBottom: 12, fontSize: 12 }}>
|
||||||
|
🌙 Window crosses midnight — ends at <strong>{windowEndTime}</strong> the <strong>next day</strong>.
|
||||||
|
The OFF command fires on the following calendar day.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Window days */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Active Days</label>
|
||||||
|
<DayPicker
|
||||||
|
selected={windowDays}
|
||||||
|
onChange={(days) => onChange({ ...form, windowDays: days })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{label}</label>
|
||||||
|
<div className="form-row" style={{ marginBottom: 6 }}>
|
||||||
|
<select value={type || 'fixed'} onChange={(e) => onTypeChange(e.target.value)}>
|
||||||
|
<option value="fixed">Fixed Time</option>
|
||||||
|
<option value="sunrise">Sunrise</option>
|
||||||
|
<option value="sunset">Sunset</option>
|
||||||
|
</select>
|
||||||
|
{isSun ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<input
|
||||||
|
type="number" placeholder="0"
|
||||||
|
value={rawOffset}
|
||||||
|
onChange={handleOffsetChange}
|
||||||
|
onBlur={handleOffsetBlur}
|
||||||
|
style={{ width: 70 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text2)', whiteSpace: 'nowrap' }}>
|
||||||
|
min (+ after, − before)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input type="time" value={time || ''} onChange={(e) => onTimeChange(e.target.value)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isSun && previewWithOffset !== null && (
|
||||||
|
<div className="sun-preview">
|
||||||
|
<span><span className="lbl">Today's {type}: </span><span className="val">{secsToHHMM(previewSecs)}</span></span>
|
||||||
|
<span>
|
||||||
|
<span className="lbl">Fires at: </span>
|
||||||
|
<span className="val">{secsToHHMM(previewWithOffset)}</span>
|
||||||
|
{offset !== 0 && <span style={{ color: 'var(--text3)', fontSize: 11 }}> ({offset > 0 ? '+' : ''}{offset} min)</span>}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text3)' }}>📍 Coordinates synced to device on save</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSun && !sunTimes && (
|
||||||
|
<div className="notice notice-warn" style={{ marginTop: 6, marginBottom: 0 }}>
|
||||||
|
⚠️ No location set — open <strong>⚙️ Settings</strong> and search for your city.
|
||||||
|
The device needs your coordinates to calculate {type} times.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScheduleEditor({ form, onChange, sunTimes }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Days</label>
|
||||||
|
<DayPicker selected={form.days || []} onChange={(days) => onChange({ ...form, days })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SunTimeField
|
||||||
|
label="Start Time"
|
||||||
|
type={form.startType} offset={form.startOffset} time={form.startTime}
|
||||||
|
onTypeChange={(v) => onChange({ ...form, startType: v })}
|
||||||
|
onOffsetChange={(v) => onChange({ ...form, startOffset: v })}
|
||||||
|
onTimeChange={(v) => onChange({ ...form, startTime: v })}
|
||||||
|
sunTimes={sunTimes}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Start Action</label>
|
||||||
|
<select value={form.startAction ?? 1} onChange={(e) => onChange({ ...form, startAction: parseFloat(e.target.value) })}>
|
||||||
|
<option value={1}>Turn ON</option>
|
||||||
|
<option value={0}>Turn OFF</option>
|
||||||
|
<option value={2}>Toggle</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SunTimeField
|
||||||
|
label="End Time (optional)"
|
||||||
|
type={form.endType} offset={form.endOffset} time={form.endTime}
|
||||||
|
onTypeChange={(v) => 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') && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>End Action</label>
|
||||||
|
<select value={form.endAction ?? -1} onChange={(e) => onChange({ ...form, endAction: parseFloat(e.target.value) })}>
|
||||||
|
<option value={-1}>None</option>
|
||||||
|
<option value={0}>Turn OFF</option>
|
||||||
|
<option value={1}>Turn ON</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
|
||||||
|
export default function ConfirmDialog({ title, message, confirmLabel = 'Confirm', danger, onConfirm, onCancel }) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
onClose={onCancel}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn btn-secondary" onClick={onCancel}>Cancel</button>
|
||||||
|
<button className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`} onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p style={{ lineHeight: 1.6, color: 'var(--text2)' }}>{message}</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="info-label">{label}</span>
|
||||||
|
<span className={`info-value${mono ? ' mono' : ''}`}>{value || '—'}</span>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-icon btn-sm"
|
||||||
|
title="Copy"
|
||||||
|
onClick={doCopy}
|
||||||
|
style={{ padding: '2px 6px', fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{copied ? '✓' : '📋'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose?.(); }}>
|
||||||
|
<div className="modal" style={wide ? { maxWidth: 700 } : {}}>
|
||||||
|
{title && <div className="modal-title">{title}</div>}
|
||||||
|
<div>{children}</div>
|
||||||
|
{footer && <div className="modal-footer">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import useSettingsStore from '../../store/settings';
|
||||||
|
|
||||||
|
export default function Toast() {
|
||||||
|
const { toasts, removeToast } = useSettingsStore();
|
||||||
|
return (
|
||||||
|
<div className="toast-container">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`toast toast-${t.type}`}
|
||||||
|
onClick={() => removeToast(t.id)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{t.msg}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function signalBars(rssi) {
|
||||||
|
const n = rssi >= -50 ? 4 : rssi >= -65 ? 3 : rssi >= -75 ? 2 : 1;
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'flex-end', gap: 1, height: 14 }}>
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: 3,
|
||||||
|
height: 3 + i * 2.5,
|
||||||
|
borderRadius: 1,
|
||||||
|
background: i <= n ? 'var(--accent)' : 'var(--border)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function securityIcon(auth) {
|
||||||
|
if (!auth || auth === 'OPEN') return <span title="Open" style={{ fontSize: 11, color: 'var(--text3)' }}>🔓</span>;
|
||||||
|
return <span title={auth} style={{ fontSize: 11 }}>🔒</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApList({ networks, selected, onSelect }) {
|
||||||
|
if (!networks || networks.length === 0) {
|
||||||
|
return <div style={{ fontSize: 13, color: 'var(--text3)', padding: '8px 0' }}>No networks found.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...networks].sort((a, b) => (b.rssi ?? -100) - (a.rssi ?? -100));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, maxHeight: 220, overflowY: 'auto' }}>
|
||||||
|
{sorted.map((ap) => (
|
||||||
|
<div
|
||||||
|
key={ap.ssid + ap.bssid}
|
||||||
|
className={`ap-item${selected === ap.ssid ? ' selected' : ''}`}
|
||||||
|
onClick={() => onSelect(ap.ssid)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
|
||||||
|
{signalBars(ap.rssi ?? -90)}
|
||||||
|
<span style={{ fontSize: 13, fontWeight: selected === ap.ssid ? 600 : 400 }}>{ap.ssid || '(hidden)'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{securityIcon(ap.auth)}
|
||||||
|
{ap.rssi !== undefined && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text3)', minWidth: 36, textAlign: 'right' }}>{ap.rssi} dBm</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{status === null && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text3)' }}>Network status unknown</span>
|
||||||
|
)}
|
||||||
|
{status === 'checking' && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text2)', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} /> Checking…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status === 'connected' && (
|
||||||
|
<span className="badge badge-online" style={{ fontSize: 12 }}>● Connected</span>
|
||||||
|
)}
|
||||||
|
{status === 'disconnected' && (
|
||||||
|
<span className="badge badge-offline" style={{ fontSize: 12 }}>● Disconnected</span>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={check} disabled={status === 'checking'}>
|
||||||
|
{status === null ? 'Check Status' : '↺ Recheck'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ padding: '16px 20px', overflowY: 'auto', height: '100%' }}>
|
||||||
|
{/* Network status header */}
|
||||||
|
<div className="info-section" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="info-section-title">Network Status</div>
|
||||||
|
<NetworkStatus device={device} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AP Scanner */}
|
||||||
|
<div className="info-section" style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||||
|
<div className="info-section-title" style={{ margin: 0 }}>Available Networks</div>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={scan} disabled={scanning}>
|
||||||
|
{scanning
|
||||||
|
? <><span className="spinner" style={{ width: 11, height: 11, borderWidth: 2 }} /> Scanning…</>
|
||||||
|
: '📡 Scan'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{networks.length > 0 || scanning ? (
|
||||||
|
<ApList networks={networks} selected={ssid} onSelect={setSsid} />
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Click Scan to discover nearby networks.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection form */}
|
||||||
|
<div className="info-section">
|
||||||
|
<div className="info-section-title">Connect to Network</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>SSID</label>
|
||||||
|
<input
|
||||||
|
placeholder="Network name"
|
||||||
|
value={ssid}
|
||||||
|
onChange={(e) => setSsid(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Security</label>
|
||||||
|
<select value={auth} onChange={(e) => setAuth(e.target.value)}>
|
||||||
|
{AUTH_TYPES.map((a) => (
|
||||||
|
<option key={a.value} value={a.value}>{a.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{auth !== 'OPEN' && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<input
|
||||||
|
type={showPass ? 'text' : 'password'}
|
||||||
|
placeholder="Wi-Fi password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => setShowPass(!showPass)}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{showPass ? '🙈' : '👁'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connectResult && (
|
||||||
|
<div className={`notice notice-${connectResult === 'success' ? 'info' : connectResult === 'connecting' ? 'warn' : 'danger'}`}
|
||||||
|
style={{ marginBottom: 10 }}>
|
||||||
|
{connectResult === 'connecting' && '⏳ Connecting to network…'}
|
||||||
|
{connectResult === 'success' && '✅ Connected successfully!'}
|
||||||
|
{connectResult === 'failed' && '❌ Connection failed. Check the device and try again.'}
|
||||||
|
{connectResult === 'badpass' && '❌ Incorrect password.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={connect}
|
||||||
|
disabled={connecting || !ssid.trim()}
|
||||||
|
>
|
||||||
|
{connecting
|
||||||
|
? <><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} /> Connecting…</>
|
||||||
|
: '🔗 Connect'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="notice notice-warn" style={{ marginTop: 14 }}>
|
||||||
|
<strong>Note:</strong> After connecting to a new network, the device will reboot and may appear offline briefly. Rediscover it after ~30 seconds.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 `<homebridgeStoragePath>/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
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Dibby Wemo Manager</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #1a1a2e;
|
||||||
|
--bg2: #16213e;
|
||||||
|
--card: #0f3460;
|
||||||
|
--accent: #e94560;
|
||||||
|
--green: #4ade80;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--border: #2d3748;
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { font-size: 1.1rem; color: var(--text); margin-bottom: 12px; }
|
||||||
|
h3 { font-size: 0.95rem; color: var(--muted); margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
.tab-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--card);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tab-btn:hover:not(.active) { color: var(--text); }
|
||||||
|
|
||||||
|
.tab-panel { display: none; }
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.card-title { font-weight: 600; font-size: 0.95rem; }
|
||||||
|
.card-subtitle { font-size: 0.78rem; color: var(--muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: 0.85; }
|
||||||
|
.btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.btn-primary { background: var(--accent); color: #fff; }
|
||||||
|
.btn-success { background: var(--green); color: #111; }
|
||||||
|
.btn-ghost { background: var(--bg2); color: var(--text); border: 1px solid var(--border); }
|
||||||
|
.btn-danger { background: #ef4444; color: #fff; }
|
||||||
|
.btn-sm { padding: 4px 10px; font-size: 0.78rem; }
|
||||||
|
|
||||||
|
/* Toggle switch */
|
||||||
|
.toggle-wrap { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.toggle {
|
||||||
|
position: relative; width: 42px; height: 24px;
|
||||||
|
display: inline-block; cursor: pointer;
|
||||||
|
}
|
||||||
|
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.slider {
|
||||||
|
position: absolute; inset: 0; background: #374151;
|
||||||
|
border-radius: 24px; transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.slider:before {
|
||||||
|
content: ''; position: absolute;
|
||||||
|
width: 18px; height: 18px; left: 3px; bottom: 3px;
|
||||||
|
background: #fff; border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
input:checked + .slider { background: var(--green); }
|
||||||
|
input:checked + .slider:before { transform: translateX(18px); }
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.form-group { margin-bottom: 12px; }
|
||||||
|
label { display: block; font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; }
|
||||||
|
input[type=text], input[type=number], select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
|
||||||
|
|
||||||
|
/* Day picker */
|
||||||
|
.day-picker { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.day-btn {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
border: 1px solid var(--border); background: var(--bg2);
|
||||||
|
color: var(--muted); cursor: pointer; font-size: 0.75rem;
|
||||||
|
font-weight: 600; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.day-btn.selected { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Chips */
|
||||||
|
.chip {
|
||||||
|
display: inline-block; padding: 2px 8px; border-radius: 12px;
|
||||||
|
font-size: 0.72rem; font-weight: 600; margin-left: 8px;
|
||||||
|
}
|
||||||
|
.chip-on { background: #14532d; color: var(--green); }
|
||||||
|
.chip-off { background: #1f2937; color: var(--muted); }
|
||||||
|
.chip-dis { background: #422006; color: #fb923c; }
|
||||||
|
|
||||||
|
/* Status / alert */
|
||||||
|
.status-bar {
|
||||||
|
background: var(--bg2); border-left: 3px solid var(--accent);
|
||||||
|
padding: 10px 14px; border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
font-size: 0.82rem; color: var(--muted); margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.alert {
|
||||||
|
padding: 10px 14px; border-radius: var(--radius);
|
||||||
|
font-size: 0.85rem; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.alert-info { background: #1e3a5f; color: #93c5fd; }
|
||||||
|
.alert-success { background: #14532d; color: var(--green); }
|
||||||
|
.alert-error { background: #450a0a; color: #fca5a5; }
|
||||||
|
|
||||||
|
/* Inline form panel — no fixed/absolute positioning needed */
|
||||||
|
#dwm-form-panel .card { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Row utils */
|
||||||
|
.flex-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.flex-col { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.spin {
|
||||||
|
display: inline-block; width: 14px; height: 14px;
|
||||||
|
border: 2px solid var(--muted); border-top-color: var(--accent);
|
||||||
|
border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty { text-align: center; color: var(--muted); padding: 32px 0; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* Location autocomplete */
|
||||||
|
.autocomplete-list {
|
||||||
|
background: var(--bg2); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); max-height: 200px; overflow-y: auto;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.autocomplete-item {
|
||||||
|
padding: 8px 12px; cursor: pointer; font-size: 0.82rem; color: var(--text);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.autocomplete-item:last-child { border-bottom: none; }
|
||||||
|
.autocomplete-item:hover { background: var(--card); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" data-tab="devices">📱 Devices</button>
|
||||||
|
<button class="tab-btn" data-tab="dwm-rules">⏰ DWM Rules</button>
|
||||||
|
<button class="tab-btn" data-tab="wemo-rules">🔌 Device Rules</button>
|
||||||
|
<button class="tab-btn" data-tab="settings">⚙️ Settings</button>
|
||||||
|
<button class="tab-btn" data-tab="help">❓ Help</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Devices Tab ──────────────────────────────────────────────────────── -->
|
||||||
|
<div id="tab-devices" class="tab-panel active">
|
||||||
|
<div class="flex-row" style="margin-bottom:16px">
|
||||||
|
<h2 style="margin:0">Wemo Devices</h2>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn btn-primary" id="btn-discover">🔍 Discover</button>
|
||||||
|
</div>
|
||||||
|
<div id="devices-status"></div>
|
||||||
|
<div id="devices-list"><div class="empty">Click Discover to find Wemo devices on your network.</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── DWM Rules Tab ────────────────────────────────────────────────────── -->
|
||||||
|
<div id="tab-dwm-rules" class="tab-panel">
|
||||||
|
|
||||||
|
<!-- List view -->
|
||||||
|
<div id="dwm-list-view">
|
||||||
|
<div class="flex-row" style="margin-bottom:12px">
|
||||||
|
<h2 style="margin:0">DWM Automation Rules</h2>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn btn-primary" id="btn-add-dwm">+ Add Rule</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scheduler heartbeat bar -->
|
||||||
|
<div id="dwm-heartbeat" style="display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:6px;margin-bottom:14px;font-size:0.8rem;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.18)">
|
||||||
|
<span id="hb-dot" style="width:10px;height:10px;border-radius:50%;background:#6b7280;flex-shrink:0"></span>
|
||||||
|
<span id="hb-text" style="color:#9ca3af">Checking scheduler…</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span id="hb-next" style="color:#6b7280;font-size:0.75rem"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dwm-rules-status"></div>
|
||||||
|
<div id="dwm-rules-list"><div class="empty">No DWM rules yet.</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inline add/edit form (hidden until needed) -->
|
||||||
|
<div id="dwm-form-panel" style="display:none">
|
||||||
|
<div class="flex-row" style="margin-bottom:16px">
|
||||||
|
<h2 id="dwm-form-title" style="margin:0">Add DWM Rule</h2>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="btn-dwm-form-cancel">✕ Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Rule Name</label>
|
||||||
|
<input type="text" id="dwm-name" placeholder="e.g. Evening Lights" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Type</label>
|
||||||
|
<select id="dwm-type">
|
||||||
|
<option value="Schedule">📅 Schedule (fixed on/off times)</option>
|
||||||
|
<option value="Countdown">⏱ Countdown (timer)</option>
|
||||||
|
<option value="Away">🏠 Away Mode (random)</option>
|
||||||
|
<option value="AlwaysOn">🔒 Always On (keep device on)</option>
|
||||||
|
<option value="Trigger">⚡ Trigger (IFTTT-style)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Target devices (Schedule / Countdown / Away / AlwaysOn) -->
|
||||||
|
<div class="form-group" id="dwm-target-group">
|
||||||
|
<label>Target Devices</label>
|
||||||
|
<select id="dwm-target-devices" multiple size="4" style="height:90px"></select>
|
||||||
|
<div style="font-size:0.75rem;color:var(--muted);margin-top:4px">Hold Ctrl/Cmd to select multiple</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trigger fields -->
|
||||||
|
<div id="dwm-trigger-fields" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Trigger Device (source)</label>
|
||||||
|
<select id="dwm-trigger-src"></select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label>When</label>
|
||||||
|
<select id="dwm-trigger-event">
|
||||||
|
<option value="any">Turns ON or OFF</option>
|
||||||
|
<option value="on">Turns ON</option>
|
||||||
|
<option value="off">Turns OFF</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label>Then</label>
|
||||||
|
<select id="dwm-trigger-action">
|
||||||
|
<option value="on">Turn ON action devices</option>
|
||||||
|
<option value="off">Turn OFF action devices</option>
|
||||||
|
<option value="mirror">Mirror (same as trigger)</option>
|
||||||
|
<option value="opposite">Opposite (invert)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Action Devices (targets)</label>
|
||||||
|
<select id="dwm-trigger-targets" multiple size="4" style="height:90px"></select>
|
||||||
|
<div style="font-size:0.75rem;color:var(--muted);margin-top:4px">Hold Ctrl/Cmd to select multiple</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="dwm-days-group">
|
||||||
|
<label>Days</label>
|
||||||
|
<div class="day-picker" id="dwm-days">
|
||||||
|
<button class="day-btn" data-day="1">Mon</button>
|
||||||
|
<button class="day-btn" data-day="2">Tue</button>
|
||||||
|
<button class="day-btn" data-day="3">Wed</button>
|
||||||
|
<button class="day-btn" data-day="4">Thu</button>
|
||||||
|
<button class="day-btn" data-day="5">Fri</button>
|
||||||
|
<button class="day-btn" data-day="6">Sat</button>
|
||||||
|
<button class="day-btn" data-day="7">Sun</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dwm-schedule-fields">
|
||||||
|
<div class="flex-row">
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label>Start Time</label>
|
||||||
|
<input type="text" id="dwm-start-time" placeholder="e.g. 8:30 PM" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label>End Time (optional)</label>
|
||||||
|
<input type="text" id="dwm-end-time" placeholder="e.g. 11:00 PM" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-row">
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label>Start Action</label>
|
||||||
|
<select id="dwm-start-action">
|
||||||
|
<option value="1">Turn ON</option>
|
||||||
|
<option value="0">Turn OFF</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label>End Action</label>
|
||||||
|
<select id="dwm-end-action">
|
||||||
|
<option value="-1">None</option>
|
||||||
|
<option value="0">Turn OFF</option>
|
||||||
|
<option value="1">Turn ON</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dwm-countdown-fields" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Countdown Duration (minutes)</label>
|
||||||
|
<input type="number" id="dwm-countdown-mins" min="1" max="1440" placeholder="60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dwm-alwayson-info" style="display:none;margin-bottom:12px;padding:10px;background:rgba(48,209,88,.1);border:1px solid rgba(48,209,88,.3);border-radius:6px;font-size:0.82rem;color:#4ade80">
|
||||||
|
🔒 The scheduler polls this device every 10 seconds. If it is found OFF it will be turned back ON automatically. No schedule needed.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="toggle-wrap">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="dwm-enabled" checked />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span style="font-size:0.88rem">Enabled</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dwm-form-error" class="alert alert-error" style="display:none"></div>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px">
|
||||||
|
<button class="btn btn-ghost" id="dwm-form-cancel-btn">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="dwm-form-save-btn">Save Rule</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Wemo Device Rules Tab ────────────────────────────────────────────── -->
|
||||||
|
<div id="tab-wemo-rules" class="tab-panel">
|
||||||
|
<div style="margin-bottom:12px">
|
||||||
|
<h2>Native Device Rules</h2>
|
||||||
|
<p style="font-size:0.82rem;color:var(--muted);margin-top:4px">
|
||||||
|
Manage on-device schedules stored in Wemo firmware. Select a device to view its rules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Select Device</label>
|
||||||
|
<select id="wemo-rules-device-select"><option value="">— choose device —</option></select>
|
||||||
|
</div>
|
||||||
|
<div id="wemo-rules-status"></div>
|
||||||
|
<div id="wemo-rules-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Settings Tab ─────────────────────────────────────────────────────── -->
|
||||||
|
<div id="tab-settings" class="tab-panel">
|
||||||
|
<h2 style="margin-bottom:16px">Settings</h2>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Location (for sunrise/sunset rules)</h3>
|
||||||
|
<div id="location-current" style="margin-bottom:10px;font-size:0.83rem;color:var(--muted)">Not set</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Search for your city</label>
|
||||||
|
<input type="text" id="location-search-input" placeholder="e.g. London" autocomplete="off" />
|
||||||
|
<div id="location-autocomplete" class="autocomplete-list" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="btn-location-save" disabled>Save Location</button>
|
||||||
|
<span id="location-status" style="font-size:0.78rem;color:var(--green);margin-left:8px"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Help Tab ──────────────────────────────────────────────────────────── -->
|
||||||
|
<div id="tab-help" class="tab-panel">
|
||||||
|
<h2 style="margin-bottom:4px">❓ Help & Guide</h2>
|
||||||
|
<p style="font-size:0.82rem;color:var(--muted);margin-bottom:20px">How to use Dibby Wemo Manager in Homebridge</p>
|
||||||
|
|
||||||
|
<!-- Getting Started -->
|
||||||
|
<div class="card" style="margin-bottom:10px">
|
||||||
|
<h3 style="color:var(--accent);margin-bottom:10px">🚀 Getting Started</h3>
|
||||||
|
<ol style="font-size:0.85rem;line-height:1.9;padding-left:18px;color:var(--text)">
|
||||||
|
<li>Go to the <strong>📱 Devices</strong> tab and click <strong>Discover</strong> — your Wemo devices on the local network will appear.</li>
|
||||||
|
<li>Devices are automatically added to HomeKit as switches. Toggle them from the Home app on your iPhone/iPad.</li>
|
||||||
|
<li>To create automation rules, go to the <strong>⏰ DWM Rules</strong> tab and click <strong>+ Add Rule</strong>.</li>
|
||||||
|
<li>Rules run inside Homebridge — no internet or Belkin cloud required.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DWM Rules -->
|
||||||
|
<div class="card" style="margin-bottom:10px">
|
||||||
|
<h3 style="color:var(--accent);margin-bottom:10px">⏰ DWM Rules — How to Create a Rule</h3>
|
||||||
|
<p style="font-size:0.83rem;color:var(--muted);margin-bottom:10px">DWM (Dibby Wemo Manager) rules are stored locally and run in Homebridge.</p>
|
||||||
|
<ol style="font-size:0.85rem;line-height:1.9;padding-left:18px;color:var(--text)">
|
||||||
|
<li>Click the <strong>⏰ DWM Rules</strong> tab at the top.</li>
|
||||||
|
<li>Click <strong>+ Add Rule</strong> — the rule form opens inline on the same page (no pop-up).</li>
|
||||||
|
<li>Enter a <strong>Rule Name</strong> (e.g. "Evening Lights").</li>
|
||||||
|
<li>Choose a <strong>Rule Type</strong> (see types below).</li>
|
||||||
|
<li>Select <strong>target devices</strong> — which lights/switches the rule controls.</li>
|
||||||
|
<li>Fill in the schedule details and click <strong>Save Rule</strong>. Click <strong>Cancel</strong> or the <strong>✕</strong> button to go back without saving.</li>
|
||||||
|
<li>The rule is active immediately — the toggle switch on the card enables/disables it without deleting it.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div style="margin-top:14px;border-top:1px solid var(--border);padding-top:12px">
|
||||||
|
<p style="font-size:0.82rem;font-weight:600;color:var(--text);margin-bottom:8px">Rule Types:</p>
|
||||||
|
<table style="width:100%;font-size:0.82rem;border-collapse:collapse">
|
||||||
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
|
<td style="padding:7px 8px;white-space:nowrap">📅 <strong>Schedule</strong></td>
|
||||||
|
<td style="padding:7px 8px;color:var(--muted)">Turn on/off at fixed times on selected days. Enter times in 12-hour format (e.g. <em>8:30 PM</em>). Set a start time and optional end time, choose the action for each.</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
|
<td style="padding:7px 8px;white-space:nowrap">⏱ <strong>Countdown</strong></td>
|
||||||
|
<td style="padding:7px 8px;color:var(--muted)">Auto-off after a set number of minutes. Useful for things like a bathroom fan or porch light.</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
|
<td style="padding:7px 8px;white-space:nowrap">🏠 <strong>Away Mode</strong></td>
|
||||||
|
<td style="padding:7px 8px;color:var(--muted)">Randomly turns lights on and off within a time window to simulate occupancy while you're away.</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
|
<td style="padding:7px 8px;white-space:nowrap">🔒 <strong>Always On</strong></td>
|
||||||
|
<td style="padding:7px 8px;color:var(--muted)">Keeps 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.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:7px 8px;white-space:nowrap">⚡ <strong>Trigger</strong></td>
|
||||||
|
<td style="padding:7px 8px;color:var(--muted)">IFTTT-style: when one device turns on/off, automatically control another. E.g. "When the porch light turns ON, turn ON the driveway lights too."</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:14px;border-top:1px solid var(--border);padding-top:12px">
|
||||||
|
<p style="font-size:0.82rem;font-weight:600;color:var(--text);margin-bottom:6px">⏰ Entering Times</p>
|
||||||
|
<p style="font-size:0.82rem;color:var(--muted);margin-bottom:6px">Times use 12-hour AM/PM format. All of these are valid:</p>
|
||||||
|
<table style="font-size:0.82rem;border-collapse:collapse">
|
||||||
|
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">8:30 PM</code></td><td style="color:var(--muted)">8:30 in the evening</td></tr>
|
||||||
|
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">8:30PM</code></td><td style="color:var(--muted)">same — space is optional</td></tr>
|
||||||
|
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">6:00 AM</code></td><td style="color:var(--muted)">6 o'clock in the morning</td></tr>
|
||||||
|
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">12:00 AM</code></td><td style="color:var(--muted)">midnight</td></tr>
|
||||||
|
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">12:00 PM</code></td><td style="color:var(--muted)">noon</td></tr>
|
||||||
|
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">9 PM</code></td><td style="color:var(--muted)">9:00 PM — minutes are optional</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trigger rules detail -->
|
||||||
|
<div class="card" style="margin-bottom:10px">
|
||||||
|
<h3 style="color:var(--accent);margin-bottom:10px">⚡ Trigger Rules (IFTTT)</h3>
|
||||||
|
<p style="font-size:0.83rem;color:var(--muted);margin-bottom:10px">Trigger rules let one device control another automatically.</p>
|
||||||
|
<ol style="font-size:0.85rem;line-height:1.9;padding-left:18px;color:var(--text)">
|
||||||
|
<li>Click <strong>+ Add Rule</strong> and select type <strong>⚡ Trigger</strong>.</li>
|
||||||
|
<li>Under <strong>Trigger Device</strong> — pick the device whose state change starts the action.</li>
|
||||||
|
<li>Under <strong>When</strong> — choose "Turns ON", "Turns OFF", or "Turns ON or OFF".</li>
|
||||||
|
<li>Under <strong>Then</strong> — choose what to do to the action devices:<br>
|
||||||
|
<span style="color:var(--muted);display:block;padding-left:12px;margin-top:2px">
|
||||||
|
• <strong>Turn ON</strong> — always turn action devices on<br>
|
||||||
|
• <strong>Turn OFF</strong> — always turn action devices off<br>
|
||||||
|
• <strong>Mirror</strong> — action devices copy the trigger (ON→ON, OFF→OFF)<br>
|
||||||
|
• <strong>Opposite</strong> — action devices invert the trigger (ON→OFF, OFF→ON)
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>Under <strong>Action Devices</strong> — select which devices to control (hold Ctrl/Cmd for multiple).</li>
|
||||||
|
<li>Click <strong>Save Rule</strong>. Homebridge polls devices every 10 s and fires the trigger on state change.</li>
|
||||||
|
</ol>
|
||||||
|
<p style="font-size:0.8rem;color:var(--muted);margin-top:8px;padding:8px;background:rgba(255,214,10,.07);border-radius:6px">
|
||||||
|
⚠️ The scheduler must be running for Trigger rules to work. If Homebridge restarts, rules resume automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Rules -->
|
||||||
|
<div class="card" style="margin-bottom:10px">
|
||||||
|
<h3 style="color:var(--accent);margin-bottom:10px">🔌 Device Rules (Native Firmware)</h3>
|
||||||
|
<p style="font-size:0.83rem;color:var(--muted);margin-bottom:8px">These are rules stored directly on the Wemo device's own firmware — separate from DWM Rules.</p>
|
||||||
|
<ul style="font-size:0.85rem;line-height:1.8;padding-left:18px;color:var(--text)">
|
||||||
|
<li>Click <strong>🔌 Device Rules</strong> tab, then select a device from the dropdown.</li>
|
||||||
|
<li>Rules stored on the device are listed. You can enable/disable or delete them.</li>
|
||||||
|
<li>Note: Wemo Dimmer V2 devices with newer firmware do <strong>not</strong> support this feature.</li>
|
||||||
|
<li>DWM Rules are recommended over device rules as they support more features and work across multiple devices.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<div class="card" style="margin-bottom:10px">
|
||||||
|
<h3 style="color:var(--accent);margin-bottom:10px">⚙️ Settings — Location</h3>
|
||||||
|
<p style="font-size:0.83rem;color:var(--muted);margin-bottom:8px">Set your city for accurate sunrise/sunset times in Schedule rules.</p>
|
||||||
|
<ol style="font-size:0.85rem;line-height:1.9;padding-left:18px;color:var(--text)">
|
||||||
|
<li>Click the <strong>⚙️ Settings</strong> tab.</li>
|
||||||
|
<li>Type your city name in the search box (e.g. "London" or "New York").</li>
|
||||||
|
<li>Pick your city from the dropdown that appears.</li>
|
||||||
|
<li>Click <strong>Save Location</strong>.</li>
|
||||||
|
<li>You can now use 🌅 Sunrise and 🌇 Sunset as start/end times in Schedule rules.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Troubleshooting -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="color:var(--accent);margin-bottom:10px">🔧 Troubleshooting</h3>
|
||||||
|
<table style="width:100%;font-size:0.82rem;border-collapse:collapse">
|
||||||
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
|
<td style="padding:7px 8px;white-space:nowrap;color:var(--text)"><strong>No devices found</strong></td>
|
||||||
|
<td style="padding:7px 8px;color:var(--muted)">Make 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.</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
|
<td style="padding:7px 8px;white-space:nowrap;color:var(--text)"><strong>HomeKit toggle not working</strong></td>
|
||||||
|
<td style="padding:7px 8px;color:var(--muted)">Restart Homebridge. Devices need to be discovered at least once before HomeKit can control them. Check the Homebridge logs for errors.</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
|
<td style="padding:7px 8px;white-space:nowrap;color:var(--text)"><strong>Rules not firing</strong></td>
|
||||||
|
<td style="padding:7px 8px;color:var(--muted)">Check the <strong>⏰ DWM Rules</strong> 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).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:7px 8px;white-space:nowrap;color:var(--text)"><strong>Settings panel blank</strong></td>
|
||||||
|
<td style="padding:7px 8px;color:var(--muted)">Run: <code style="background:var(--bg2);padding:1px 5px;border-radius:3px">npm install --prefix "%APPDATA%/npm/node_modules/homebridge-dibby-wemo"</code> then restart Homebridge.</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
|
? `<div class="alert alert-${type}">${msg}</div>`
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function spinner() { return '<span class="spin"></span>'; }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 = '<div class="empty">No devices found. Click Discover to scan your network.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = _devices.map((d, i) => `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<div class="card-title">${esc(d.friendlyName ?? d.host)}</div>
|
||||||
|
<div class="card-subtitle">${esc(d.host)}:${d.port} — ${esc(d.productModel ?? 'Wemo Device')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-wrap">
|
||||||
|
<span id="dev-state-label-${i}" style="font-size:0.82rem;color:var(--muted)">…</span>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="dev-toggle-${i}" onchange="setDeviceState(${i},this.checked)" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).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 = '<div class="empty">No DWM rules yet. Click "+ Add Rule" to create one.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const typeIcon = { Schedule: '📅', Away: '🏠', Countdown: '⏱', AlwaysOn: '🔒', Trigger: '⚡' };
|
||||||
|
el.innerHTML = _dwmRules.map((r) => `
|
||||||
|
<div class="card" data-rule-id="${r.id}">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<div class="card-title">
|
||||||
|
${typeIcon[r.type] || '📅'} ${esc(r.name)}
|
||||||
|
<span class="chip ${r.enabled ? 'chip-on' : 'chip-dis'}">${r.enabled ? 'enabled' : 'disabled'}</span>
|
||||||
|
<span class="chip chip-off">${esc(r.type)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-subtitle">${dwmRuleSummary(r)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-row">
|
||||||
|
<label class="toggle" title="${r.enabled ? 'Disable' : 'Enable'} rule">
|
||||||
|
<input type="checkbox" ${r.enabled ? 'checked' : ''} onchange="toggleDwmRule('${r.id}', this.checked)" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="openDwmEdit('${r.id}')">Edit</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteDwmRule('${r.id}')">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).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 = '<span style="color:#fca5a5;flex:1">Delete this rule?</span>'
|
||||||
|
+ `<button class="btn btn-danger btn-sm" onclick="deleteDwmRule('${id}')">Yes, delete</button>`
|
||||||
|
+ '<button class="btn btn-ghost btn-sm" onclick="this.closest(\'.delete-confirm-row\').remove()">Cancel</button>';
|
||||||
|
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) =>
|
||||||
|
`<option value="${esc(d.host)}:${d.port}">${esc(d.friendlyName ?? d.host)}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Populate all device selects
|
||||||
|
document.getElementById('dwm-target-devices').innerHTML = devOptions;
|
||||||
|
document.getElementById('dwm-trigger-src').innerHTML = '<option value="">— select device —</option>' + 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 = '<option value="">— choose device —</option>' +
|
||||||
|
_devices.map((d) =>
|
||||||
|
`<option value="${esc(d.host)}:${d.port}">${esc(d.friendlyName ?? d.host)} (${esc(d.host)})</option>`
|
||||||
|
).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 = '<div class="empty">No on-device rules found.</div>';
|
||||||
|
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 `<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<div class="card-title">
|
||||||
|
${esc(r.Name)}
|
||||||
|
<span class="chip ${enabled ? 'chip-on' : 'chip-dis'}">${enabled ? 'enabled' : 'disabled'}</span>
|
||||||
|
<span class="chip chip-off">${esc(r.Type)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-subtitle">${dayList} · ${startTime}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-row">
|
||||||
|
<label class="toggle" title="${enabled ? 'Disable' : 'Enable'} on device">
|
||||||
|
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleWemoRule('${esc(host)}',${port},'${r.RuleID}',this.checked)" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteWemoRule('${esc(host)}',${port},'${r.RuleID}')">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).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) =>
|
||||||
|
`<div class="autocomplete-item" data-idx="${i}">${esc(r.label)}</div>`
|
||||||
|
).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, '>').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)
|
||||||
|
})();
|
||||||
@@ -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())();
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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>([^<]+)<\/firmwareVersion>/i);
|
||||||
|
const udnMatch = sx.data.match(/<UDN>([^<]+)<\/UDN>/i);
|
||||||
|
const dtMatch = sx.data.match(/<deviceType>([^<]+)<\/deviceType>/i);
|
||||||
|
const mdMatch = sx.data.match(/<modelDescription>([^<]+)<\/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 = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:StoreRules xmlns:u="urn:Belkin:service:rules:1">
|
||||||
|
<ruleDbVersion>${version}</ruleDbVersion>
|
||||||
|
<StartSync>NOSYNC</StartSync>
|
||||||
|
<ruleDbBody><![CDATA[${b64}]]></ruleDbBody>
|
||||||
|
</u:StoreRules>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>`;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "@wemo-manager/core",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user