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
+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;
}