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:
SRS IT
2026-03-28 16:30:43 -04:00
commit 27be1892ed
75 changed files with 14322 additions and 0 deletions
+54
View File
@@ -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/
+21
View File
@@ -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.
+214
View File
@@ -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:
> `&lt;![CDATA[base64data]]&gt;`
> 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.*
+194
View File
@@ -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 (3090 min) then off (115 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
+61
View File
@@ -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'),
},
},
},
},
});
+122
View File
@@ -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/**/*"
]
}
}
+121
View File
@@ -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 &nbsp;·&nbsp; 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
+41
View File
@@ -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);
});
+81
View File
@@ -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 };
+76
View File
@@ -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,
};
+72
View File
@@ -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 };
+72
View File
@@ -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 };
+336
View File
@@ -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();
});
+75
View File
@@ -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));
};
+84
View File
@@ -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();
});
};
+104
View File
@@ -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);
});
};
+22
View File
@@ -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);
});
+723
View File
@@ -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 3090 min, OFF 115 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 3090 minutes, OFF for 115 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 };
+127
View File
@@ -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 };
+94
View File
@@ -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,
};
+321
View File
@@ -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
+101
View File
@@ -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);
},
});
+14
View File
@@ -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>
+151
View File
@@ -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. &nbsp;
<strong>Clear Wi-Fi</strong> = Wi-Fi settings only. &nbsp;
<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 &nbsp;·&nbsp; 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> (3090 min) then <strong>off</strong> (115 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>
);
}
+10
View File
@@ -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;
}
+222
View File
@@ -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 &amp; 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ---------------------------------------------------------------------------
// 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())();
+20
View File
@@ -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;
+188
View File
@@ -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 };
+721
View File
@@ -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 3090 min, OFF 115 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;
+118
View File
@@ -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;
+81
View File
@@ -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 };
+76
View File
@@ -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>&lt;![CDATA[${b64}]]&gt;</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,
};
+34
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "@wemo-manager/core",
"version": "2.0.0",
"private": true,
"main": "src/index.js"
}
+15
View File
@@ -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,
};
+81
View File
@@ -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 };
+76
View File
@@ -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,
};