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