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
+222
View File
@@ -0,0 +1,222 @@
# homebridge-dibby-wemo
**Homebridge plugin for local Belkin Wemo control — no cloud required.**
Registers all Wemo devices on your local network as HomeKit switches and provides a full scheduling engine via a custom Homebridge UI panel. All device communication is direct local UPnP/SOAP — no Belkin account needed.
---
## Installation
### Via Homebridge UI (recommended)
1. Open Homebridge UI → **Plugins**
2. Search for `homebridge-dibby-wemo`
3. Click **Install**
4. Restart Homebridge
### Via npm
```bash
npm install -g homebridge-dibby-wemo
```
---
## Configuration
Add to your Homebridge `config.json`:
```json
{
"platforms": [
{
"platform": "DibbyWemo",
"name": "DibbyWemo"
}
]
}
```
Restart Homebridge. All Wemo devices on your network are discovered automatically and appear in HomeKit.
### Optional config properties
```json
{
"platform": "DibbyWemo",
"name": "DibbyWemo",
"discoveryTimeout": 10000,
"pollInterval": 30,
"manualDevices": [
{ "host": "192.168.1.50", "port": 49153 }
]
}
```
| Property | Type | Default | Description |
|---|---|---|---|
| `discoveryTimeout` | number | `10000` | SSDP discovery window in milliseconds |
| `pollInterval` | number | `30` | How often (seconds) to poll device state for HomeKit |
| `manualDevices` | array | `[]` | Devices to add by IP if SSDP discovery misses them |
---
## Custom UI
Once installed, open the plugin settings in Homebridge UI. The plugin provides a full custom panel with five tabs:
### 📱 Devices Tab
- Lists all discovered Wemo devices with their model, firmware version, and IP address
- Toggle any device on or off directly from the UI
- **Discover** button re-runs SSDP discovery and updates the device list
### ⏰ DWM Rules Tab
Create and manage automation rules that run inside Homebridge.
**Scheduler status bar** — shown at the top of the tab:
- 🟢 **Green** — scheduler is running, shows total schedule entries and next upcoming rule
- 🟠 **Amber** — scheduler may have stopped (no heartbeat for 90+ seconds) — restart Homebridge
- 🔴 **Red** — scheduler is not running — check the `DibbyWemo` platform is in `config.json`
**Rule types:**
| Icon | Type | Description |
|---|---|---|
| 📅 | **Schedule** | Turn devices on/off at specific times on selected days |
| ⏱ | **Countdown** | Active window — on at start, off at end (cross-midnight aware) |
| 🏠 | **Away Mode** | Randomised on/off simulation during a time window |
| 🔒 | **Always On** | Device is kept ON at all times; any off-state is corrected within 10 seconds |
| ⚡ | **Trigger** | IFTTT-style: when one device changes state, control another |
**Creating a rule:**
1. Click **+ ADD RULE**
2. Enter a name, select the rule type
3. Select target device(s) and set times / options
4. Click **Save Rule**
Rules take effect on the next 30-second scheduler tick — no restart needed.
**Editing / deleting a rule:**
- Click **EDIT** to open the inline form
- Click **DELETE** → confirm with **Yes, delete** in the inline bar that appears
**Times use 12-hour AM/PM format.** Examples: `8:30 PM`, `6:00 AM`, `12:00 AM` (midnight), `9 PM`
### 🔌 Device Rules Tab
Manage rules stored directly on the Wemo device's own firmware:
1. Select a device from the dropdown
2. Click **Load Rules** to fetch the device's rule database
3. Toggle rules on/off or delete them
4. Click **Add Rule** to create a new native firmware rule
> Native firmware rules are separate from DWM Rules. DWM Rules are recommended as they support more features and work across multiple devices simultaneously.
> Wemo Dimmer V2 (WDS060) with newer RTOS firmware does not support `FetchRules`/`StoreRules`. These devices show a warning in the Device Rules tab.
### ⚙️ Settings Tab
Set your **location** for sunrise/sunset-based scheduling:
1. Type your city name in the search box
2. Select your city from the dropdown
3. Click **Save Location**
Once set, you can use Sunrise and Sunset as rule start/end times.
### ❓ Help Tab
Built-in documentation covering all features, rule types, time format, and troubleshooting.
---
## How It Works
### Device Discovery
At startup, the plugin broadcasts an SSDP M-SEARCH packet to `239.255.255.250:1900`. Wemo devices respond with their location URL, from which the plugin fetches device details (`/setup.xml`) and registers each device as a HomeKit switch accessory.
Cached devices are restored immediately on the next restart so HomeKit doesn't time out waiting for SSDP to complete.
### HomeKit Control
All on/off commands use direct UPnP SOAP requests to the device:
- `SetBinaryState` — set on (`1`) or off (`0`)
- `GetBinaryState` — read current state
The plugin polls each device every `pollInterval` seconds and pushes state changes to HomeKit.
### DWM Scheduler
The scheduler runs inside the Homebridge process:
- **30-second tick** — reloads rules from store, schedules upcoming events
- **65-second look-ahead window** — pre-schedules `setTimeout` callbacks for precise firing
- **10-minute catch-up** — on restart, fires any rules whose time fell within the last 10 minutes
- **Health monitor** — polls all referenced devices every 10 seconds for AlwaysOn and Trigger rule enforcement
- **Heartbeat** — writes scheduler status to the store every tick; the UI reads this to show the status bar
Rules are stored in `<homebridgeStoragePath>/dibby-wemo.json`. The scheduler reloads this file on every tick, so rules created or edited in the UI take effect within 30 seconds without a restart.
### Native Firmware Rules
Wemo devices store their own rules in a SQLite database inside a ZIP archive. The plugin:
1. Calls `FetchRules` to get the current database URL
2. Downloads and extracts the ZIP to get the SQLite file
3. Opens it with `sql.js` (WebAssembly SQLite — no native compilation)
4. Modifies the database
5. Re-ZIPs, base64-encodes, and uploads via `StoreRules`
---
## Troubleshooting
| Problem | Solution |
|---|---|
| No devices found | Ensure PC and Wemo devices are on the same network. Some routers block SSDP multicast — add devices manually via `manualDevices` in config. |
| HomeKit switch unresponsive | Restart Homebridge. The device must be discovered at least once to register. Check Homebridge logs for SOAP errors. |
| Rules not firing | Check the scheduler status bar in the DWM Rules tab. 🔴 Red = DibbyWemo platform missing from config. 🟠 Amber = restart Homebridge. |
| Settings gear icon missing | Ensure `customUi: true` is in the plugin's `package.json` and `config.schema.json`. Upgrade `homebridge-config-ui-x` to v5+. |
| Dimmer device shows warning | Wemo Dimmer V2 (WDS060) newer firmware does not support FetchRules. Power control still works. |
| Rule was created but not showing | The UI data refreshes on tab open. Switch away and back to the DWM Rules tab, or restart Homebridge and hard-refresh the browser (Ctrl+Shift+R). |
---
## Data Storage
All plugin data is stored in the Homebridge storage directory (default `~/.homebridge/`):
**`dibby-wemo.json`** — main plugin store:
```json
{
"location": { "lat": 0, "lng": 0, "city": "...", "country": "..." },
"devices": [...],
"dwmRules": [...],
"schedulerHeartbeat": { "running": true, "ts": "...", "upcoming": [...] }
}
```
No data is sent outside your local network.
---
## Requirements
- Homebridge ≥ 1.6.0
- Node.js ≥ 18
- homebridge-config-ui-x ≥ 5.0.0 (for custom UI panel)
- Wemo devices on the same LAN as the Homebridge host
---
## License
MIT
@@ -0,0 +1,50 @@
{
"pluginAlias": "DibbyWemo",
"pluginType": "platform",
"singular": true,
"customUi": true,
"headerDisplay": "**Dibby Wemo Manager** Local Wemo control with DWM scheduling. No Belkin cloud required.",
"schema": {
"type": "object",
"properties": {
"name": {
"title": "Plugin Name",
"type": "string",
"default": "DibbyWemo"
},
"location": {
"title": "Location (for sunrise/sunset rules)",
"type": "string",
"description": "Set your city in the plugin settings panel (click the Settings icon) for accurate sunrise/sunset times. This field is filled in automatically.",
"readOnly": true
},
"discoveryTimeout": {
"title": "Discovery Timeout (ms)",
"type": "integer",
"default": 10000,
"minimum": 3000,
"description": "How long to wait for SSDP discovery responses"
},
"pollInterval": {
"title": "Device Poll Interval (seconds)",
"type": "integer",
"default": 30,
"minimum": 10,
"description": "How often to poll device state for HomeKit updates"
},
"manualDevices": {
"title": "Manual Devices",
"type": "array",
"description": "Add devices that don't respond to SSDP discovery",
"items": {
"type": "object",
"properties": {
"host": { "title": "IP Address", "type": "string" },
"port": { "title": "Port", "type": "integer", "default": 49153 }
},
"required": ["host"]
}
}
}
}
}
@@ -0,0 +1,555 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Dibby Wemo Manager</title>
<style>
:root {
--bg: #1a1a2e;
--bg2: #16213e;
--card: #0f3460;
--accent: #e94560;
--green: #4ade80;
--text: #e2e8f0;
--muted: #94a3b8;
--border: #2d3748;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 16px;
}
h2 { font-size: 1.1rem; color: var(--text); margin-bottom: 12px; }
h3 { font-size: 0.95rem; color: var(--muted); margin-bottom: 8px; }
.tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 2px solid var(--border);
padding-bottom: 2px;
}
.tab-btn {
background: none;
border: none;
color: var(--muted);
padding: 8px 16px;
cursor: pointer;
font-size: 0.9rem;
border-radius: var(--radius) var(--radius) 0 0;
transition: color 0.15s, background 0.15s;
}
.tab-btn.active {
color: var(--text);
background: var(--card);
font-weight: 600;
}
.tab-btn:hover:not(.active) { color: var(--text); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Cards */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 10px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-title { font-weight: 600; font-size: 0.95rem; }
.card-subtitle { font-size: 0.78rem; color: var(--muted); margin-top: 2px; }
/* Buttons */
.btn {
padding: 6px 14px;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-success { background: var(--green); color: #111; }
.btn-ghost { background: var(--bg2); color: var(--text); border: 1px solid var(--border); }
.btn-danger { background: #ef4444; color: #fff; }
.btn-sm { padding: 4px 10px; font-size: 0.78rem; }
/* Toggle switch */
.toggle-wrap { display: flex; align-items: center; gap: 8px; }
.toggle {
position: relative; width: 42px; height: 24px;
display: inline-block; cursor: pointer;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; inset: 0; background: #374151;
border-radius: 24px; transition: background 0.2s;
}
.slider:before {
content: ''; position: absolute;
width: 18px; height: 18px; left: 3px; bottom: 3px;
background: #fff; border-radius: 50%;
transition: transform 0.2s;
}
input:checked + .slider { background: var(--green); }
input:checked + .slider:before { transform: translateX(18px); }
/* Form */
.form-group { margin-bottom: 12px; }
label { display: block; font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; }
input[type=text], input[type=number], select {
width: 100%;
background: var(--bg2);
border: 1px solid var(--border);
color: var(--text);
padding: 7px 10px;
border-radius: var(--radius);
font-size: 0.875rem;
}
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
/* Day picker */
.day-picker { display: flex; gap: 6px; flex-wrap: wrap; }
.day-btn {
width: 36px; height: 36px; border-radius: 50%;
border: 1px solid var(--border); background: var(--bg2);
color: var(--muted); cursor: pointer; font-size: 0.75rem;
font-weight: 600; transition: all 0.15s;
}
.day-btn.selected { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Chips */
.chip {
display: inline-block; padding: 2px 8px; border-radius: 12px;
font-size: 0.72rem; font-weight: 600; margin-left: 8px;
}
.chip-on { background: #14532d; color: var(--green); }
.chip-off { background: #1f2937; color: var(--muted); }
.chip-dis { background: #422006; color: #fb923c; }
/* Status / alert */
.status-bar {
background: var(--bg2); border-left: 3px solid var(--accent);
padding: 10px 14px; border-radius: 0 var(--radius) var(--radius) 0;
font-size: 0.82rem; color: var(--muted); margin-bottom: 16px;
}
.alert {
padding: 10px 14px; border-radius: var(--radius);
font-size: 0.85rem; margin-bottom: 12px;
}
.alert-info { background: #1e3a5f; color: #93c5fd; }
.alert-success { background: #14532d; color: var(--green); }
.alert-error { background: #450a0a; color: #fca5a5; }
/* Inline form panel — no fixed/absolute positioning needed */
#dwm-form-panel .card { margin-bottom: 0; }
/* Row utils */
.flex-row { display: flex; align-items: center; gap: 8px; }
.flex-col { display: flex; flex-direction: column; gap: 4px; }
.spacer { flex: 1; }
/* Spinner */
.spin {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--muted); border-top-color: var(--accent);
border-radius: 50%; animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Empty state */
.empty { text-align: center; color: var(--muted); padding: 32px 0; font-size: 0.9rem; }
/* Location autocomplete */
.autocomplete-list {
background: var(--bg2); border: 1px solid var(--border);
border-radius: var(--radius); max-height: 200px; overflow-y: auto;
margin-top: 4px;
}
.autocomplete-item {
padding: 8px 12px; cursor: pointer; font-size: 0.82rem; color: var(--text);
border-bottom: 1px solid var(--border);
}
.autocomplete-item:last-child { border-bottom: none; }
.autocomplete-item:hover { background: var(--card); }
</style>
</head>
<body>
<div class="tabs">
<button class="tab-btn active" data-tab="devices">📱 Devices</button>
<button class="tab-btn" data-tab="dwm-rules">⏰ DWM Rules</button>
<button class="tab-btn" data-tab="wemo-rules">🔌 Device Rules</button>
<button class="tab-btn" data-tab="settings">⚙️ Settings</button>
<button class="tab-btn" data-tab="help">❓ Help</button>
</div>
<!-- ── Devices Tab ──────────────────────────────────────────────────────── -->
<div id="tab-devices" class="tab-panel active">
<div class="flex-row" style="margin-bottom:16px">
<h2 style="margin:0">Wemo Devices</h2>
<div class="spacer"></div>
<button class="btn btn-primary" id="btn-discover">🔍 Discover</button>
</div>
<div id="devices-status"></div>
<div id="devices-list"><div class="empty">Click Discover to find Wemo devices on your network.</div></div>
</div>
<!-- ── DWM Rules Tab ────────────────────────────────────────────────────── -->
<div id="tab-dwm-rules" class="tab-panel">
<!-- List view -->
<div id="dwm-list-view">
<div class="flex-row" style="margin-bottom:12px">
<h2 style="margin:0">DWM Automation Rules</h2>
<div class="spacer"></div>
<button class="btn btn-primary" id="btn-add-dwm">+ Add Rule</button>
</div>
<!-- Scheduler heartbeat bar -->
<div id="dwm-heartbeat" style="display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:6px;margin-bottom:14px;font-size:0.8rem;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.18)">
<span id="hb-dot" style="width:10px;height:10px;border-radius:50%;background:#6b7280;flex-shrink:0"></span>
<span id="hb-text" style="color:#9ca3af">Checking scheduler…</span>
<div class="spacer"></div>
<span id="hb-next" style="color:#6b7280;font-size:0.75rem"></span>
</div>
<div id="dwm-rules-status"></div>
<div id="dwm-rules-list"><div class="empty">No DWM rules yet.</div></div>
</div>
<!-- Inline add/edit form (hidden until needed) -->
<div id="dwm-form-panel" style="display:none">
<div class="flex-row" style="margin-bottom:16px">
<h2 id="dwm-form-title" style="margin:0">Add DWM Rule</h2>
<div class="spacer"></div>
<button class="btn btn-ghost btn-sm" id="btn-dwm-form-cancel">✕ Cancel</button>
</div>
<div class="card">
<div class="form-group">
<label>Rule Name</label>
<input type="text" id="dwm-name" placeholder="e.g. Evening Lights" />
</div>
<div class="form-group">
<label>Type</label>
<select id="dwm-type">
<option value="Schedule">📅 Schedule (fixed on/off times)</option>
<option value="Countdown">⏱ Countdown (timer)</option>
<option value="Away">🏠 Away Mode (random)</option>
<option value="AlwaysOn">🔒 Always On (keep device on)</option>
<option value="Trigger">⚡ Trigger (IFTTT-style)</option>
</select>
</div>
<!-- Target devices (Schedule / Countdown / Away / AlwaysOn) -->
<div class="form-group" id="dwm-target-group">
<label>Target Devices</label>
<select id="dwm-target-devices" multiple size="4" style="height:90px"></select>
<div style="font-size:0.75rem;color:var(--muted);margin-top:4px">Hold Ctrl/Cmd to select multiple</div>
</div>
<!-- Trigger fields -->
<div id="dwm-trigger-fields" style="display:none">
<div class="form-group">
<label>Trigger Device (source)</label>
<select id="dwm-trigger-src"></select>
</div>
<div style="display:flex;gap:10px">
<div class="form-group" style="flex:1">
<label>When</label>
<select id="dwm-trigger-event">
<option value="any">Turns ON or OFF</option>
<option value="on">Turns ON</option>
<option value="off">Turns OFF</option>
</select>
</div>
<div class="form-group" style="flex:1">
<label>Then</label>
<select id="dwm-trigger-action">
<option value="on">Turn ON action devices</option>
<option value="off">Turn OFF action devices</option>
<option value="mirror">Mirror (same as trigger)</option>
<option value="opposite">Opposite (invert)</option>
</select>
</div>
</div>
<div class="form-group">
<label>Action Devices (targets)</label>
<select id="dwm-trigger-targets" multiple size="4" style="height:90px"></select>
<div style="font-size:0.75rem;color:var(--muted);margin-top:4px">Hold Ctrl/Cmd to select multiple</div>
</div>
</div>
<div class="form-group" id="dwm-days-group">
<label>Days</label>
<div class="day-picker" id="dwm-days">
<button class="day-btn" data-day="1">Mon</button>
<button class="day-btn" data-day="2">Tue</button>
<button class="day-btn" data-day="3">Wed</button>
<button class="day-btn" data-day="4">Thu</button>
<button class="day-btn" data-day="5">Fri</button>
<button class="day-btn" data-day="6">Sat</button>
<button class="day-btn" data-day="7">Sun</button>
</div>
</div>
<div id="dwm-schedule-fields">
<div class="flex-row">
<div class="form-group" style="flex:1">
<label>Start Time</label>
<input type="text" id="dwm-start-time" placeholder="e.g. 8:30 PM" />
</div>
<div class="form-group" style="flex:1">
<label>End Time (optional)</label>
<input type="text" id="dwm-end-time" placeholder="e.g. 11:00 PM" />
</div>
</div>
<div class="flex-row">
<div class="form-group" style="flex:1">
<label>Start Action</label>
<select id="dwm-start-action">
<option value="1">Turn ON</option>
<option value="0">Turn OFF</option>
</select>
</div>
<div class="form-group" style="flex:1">
<label>End Action</label>
<select id="dwm-end-action">
<option value="-1">None</option>
<option value="0">Turn OFF</option>
<option value="1">Turn ON</option>
</select>
</div>
</div>
</div>
<div id="dwm-countdown-fields" style="display:none">
<div class="form-group">
<label>Countdown Duration (minutes)</label>
<input type="number" id="dwm-countdown-mins" min="1" max="1440" placeholder="60" />
</div>
</div>
<div id="dwm-alwayson-info" style="display:none;margin-bottom:12px;padding:10px;background:rgba(48,209,88,.1);border:1px solid rgba(48,209,88,.3);border-radius:6px;font-size:0.82rem;color:#4ade80">
🔒 The scheduler polls this device every 10 seconds. If it is found OFF it will be turned back ON automatically. No schedule needed.
</div>
<div class="form-group">
<div class="toggle-wrap">
<label class="toggle">
<input type="checkbox" id="dwm-enabled" checked />
<span class="slider"></span>
</label>
<span style="font-size:0.88rem">Enabled</span>
</div>
</div>
<div id="dwm-form-error" class="alert alert-error" style="display:none"></div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px">
<button class="btn btn-ghost" id="dwm-form-cancel-btn">Cancel</button>
<button class="btn btn-primary" id="dwm-form-save-btn">Save Rule</button>
</div>
</div>
</div>
</div>
<!-- ── Wemo Device Rules Tab ────────────────────────────────────────────── -->
<div id="tab-wemo-rules" class="tab-panel">
<div style="margin-bottom:12px">
<h2>Native Device Rules</h2>
<p style="font-size:0.82rem;color:var(--muted);margin-top:4px">
Manage on-device schedules stored in Wemo firmware. Select a device to view its rules.
</p>
</div>
<div class="form-group">
<label>Select Device</label>
<select id="wemo-rules-device-select"><option value="">— choose device —</option></select>
</div>
<div id="wemo-rules-status"></div>
<div id="wemo-rules-list"></div>
</div>
<!-- ── Settings Tab ─────────────────────────────────────────────────────── -->
<div id="tab-settings" class="tab-panel">
<h2 style="margin-bottom:16px">Settings</h2>
<div class="card">
<h3>Location (for sunrise/sunset rules)</h3>
<div id="location-current" style="margin-bottom:10px;font-size:0.83rem;color:var(--muted)">Not set</div>
<div class="form-group">
<label>Search for your city</label>
<input type="text" id="location-search-input" placeholder="e.g. London" autocomplete="off" />
<div id="location-autocomplete" class="autocomplete-list" style="display:none"></div>
</div>
<button class="btn btn-ghost btn-sm" id="btn-location-save" disabled>Save Location</button>
<span id="location-status" style="font-size:0.78rem;color:var(--green);margin-left:8px"></span>
</div>
</div>
<!-- ── Help Tab ──────────────────────────────────────────────────────────── -->
<div id="tab-help" class="tab-panel">
<h2 style="margin-bottom:4px">❓ Help &amp; Guide</h2>
<p style="font-size:0.82rem;color:var(--muted);margin-bottom:20px">How to use Dibby Wemo Manager in Homebridge</p>
<!-- Getting Started -->
<div class="card" style="margin-bottom:10px">
<h3 style="color:var(--accent);margin-bottom:10px">🚀 Getting Started</h3>
<ol style="font-size:0.85rem;line-height:1.9;padding-left:18px;color:var(--text)">
<li>Go to the <strong>📱 Devices</strong> tab and click <strong>Discover</strong> — your Wemo devices on the local network will appear.</li>
<li>Devices are automatically added to HomeKit as switches. Toggle them from the Home app on your iPhone/iPad.</li>
<li>To create automation rules, go to the <strong>⏰ DWM Rules</strong> tab and click <strong>+ Add Rule</strong>.</li>
<li>Rules run inside Homebridge — no internet or Belkin cloud required.</li>
</ol>
</div>
<!-- DWM Rules -->
<div class="card" style="margin-bottom:10px">
<h3 style="color:var(--accent);margin-bottom:10px">⏰ DWM Rules — How to Create a Rule</h3>
<p style="font-size:0.83rem;color:var(--muted);margin-bottom:10px">DWM (Dibby Wemo Manager) rules are stored locally and run in Homebridge.</p>
<ol style="font-size:0.85rem;line-height:1.9;padding-left:18px;color:var(--text)">
<li>Click the <strong>⏰ DWM Rules</strong> tab at the top.</li>
<li>Click <strong>+ Add Rule</strong> — the rule form opens inline on the same page (no pop-up).</li>
<li>Enter a <strong>Rule Name</strong> (e.g. "Evening Lights").</li>
<li>Choose a <strong>Rule Type</strong> (see types below).</li>
<li>Select <strong>target devices</strong> — which lights/switches the rule controls.</li>
<li>Fill in the schedule details and click <strong>Save Rule</strong>. Click <strong>Cancel</strong> or the <strong></strong> button to go back without saving.</li>
<li>The rule is active immediately — the toggle switch on the card enables/disables it without deleting it.</li>
</ol>
<div style="margin-top:14px;border-top:1px solid var(--border);padding-top:12px">
<p style="font-size:0.82rem;font-weight:600;color:var(--text);margin-bottom:8px">Rule Types:</p>
<table style="width:100%;font-size:0.82rem;border-collapse:collapse">
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap">📅 <strong>Schedule</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Turn on/off at fixed times on selected days. Enter times in 12-hour format (e.g. <em>8:30 PM</em>). Set a start time and optional end time, choose the action for each.</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap"><strong>Countdown</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Auto-off after a set number of minutes. Useful for things like a bathroom fan or porch light.</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap">🏠 <strong>Away Mode</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Randomly turns lights on and off within a time window to simulate occupancy while you're away.</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap">🔒 <strong>Always On</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Keeps a device permanently ON. If it's switched off by anyone, it will be turned back on within 10 seconds automatically. No time fields needed.</td>
</tr>
<tr>
<td style="padding:7px 8px;white-space:nowrap"><strong>Trigger</strong></td>
<td style="padding:7px 8px;color:var(--muted)">IFTTT-style: when one device turns on/off, automatically control another. E.g. "When the porch light turns ON, turn ON the driveway lights too."</td>
</tr>
</table>
</div>
<div style="margin-top:14px;border-top:1px solid var(--border);padding-top:12px">
<p style="font-size:0.82rem;font-weight:600;color:var(--text);margin-bottom:6px">⏰ Entering Times</p>
<p style="font-size:0.82rem;color:var(--muted);margin-bottom:6px">Times use 12-hour AM/PM format. All of these are valid:</p>
<table style="font-size:0.82rem;border-collapse:collapse">
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">8:30 PM</code></td><td style="color:var(--muted)">8:30 in the evening</td></tr>
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">8:30PM</code></td><td style="color:var(--muted)">same — space is optional</td></tr>
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">6:00 AM</code></td><td style="color:var(--muted)">6 o'clock in the morning</td></tr>
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">12:00 AM</code></td><td style="color:var(--muted)">midnight</td></tr>
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">12:00 PM</code></td><td style="color:var(--muted)">noon</td></tr>
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">9 PM</code></td><td style="color:var(--muted)">9:00 PM — minutes are optional</td></tr>
</table>
</div>
</div>
<!-- Trigger rules detail -->
<div class="card" style="margin-bottom:10px">
<h3 style="color:var(--accent);margin-bottom:10px">⚡ Trigger Rules (IFTTT)</h3>
<p style="font-size:0.83rem;color:var(--muted);margin-bottom:10px">Trigger rules let one device control another automatically.</p>
<ol style="font-size:0.85rem;line-height:1.9;padding-left:18px;color:var(--text)">
<li>Click <strong>+ Add Rule</strong> and select type <strong>⚡ Trigger</strong>.</li>
<li>Under <strong>Trigger Device</strong> — pick the device whose state change starts the action.</li>
<li>Under <strong>When</strong> — choose "Turns ON", "Turns OFF", or "Turns ON or OFF".</li>
<li>Under <strong>Then</strong> — choose what to do to the action devices:<br>
<span style="color:var(--muted);display:block;padding-left:12px;margin-top:2px">
<strong>Turn ON</strong> — always turn action devices on<br>
<strong>Turn OFF</strong> — always turn action devices off<br>
<strong>Mirror</strong> — action devices copy the trigger (ON→ON, OFF→OFF)<br>
<strong>Opposite</strong> — action devices invert the trigger (ON→OFF, OFF→ON)
</span>
</li>
<li>Under <strong>Action Devices</strong> — select which devices to control (hold Ctrl/Cmd for multiple).</li>
<li>Click <strong>Save Rule</strong>. Homebridge polls devices every 10 s and fires the trigger on state change.</li>
</ol>
<p style="font-size:0.8rem;color:var(--muted);margin-top:8px;padding:8px;background:rgba(255,214,10,.07);border-radius:6px">
⚠️ The scheduler must be running for Trigger rules to work. If Homebridge restarts, rules resume automatically.
</p>
</div>
<!-- Device Rules -->
<div class="card" style="margin-bottom:10px">
<h3 style="color:var(--accent);margin-bottom:10px">🔌 Device Rules (Native Firmware)</h3>
<p style="font-size:0.83rem;color:var(--muted);margin-bottom:8px">These are rules stored directly on the Wemo device's own firmware — separate from DWM Rules.</p>
<ul style="font-size:0.85rem;line-height:1.8;padding-left:18px;color:var(--text)">
<li>Click <strong>🔌 Device Rules</strong> tab, then select a device from the dropdown.</li>
<li>Rules stored on the device are listed. You can enable/disable or delete them.</li>
<li>Note: Wemo Dimmer V2 devices with newer firmware do <strong>not</strong> support this feature.</li>
<li>DWM Rules are recommended over device rules as they support more features and work across multiple devices.</li>
</ul>
</div>
<!-- Settings -->
<div class="card" style="margin-bottom:10px">
<h3 style="color:var(--accent);margin-bottom:10px">⚙️ Settings — Location</h3>
<p style="font-size:0.83rem;color:var(--muted);margin-bottom:8px">Set your city for accurate sunrise/sunset times in Schedule rules.</p>
<ol style="font-size:0.85rem;line-height:1.9;padding-left:18px;color:var(--text)">
<li>Click the <strong>⚙️ Settings</strong> tab.</li>
<li>Type your city name in the search box (e.g. "London" or "New York").</li>
<li>Pick your city from the dropdown that appears.</li>
<li>Click <strong>Save Location</strong>.</li>
<li>You can now use 🌅 Sunrise and 🌇 Sunset as start/end times in Schedule rules.</li>
</ol>
</div>
<!-- Troubleshooting -->
<div class="card">
<h3 style="color:var(--accent);margin-bottom:10px">🔧 Troubleshooting</h3>
<table style="width:100%;font-size:0.82rem;border-collapse:collapse">
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap;color:var(--text)"><strong>No devices found</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Make sure your PC and Wemo devices are on the same WiFi network. Try clicking Discover again. Some routers block SSDP multicast — add a manual device entry via the Homebridge config.</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap;color:var(--text)"><strong>HomeKit toggle not working</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Restart Homebridge. Devices need to be discovered at least once before HomeKit can control them. Check the Homebridge logs for errors.</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap;color:var(--text)"><strong>Rules not firing</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Check the <strong>⏰ DWM Rules</strong> tab status bar. 🟢 Green = scheduler running fine. 🟠 Amber = scheduler may have stopped — restart Homebridge. 🔴 Red = scheduler not running — check the DibbyWemo platform is in your Homebridge config. Times use 12-hour AM/PM (e.g. 8:30 PM).</td>
</tr>
<tr>
<td style="padding:7px 8px;white-space:nowrap;color:var(--text)"><strong>Settings panel blank</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Run: <code style="background:var(--bg2);padding:1px 5px;border-radius:3px">npm install --prefix "%APPDATA%/npm/node_modules/homebridge-dibby-wemo"</code> then restart Homebridge.</td>
</tr>
</table>
</div>
</div>
<script src="index.js"></script>
</body>
</html>
@@ -0,0 +1,768 @@
/* Dibby Wemo Manager — Homebridge custom UI */
/* global homebridge */
'use strict';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let _devices = [];
let _dwmRules = [];
let _wemoRules = null; // { rules, ruleDevices, targets } for selected device
let _editingDwmId = null; // null = create, string = update
let _selectedDwmDays = new Set();
let _pendingLocation = null; // { lat, lng, label }
// ---------------------------------------------------------------------------
// Tabs
// ---------------------------------------------------------------------------
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach((p) => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(`tab-${btn.dataset.tab}`).classList.add('active');
});
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Display seconds as 12-hour time: "8:30 AM" / "11:00 PM"
function secsToHHMM(secs) {
if (secs == null || secs < 0) return '';
const totalMins = Math.floor(secs / 60);
let h = Math.floor(totalMins / 60) % 24;
const m = totalMins % 60;
const ampm = h < 12 ? 'AM' : 'PM';
h = h % 12 || 12; // 0 → 12, 13 → 1, etc.
return `${h}:${String(m).padStart(2, '0')} ${ampm}`;
}
// Accept "8:30 AM", "8:30AM", "08:30 am", "8:30" (24-hr fallback), "8 AM"
function hhmmToSecs(str) {
if (!str) return -1;
str = str.trim().toUpperCase();
const match = str.match(/^(\d{1,2})(?::(\d{2}))?\s*(AM|PM)?$/);
if (!match) return -1;
let h = parseInt(match[1], 10);
const m = match[2] ? parseInt(match[2], 10) : 0;
const period = match[3];
if (isNaN(h) || isNaN(m) || m > 59) return -1;
if (period) {
// 12-hour mode
if (h < 1 || h > 12) return -1;
if (period === 'AM') h = h === 12 ? 0 : h;
else h = h === 12 ? 12 : h + 12;
} else {
// 24-hour fallback
if (h > 23) return -1;
}
return h * 3600 + m * 60;
}
const DAY_NAMES = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
function dayLabel(dayIds) {
if (!dayIds?.length) return '—';
if (dayIds.length === 7) return 'Every day';
return dayIds.map((d) => DAY_NAMES[d] ?? d).join(', ');
}
function showStatus(containerId, msg, type = 'info') {
const el = document.getElementById(containerId);
if (!el) return;
el.innerHTML = msg
? `<div class="alert alert-${type}">${msg}</div>`
: '';
}
function spinner() { return '<span class="spin"></span>'; }
// ---------------------------------------------------------------------------
// Devices tab
// ---------------------------------------------------------------------------
async function loadDevices() {
showStatus('devices-status', spinner() + ' Loading…', 'info');
try {
_devices = await homebridge.request('/devices/list');
renderDevices();
showStatus('devices-status', '');
} catch (e) {
showStatus('devices-status', 'Failed to load devices: ' + e.message, 'error');
}
}
async function discoverDevices() {
const btn = document.getElementById('btn-discover');
btn.disabled = true;
showStatus('devices-status', spinner() + ' Scanning for devices (up to 10 s)…', 'info');
try {
_devices = await homebridge.request('/devices/discover', { timeout: 10000 });
renderDevices();
showStatus('devices-status', `Found ${_devices.length} device(s)`, 'success');
refreshWemoDeviceSelect();
} catch (e) {
showStatus('devices-status', 'Discovery failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
}
function renderDevices() {
const el = document.getElementById('devices-list');
if (!_devices.length) {
el.innerHTML = '<div class="empty">No devices found. Click Discover to scan your network.</div>';
return;
}
el.innerHTML = _devices.map((d, i) => `
<div class="card">
<div class="card-header">
<div>
<div class="card-title">${esc(d.friendlyName ?? d.host)}</div>
<div class="card-subtitle">${esc(d.host)}:${d.port}${esc(d.productModel ?? 'Wemo Device')}</div>
</div>
<div class="toggle-wrap">
<span id="dev-state-label-${i}" style="font-size:0.82rem;color:var(--muted)">…</span>
<label class="toggle">
<input type="checkbox" id="dev-toggle-${i}" onchange="setDeviceState(${i},this.checked)" />
<span class="slider"></span>
</label>
</div>
</div>
</div>
`).join('');
// Fetch state for each device
_devices.forEach((d, i) => fetchDeviceState(i, d));
}
async function fetchDeviceState(idx, device) {
try {
const on = await homebridge.request('/devices/state', { host: device.host, port: device.port });
const toggle = document.getElementById(`dev-toggle-${idx}`);
const label = document.getElementById(`dev-state-label-${idx}`);
if (toggle) toggle.checked = !!on;
if (label) label.textContent = on ? 'ON' : 'OFF';
} catch { /* device unreachable */ }
}
async function setDeviceState(idx, on) {
const d = _devices[idx];
if (!d) return;
const label = document.getElementById(`dev-state-label-${idx}`);
if (label) label.textContent = on ? 'ON' : 'OFF';
try {
await homebridge.request('/devices/setState', { host: d.host, port: d.port, on });
} catch (e) {
showStatus('devices-status', `Failed to set ${d.friendlyName}: ${e.message}`, 'error');
// Revert toggle
const toggle = document.getElementById(`dev-toggle-${idx}`);
if (toggle) toggle.checked = !on;
if (label) label.textContent = !on ? 'ON' : 'OFF';
}
}
document.getElementById('btn-discover').addEventListener('click', discoverDevices);
// ---------------------------------------------------------------------------
// DWM Rules tab
// ---------------------------------------------------------------------------
async function loadDwmRules() {
try {
_dwmRules = await homebridge.request('/rules/list');
renderDwmRules();
} catch (e) {
showStatus('dwm-rules-status', 'Failed to load rules: ' + e.message, 'error');
}
}
function dwmRuleSummary(r) {
if (r.type === 'AlwaysOn') {
const devs = (r.targetDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || 'no targets';
return `🔒 Enforced ON every 10 s · ${devs}`;
}
if (r.type === 'Trigger') {
const src = esc(r.triggerDevice?.name ?? r.triggerDevice?.host ?? '?');
const when = r.triggerEvent === 'on' ? 'ON' : r.triggerEvent === 'off' ? 'OFF' : 'ON/OFF';
const action = r.action === 'mirror' ? 'mirror' : r.action === 'opposite' ? 'opposite' : (r.action ?? 'on').toUpperCase();
const targets = (r.actionDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || '—';
return `⚡ If ${src}${when}, then ${action} (${targets})`;
}
if (r.type === 'Countdown') {
const mins = r.countdownTime ? Math.round(r.countdownTime / 60) : null;
return mins ? `${mins} min auto-off` : '—';
}
const days = dayLabel(r.days);
const devs = (r.targetDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || 'no targets';
const start = secsToHHMM(r.startTime) || '—';
const end = r.endTime > 0 ? ' ' + secsToHHMM(r.endTime) : '';
return `${days} · ${start}${end} · ${devs}`;
}
function renderDwmRules() {
const el = document.getElementById('dwm-rules-list');
if (!_dwmRules.length) {
el.innerHTML = '<div class="empty">No DWM rules yet. Click "+ Add Rule" to create one.</div>';
return;
}
const typeIcon = { Schedule: '📅', Away: '🏠', Countdown: '⏱', AlwaysOn: '🔒', Trigger: '⚡' };
el.innerHTML = _dwmRules.map((r) => `
<div class="card" data-rule-id="${r.id}">
<div class="card-header">
<div>
<div class="card-title">
${typeIcon[r.type] || '📅'} ${esc(r.name)}
<span class="chip ${r.enabled ? 'chip-on' : 'chip-dis'}">${r.enabled ? 'enabled' : 'disabled'}</span>
<span class="chip chip-off">${esc(r.type)}</span>
</div>
<div class="card-subtitle">${dwmRuleSummary(r)}</div>
</div>
<div class="flex-row">
<label class="toggle" title="${r.enabled ? 'Disable' : 'Enable'} rule">
<input type="checkbox" ${r.enabled ? 'checked' : ''} onchange="toggleDwmRule('${r.id}', this.checked)" />
<span class="slider"></span>
</label>
<button class="btn btn-ghost btn-sm" onclick="openDwmEdit('${r.id}')">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteDwmRule('${r.id}')">Delete</button>
</div>
</div>
</div>
`).join('');
}
async function toggleDwmRule(id, enabled) {
try {
await homebridge.request('/rules/update', { id, updates: { enabled } });
await loadDwmRules();
} catch (e) {
showStatus('dwm-rules-status', 'Toggle failed: ' + e.message, 'error');
await loadDwmRules();
}
}
function deleteDwmRule(id) {
// confirm() is blocked in cross-origin iframes — use inline confirm row instead
const card = document.querySelector(`[data-rule-id="${id}"]`);
if (!card) return;
// If already showing confirm, execute delete
const existing = card.querySelector('.delete-confirm-row');
if (existing) {
existing.remove();
homebridge.request('/rules/delete', { id })
.then(() => loadDwmRules())
.catch((e) => showStatus('dwm-rules-status', 'Delete failed: ' + e.message, 'error'));
return;
}
// Show inline confirm bar
const row = document.createElement('div');
row.className = 'delete-confirm-row';
row.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:8px;padding:6px 10px;background:rgba(239,68,68,.12);border-radius:6px;font-size:0.8rem';
row.innerHTML = '<span style="color:#fca5a5;flex:1">Delete this rule?</span>'
+ `<button class="btn btn-danger btn-sm" onclick="deleteDwmRule('${id}')">Yes, delete</button>`
+ '<button class="btn btn-ghost btn-sm" onclick="this.closest(\'.delete-confirm-row\').remove()">Cancel</button>';
card.appendChild(row);
// Auto-dismiss after 5 seconds
setTimeout(() => row.remove(), 5000);
}
document.getElementById('btn-add-dwm').addEventListener('click', () => openDwmEdit(null));
// ── DWM Inline Form ───────────────────────────────────────────────────────────
function openDwmEdit(id) {
_editingDwmId = id;
_selectedDwmDays = new Set();
document.getElementById('dwm-form-error').style.display = 'none';
document.getElementById('dwm-form-title').textContent = id ? 'Edit DWM Rule' : 'Add DWM Rule';
const devOptions = _devices.map((d) =>
`<option value="${esc(d.host)}:${d.port}">${esc(d.friendlyName ?? d.host)}</option>`
).join('');
// Populate all device selects
document.getElementById('dwm-target-devices').innerHTML = devOptions;
document.getElementById('dwm-trigger-src').innerHTML = '<option value="">— select device —</option>' + devOptions;
document.getElementById('dwm-trigger-targets').innerHTML = devOptions;
if (id) {
const r = _dwmRules.find((x) => x.id === id);
if (!r) return;
document.getElementById('dwm-name').value = r.name ?? '';
document.getElementById('dwm-type').value = r.type ?? 'Schedule';
document.getElementById('dwm-enabled').checked = r.enabled !== false;
document.getElementById('dwm-start-time').value = secsToHHMM(r.startTime);
document.getElementById('dwm-end-time').value = secsToHHMM(r.endTime);
document.getElementById('dwm-start-action').value = String(r.startAction ?? 1);
document.getElementById('dwm-end-action').value = String(r.endAction ?? -1);
document.getElementById('dwm-countdown-mins').value =
r.countdownTime ? String(Math.round(r.countdownTime / 60)) : '';
_selectedDwmDays = new Set((r.days ?? []).map(Number));
// Select target devices
const targets = (r.targetDevices ?? []).map((td) => `${td.host}:${td.port}`);
Array.from(document.getElementById('dwm-target-devices').options).forEach((opt) => {
opt.selected = targets.includes(opt.value);
});
// Trigger-specific
if (r.type === 'Trigger') {
const srcKey = r.triggerDevice ? `${r.triggerDevice.host}:${r.triggerDevice.port}` : '';
document.getElementById('dwm-trigger-src').value = srcKey;
document.getElementById('dwm-trigger-event').value = r.triggerEvent ?? 'any';
document.getElementById('dwm-trigger-action').value = r.action ?? 'on';
const actKeys = (r.actionDevices ?? []).map((td) => `${td.host}:${td.port}`);
Array.from(document.getElementById('dwm-trigger-targets').options).forEach((opt) => {
opt.selected = actKeys.includes(opt.value);
});
}
} else {
document.getElementById('dwm-name').value = '';
document.getElementById('dwm-type').value = 'Schedule';
document.getElementById('dwm-enabled').checked = true;
document.getElementById('dwm-start-time').value = '';
document.getElementById('dwm-end-time').value = '';
document.getElementById('dwm-start-action').value = '1';
document.getElementById('dwm-end-action').value = '-1';
document.getElementById('dwm-countdown-mins').value = '';
document.getElementById('dwm-trigger-src').value = '';
document.getElementById('dwm-trigger-event').value = 'any';
document.getElementById('dwm-trigger-action').value = 'on';
Array.from(document.getElementById('dwm-target-devices').options).forEach((opt) => { opt.selected = false; });
Array.from(document.getElementById('dwm-trigger-targets').options).forEach((opt) => { opt.selected = false; });
}
updateDwmDayButtons();
updateDwmTypeFields();
document.getElementById('dwm-list-view').style.display = 'none';
document.getElementById('dwm-form-panel').style.display = '';
window.scrollTo(0, 0);
}
function updateDwmDayButtons() {
document.querySelectorAll('#dwm-days .day-btn').forEach((btn) => {
const d = Number(btn.dataset.day);
btn.classList.toggle('selected', _selectedDwmDays.has(d));
});
}
function updateDwmTypeFields() {
const type = document.getElementById('dwm-type').value;
const isSchedule = type === 'Schedule' || type === 'Away';
const isCountdown = type === 'Countdown';
const isAlwaysOn = type === 'AlwaysOn';
const isTrigger = type === 'Trigger';
const isTimeBased = isSchedule || isCountdown;
document.getElementById('dwm-target-group').style.display = isTrigger ? 'none' : '';
document.getElementById('dwm-days-group').style.display = isTrigger || isAlwaysOn ? 'none' : '';
document.getElementById('dwm-schedule-fields').style.display = isCountdown || isTrigger || isAlwaysOn ? 'none' : '';
document.getElementById('dwm-countdown-fields').style.display = isCountdown ? '' : 'none';
document.getElementById('dwm-trigger-fields').style.display = isTrigger ? '' : 'none';
document.getElementById('dwm-alwayson-info').style.display = isAlwaysOn ? '' : 'none';
}
document.querySelectorAll('#dwm-days .day-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const d = Number(btn.dataset.day);
if (_selectedDwmDays.has(d)) _selectedDwmDays.delete(d);
else _selectedDwmDays.add(d);
updateDwmDayButtons();
});
});
document.getElementById('dwm-type').addEventListener('change', updateDwmTypeFields);
function closeDwmModal() {
document.getElementById('dwm-form-panel').style.display = 'none';
document.getElementById('dwm-list-view').style.display = '';
}
document.getElementById('btn-dwm-form-cancel').addEventListener('click', closeDwmModal);
document.getElementById('dwm-form-cancel-btn').addEventListener('click', closeDwmModal);
document.getElementById('dwm-form-save-btn').addEventListener('click', async () => {
const errEl = document.getElementById('dwm-form-error');
errEl.style.display = 'none';
const name = document.getElementById('dwm-name').value.trim();
const type = document.getElementById('dwm-type').value;
const enabled = document.getElementById('dwm-enabled').checked;
if (!name) { showModalError('Rule name is required'); return; }
const devFromKey = (key) => {
const [host, port] = key.split(':');
const dev = _devices.find((d) => d.host === host && String(d.port) === port);
return { host, port: Number(port), name: dev?.friendlyName ?? host, udn: dev?.udn };
};
// ── AlwaysOn ──────────────────────────────────────────────────────────────
if (type === 'AlwaysOn') {
const selEl = document.getElementById('dwm-target-devices');
const selectedDevs = Array.from(selEl.selectedOptions).map((opt) => devFromKey(opt.value));
if (!selectedDevs.length) { showModalError('Select at least one device to keep on'); return; }
const rule = { name, type, enabled, targetDevices: selectedDevs };
try {
if (_editingDwmId) await homebridge.request('/rules/update', { id: _editingDwmId, updates: rule });
else await homebridge.request('/rules/create', rule);
closeDwmModal();
await loadDwmRules();
} catch (e) { showModalError('Save failed: ' + e.message); }
return;
}
// ── Trigger ───────────────────────────────────────────────────────────────
if (type === 'Trigger') {
const srcKey = document.getElementById('dwm-trigger-src').value;
if (!srcKey) { showModalError('Select a trigger (source) device'); return; }
const actTargets = Array.from(document.getElementById('dwm-trigger-targets').selectedOptions)
.map((opt) => devFromKey(opt.value));
if (!actTargets.length) { showModalError('Select at least one action device'); return; }
const rule = {
name, type, enabled,
triggerDevice: devFromKey(srcKey),
triggerEvent: document.getElementById('dwm-trigger-event').value,
action: document.getElementById('dwm-trigger-action').value,
actionDevices: actTargets,
};
try {
if (_editingDwmId) await homebridge.request('/rules/update', { id: _editingDwmId, updates: rule });
else await homebridge.request('/rules/create', rule);
closeDwmModal();
await loadDwmRules();
} catch (e) { showModalError('Save failed: ' + e.message); }
return;
}
// ── Schedule / Countdown / Away ───────────────────────────────────────────
if (_selectedDwmDays.size === 0 && type !== 'Countdown') {
showModalError('Select at least one day'); return;
}
const selEl = document.getElementById('dwm-target-devices');
const selectedDevs = Array.from(selEl.selectedOptions).map((opt) => devFromKey(opt.value));
if (!selectedDevs.length) { showModalError('Select at least one target device'); return; }
const rule = {
name, type, enabled,
days: Array.from(_selectedDwmDays).sort(),
targetDevices: selectedDevs,
};
if (type === 'Countdown') {
const mins = Number(document.getElementById('dwm-countdown-mins').value);
if (!mins || mins < 1) { showModalError('Enter countdown duration in minutes'); return; }
rule.countdownTime = mins * 60;
} else {
const startSecs = hhmmToSecs(document.getElementById('dwm-start-time').value);
if (startSecs < 0) { showModalError('Enter a valid start time (HH:MM)'); return; }
rule.startTime = startSecs;
rule.endTime = hhmmToSecs(document.getElementById('dwm-end-time').value);
rule.startAction = Number(document.getElementById('dwm-start-action').value);
rule.endAction = Number(document.getElementById('dwm-end-action').value);
}
try {
if (_editingDwmId) {
await homebridge.request('/rules/update', { id: _editingDwmId, updates: rule });
} else {
await homebridge.request('/rules/create', rule);
}
closeDwmModal();
await loadDwmRules();
} catch (e) {
showModalError('Save failed: ' + e.message);
}
});
function showModalError(msg) {
const el = document.getElementById('dwm-form-error');
el.textContent = msg;
el.style.display = 'block';
}
// ---------------------------------------------------------------------------
// Wemo Device Rules tab
// ---------------------------------------------------------------------------
function refreshWemoDeviceSelect() {
const sel = document.getElementById('wemo-rules-device-select');
const cur = sel.value;
sel.innerHTML = '<option value="">— choose device —</option>' +
_devices.map((d) =>
`<option value="${esc(d.host)}:${d.port}">${esc(d.friendlyName ?? d.host)} (${esc(d.host)})</option>`
).join('');
if (cur) sel.value = cur;
}
document.getElementById('wemo-rules-device-select').addEventListener('change', async function () {
const val = this.value;
if (!val) { document.getElementById('wemo-rules-list').innerHTML = ''; return; }
const [host, portStr] = val.split(':');
const port = Number(portStr);
showStatus('wemo-rules-status', spinner() + ' Fetching rules from device…', 'info');
document.getElementById('wemo-rules-list').innerHTML = '';
try {
_wemoRules = await homebridge.request('/rules/wemo/list', { host, port });
showStatus('wemo-rules-status', '');
renderWemoRules(host, port);
} catch (e) {
if (String(e.message).includes('FetchRules') || String(e.message).includes('rules1')) {
showStatus('wemo-rules-status',
'⚠️ This device does not support the Wemo Rules service (e.g. Dimmer V2 with newer firmware).', 'info');
} else {
showStatus('wemo-rules-status', 'Failed: ' + e.message, 'error');
}
}
});
function renderWemoRules(host, port) {
const el = document.getElementById('wemo-rules-list');
if (!_wemoRules?.rules?.length) {
el.innerHTML = '<div class="empty">No on-device rules found.</div>';
return;
}
el.innerHTML = _wemoRules.rules.map((r) => {
const devices = (_wemoRules.ruleDevices ?? []).filter((rd) => String(rd.RuleID) === String(r.RuleID));
const enabled = String(r.State) === '1';
const dayList = [...new Set(devices.map((d) => d.DayID))].sort().map((d) => DAY_NAMES[d] ?? d).join(', ') || '—';
const startTime = devices[0]?.StartTime >= 0 ? secsToHHMM(devices[0].StartTime) : '—';
return `<div class="card">
<div class="card-header">
<div>
<div class="card-title">
${esc(r.Name)}
<span class="chip ${enabled ? 'chip-on' : 'chip-dis'}">${enabled ? 'enabled' : 'disabled'}</span>
<span class="chip chip-off">${esc(r.Type)}</span>
</div>
<div class="card-subtitle">${dayList} · ${startTime}</div>
</div>
<div class="flex-row">
<label class="toggle" title="${enabled ? 'Disable' : 'Enable'} on device">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleWemoRule('${esc(host)}',${port},'${r.RuleID}',this.checked)" />
<span class="slider"></span>
</label>
<button class="btn btn-danger btn-sm" onclick="deleteWemoRule('${esc(host)}',${port},'${r.RuleID}')">Delete</button>
</div>
</div>
</div>`;
}).join('');
}
async function toggleWemoRule(host, port, ruleId, enabled) {
showStatus('wemo-rules-status', spinner() + ' Updating device…', 'info');
try {
await homebridge.request('/rules/wemo/toggle', { host, port, ruleId, enabled });
showStatus('wemo-rules-status', 'Rule updated ✓', 'success');
// Refresh list
_wemoRules = await homebridge.request('/rules/wemo/list', { host, port });
renderWemoRules(host, port);
setTimeout(() => showStatus('wemo-rules-status', ''), 2500);
} catch (e) {
showStatus('wemo-rules-status', 'Toggle failed: ' + e.message, 'error');
_wemoRules = await homebridge.request('/rules/wemo/list', { host, port });
renderWemoRules(host, port);
}
}
async function deleteWemoRule(host, port, ruleId) {
if (!confirm('Delete this on-device rule? This cannot be undone.')) return;
showStatus('wemo-rules-status', spinner() + ' Deleting…', 'info');
try {
await homebridge.request('/rules/wemo/delete', { host, port, ruleId });
showStatus('wemo-rules-status', 'Rule deleted ✓', 'success');
_wemoRules = await homebridge.request('/rules/wemo/list', { host, port });
renderWemoRules(host, port);
setTimeout(() => showStatus('wemo-rules-status', ''), 2500);
} catch (e) {
showStatus('wemo-rules-status', 'Delete failed: ' + e.message, 'error');
}
}
// ---------------------------------------------------------------------------
// Settings — Location
// ---------------------------------------------------------------------------
async function loadLocation() {
try {
const loc = await homebridge.request('/location/get');
updateLocationDisplay(loc);
} catch { /* ignore */ }
}
function updateLocationDisplay(loc) {
const el = document.getElementById('location-current');
if (loc?.lat != null) {
el.textContent = `📍 ${loc.label ?? `${loc.lat}, ${loc.lng}`}`;
} else {
el.textContent = 'Not set';
}
}
let _locSearchTimer = null;
document.getElementById('location-search-input').addEventListener('input', function () {
clearTimeout(_locSearchTimer);
const q = this.value.trim();
if (q.length < 2) { hideAutocomplete(); return; }
_locSearchTimer = setTimeout(() => searchLocation(q), 400);
});
async function searchLocation(query) {
try {
const results = await homebridge.request('/location/search', { query });
showAutocomplete(results);
} catch { hideAutocomplete(); }
}
function showAutocomplete(results) {
const el = document.getElementById('location-autocomplete');
if (!results.length) { hideAutocomplete(); return; }
el.innerHTML = results.map((r, i) =>
`<div class="autocomplete-item" data-idx="${i}">${esc(r.label)}</div>`
).join('');
el.style.display = 'block';
el._results = results;
el.querySelectorAll('.autocomplete-item').forEach((item, i) => {
item.addEventListener('click', () => {
_pendingLocation = el._results[i];
document.getElementById('location-search-input').value = _pendingLocation.label;
hideAutocomplete();
document.getElementById('btn-location-save').disabled = false;
});
});
}
function hideAutocomplete() {
const el = document.getElementById('location-autocomplete');
el.style.display = 'none';
}
document.getElementById('btn-location-save').addEventListener('click', async () => {
if (!_pendingLocation) return;
try {
await homebridge.request('/location/set', _pendingLocation);
updateLocationDisplay(_pendingLocation);
document.getElementById('location-status').textContent = 'Saved ✓';
document.getElementById('btn-location-save').disabled = true;
_pendingLocation = null;
setTimeout(() => { document.getElementById('location-status').textContent = ''; }, 2500);
} catch (e) {
document.getElementById('location-status').textContent = 'Failed: ' + e.message;
}
});
// ---------------------------------------------------------------------------
// XSS-safe text escaping
// ---------------------------------------------------------------------------
function esc(str) {
return String(str ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ---------------------------------------------------------------------------
// Scheduler heartbeat
// ---------------------------------------------------------------------------
async function refreshHeartbeat() {
const dot = document.getElementById('hb-dot');
const text = document.getElementById('hb-text');
const next = document.getElementById('hb-next');
if (!dot) return;
try {
const hb = await homebridge.request('/scheduler/status');
if (!hb || !hb.running) {
dot.style.background = '#ef4444';
text.style.color = '#fca5a5';
text.textContent = hb?.ts
? '⚠ Scheduler stopped — restart Homebridge to recover'
: '⚠ Scheduler not running — check Homebridge config has DibbyWemo platform';
next.textContent = '';
return;
}
if (hb.stale) {
dot.style.background = '#f97316';
text.style.color = '#fdba74';
text.textContent = '⚠ Scheduler may be unresponsive (last heartbeat: ' + _relTime(hb.ts) + ')';
next.textContent = '';
return;
}
// Healthy
dot.style.background = '#22c55e';
text.style.color = '#4ade80';
text.textContent = '✓ Scheduler running · ' + hb.totalEntries + ' schedule entr' + (hb.totalEntries === 1 ? 'y' : 'ies');
// Last fired
if (hb.lastFire) {
const icon = hb.lastFire.success ? '✓' : '⚠';
next.textContent = 'Last: ' + icon + ' ' + hb.lastFire.msg.replace(/\s*[✓⚠]\s*$/, '') + ' · ' + _relTime(hb.lastFire.at);
next.style.color = hb.lastFire.success ? 'var(--muted)' : '#fca5a5';
} else if (hb.upcoming && hb.upcoming.length) {
const u = hb.upcoming[0];
next.textContent = 'Next: ' + u.ruleName + ' → ' + u.action + ' at ' + u.at;
next.style.color = 'var(--muted)';
} else {
next.textContent = 'No upcoming rules today';
next.style.color = 'var(--muted)';
}
} catch {
dot.style.background = 'var(--muted)';
text.style.color = 'var(--muted)';
text.textContent = 'Scheduler status unavailable';
next.textContent = '';
}
}
function _relTime(iso) {
const diff = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
return Math.floor(diff / 3600) + 'h ago';
}
// Poll heartbeat every 35 seconds while on the DWM tab
let _hbTimer = null;
function startHeartbeatPolling() {
refreshHeartbeat();
_hbTimer = setInterval(refreshHeartbeat, 35_000);
}
function stopHeartbeatPolling() {
if (_hbTimer) { clearInterval(_hbTimer); _hbTimer = null; }
}
// Start/stop polling when tab changes
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
if (btn.dataset.tab === 'dwm-rules') startHeartbeatPolling();
else stopHeartbeatPolling();
});
});
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
(async function init() {
await loadDevices();
await loadDwmRules();
await loadLocation();
refreshWemoDeviceSelect();
startHeartbeatPolling(); // start immediately (DWM tab is not default, but still useful)
})();
@@ -0,0 +1,151 @@
'use strict';
/**
* Homebridge custom UI server for homebridge-dibby-wemo.
*
* Runs as a child process managed by homebridge-config-ui-x.
* Communicates with the frontend via this.onRequest() / homebridge.request().
*
* Provides:
* - devices.list → saved device list (from plugin store)
* - devices.discover → trigger SSDP discovery
* - devices.state → get binary state of a device
* - devices.setState → set binary state of a device
* - rules.list → DWM rules from plugin store
* - rules.create → create a DWM rule
* - rules.update → update a DWM rule
* - rules.delete → delete a DWM rule
* - rules.wemo.list → fetch native device rules from a Wemo device
* - rules.wemo.toggle → enable / disable a native Wemo device rule
* - rules.wemo.delete → delete a native Wemo device rule
* - location.get → get stored location
* - location.search → geocode query via Nominatim
* - location.set → save location
*/
const { HomebridgePluginUiServer } = require('@homebridge/plugin-ui-utils');
const path = require('path');
const DwmStore = require('../lib/store');
const wemoClient = require('../lib/wemo-client');
const axios = require('axios');
class DibbyWemoUiServer extends HomebridgePluginUiServer {
constructor() {
super();
// Shared store instance — storagePath provided by homebridge-config-ui-x
this._store = new DwmStore(this.homebridgeStoragePath);
// ── Devices ─────────────────────────────────────────────────────────────
this.onRequest('/devices/list', async () => {
return this._store.getDevices();
});
this.onRequest('/devices/discover', async ({ timeout } = {}) => {
const ms = typeof timeout === 'number' ? timeout : 10_000;
const devices = await wemoClient.discoverDevices(ms);
// Persist updated list
this._store.saveDevices(devices.map((d) => ({
host: d.host,
port: d.port,
udn: d.udn ?? `${d.host}:${d.port}`,
friendlyName: d.friendlyName ?? d.host,
productModel: d.productModel ?? 'Wemo Device',
firmwareVersion: d.firmwareVersion ?? null,
})));
return devices;
});
this.onRequest('/devices/state', async ({ host, port }) => {
return await wemoClient.getBinaryState(host, Number(port));
});
this.onRequest('/devices/setState', async ({ host, port, on }) => {
await wemoClient.setBinaryState(host, Number(port), !!on);
return { ok: true };
});
// ── DWM Rules ────────────────────────────────────────────────────────────
this.onRequest('/rules/list', async () => {
return this._store.getDwmRules();
});
this.onRequest('/rules/create', async (rule) => {
return this._store.createDwmRule(rule);
});
this.onRequest('/rules/update', async ({ id, updates }) => {
return this._store.updateDwmRule(id, updates);
});
this.onRequest('/rules/delete', async ({ id }) => {
this._store.deleteDwmRule(id);
return { ok: true };
});
// ── Scheduler heartbeat ───────────────────────────────────────────────────
this.onRequest('/scheduler/status', async () => {
const hb = this._store.getHeartbeat();
if (!hb) return { running: false, stale: false, ts: null };
const ageMs = Date.now() - new Date(hb.ts).getTime();
// stale if no heartbeat for > 90 seconds (3 missed ticks)
return { ...hb, stale: ageMs > 90_000 };
});
// ── Native Wemo Device Rules ──────────────────────────────────────────────
this.onRequest('/rules/wemo/list', async ({ host, port }) => {
return await wemoClient.fetchRules(host, Number(port));
});
this.onRequest('/rules/wemo/toggle', async ({ host, port, ruleId, enabled }) => {
await wemoClient.toggleRule(host, Number(port), ruleId, !!enabled);
return { ok: true };
});
this.onRequest('/rules/wemo/delete', async ({ host, port, ruleId }) => {
await wemoClient.deleteRule(host, Number(port), ruleId);
return { ok: true };
});
this.onRequest('/rules/wemo/create', async ({ host, port, ruleData }) => {
const id = await wemoClient.createRule(host, Number(port), ruleData);
return { ok: true, id };
});
this.onRequest('/rules/wemo/update', async ({ host, port, ruleId, ruleData }) => {
await wemoClient.updateRule(host, Number(port), ruleId, ruleData);
return { ok: true };
});
// ── Location ──────────────────────────────────────────────────────────────
this.onRequest('/location/get', async () => {
return this._store.getLocation();
});
this.onRequest('/location/set', async (loc) => {
this._store.setLocation(loc);
return { ok: true };
});
this.onRequest('/location/search', async ({ query }) => {
try {
const res = await axios.get('https://nominatim.openstreetmap.org/search', {
params: { q: query, format: 'json', limit: 8, addressdetails: 1 },
headers: { 'User-Agent': 'homebrige-dibby-wemo/1.0' },
timeout: 8000,
});
return (res.data || []).map((r) => ({
lat: parseFloat(r.lat),
lng: parseFloat(r.lon),
label: r.display_name,
city: r.address?.city || r.address?.town || r.address?.village || '',
country: r.address?.country || '',
}));
} catch { return []; }
});
this.ready();
}
}
(() => new DibbyWemoUiServer())();
+20
View File
@@ -0,0 +1,20 @@
'use strict';
/**
* homebridge-dibby-wemo
*
* Homebridge plugin entry point.
*
* Registers the DibbyWemo platform so Homebridge discovers Wemo devices and
* exposes them to HomeKit as Switch accessories. Also runs the DWM scheduler
* for local time-based automations — no Belkin cloud required.
*/
const { WemoPlatform, PLUGIN_NAME, PLATFORM_NAME } = require('./lib/platform');
/**
* @param {object} api - The Homebridge API object
*/
module.exports = (api) => {
api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, WemoPlatform);
};
@@ -0,0 +1,95 @@
'use strict';
/**
* WemoSwitchAccessory
*
* Represents a single Wemo device as a HomeKit Switch.
* State is polled on the configured interval and pushed to HomeKit.
*/
class WemoSwitchAccessory {
/**
* @param {object} params
* @param {object} params.platform - WemoPlatform instance
* @param {object} params.accessory - PlatformAccessory from Homebridge
* @param {object} params.device - { host, port, udn, friendlyName, ... }
* @param {object} params.wemoClient - wemo-client module
* @param {number} params.pollInterval - poll interval in seconds
*/
constructor({ platform, accessory, device, wemoClient, pollInterval = 30 }) {
this.platform = platform;
this.accessory = accessory;
this.device = device;
this.wemo = wemoClient;
this.pollInterval = pollInterval;
this.log = platform.log;
const { Service, Characteristic } = platform.api.hap;
// ── Accessory information ───────────────────────────────────────────────
this.accessory.getService(Service.AccessoryInformation)
?.setCharacteristic(Characteristic.Manufacturer, 'Belkin')
.setCharacteristic(Characteristic.Model, device.productModel ?? 'Wemo Switch')
.setCharacteristic(Characteristic.SerialNumber, device.udn ?? device.host);
// ── Switch service ──────────────────────────────────────────────────────
this.switchService = this.accessory.getService(Service.Switch)
|| this.accessory.addService(Service.Switch, device.friendlyName ?? device.host);
this.switchService.getCharacteristic(Characteristic.On)
.onGet(this._getOn.bind(this))
.onSet(this._setOn.bind(this));
// ── Initial state + poll ────────────────────────────────────────────────
this._currentState = false;
this._pollTimer = null;
this._startPolling();
}
// ── HomeKit handlers ──────────────────────────────────────────────────────
async _getOn() {
try {
this._currentState = await this.wemo.getBinaryState(this.device.host, this.device.port);
} catch (e) {
this.log.warn(`[${this.device.friendlyName}] getBinaryState failed: ${e.message}`);
}
return this._currentState;
}
async _setOn(value) {
try {
await this.wemo.setBinaryState(this.device.host, this.device.port, !!value);
this._currentState = !!value;
} catch (e) {
this.log.error(`[${this.device.friendlyName}] setBinaryState failed: ${e.message}`);
throw new this.platform.api.hap.HapStatusError(
this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
);
}
}
// ── Polling ───────────────────────────────────────────────────────────────
_startPolling() {
this._pollTimer = setInterval(async () => {
try {
const newState = await this.wemo.getBinaryState(this.device.host, this.device.port);
if (newState !== this._currentState) {
this._currentState = newState;
const { Characteristic } = this.platform.api.hap;
this.switchService.updateCharacteristic(Characteristic.On, newState);
}
} catch { /* device unreachable — keep last state */ }
}, this.pollInterval * 1000);
}
stopPolling() {
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
}
}
module.exports = WemoSwitchAccessory;
+188
View File
@@ -0,0 +1,188 @@
'use strict';
/**
* WemoPlatform
*
* Homebridge platform plugin. Discovers Wemo devices via SSDP (and any
* manually-configured hosts), registers each as a Switch accessory, and
* runs the DWM local scheduler for time-based automation rules.
*/
const DwmStore = require('./store');
const wemoClient = require('./wemo-client');
const DwmScheduler = require('./scheduler');
const WemoSwitchAccessory = require('./accessory');
const PLUGIN_NAME = 'homebridge-dibby-wemo';
const PLATFORM_NAME = 'DibbyWemo';
class WemoPlatform {
/**
* @param {object} log - Homebridge logger
* @param {object} config - Platform config from config.json
* @param {object} api - Homebridge API
*/
constructor(log, config, api) {
this.log = log;
this.config = config ?? {};
this.api = api;
this._accessories = new Map(); // uuid → PlatformAccessory
this._handlers = new Map(); // uuid → WemoSwitchAccessory
// Store in Homebridge's user storage directory
this._store = new DwmStore(api.user.storagePath());
// Location is set via the custom UI settings panel (city search) and stored
// in the plugin's DwmStore — no raw lat/lng in config.json needed.
// DWM Scheduler
this._scheduler = new DwmScheduler({
store: this._store,
wemoClient,
log,
});
this._scheduler.onFire(({ success, msg }) => {
if (success) log.info('[DWM] ' + msg);
else log.warn('[DWM] ' + msg);
});
// Homebridge calls didFinishLaunching once the restore cache is ready
api.on('didFinishLaunching', () => {
this._discoverDevices();
this._scheduler.start().catch((e) => log.error('[DWM Scheduler] Start failed: ' + e.message));
});
log.info('DibbyWemo platform initialised');
}
// ── Homebridge lifecycle ──────────────────────────────────────────────────
/**
* Called for each accessory restored from cache on startup.
* We immediately attach handlers using the device context stored in the
* accessory so HomeKit requests don't time out during the SSDP window.
*/
configureAccessory(accessory) {
this.log.info('Restoring cached accessory: ' + accessory.displayName);
this._accessories.set(accessory.UUID, accessory);
// Re-attach handlers right away if we have saved device context
const device = accessory.context?.device;
if (device?.host && device?.port) {
const pollInterval = this.config.pollInterval ?? 30;
this._handlers.get(accessory.UUID)?.stopPolling();
const handler = new WemoSwitchAccessory({
platform: this,
accessory,
device,
wemoClient,
pollInterval,
});
this._handlers.set(accessory.UUID, handler);
}
}
// ── Discovery ─────────────────────────────────────────────────────────────
async _discoverDevices() {
const timeout = this.config.discoveryTimeout ?? 10_000;
const pollInterval = this.config.pollInterval ?? 30;
this.log.info('Starting Wemo device discovery…');
let discovered = [];
try {
discovered = await wemoClient.discoverDevices(timeout);
} catch (e) {
this.log.error('SSDP discovery failed: ' + e.message);
}
// Merge in manually-configured devices
const manual = (this.config.manualDevices ?? []).map(({ host, port }) => ({
host, port: port ?? 49153,
}));
for (const m of manual) {
if (!discovered.find((d) => d.host === m.host && d.port === m.port)) {
try {
const info = await wemoClient.getDeviceInfo(m.host, m.port);
discovered.push({ ...m, ...info });
} catch {
discovered.push(m);
}
}
}
this.log.info(`Found ${discovered.length} Wemo device(s)`);
// Save discovered device list for the custom UI
this._store.saveDevices(discovered.map((d) => ({
host: d.host,
port: d.port,
udn: d.udn ?? `${d.host}:${d.port}`,
friendlyName: d.friendlyName ?? d.host,
productModel: d.productModel ?? 'Wemo Device',
firmwareVersion: d.firmwareVersion ?? null,
})));
for (const device of discovered) {
this._registerDevice(device, pollInterval);
}
// Remove stale accessories (devices no longer discovered)
const activeUUIDs = new Set(discovered.map((d) => this._uuidForDevice(d)));
for (const [uuid, acc] of this._accessories) {
if (!activeUUIDs.has(uuid)) {
this.log.info('Removing stale accessory: ' + acc.displayName);
this._handlers.get(uuid)?.stopPolling();
this._handlers.delete(uuid);
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [acc]);
this._accessories.delete(uuid);
}
}
}
_uuidForDevice(device) {
const id = device.udn ?? `${device.host}:${device.port}`;
return this.api.hap.uuid.generate(id);
}
_registerDevice(device, pollInterval) {
const uuid = this._uuidForDevice(device);
const name = device.friendlyName ?? device.host;
let accessory = this._accessories.get(uuid);
if (!accessory) {
this.log.info('Adding new accessory: ' + name);
accessory = new this.api.platformAccessory(name, uuid);
this._accessories.set(uuid, accessory);
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
} else {
accessory.displayName = name;
}
// Persist device connection info so configureAccessory can restore it on
// the next restart without waiting for SSDP to complete.
accessory.context.device = {
host: device.host,
port: device.port,
udn: device.udn ?? `${device.host}:${device.port}`,
friendlyName: device.friendlyName ?? device.host,
productModel: device.productModel ?? 'Wemo Device',
firmwareVersion: device.firmwareVersion ?? null,
};
// (Re)create handler so device info is up to date
this._handlers.get(uuid)?.stopPolling();
const handler = new WemoSwitchAccessory({
platform: this,
accessory,
device,
wemoClient,
pollInterval,
});
this._handlers.set(uuid, handler);
}
}
module.exports = { WemoPlatform, PLUGIN_NAME, PLATFORM_NAME };
+721
View File
@@ -0,0 +1,721 @@
'use strict';
/**
* DWM Scheduler — Homebridge edition.
*
* Identical logic to the desktop LocalScheduler but takes store + wemoClient
* as constructor dependencies instead of top-level requires.
*
* Rule types handled:
* - Schedule / Away (fixed times) → pre-computed {dayId, targetSecs, action} entries
* - Countdown with active window → ON at windowStart, OFF at windowEnd (cross-midnight aware)
* - Away Mode → randomisation loop: ON 3090 min, OFF 115 min within window
* - AlwaysOn → health monitor enforces ON every 10 s; no schedule entry
* - Trigger → if device A changes state, fire action on device B
*
* Usage:
* const scheduler = new DwmScheduler({ store, wemoClient, log });
* scheduler.onFire(({ success, msg }) => log.info(msg));
* await scheduler.start();
*/
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Wemo DayID: 1=Mon … 7=Sun. JS getDay(): 0=Sun … 6=Sat. */
function jsToWemoDayId(jsDay) { return jsDay === 0 ? 7 : jsDay; }
function secondsFromMidnight(date) {
return date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds();
}
function secsToHHMM(secs) {
const h = Math.floor(secs / 3600) % 24;
const m = Math.floor((secs % 3600) / 60);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
function actionLabel(a) {
return a === 1 ? 'ON' : a === 0 ? 'OFF' : `action(${a})`;
}
function randBetween(min, max) {
return min + Math.floor(Math.random() * (max - min + 1));
}
// ── Constants ─────────────────────────────────────────────────────────────────
const HEALTH_POLL_MS = 10_000; // poll devices every 10 seconds
const CATCHUP_WINDOW_S = 10 * 60; // catch up rules missed within last 10 minutes
// ── DwmScheduler ─────────────────────────────────────────────────────────────
class DwmScheduler {
/**
* @param {object} deps
* @param {import('./store')} deps.store - DwmStore instance
* @param {object} deps.wemoClient - wemo-client module
* @param {{ info, warn, error }} deps.log - Homebridge log object
*/
constructor({ store, wemoClient, log }) {
this._store = store;
this._wemo = wemoClient;
this._log = log ?? console;
this._schedule = []; // pre-computed time entries for Schedule/Countdown rules
this._awayLoops = new Map(); // ruleId → away-loop state for active Away Mode rules
this._firedToday = new Set(); // prevent double-firing within a tick window
this._timers = [];
this._tickTimer = null;
this._running = false;
this._lastDate = null;
this._onFire = null; // ({success, msg, entry}) notification callback
this._lastFireMsg = null; // last fire event for heartbeat
this._onStatus = null; // (statusObj) status callback
this._onHealth = null; // ({host, port, name, online, msg}) health event callback
this._deviceHealth = new Map(); // 'host:port' → true | false
this._triggerStates = new Map(); // 'host:port' → last known boolean state (for Trigger rules)
this._healthTimer = null;
this._startedAt = null;
}
// ── Public API ────────────────────────────────────────────────────────────
isRunning() { return this._running; }
// Internal helper — records every fire event then forwards to caller
_emit(event) {
this._lastFireMsg = { msg: event.msg, success: event.success, at: new Date().toISOString() };
this._onFire?.(event);
}
onFire(cb) { this._onFire = cb; }
onStatus(cb) { this._onStatus = cb; }
onHealth(cb) { this._onHealth = cb; }
getHealthStatus() {
const out = {};
for (const [key, online] of this._deviceHealth) out[key] = online;
return out;
}
async start() {
if (this._running) this._clearTimers();
this._running = true;
this._startedAt = new Date();
this._firedToday = new Set();
this._loadSchedule();
this._resumeAwayLoops();
this._catchUpMissedRules();
this._tick();
this._startHealthMonitor();
const status = this._buildStatus();
this._onStatus?.(status);
this._log.info?.('[DWM Scheduler] Started — ' + this._schedule.length + ' schedule entries loaded');
return status;
}
stop() {
this._running = false;
this._clearTimers();
this._stopAllAwayLoops(false);
this._stopHealthMonitor();
this._schedule = [];
this._firedToday = new Set();
this._lastDate = null;
this._deviceHealth = new Map();
this._triggerStates = new Map();
this._log.info?.('[DWM Scheduler] Stopped');
return { running: false };
}
reload() {
if (!this._running) return;
this._stopAllAwayLoops(false);
this._loadSchedule();
this._catchUpMissedRules();
this._scheduleUpcoming();
this._resumeAwayLoops();
const status = this._buildStatus();
this._onStatus?.(status);
this._log.info?.('[DWM Scheduler] Reloaded — ' + this._schedule.length + ' schedule entries');
return status;
}
getStatus() { return this._buildStatus(); }
// ── Schedule loading ──────────────────────────────────────────────────────
_loadSchedule() {
const schedule = [];
const rules = this._store.getDwmRules();
for (const rule of rules) {
if (!rule.enabled) continue;
// ── AlwaysOn / Trigger — handled entirely by the health-monitor poll ──
if (rule.type === 'AlwaysOn' || rule.type === 'Trigger') continue;
// Away Mode
if (rule.type === 'Away') {
const startSecs = Number(rule.startTime ?? -1);
const endSecs = Number(rule.endTime ?? -1);
if (startSecs < 0) continue;
for (const dayId of (rule.days ?? [])) {
const td0 = rule.targetDevices?.[0];
schedule.push({
ruleId: rule.id, ruleName: rule.name,
targetHost: td0?.host ?? '', targetPort: td0?.port ?? 0,
dayId: Number(dayId), targetSecs: startSecs,
action: 1, isAwayStart: true,
});
if (endSecs >= 0) {
schedule.push({
ruleId: rule.id + '-away-end', ruleName: rule.name,
targetHost: td0?.host ?? '', targetPort: td0?.port ?? 0,
dayId: Number(dayId), targetSecs: endSecs,
action: 0, isAwayEnd: true, awayRuleId: rule.id,
});
}
}
continue;
}
// Countdown with active window
if (rule.type === 'Countdown') {
const windowStart = Number(rule.windowStart ?? -1);
const windowEnd = Number(rule.windowEnd ?? -1);
if (windowStart < 0 || !(rule.windowDays?.length)) continue;
const crossesMidnight = windowEnd >= 0 && windowEnd < windowStart;
for (const dayId of rule.windowDays) {
for (const td of (rule.targetDevices ?? [])) {
if (!td.host || !td.port) continue;
schedule.push({ ruleId: rule.id, ruleName: rule.name,
targetHost: td.host, targetPort: td.port,
dayId: Number(dayId), targetSecs: windowStart, action: 1 });
if (windowEnd >= 0) {
const offDayId = crossesMidnight ? (Number(dayId) % 7) + 1 : Number(dayId);
schedule.push({ ruleId: rule.id + '-wend', ruleName: rule.name,
targetHost: td.host, targetPort: td.port,
dayId: offDayId, targetSecs: windowEnd, action: 0 });
}
}
}
continue;
}
// Schedule / time-based
const startSecs = Number(rule.startTime ?? -1);
const endSecs = Number(rule.endTime ?? -1);
const startAction = Number(rule.startAction ?? 1);
const endAction = Number(rule.endAction ?? -1);
if (startSecs < 0) continue;
for (const dayId of (rule.days ?? [])) {
for (const td of (rule.targetDevices ?? [])) {
if (!td.host || !td.port) continue;
if (startAction >= 0) {
schedule.push({ ruleId: rule.id, ruleName: rule.name,
targetHost: td.host, targetPort: td.port,
dayId: Number(dayId), targetSecs: startSecs, action: startAction });
}
if (endSecs > 0 && endAction >= 0) {
schedule.push({ ruleId: rule.id, ruleName: rule.name,
targetHost: td.host, targetPort: td.port,
dayId: Number(dayId), targetSecs: endSecs, action: endAction });
}
}
}
}
this._schedule = schedule;
this._lastDate = new Date().toDateString();
}
// ── Away Mode loop ────────────────────────────────────────────────────────
_resumeAwayLoops() {
if (!this._running) return;
const now = new Date();
const nowSecs = secondsFromMidnight(now);
const todayId = jsToWemoDayId(now.getDay());
const rules = this._store.getDwmRules();
for (const rule of rules) {
if (!rule.enabled || rule.type !== 'Away') continue;
if (this._awayLoops.has(rule.id)) continue;
const startSecs = Number(rule.startTime ?? -1);
const endSecs = Number(rule.endTime ?? -1);
if (startSecs < 0) continue;
if (!(rule.days ?? []).includes(todayId)) continue;
const inWindow = endSecs >= 0
? (startSecs <= endSecs ? (nowSecs >= startSecs && nowSecs < endSecs)
: (nowSecs >= startSecs || nowSecs < endSecs))
: nowSecs >= startSecs;
if (inWindow) this._startAwayLoop(rule);
}
}
_startAwayLoop(rule) {
const existing = this._awayLoops.get(rule.id);
if (existing?.timer) clearTimeout(existing.timer);
const devices = (rule.targetDevices ?? []).filter(td => td.host && td.port);
if (!devices.length) return;
const loop = { rule, devices, endSecs: Number(rule.endTime ?? -1), timer: null, isOn: false };
this._awayLoops.set(rule.id, loop);
this._awayStep(rule.id, true);
}
_awayStep(ruleId, turnOn) {
if (!this._running) return;
const loop = this._awayLoops.get(ruleId);
if (!loop) return;
const nowSecs = secondsFromMidnight(new Date());
if (loop.endSecs >= 0 && nowSecs >= loop.endSecs) {
this._stopAwayLoop(ruleId, true);
return;
}
loop.isOn = turnOn;
for (const td of loop.devices) {
this._wemo.setBinaryState(td.host, td.port, turnOn)
.then(() => {
this._emit({ success: true,
msg: `"${loop.rule.name}" Away → ${turnOn ? 'ON' : 'OFF'} (${td.host}) ✓`,
entry: { action: turnOn ? 1 : 0 } });
})
.catch((e) => {
this._emit({ success: false,
msg: `"${loop.rule.name}" Away → ${turnOn ? 'ON' : 'OFF'} FAILED (${td.host}): ${e.message}`,
entry: { action: turnOn ? 1 : 0 } });
});
}
const delaySecs = turnOn ? randBetween(30, 90) * 60 : randBetween(1, 15) * 60;
if (loop.endSecs >= 0) {
const remaining = loop.endSecs - nowSecs;
if (delaySecs >= remaining) return;
}
loop.timer = setTimeout(() => this._awayStep(ruleId, !turnOn), delaySecs * 1000);
}
_stopAwayLoop(ruleId, forceOff) {
const loop = this._awayLoops.get(ruleId);
if (!loop) return;
if (loop.timer) clearTimeout(loop.timer);
this._awayLoops.delete(ruleId);
if (forceOff) {
for (const td of loop.devices) {
this._wemo.setBinaryState(td.host, td.port, false).catch(() => {});
}
this._emit({ success: true,
msg: `"${loop.rule.name}" Away Mode window ended — all devices OFF`,
entry: { action: 0 } });
}
}
_stopAllAwayLoops(forceOff) {
for (const [id] of this._awayLoops) this._stopAwayLoop(id, forceOff);
}
// ── Tick / scheduling ─────────────────────────────────────────────────────
_tick() {
if (!this._running) return;
// Always reschedule FIRST — even if something below throws, the next tick
// still runs. Clears any previous timer so we don't double-fire.
if (this._tickTimer) clearTimeout(this._tickTimer);
this._tickTimer = setTimeout(() => this._tick(), 30_000);
try {
const now = new Date();
const today = now.toDateString();
if (today !== this._lastDate) {
// Day rolled over — full reset
this._firedToday = new Set();
this._stopAllAwayLoops(false);
this._loadSchedule();
this._resumeAwayLoops();
this._onStatus?.(this._buildStatus());
} else {
// Reload rules on every tick so newly created/edited rules are picked up
// without requiring a Homebridge restart. _firedToday prevents double-firing.
this._loadSchedule();
}
this._scheduleUpcoming();
this._writeHeartbeat();
} catch (e) {
this._log.error?.('[DWM Scheduler] Tick error (scheduler still running): ' + e.message);
}
}
_clearTimers() {
for (const t of this._timers) clearTimeout(t);
this._timers = [];
if (this._tickTimer) { clearTimeout(this._tickTimer); this._tickTimer = null; }
}
_scheduleUpcoming() {
if (!this._running) return;
const now = new Date();
const nowSecs = secondsFromMidnight(now);
const todayId = jsToWemoDayId(now.getDay());
const dayStart = new Date(now); dayStart.setHours(0, 0, 0, 0);
const windowEnd = nowSecs + 65;
for (const entry of this._schedule) {
if (entry.dayId !== todayId) continue;
if (entry.targetSecs < nowSecs - 5) continue;
if (entry.targetSecs > windowEnd) continue;
const key = `${entry.ruleId}-${entry.dayId}-${entry.targetSecs}-${entry.targetHost}`;
if (this._firedToday.has(key)) continue;
this._firedToday.add(key);
const fireAt = dayStart.getTime() + entry.targetSecs * 1000;
const delay = Math.max(0, fireAt - Date.now());
const t = setTimeout(() => this._fire(entry), delay);
this._timers.push(t);
}
}
async _fire(entry) {
if (entry.isAwayStart) {
const rule = this._store.getDwmRules().find(r => r.id === entry.ruleId);
if (rule && rule.enabled) {
this._startAwayLoop(rule);
this._emit({ success: true, msg: `"${entry.ruleName}" Away Mode started`, entry });
}
return;
}
if (entry.isAwayEnd) {
this._stopAwayLoop(entry.awayRuleId, true);
return;
}
const label = actionLabel(entry.action);
const wantOn = entry.action === 1;
try {
await this._wemo.setBinaryState(entry.targetHost, entry.targetPort, wantOn);
await new Promise((r) => setTimeout(r, 3000));
let confirmed = true;
try {
const state = await this._wemo.getBinaryState(entry.targetHost, entry.targetPort);
confirmed = (!!state) === wantOn;
} catch { confirmed = null; }
const suffix = confirmed === null ? ' (unverified)' : confirmed ? ' ✓' : ' ⚠ retrying';
this._emit({ success: true,
msg: `"${entry.ruleName}" → ${label} (${entry.targetHost})${suffix}`, entry });
if (confirmed === false) {
await new Promise((r) => setTimeout(r, 5000));
try {
await this._wemo.setBinaryState(entry.targetHost, entry.targetPort, wantOn);
this._emit({ success: true, msg: `"${entry.ruleName}" → ${label} retry OK`, entry });
} catch { /* silent */ }
}
} catch (e) {
this._emit({ success: false,
msg: `"${entry.ruleName}" → ${label} FAILED: ${e.message}`, entry });
}
}
// ── Missed-rule catch-up ──────────────────────────────────────────────────
/**
* On start, fire any Schedule/Countdown entries whose time fell within the
* last CATCHUP_WINDOW_S seconds (i.e. Homebridge was restarting when they
* were supposed to run). Away Mode windows are handled by _resumeAwayLoops.
*/
_catchUpMissedRules() {
if (!this._schedule.length) return;
const now = new Date();
const nowSecs = secondsFromMidnight(now);
const todayId = jsToWemoDayId(now.getDay());
const missed = [];
for (const entry of this._schedule) {
if (entry.isAwayStart || entry.isAwayEnd) continue;
if (entry.dayId !== todayId) continue;
const age = nowSecs - entry.targetSecs;
if (age <= 0 || age > CATCHUP_WINDOW_S) continue;
const key = `${entry.ruleId}-${entry.dayId}-${entry.targetSecs}-${entry.targetHost}`;
if (this._firedToday.has(key)) continue;
missed.push({ entry, key });
}
for (const { entry, key } of missed) {
this._firedToday.add(key);
this._emit({ success: true,
msg: `[catch-up] "${entry.ruleName}" → ${actionLabel(entry.action)} (${entry.targetHost})`, entry });
this._fire(entry);
}
if (missed.length) {
this._onStatus?.(this._buildStatus());
}
}
// ── Health monitor ────────────────────────────────────────────────────────
_startHealthMonitor() {
if (this._healthTimer) return;
// Small initial delay so start() returns quickly before first poll
this._healthTimer = setTimeout(() => this._pollDeviceHealth(), 15_000);
}
_stopHealthMonitor() {
if (this._healthTimer) { clearTimeout(this._healthTimer); this._healthTimer = null; }
}
/**
* Collect every unique host:port referenced in enabled DWM rules,
* probe each one, track online/offline state, and emit _onHealth events
* on transitions. When a device comes back online, enforce the state
* it should currently be in according to the active schedule.
*/
async _pollDeviceHealth() {
if (!this._running) return;
// Build device map: all targets + trigger source devices
const deviceMap = new Map(); // 'host:port' → { host, port, name }
const allRules = this._store.getDwmRules();
const alwaysOnSet = new Set(); // keys with an active AlwaysOn rule
const triggerSrcSet = new Set(); // keys that are trigger source devices
const addDev = (td) => {
if (!td?.host || !td?.port) return;
const key = `${td.host}:${td.port}`;
if (!deviceMap.has(key))
deviceMap.set(key, { host: td.host, port: Number(td.port), name: td.name ?? td.host });
return key;
};
for (const rule of allRules) {
if (!rule.enabled) continue;
if (rule.type === 'Trigger') {
const k = addDev(rule.triggerDevice);
if (k) triggerSrcSet.add(k);
for (const td of (rule.actionDevices ?? [])) addDev(td);
continue;
}
for (const td of (rule.targetDevices ?? [])) {
const k = addDev(td);
if (k && rule.type === 'AlwaysOn') alwaysOnSet.add(k);
}
}
for (const [key, dev] of deviceMap) {
const wasOnline = this._deviceHealth.get(key); // undefined = first check
try {
const isOn = await this._wemo.getBinaryState(dev.host, dev.port);
if (wasOnline === false) {
// ── Just came back online ──────────────────────────────────────
this._deviceHealth.set(key, true);
this._onHealth?.({ ...dev, online: true,
msg: `${dev.name} came back online` });
await this._enforceCurrentState(dev);
} else {
this._deviceHealth.set(key, true);
if (wasOnline === undefined) {
this._onHealth?.({ ...dev, online: true, msg: `${dev.name} online` });
}
}
// ── AlwaysOn enforcement ──────────────────────────────────────────
if (alwaysOnSet.has(key) && !isOn) {
try {
await this._wemo.setBinaryState(dev.host, dev.port, true);
this._emit({ success: true,
msg: `[always-on] ${dev.name} was OFF — turned ON ✓` });
} catch (e) {
this._emit({ success: false,
msg: `[always-on] ${dev.name} turn-ON failed: ${e.message}` });
}
}
// ── Trigger detection — fire rules if this device changed state ──
if (triggerSrcSet.has(key)) {
const prevState = this._triggerStates.get(key);
this._triggerStates.set(key, isOn);
if (prevState !== undefined && prevState !== isOn) {
await this._fireTriggerRules(key, isOn);
}
}
} catch (e) {
this._deviceHealth.set(key, false);
if (wasOnline !== false) {
this._onHealth?.({ ...dev, online: false,
msg: `${dev.name} unreachable: ${e.message}` });
}
}
}
// Schedule next poll
if (this._running) {
this._healthTimer = setTimeout(() => this._pollDeviceHealth(), HEALTH_POLL_MS);
}
}
/**
* For a device that just came back online, find the most recent Schedule
* entry that should have fired today and push that state to the device.
*/
async _enforceCurrentState(dev) {
const now = new Date();
const nowSecs = secondsFromMidnight(now);
const todayId = jsToWemoDayId(now.getDay());
let best = null;
for (const entry of this._schedule) {
if (entry.isAwayStart || entry.isAwayEnd) continue;
if (entry.targetHost !== dev.host) continue;
if (entry.dayId !== todayId) continue;
if (entry.targetSecs > nowSecs) continue;
if (!best || entry.targetSecs > best.targetSecs) best = entry;
}
if (!best) return;
const wantOn = best.action === 1;
try {
await this._wemo.setBinaryState(dev.host, dev.port, wantOn);
this._emit({
success: true,
msg: `[enforce] "${best.ruleName}" → ${actionLabel(best.action)} restored on ${dev.name}`,
entry: best,
});
} catch (e) {
this._emit({
success: false,
msg: `[enforce] "${best.ruleName}" → ${actionLabel(best.action)} FAILED on ${dev.name}: ${e.message}`,
entry: best,
});
}
}
// ── Trigger rules ─────────────────────────────────────────────────────────
/**
* A trigger device changed state. Find every enabled Trigger rule whose
* triggerDevice matches sourceKey and whose triggerEvent matches, then
* fire the action on each actionDevice.
*
* triggerEvent: 'on' | 'off' | 'any'
* action: 'on' | 'off' | 'mirror' | 'opposite'
*/
async _fireTriggerRules(sourceKey, isOn) {
const rules = this._store.getDwmRules().filter((r) =>
r.enabled &&
r.type === 'Trigger' &&
r.triggerDevice?.host &&
`${r.triggerDevice.host}:${r.triggerDevice.port}` === sourceKey
);
for (const rule of rules) {
const matches =
rule.triggerEvent === 'any' ||
(rule.triggerEvent === 'on' && isOn) ||
(rule.triggerEvent === 'off' && !isOn);
if (!matches) continue;
let targetOn;
if (rule.action === 'on') targetOn = true;
else if (rule.action === 'off') targetOn = false;
else if (rule.action === 'mirror') targetOn = isOn;
else if (rule.action === 'opposite') targetOn = !isOn;
else continue;
for (const dev of (rule.actionDevices ?? [])) {
if (!dev.host || !dev.port) continue;
try {
await this._wemo.setBinaryState(dev.host, Number(dev.port), targetOn);
this._emit({ success: true,
msg: `[trigger] "${rule.name}" → ${dev.name ?? dev.host} ${targetOn ? 'ON' : 'OFF'}` });
} catch (e) {
this._emit({ success: false,
msg: `[trigger] "${rule.name}" → ${dev.name ?? dev.host} FAILED: ${e.message}` });
}
}
}
}
// ── Heartbeat ─────────────────────────────────────────────────────────────
_writeHeartbeat() {
try {
const status = this._buildStatus();
const lastFire = this._lastFireMsg ?? null;
this._store.saveHeartbeat({
running: true,
startedAt: this._startedAt?.toISOString() ?? null,
totalEntries: status.totalEntries,
upcoming: status.upcoming.slice(0, 3),
lastFire,
});
} catch { /* non-critical */ }
}
// ── Status ────────────────────────────────────────────────────────────────
_buildStatus() {
const now = new Date();
const nowSecs = secondsFromMidnight(now);
const todayId = jsToWemoDayId(now.getDay());
const awayActive = [];
for (const [, loop] of this._awayLoops) {
awayActive.push({ ruleName: loop.rule.name, action: loop.isOn ? 'ON (Away)' : 'OFF (Away)', at: 'now' });
}
const seen = new Set();
const upcoming = this._schedule
.filter(e => e.dayId === todayId && e.targetSecs > nowSecs && !e.isAwayEnd)
.sort((a, b) => a.targetSecs - b.targetSecs)
.reduce((acc, e) => {
const key = `${e.ruleId}|${e.targetSecs}|${e.action}|${e.targetHost}`;
if (!seen.has(key)) {
seen.add(key);
acc.push({
ruleName: e.ruleName, targetHost: e.targetHost,
action: e.isAwayStart ? 'Away Mode start' : actionLabel(e.action),
at: secsToHHMM(e.targetSecs),
});
}
return acc;
}, [])
.slice(0, 8);
return {
running: this._running,
totalEntries: this._schedule.length,
awayActive,
upcoming: [...awayActive, ...upcoming].slice(0, 8),
};
}
}
module.exports = DwmScheduler;
+118
View File
@@ -0,0 +1,118 @@
'use strict';
/**
* DWM Store — Homebridge edition.
*
* Stores devices, DWM rules, and location in a single JSON file inside
* Homebridge's storagePath (passed in at construction time, not via Electron).
*
* Schema mirrors the desktop store exactly so DWM rules created in the desktop
* app can be imported / shared.
*/
const path = require('path');
const fs = require('fs');
const DEFAULTS = {
location: null,
devices: [],
deviceGroups: [],
deviceOrder: [],
disabledRules: {},
dwmRules: [],
schedulerHeartbeat: null,
};
class DwmStore {
constructor(storagePath) {
this._filePath = path.join(storagePath, 'dibby-wemo.json');
}
// ── Internal I/O ──────────────────────────────────────────────────────────
_load() {
try {
return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(this._filePath, 'utf8')) };
} catch {
return { ...DEFAULTS };
}
}
_save(data) {
fs.writeFileSync(this._filePath, JSON.stringify(data, null, 2), 'utf8');
}
// ── Location ──────────────────────────────────────────────────────────────
getLocation() { return this._load().location; }
setLocation(loc) { const d = this._load(); d.location = loc; this._save(d); }
// ── Devices ───────────────────────────────────────────────────────────────
getDevices() { return this._load().devices ?? []; }
saveDevices(list) { const d = this._load(); d.devices = list; this._save(d); }
getDeviceOrder() { return this._load().deviceOrder ?? []; }
saveDeviceOrder(order) { const d = this._load(); d.deviceOrder = order; this._save(d); }
getDeviceGroups() { return this._load().deviceGroups ?? []; }
saveDeviceGroups(groups) { const d = this._load(); d.deviceGroups = groups; this._save(d); }
// ── Disabled-rule backups ─────────────────────────────────────────────────
getDisabledRules() { return this._load().disabledRules ?? {}; }
setDisabledRule(key, ruleDevicesRows) {
const d = this._load();
if (!d.disabledRules) d.disabledRules = {};
d.disabledRules[key] = ruleDevicesRows;
this._save(d);
}
clearDisabledRule(key) {
const d = this._load();
if (!d.disabledRules) return;
delete d.disabledRules[key];
this._save(d);
}
// ── DWM Rules ─────────────────────────────────────────────────────────────
getDwmRules() { return this._load().dwmRules ?? []; }
createDwmRule(rule) {
const d = this._load();
if (!d.dwmRules) d.dwmRules = [];
const id = `dwm-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const now = new Date().toISOString();
const newRule = { ...rule, id, createdAt: now, updatedAt: now };
d.dwmRules.push(newRule);
this._save(d);
return newRule;
}
updateDwmRule(id, updates) {
const d = this._load();
if (!d.dwmRules) d.dwmRules = [];
const idx = d.dwmRules.findIndex((r) => r.id === id);
if (idx === -1) throw new Error(`DWM rule not found: ${id}`);
d.dwmRules[idx] = { ...d.dwmRules[idx], ...updates, id, updatedAt: new Date().toISOString() };
this._save(d);
return d.dwmRules[idx];
}
deleteDwmRule(id) {
const d = this._load();
if (!d.dwmRules) return;
d.dwmRules = d.dwmRules.filter((r) => r.id !== id);
this._save(d);
}
// ── Scheduler heartbeat ───────────────────────────────────────────────────
getHeartbeat() { return this._load().schedulerHeartbeat ?? null; }
saveHeartbeat(hb) {
const d = this._load();
d.schedulerHeartbeat = { ...hb, ts: new Date().toISOString() };
this._save(d);
}
}
module.exports = DwmStore;
+81
View File
@@ -0,0 +1,81 @@
'use strict';
/**
* Calculate sunrise and sunset times for a given location and date.
* Pure JS no external dependencies.
*
* Algorithm: NOAA Solar Calculator (Jean Meeus, Astronomical Algorithms).
*
* @param {number} lat Latitude in decimal degrees (positive = North)
* @param {number} lng Longitude in decimal degrees (positive = East)
* @param {Date} date Date to calculate for (default: today)
* @returns {{ sunrise: number|null, sunset: number|null }}
* Times as integer seconds from LOCAL midnight.
* null for each value if polar day or polar night.
*/
function sunTimes(lat, lng, date = new Date()) {
const D2R = Math.PI / 180;
const R2D = 180 / Math.PI;
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const A = Math.floor((14 - month) / 12);
const Y = year + 4800 - A;
const M = month + 12 * A - 3;
const JDN = day + Math.floor((153 * M + 2) / 5) + 365 * Y
+ Math.floor(Y / 4) - Math.floor(Y / 100) + Math.floor(Y / 400) - 32045;
const JD = JDN - 0.5;
const T = (JD - 2451545.0) / 36525.0;
let L0 = 280.46646 + T * (36000.76983 + T * 0.0003032);
L0 = ((L0 % 360) + 360) % 360;
let Mdeg = 357.52911 + T * (35999.05029 - 0.0001537 * T);
Mdeg = ((Mdeg % 360) + 360) % 360;
const Mrad = Mdeg * D2R;
const C = (1.914602 - T * (0.004817 + 0.000014 * T)) * Math.sin(Mrad)
+ (0.019993 - 0.000101 * T) * Math.sin(2 * Mrad)
+ 0.000289 * Math.sin(3 * Mrad);
const omega = 125.04 - 1934.136 * T;
const lambda = (L0 + C) - 0.00569 - 0.00478 * Math.sin(omega * D2R);
const eps0 = 23.0
+ (26.0 + (21.448 - T * (46.8150 + T * (0.00059 - T * 0.001813))) / 60.0) / 60.0;
const eps = (eps0 + 0.00256 * Math.cos(omega * D2R)) * D2R;
const sinDec = Math.sin(eps) * Math.sin(lambda * D2R);
const decl = Math.asin(sinDec);
const e = 0.016708634 - T * (0.000042037 + 0.0000001267 * T);
const y = Math.pow(Math.tan(eps / 2), 2);
const EqT = 4 * R2D * (
y * Math.sin(2 * L0 * D2R)
- 2 * e * Math.sin(Mrad)
+ 4 * e * y * Math.sin(Mrad) * Math.cos(2 * L0 * D2R)
- 0.5 * y * y * Math.sin(4 * L0 * D2R)
- 1.25 * e * e * Math.sin(2 * Mrad)
);
const cosHA = (Math.cos(90.833 * D2R) - Math.sin(lat * D2R) * sinDec)
/ (Math.cos(lat * D2R) * Math.cos(decl));
if (cosHA < -1 || cosHA > 1) {
return { sunrise: null, sunset: null };
}
const HA = Math.acos(cosHA) * R2D;
const tzOffsetMin = -date.getTimezoneOffset();
const solarNoon = 720.0 - 4.0 * lng - EqT + tzOffsetMin;
return {
sunrise: Math.round((solarNoon - HA * 4.0) * 60),
sunset: Math.round((solarNoon + HA * 4.0) * 60),
};
}
module.exports = { sunTimes };
+76
View File
@@ -0,0 +1,76 @@
'use strict';
/** Day numbers: 1=Monday ... 7=Sunday (Wemo convention) */
const DAY_NUMBERS = { Monday:1, Tuesday:2, Wednesday:3, Thursday:4, Friday:5, Saturday:6, Sunday:7 };
const DAY_NAMES = { 1:'Monday', 2:'Tuesday', 3:'Wednesday', 4:'Thursday', 5:'Friday', 6:'Saturday', 7:'Sunday' };
const DAY_SHORT = { 1:'Mon', 2:'Tue', 3:'Wed', 4:'Thu', 5:'Fri', 6:'Sat', 7:'Sun' };
/** Rule types stored in RULES.Type */
const RULE_TYPES = {
SCHEDULE: 'Schedule',
AWAY: 'Away',
COUNTDOWN: 'Countdown',
LONG_PRESS: 'Long Press',
};
/** Start/End action values */
const ACTIONS = { ON: 1.0, OFF: 0.0, TOGGLE: 2.0, NONE: -1.0 };
/** Network status codes returned by GetNetworkStatus */
const NETWORK_STATUS = { FAILED: '0', SUCCESS: '1', WRONG_PASSWORD: '2', CONNECTING: '3' };
/** Wemo device reset codes for ReSetup action */
const RESET_CODES = { CLEAR_DATA: 1, FACTORY_RESET: 2, CLEAR_WIFI: 5 };
/** Default RULEDEVICES field values */
const RD_DEFAULTS = {
GroupID: 0,
RuleDuration: 0,
StartAction: 1.0,
EndAction: -1.0,
SensorDuration: 2,
Type: -1,
Value: -1,
Level: -1,
ZBCapabilityStart: '',
ZBCapabilityEnd: '',
OnModeOffset: -1,
OffModeOffset: -1,
CountdownTime: 0,
EndTime: -1,
};
/** Sun time sentinel codes stored in RULEDEVICES.StartTime / EndTime */
const SUN_CODES = { SUNRISE: -2, SUNSET: -3 };
function namesToDayNumbers(names) {
return names.map((n) => DAY_NUMBERS[n]).filter(Boolean).sort((a, b) => a - b);
}
function dayNumbersToNames(numbers) {
return numbers.map((n) => DAY_NAMES[n]).filter(Boolean);
}
function dayNumbersToShort(numbers) {
return numbers.map((n) => DAY_SHORT[n]).filter(Boolean);
}
function timeToSecs(hhmm) {
if (!hhmm || !hhmm.includes(':')) return 0;
const [h, m] = hhmm.split(':').map(Number);
return h * 3600 + m * 60;
}
function secsToHHMM(secs) {
if (secs === undefined || secs === null || secs < 0) return '00:00';
const h = Math.floor(secs / 3600) % 24;
const m = Math.floor((secs % 3600) / 60);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
module.exports = {
DAY_NUMBERS, DAY_NAMES, DAY_SHORT,
RULE_TYPES, ACTIONS, NETWORK_STATUS, RESET_CODES, RD_DEFAULTS, SUN_CODES,
namesToDayNumbers, dayNumbersToNames, dayNumbersToShort,
timeToSecs, secsToHHMM,
};
@@ -0,0 +1,489 @@
'use strict';
/**
* Wemo SOAP client + SSDP discovery + rules CRUD.
*
* Self-contained: no Electron, no store dependency.
* Adapted from apps/desktop/src/main/wemo.js — same protocol, same SQL schema.
*/
const dgram = require('dgram');
const path = require('path');
const http = require('http');
const axios = require('axios');
const AdmZip = require('adm-zip');
const { parseStringPromise } = require('xml2js');
const { create } = require('xmlbuilder2');
// Core helpers — bundled locally so the plugin is self-contained
const { namesToDayNumbers, timeToSecs } = require('./types');
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const NO_KEEPALIVE = new http.Agent({ keepAlive: false });
const WEMO_PORTS = [49153, 49152, 49154, 49155, 49156];
const BE_SVC = 'urn:Belkin:service:basicevent:1';
const BE_URL = '/upnp/control/basicevent1';
const TS_SVC = 'urn:Belkin:service:timesync:1';
const TS_URL = '/upnp/control/timesync1';
const RULES_SVC = 'urn:Belkin:service:rules:1';
const RULES_URL = '/upnp/control/rules1';
const RULE_TYPE_TO_DEVICE = {
'Schedule': 'Time Interval',
'Countdown': 'Countdown Rule',
'Away': 'Away Mode',
};
// ---------------------------------------------------------------------------
// sql.js (WASM SQLite)
// ---------------------------------------------------------------------------
let SQL = null;
async function getSql(log) {
if (!SQL) {
const fs = require('fs');
const initSqlJs = require('sql.js');
const candidates = [
path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
path.join(__dirname, '..', '..', '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
path.join(__dirname, 'sql-wasm.wasm'),
];
let wasmBinary = null;
for (const p of candidates) {
try { wasmBinary = fs.readFileSync(p); break; } catch { /* try next */ }
}
if (!wasmBinary) {
throw new Error(`sql-wasm.wasm not found. Tried:\n${candidates.join('\n')}`);
}
SQL = await initSqlJs({ wasmBinary });
}
return SQL;
}
// ---------------------------------------------------------------------------
// SOAP helpers
// ---------------------------------------------------------------------------
async function soapRequest(host, port, controlURL, serviceType, action, args = {}, timeoutMs = 10_000) {
const url = `http://${host}:${port}${controlURL}`;
const root = create({ version: '1.0', encoding: 'utf-8' })
.ele('s:Envelope', { 'xmlns:s': 'http://schemas.xmlsoap.org/soap/envelope/', 's:encodingStyle': 'http://schemas.xmlsoap.org/soap/encoding/' })
.ele('s:Body')
.ele(`u:${action}`, { [`xmlns:u`]: serviceType });
for (const [k, v] of Object.entries(args)) root.ele(k).txt(v);
const xml = root.doc().end({ headless: false });
const res = await axios.post(url, xml, {
headers: {
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPACTION': `"${serviceType}#${action}"`,
'Connection': 'close',
},
httpAgent: NO_KEEPALIVE,
timeout: timeoutMs,
});
const parsed = await parseStringPromise(res.data, { explicitArray: false, ignoreAttrs: true });
const body = parsed['s:Envelope']['s:Body'];
return body[`u:${action}Response`] ?? body;
}
async function soapWithFallback(host, port, controlURL, serviceType, action, args = {}) {
const portsToTry = [port, ...WEMO_PORTS.filter((p) => p !== port)];
let lastErr = null;
for (const tryPort of portsToTry) {
try {
return await soapRequest(host, tryPort, controlURL, serviceType, action, args);
} catch (err) {
lastErr = err;
const isConn = err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT';
if (!isConn) throw err;
}
}
throw lastErr || new Error(`${host}: all ports failed for ${action}`);
}
// ---------------------------------------------------------------------------
// Device control
// ---------------------------------------------------------------------------
async function getBinaryState(host, port) {
const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetBinaryState');
const raw = String(res['BinaryState'] ?? '0');
return raw === '1' || raw === '8';
}
async function setBinaryState(host, port, on) {
await soapWithFallback(host, port, BE_URL, BE_SVC, 'SetBinaryState', { BinaryState: on ? '1' : '0' });
}
// ---------------------------------------------------------------------------
// Device info
// ---------------------------------------------------------------------------
function resolveProductModel(udn, deviceType, firmwareSuffix) {
const udnBase = String(udn || '').replace(/^uuid:/i, '');
const parts = udnBase.split('-');
const udnPrefix = parts.slice(0, 2).join('-').toLowerCase();
const udnType = parts[0].toLowerCase();
const fwSuffix = String(firmwareSuffix || '').toUpperCase();
const dt = String(deviceType || '').toLowerCase();
if (udnPrefix === 'lightswitch-3_0') return 'Wemo 3-Way Smart Switch (WLS0403)';
if (udnPrefix === 'lightswitch-2_0') return 'Wemo Light Switch (WLS040)';
if (udnPrefix === 'lightswitch-1_0') {
if (fwSuffix.includes('OWRT-LS')) return 'Wemo Light Switch (F7C030)';
return 'Wemo Light Switch (WLS040)';
}
if (udnType === 'dimmer' || dt.includes('dimmer') || fwSuffix.includes('WDS'))
return 'Wemo WiFi Smart Dimmer (WDS060)';
if (udnType === 'insight' || dt.includes('insight')) return 'Wemo Insight Smart Plug (F7C029)';
if (udnPrefix === 'socket-2_0') return 'Wemo Mini Smart Plug (F7C063)';
if (udnPrefix === 'socket-1_0') {
if (fwSuffix.includes('OWRT-SNS')) return 'Wemo Switch (F7C027)';
return 'Wemo Smart Plug';
}
if (udnType === 'socket') return 'Wemo Smart Plug';
return null;
}
async function getDeviceInfo(host, port) {
const results = {};
try {
const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetFriendlyName');
results.friendlyName = String(res['FriendlyName'] ?? '').trim();
} catch { results.friendlyName = null; }
try {
const sx = await axios.get(`http://${host}:${port}/setup.xml`, { timeout: 5000, httpAgent: NO_KEEPALIVE });
const fwMatch = sx.data.match(/<firmwareVersion>([^<]+)<\/firmwareVersion>/i);
const udnMatch = sx.data.match(/<UDN>([^<]+)<\/UDN>/i);
const dtMatch = sx.data.match(/<deviceType>([^<]+)<\/deviceType>/i);
const mdMatch = sx.data.match(/<modelDescription>([^<]+)<\/modelDescription>/i);
results.firmwareVersion = fwMatch ? fwMatch[1].trim() : null;
results.modelDescription = mdMatch ? mdMatch[1].trim() : null;
if (udnMatch) {
results.udn = udnMatch[1].trim();
const fw = results.firmwareVersion || '';
const fwSuffix = fw.split('PVT-').pop() || '';
results.productModel = resolveProductModel(results.udn, dtMatch ? dtMatch[1] : '', fwSuffix);
}
} catch { /* non-fatal */ }
return results;
}
// ---------------------------------------------------------------------------
// SSDP Discovery
// ---------------------------------------------------------------------------
function discoverDevices(timeoutMs = 10_000) {
return new Promise((resolve) => {
const SSDP_ADDR = '239.255.255.250';
const SSDP_PORT = 1900;
const M_SEARCH = [
'M-SEARCH * HTTP/1.1',
`HOST: ${SSDP_ADDR}:${SSDP_PORT}`,
'MAN: "ssdp:discover"',
'MX: 3',
'ST: urn:Belkin:device:**',
'', '',
].join('\r\n');
const found = new Map();
const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true });
sock.on('message', async (msg) => {
const text = msg.toString();
const locMatch = text.match(/LOCATION:\s*(http:\/\/([^:]+):(\d+)\/setup\.xml)/i);
if (!locMatch) return;
const [, , ip, portStr] = locMatch;
const port = parseInt(portStr, 10);
const key = `${ip}:${port}`;
if (found.has(key)) return;
found.set(key, { host: ip, port, discovering: true });
try {
const info = await getDeviceInfo(ip, port);
found.set(key, { host: ip, port, ...info });
} catch { /* keep partial entry */ }
});
sock.bind(() => {
const buf = Buffer.from(M_SEARCH);
sock.send(buf, 0, buf.length, SSDP_PORT, SSDP_ADDR);
});
setTimeout(() => {
try { sock.close(); } catch { /* ignore */ }
resolve(Array.from(found.values()));
}, timeoutMs);
});
}
// ---------------------------------------------------------------------------
// Rules — fetch (ZIP + SQLite)
// ---------------------------------------------------------------------------
async function fetchRules(host, port) {
const res = await soapWithFallback(host, port, RULES_URL, RULES_SVC, 'FetchRules');
const version = String(res['ruleDbVersion'] ?? '0');
const dbUrl = String(res['ruleDbPath'] ?? '');
if (!dbUrl) throw new Error('FetchRules returned no ruleDbPath');
const dlRes = await axios.get(dbUrl, { responseType: 'arraybuffer', timeout: 15_000 });
const zip = new AdmZip(Buffer.from(dlRes.data));
const entry = zip.getEntries().find((e) => e.entryName.endsWith('.db'));
if (!entry) throw new Error('No .db file in rules ZIP');
const SQL = await getSql();
const db = new SQL.Database(entry.getData());
const rules = _dbQuery(db, 'SELECT * FROM RULES');
const ruleDevices = _dbQuery(db, 'SELECT * FROM RULEDEVICES');
const targets = _dbQuery(db, 'SELECT * FROM TARGETDEVICES');
db.close();
return { version, rules, ruleDevices, targets };
}
function _dbQuery(db, sql) {
const rows = [];
const stmt = db.prepare(sql);
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
return rows;
}
// ---------------------------------------------------------------------------
// Rules — store (ZIP + CDATA encode)
// ---------------------------------------------------------------------------
async function storeRules(host, port, version, dbBuffer) {
const zip = new AdmZip();
zip.addFile('temppluginRules.db', dbBuffer);
const b64 = zip.toBuffer().toString('base64');
// CRITICAL: body must be entity-encoded CDATA — hand-crafted XML only
const soapXml = `<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:StoreRules xmlns:u="urn:Belkin:service:rules:1">
<ruleDbVersion>${version}</ruleDbVersion>
<StartSync>NOSYNC</StartSync>
<ruleDbBody>&lt;![CDATA[${b64}]]&gt;</ruleDbBody>
</u:StoreRules>
</s:Body>
</s:Envelope>`;
const url = `http://${host}:${port}${RULES_URL}`;
const res = await axios.post(url, soapXml, {
headers: {
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPACTION': `"${RULES_SVC}#StoreRules"`,
'Connection': 'close',
},
httpAgent: NO_KEEPALIVE,
timeout: 20_000,
});
if (String(res.data).includes('failed')) throw new Error('StoreRules: device returned failure');
}
// ---------------------------------------------------------------------------
// Rules — create / update / delete / toggle
// ---------------------------------------------------------------------------
async function createRule(host, port, ruleData) {
const SQL = await getSql();
const { version, rules, ruleDevices, targets } = await fetchRules(host, port);
const db = new SQL.Database();
_createSchema(db);
for (const r of rules) _insertRule(db, r);
for (const r of ruleDevices) _insertRuleDevice(db, r);
for (const r of targets) _insertTargetDevice(db, r);
const newId = _nextRuleId(db);
_insertNewRule(db, newId, ruleData);
const buf = Buffer.from(db.export());
db.close();
await storeRules(host, port, String(parseInt(version, 10) + 2), buf);
return newId;
}
async function updateRule(host, port, ruleId, ruleData) {
const SQL = await getSql();
const { version, rules, ruleDevices, targets } = await fetchRules(host, port);
const db = new SQL.Database();
_createSchema(db);
for (const r of rules) _insertRule(db, r);
for (const r of ruleDevices) _insertRuleDevice(db, r);
for (const r of targets) _insertTargetDevice(db, r);
db.run('DELETE FROM RULES WHERE RuleID = ?', [String(ruleId)]);
db.run('DELETE FROM RULEDEVICES WHERE RuleID = ?', [String(ruleId)]);
db.run('DELETE FROM TARGETDEVICES WHERE RuleID = ?', [String(ruleId)]);
_insertNewRule(db, ruleId, ruleData);
const buf = Buffer.from(db.export());
db.close();
await storeRules(host, port, String(parseInt(version, 10) + 2), buf);
}
async function deleteRule(host, port, ruleId) {
const SQL = await getSql();
const { version, rules, ruleDevices, targets } = await fetchRules(host, port);
const db = new SQL.Database();
_createSchema(db);
for (const r of rules) _insertRule(db, r);
for (const r of ruleDevices) _insertRuleDevice(db, r);
for (const r of targets) _insertTargetDevice(db, r);
db.run('DELETE FROM RULES WHERE RuleID = ?', [String(ruleId)]);
db.run('DELETE FROM RULEDEVICES WHERE RuleID = ?', [String(ruleId)]);
db.run('DELETE FROM TARGETDEVICES WHERE RuleID = ?', [String(ruleId)]);
const buf = Buffer.from(db.export());
db.close();
await storeRules(host, port, String(parseInt(version, 10) + 2), buf);
}
async function toggleRule(host, port, ruleId, enabled) {
const SQL = await getSql();
const { version, rules, ruleDevices, targets } = await fetchRules(host, port);
const db = new SQL.Database();
_createSchema(db);
for (const r of rules) _insertRule(db, r);
for (const r of ruleDevices) _insertRuleDevice(db, r);
for (const r of targets) _insertTargetDevice(db, r);
db.run('UPDATE RULES SET State = ? WHERE RuleID = ?', [enabled ? '1' : '0', String(ruleId)]);
const buf = Buffer.from(db.export());
db.close();
await storeRules(host, port, String(parseInt(version, 10) + 2), buf);
}
// ---------------------------------------------------------------------------
// SQLite helpers (schema + insert helpers)
// ---------------------------------------------------------------------------
function _createSchema(db) {
db.run(`CREATE TABLE IF NOT EXISTS RULES (
RuleID TEXT, Name TEXT, Type TEXT, RuleOrder INTEGER,
StartDate TEXT DEFAULT '12201982', EndDate TEXT DEFAULT '07301982',
State TEXT DEFAULT '1', Sync TEXT DEFAULT 'NOSYNC'
)`);
db.run(`CREATE TABLE IF NOT EXISTS RULEDEVICES (
RuleDevicePK INTEGER PRIMARY KEY AUTOINCREMENT,
RuleID TEXT, DeviceID TEXT, GroupID INTEGER, DayID INTEGER,
StartTime INTEGER, RuleDuration INTEGER, StartAction INTEGER, EndAction INTEGER,
SensorDuration INTEGER, Type INTEGER, Value INTEGER, Level INTEGER,
ZBCapabilityStart TEXT, ZBCapabilityEnd TEXT,
OnModeOffset INTEGER, OffModeOffset INTEGER, CountdownTime INTEGER, EndTime INTEGER
)`);
db.run(`CREATE TABLE IF NOT EXISTS TARGETDEVICES (
TargetDevicesPK INTEGER PRIMARY KEY AUTOINCREMENT,
RuleID TEXT, DeviceID TEXT, DeviceIndex INTEGER
)`);
}
function _insertRule(db, r) {
db.run(
'INSERT INTO RULES (RuleID,Name,Type,RuleOrder,StartDate,EndDate,State,Sync) VALUES (?,?,?,?,?,?,?,?)',
[r.RuleID, r.Name, r.Type, r.RuleOrder, r.StartDate ?? '12201982', r.EndDate ?? '07301982', r.State ?? '1', r.Sync ?? 'NOSYNC']
);
}
function _insertRuleDevice(db, r) {
db.run(
`INSERT INTO RULEDEVICES (RuleID,DeviceID,GroupID,DayID,StartTime,RuleDuration,StartAction,EndAction,
SensorDuration,Type,Value,Level,ZBCapabilityStart,ZBCapabilityEnd,
OnModeOffset,OffModeOffset,CountdownTime,EndTime)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[r.RuleID, r.DeviceID, r.GroupID ?? 0, r.DayID, r.StartTime, r.RuleDuration ?? 0,
r.StartAction, r.EndAction ?? -1, r.SensorDuration ?? 0, r.Type ?? 0, r.Value ?? 0,
r.Level ?? 0, r.ZBCapabilityStart ?? '', r.ZBCapabilityEnd ?? '',
r.OnModeOffset ?? 0, r.OffModeOffset ?? 0, r.CountdownTime ?? 0, r.EndTime ?? -1]
);
}
function _insertTargetDevice(db, r) {
db.run(
'INSERT INTO TARGETDEVICES (RuleID,DeviceID,DeviceIndex) VALUES (?,?,?)',
[r.RuleID, r.DeviceID, r.DeviceIndex ?? 0]
);
}
function _nextRuleId(db) {
const stmt = db.prepare('SELECT CAST(MAX(CAST(RuleID AS INTEGER)) AS INTEGER) AS mx FROM RULES');
let mx = 0;
if (stmt.step()) { mx = stmt.getAsObject().mx ?? 0; }
stmt.free();
return mx + 1;
}
function _insertNewRule(db, ruleId, ruleData) {
// namesToDayNumbers + timeToSecs already required at top of file
const days = ruleData.days ?? [];
const dayNums = typeof days[0] === 'string' ? namesToDayNumbers(days) : days.map(Number);
const devId = ruleData.deviceId ?? ruleData.udn ?? '';
const ruleType = RULE_TYPE_TO_DEVICE[ruleData.type] ?? ruleData.type ?? 'Time Interval';
let startSecs, endSecs;
if (ruleData.startTime != null) {
startSecs = typeof ruleData.startTime === 'string'
? timeToSecs(ruleData.startTime) : Number(ruleData.startTime);
} else startSecs = 0;
if (ruleData.endTime != null && ruleData.endTime !== '') {
endSecs = typeof ruleData.endTime === 'string'
? timeToSecs(ruleData.endTime) : Number(ruleData.endTime);
} else endSecs = -1;
const startAction = ruleData.startAction ?? 1;
const endAction = ruleData.endAction ?? -1;
db.run(
'INSERT INTO RULES (RuleID,Name,Type,RuleOrder,StartDate,EndDate,State,Sync) VALUES (?,?,?,?,?,?,?,?)',
[String(ruleId), ruleData.name ?? 'Rule', ruleType, ruleId,
'12201982', '07301982', ruleData.enabled !== false ? '1' : '0', 'NOSYNC']
);
for (const dayId of dayNums) {
db.run(
`INSERT INTO RULEDEVICES (RuleID,DeviceID,GroupID,DayID,StartTime,RuleDuration,StartAction,EndAction,
SensorDuration,Type,Value,Level,ZBCapabilityStart,ZBCapabilityEnd,
OnModeOffset,OffModeOffset,CountdownTime,EndTime)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[String(ruleId), devId, 0, dayId, startSecs, 0,
startAction, endAction, 0, 0, 0, 0, '', '',
0, 0, ruleData.countdownTime ?? 0, endSecs]
);
}
db.run(
'INSERT INTO TARGETDEVICES (RuleID,DeviceID,DeviceIndex) VALUES (?,?,?)',
[String(ruleId), devId, 0]
);
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
module.exports = {
getBinaryState,
setBinaryState,
getDeviceInfo,
discoverDevices,
fetchRules,
storeRules,
createRule,
updateRule,
deleteRule,
toggleRule,
};
+34
View File
@@ -0,0 +1,34 @@
{
"name": "homebridge-dibby-wemo",
"version": "1.0.0",
"description": "Dibby Wemo Manager Homebridge plugin for local Wemo control with DWM scheduling. No Belkin cloud required.",
"main": "index.js",
"customUi": true,
"scripts": {
"install-plugin": "npm install -g . && npm install --prefix \"%APPDATA%/npm/node_modules/homebridge-dibby-wemo\""
},
"keywords": [
"homebridge-plugin",
"wemo",
"belkin",
"homekit",
"smart-home"
],
"engines": {
"homebridge": ">=1.6.0",
"node": ">=18"
},
"author": "SRS IT",
"license": "MIT",
"dependencies": {
"adm-zip": "^0.5.14",
"axios": "^1.7.0",
"sql.js": "^1.12.0",
"xml2js": "^0.6.2",
"xmlbuilder2": "^4.0.3",
"@homebridge/plugin-ui-utils": "^2.2.0"
},
"devDependencies": {
"homebridge": "^1.8.0"
}
}