51 Commits

Author SHA1 Message Date
ThaMunsta 905b54803d maybe now
Build Images and Deploy / Update-PROD-Stack (push) Successful in 18s
2026-04-06 21:01:48 -04:00
ThaMunsta b724e805dd better fix for toggle?
Build Images and Deploy / Update-PROD-Stack (push) Successful in 19s
2026-04-06 20:58:43 -04:00
ThaMunsta 93dc2952ec testing fixes for toggles and sockets
Build Images and Deploy / Update-PROD-Stack (push) Successful in 20s
2026-04-06 20:55:29 -04:00
ThaMunsta cd0ca8cf7c web sockety things
Build Images and Deploy / Update-PROD-Stack (push) Successful in 19s
2026-03-30 23:21:23 -04:00
ThaMunsta 0a4ff9bf76 holy shit toggle button
Build Images and Deploy / Update-PROD-Stack (push) Successful in 19s
2026-03-30 23:15:35 -04:00
ThaMunsta 5180b3bce1 on off toggle fix
Build Images and Deploy / Update-PROD-Stack (push) Successful in 19s
2026-03-30 23:12:48 -04:00
ThaMunsta d7e9f0d3d3 state is still being weird
Build Images and Deploy / Update-PROD-Stack (push) Successful in 19s
2026-03-30 23:09:04 -04:00
ThaMunsta f7c952cec4 toggle button state on load
Build Images and Deploy / Update-PROD-Stack (push) Successful in 19s
2026-03-30 22:59:49 -04:00
ThaMunsta 9e6101dc3c another shot at brightness adjustments
Build Images and Deploy / Update-PROD-Stack (push) Successful in 19s
2026-03-30 22:57:42 -04:00
ThaMunsta 0bb3398097 toggle button fixes
Build Images and Deploy / Update-PROD-Stack (push) Successful in 19s
2026-03-30 22:44:34 -04:00
ThaMunsta 7ea32cee8c bugfix for rule fetch, feat: device info and trying to fix dimmer
Build Images and Deploy / Update-PROD-Stack (push) Successful in 18s
2026-03-30 22:38:52 -04:00
ThaMunsta 5bffb1064d more debug logs
Build Images and Deploy / Update-PROD-Stack (push) Successful in 11s
2026-03-30 22:29:18 -04:00
ThaMunsta da2693ae68 dimming and fix for manual add
Build Images and Deploy / Update-PROD-Stack (push) Successful in 19s
2026-03-30 22:16:17 -04:00
ThaMunsta 70c98af759 manual add button
Build Images and Deploy / Update-PROD-Stack (push) Successful in 18s
2026-03-30 22:10:30 -04:00
ThaMunsta 0977a610ff change compose files
Build Images and Deploy / Update-PROD-Stack (push) Successful in 19s
2026-03-30 22:05:21 -04:00
ThaMunsta ab17324f85 feat: add deployment workflows for web, Android, Docker, macOS, and Linux
Build Images and Deploy / Update-PROD-Stack (push) Successful in 34s
2026-03-30 22:00:36 -04:00
SRS IT f5e69fa9cd Fix Android workflow: add contents:write permission for release upload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:29:56 -04:00
SRS IT fe48f9b465 Add Android Capacitor app and build workflow
- apps/android/: Capacitor project wrapping the mobile web UI
- www/index.html: full DWM remote UI with first-run server IP setup screen
- capacitor.config.json: app ID com.dibby.wemo, allowMixedContent enabled
- .github/workflows/build-android.yml: builds debug APK on Ubuntu via Gradle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:20:27 -04:00
SRS IT 3480c75f4c feat: add macOS build, Docker image, and CI workflows
macOS (.dmg):
- Add mac build config to apps/desktop/package.json (x64 + arm64 DMGs)
- .github/workflows/build-mac.yml — builds on macos-latest, uploads to release

Docker (headless scheduler + web remote):
- docker/server.js — Node.js entry point using homebridge-plugin lib;
  REST API + WebSocket, serves mobile web UI on PORT (default 3456)
- docker/package.json — production deps (adm-zip, axios, sql.js, ws, xml2js, xmlbuilder2)
- Dockerfile — node:20-alpine image; VOLUME /data for persistent config
- .github/workflows/build-docker.yml — builds linux/amd64+arm64,
  pushes to ghcr.io/k0rb3nd4ll4s/dibby-wemo-manager

Usage:
  docker run -d --network host -v /opt/wemo:/data \
    ghcr.io/k0rb3nd4ll4s/dibby-wemo-manager:latest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:27:03 -04:00
SRS IT 58f2724d17 fix: add contents:write permission for release uploads 2026-03-29 15:31:27 -04:00
SRS IT 05079ca545 fix: remove arch from linux targets — let CLI --x64/--arm64 flag control arch 2026-03-29 14:38:32 -04:00
SRS IT 129f22a785 fix: add homepage field required by electron-builder deb/rpm targets 2026-03-29 14:27:21 -04:00
SRS IT 020d43396f fix: add --publish never to prevent electron-builder auto-publish in CI 2026-03-29 14:23:23 -04:00
SRS IT fb688bdfd3 fix: disable npm native module rebuild in electron-builder
All production deps (sql.js, ws, xml2js, etc.) are pure JS or WASM —
none need native rebuilding for the Electron runtime. Disabling npmRebuild
skips the @electron/rebuild step that was failing to find
app-builder-lib/out/util/rebuild/remote-rebuild.js in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:41:12 -04:00
SRS IT 0aac2b60eb fix: copy bundled app-builder binary to system PATH instead of downloading
app-builder-bin v5 alpha ships the binary inside the npm package itself
(no GitHub release assets). Copy the npm-bundled binary to /usr/local/bin
using the path from require('app-builder-bin').appBuilderPath so
USE_SYSTEM_APP_BUILDER=true can find it in PATH.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:35:39 -04:00
SRS IT d8c32b438f fix: install app-builder to system PATH, use USE_SYSTEM_APP_BUILDER
The binary disappears from node_modules between the verify step and the
electron-builder step (likely re-installed/cleared during dep scan).
Install it to /usr/local/bin/ and use USE_SYSTEM_APP_BUILDER=true so
electron-builder looks it up by name in PATH — bypassing all path
resolution and caching issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:10:20 -04:00
SRS IT 2e9dade24f fix: use CUSTOM_APP_BUILDER_PATH to resolve app-builder binary in CI
app-builder-bin/index.js supports CUSTOM_APP_BUILDER_PATH env var which
overrides the default binary resolution. Set this explicitly on both
Linux build steps to ensure electron-builder finds the binary regardless
of working directory or npm workspace hoisting quirks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:20:29 -04:00
SRS IT c5501c945f debug: diagnose app-builder path from desktop working directory 2026-03-29 00:06:51 -04:00
SRS IT e79ff02141 fix: add app-builder binary download step to Linux workflow
The postinstall script for app-builder-bin does not download the binary
automatically in the GitHub Actions environment. Add an explicit step
to download and chmod the binary before running electron-builder.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:04:28 -04:00
SRS IT 37f4a4ea25 fix: move node-windows to optionalDependencies to fix Linux CI build
app-builder runs npm install --production inside the app dir during
packaging. node-windows has a Windows-only postinstall script that
fails on Linux with ENOENT. Moving it to optionalDependencies makes
npm treat install failures as non-fatal on non-Windows platforms.

Also simplify build-linux.yml — removes unnecessary app-builder binary
verification steps that were masking the actual root cause.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:56:26 -04:00
SRS IT 7bd3a81bda feat: port countdown/away action features to Windows desktop app
- CountdownEditor: new Condition dropdown (ON->OFF / OFF->ON)
- AwayModeEditor: Window Start/End Action dropdowns
- RuleEditor: persist countdownAction field
- scheduler: countdown now state-change-driven with window check;
  away mode respects startAction/endAction; _stopAwayLoop uses endAction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:19:16 -04:00
SRS IT 3c155f7cfd feat: add active time window to countdown rules
UI: Active Window Start/End time inputs on countdown form.
Leave blank = runs any time. End before start = crosses midnight.
Scheduler: checks current time against window before starting timer;
supports cross-midnight windows (e.g. 9:00 AM to 4:00 AM next day).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:32:29 -04:00
SRS IT e8b365e5a7 feat: persist and cache all known devices; discovery only adds/updates
- Store.mergeDevices(): updates existing by UDN, adds new, keeps offline devices
- platform.js: merges discovered into cache; registers cached-offline devices
  in HomeKit so they remain visible; only removes truly orphaned accessories
- server.js: discover endpoint merges and returns full known device list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:28:11 -04:00
SRS IT e52b3578dc feat: countdown rule fires only when device matches configured condition
Countdown is now state-change-driven (no scheduled window):
- 'If turns ON → auto-OFF after duration' (on_to_off)
- 'If turns OFF → auto-ON after duration' (off_to_on)
Scheduler polls device state; timer only starts when state matches
the chosen condition. Cancels any pending timer if state changes again.
Away Mode startAction/endAction already wired; _stopAwayLoop uses endAction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:24:11 -04:00
SRS IT 5024996523 fix: simplify countdown action to plain Turn ON / Turn OFF, no auto-reverse 2026-03-28 22:17:14 -04:00
SRS IT 4c09fd0b66 feat: add ON/OFF action config for Countdown and Away rules in Homebridge plugin
- Countdown: new 'Action when timer fires' dropdown (Turn ON / Turn OFF);
  scheduler uses rule.countdownAction instead of hardcoded 1/0
- Away Mode: scheduler now reads startAction/endAction from the rule
  (UI already exposed these fields); _stopAwayLoop respects endAction
  instead of always forcing OFF at window end

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:15:01 -04:00
SRS IT 2f9f68eca7 debug: add pre-build ldd/file diagnostics before electron-builder 2026-03-28 21:35:03 -04:00
SRS IT 4f4d2a7b61 fix: install from workspace root; add robust app-builder binary fallback 2026-03-28 21:26:52 -04:00
SRS IT b3af43120d fix: install in apps/desktop dir, add ldd debug for app-builder binary 2026-03-28 21:00:30 -04:00
SRS IT 817b91960c fix: use electronuserland/builder Docker image for Linux builds
The app-builder binary (electron-builder's internal tool) fails to spawn
on plain ubuntu-latest runners due to missing shared libraries.

The official electronuserland/builder Docker image has all required
dependencies pre-installed and is the recommended build environment.
This eliminates the ENOENT spawn error entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:52:59 -04:00
SRS IT c9ae546d4a fix: use plain npm install, verify app-builder binary separately
- Remove --ignore-scripts (it prevented app-builder-bin postinstall)
- Add binary verification step with fallback postinstall
- Use npm_config_node_windows_skip_install env var for node-windows
- Keep CSC_IDENTITY_AUTO_DISCOVERY=false to skip code signing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:48:44 -04:00
SRS IT 9b1a81b968 fix: download app-builder binary explicitly after --ignore-scripts install
--ignore-scripts skipped app-builder-bin postinstall which downloads the
electron-builder binary, causing ENOENT at build time.
Add explicit step: node node_modules/app-builder-bin/install.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:45:55 -04:00
SRS IT 2790f5a82d fix: robust Linux CI — root workspace install, split build steps, skip codesign
- npm install at root with --ignore-scripts --legacy-peer-deps
  (prevents node-windows and other Windows postinstall scripts failing)
- CSC_IDENTITY_AUTO_DISCOVERY=false disables electron-builder cert search
- Split vite build, standalone bundle, and electron-builder into separate steps
  so failures are easier to identify in CI logs
- Added libarchive-tools for AppImage generation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:41:44 -04:00
SRS IT a33a97cefc fix: scope Linux build to desktop workspace, skip Windows postinstall scripts
- Run npm install --ignore-scripts inside apps/desktop only
  (avoids node-windows postinstall breaking on Ubuntu runner)
- Run electron-rebuild explicitly after install
- Run build commands directly in apps/desktop working directory
- arm64 reuses vite output from x64 build step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:36:55 -04:00
SRS IT 5a5155c216 chore: add root package.json, lock files, and gitignore *.tgz
- Root package.json (workspace definition) was missing from initial commit
- Commit root package-lock.json for reproducible installs
- Commit packages/homebridge-plugin/package-lock.json
- Add *.tgz to .gitignore (built npm tarballs are release assets, not source)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:34:13 -04:00
SRS IT 3bcc427683 feat: add Import/Export for DWM rules in Homebridge UI
Export:
- '⬇ Export' button downloads all rules as dwm-rules-YYYY-MM-DD.json
- Compatible with desktop app rule format

Import:
- '⬆ Import' button opens file picker (accepts .json)
- Preview panel shows rule names + types before committing
- Merge mode: adds rules, skips any whose name already exists
- Replace mode: deletes all current rules then imports
- Server strips imported IDs/timestamps — fresh ones are assigned
- Reports imported/skipped count on completion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:25:12 -04:00
SRS IT 951d4c4eaa feat: add sunrise/sunset support to Homebridge plugin
Scheduler:
- Import sun.js calculator (already existed, never wired in)
- resolveSecs() maps -2=sunrise/-3=sunset sentinels + offset to actual seconds
- getTodaySun() reads stored location from store
- _loadSchedule(), _resumeAwayLoops(), _startAwayLoop() all resolve sun times

Server:
- Add /sun-times endpoint returning today's sunrise/sunset in seconds

UI:
- Start Time and End Time fields now show Fixed/Sunrise/Sunset dropdown
- Offset field (minutes before/after) shown when sun type selected
- Live preview shows today's base time + fires-at time with offset
- Save handler writes -2/-3 sentinels + startType/endType/startOffset/endOffset
- openDwmEdit() restores sun type and offset when editing existing rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:20:32 -04:00
SRS IT b200c45385 Fix: resolve sunrise/sunset times in desktop scheduler
Sun-based rules (startTime -2=sunrise, -3=sunset) were silently skipped
with 'if (startSecs < 0) continue'. The sun.js calculator existed but
was never called.

- Import calcSunTimes from ./core/sun
- Compute today's sunrise/sunset once at start of _loadSchedule()
- resolveSecs() maps -2/-3 sentinels to actual seconds + offset
- Both Away Mode and Schedule sections now fire at correct sun times
- Rules with no location set continue to be skipped gracefully

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:15:18 -04:00
SRS IT 3fa94ea6e3 Fix: remove npm cache from workflow (no lock file in repo)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 18:28:05 -04:00
SRS IT 3d98f684cf Add GitHub Actions workflow to build and upload Linux packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:48:56 -04:00
SRS IT 328a607e28 docs: update all READMEs for cross-platform support and add wemo-core docs
- Root README: add Linux x64 + ARM64 download targets, update release
  assets table, document background scheduler differences per OS,
  add wemo-core shared package description, use repo name dibby-wemo-manager
- apps/desktop README: full rewrite covering Windows installer/portable,
  Linux AppImage/.deb/.rpm (x64 + ARM64), all build commands per OS,
  data storage paths per OS, requirements table
- packages/wemo-core: new README documenting all exports, constants,
  helper functions, day number convention, sun sentinel codes
- about.html: fix version string from "2.0" to "2.0.0"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:38:31 -04:00
43 changed files with 13061 additions and 202 deletions
+81
View File
@@ -0,0 +1,81 @@
name: Build Android APK
on:
workflow_dispatch:
inputs:
tag:
description: 'Version tag (e.g. 2.0.0)'
required: false
default: '2.0.0'
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android platform tools
run: |
sdkmanager "platforms;android-34" "build-tools;34.0.0"
- name: Install Capacitor dependencies
working-directory: apps/android
run: npm install
- name: Add Android platform
working-directory: apps/android
run: npx cap add android || echo "Android platform already exists"
- name: Sync Capacitor
working-directory: apps/android
run: npx cap sync android
- name: Make gradlew executable
working-directory: apps/android/android
run: chmod +x gradlew
- name: Build debug APK
working-directory: apps/android/android
run: ./gradlew assembleDebug
- name: Rename APK
run: |
VERSION="${{ github.event.inputs.tag }}"
cp apps/android/android/app/build/outputs/apk/debug/app-debug.apk \
"dibby-wemo-$VERSION-android.apk"
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: dibby-wemo-android-apk
path: dibby-wemo-*.apk
retention-days: 30
- name: Upload to GitHub Release
if: github.event.inputs.tag != ''
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ github.event.inputs.tag }}
files: dibby-wemo-*.apk
fail_on_unmatched_files: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+43
View File
@@ -0,0 +1,43 @@
name: Build & Push Docker Image
on:
workflow_dispatch:
inputs:
tag:
description: 'Docker image tag (e.g. 2.0.0)'
required: true
default: '2.0.0'
jobs:
build-docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/k0rb3nd4ll4s/dibby-wemo-manager:${{ github.event.inputs.tag }}
ghcr.io/k0rb3nd4ll4s/dibby-wemo-manager:latest
cache-from: type=gha
cache-to: type=gha,mode=max
+78
View File
@@ -0,0 +1,78 @@
name: Build & Upload Linux Packages
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to upload assets to (e.g. v2.0.0)'
required: true
default: 'v2.0.0'
jobs:
build-linux:
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Linux build tools
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq rpm fakeroot dpkg libarchive-tools
- name: Install workspace dependencies
run: npm install --legacy-peer-deps
- name: Install app-builder to system PATH
run: |
# The binary is bundled in the npm package for the current platform.
# Copy it to /usr/local/bin so USE_SYSTEM_APP_BUILDER=true can find it.
BINARY=$(node -e "console.log(require('./node_modules/app-builder-bin').appBuilderPath)")
echo "app-builder binary: $BINARY"
chmod +x "$BINARY"
sudo cp "$BINARY" /usr/local/bin/app-builder
app-builder --version
- name: Vite build
run: npx electron-vite build
working-directory: apps/desktop
- name: Bundle standalone scheduler
run: node scripts/bundle-standalone.js
working-directory: apps/desktop
- name: Build Linux x64 packages
run: npx electron-builder --linux --x64 --publish never
working-directory: apps/desktop
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
USE_SYSTEM_APP_BUILDER: "true"
- name: Build Linux arm64 packages
run: npx electron-builder --linux --arm64 --publish never
working-directory: apps/desktop
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
USE_SYSTEM_APP_BUILDER: "true"
- name: List build output
run: ls -lh apps/desktop/dist/
- name: Upload Linux packages to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag }}
files: |
apps/desktop/dist/*.AppImage
apps/desktop/dist/*.deb
apps/desktop/dist/*.rpm
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+52
View File
@@ -0,0 +1,52 @@
name: Build & Upload macOS Package
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to upload assets to (e.g. v2.0.0)'
required: true
default: 'v2.0.0'
jobs:
build-mac:
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install workspace dependencies
run: npm install --legacy-peer-deps
- name: Vite build
run: npx electron-vite build
working-directory: apps/desktop
- name: Bundle standalone scheduler
run: node scripts/bundle-standalone.js
working-directory: apps/desktop
- name: Build macOS packages
run: npx electron-builder --mac --publish never
working-directory: apps/desktop
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: List build output
run: ls -lh apps/desktop/dist/
- name: Upload macOS packages to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag }}
files: apps/desktop/dist/*.dmg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+89
View File
@@ -0,0 +1,89 @@
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
name: Build Images and Deploy
run-name: ${{ gitea.actor }} is building new PROD images and redeploying the existing stack 🚀
on:
push:
# not working right now https://github.com/actions/runner/issues/2324
# paths-ignore:
# - **.yml
branches:
- main
env:
STACK_NAME: dibbly
DOT_ENV: ${{ secrets.PROD_ENV }}
PORTAINER_TOKEN: ${{ vars.PORTAINER_TOKEN }}
PORTAINER_API_URL: https://portainer.dev.nervesocket.com/api
ENDPOINT_NAME: "mini" #sometimes "primary"
IMAGE_TAG: "reg.dev.nervesocket.com/dibbly:latest"
jobs:
Update-PROD-Stack:
runs-on: ubuntu-latest
steps:
# if: contains(github.event.pull_request.head.ref, 'init-stack')
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and push PROD Docker image
run: |
echo $DOT_ENV | base64 -d > .env
docker buildx build --push -f Dockerfile -t $IMAGE_TAG .
- name: Get the endpoint ID
# Usually ID is 1, but you can get it from the API. Only skip this if you are VERY sure.
run: |
ENDPOINT_ID=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/endpoints" | jq -r ".[] | select(.Name==\"$ENDPOINT_NAME\") | .Id")
echo "ENDPOINT_ID=$ENDPOINT_ID" >> $GITHUB_ENV
echo "Got stack Endpoint ID: $ENDPOINT_ID"
- name: Fetch stack ID from Portainer
run: |
STACK_ID=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks" | jq -r ".[] | select(.Name==\"$STACK_NAME\" and .EndpointId==$ENDPOINT_ID) | .Id")
echo "STACK_ID=$STACK_ID" >> $GITHUB_ENV
echo "Got stack ID: $STACK_ID matched with Endpoint ID: $ENDPOINT_ID"
- name: Fetch Stack
run: |
# Get the stack details (including env vars)
STACK_DETAILS=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks/$STACK_ID")
# Extract environment variables from the stack
echo "$STACK_DETAILS" | jq -r '.Env' > stack_env.json
echo "Existing stack environment variables:"
cat stack_env.json
- name: Redeploy stack in Portainer
run: |
# Read stack file content
STACK_FILE_CONTENT=$(echo "$(<web-compose.yml )")
# Read existing environment variables from the fetched stack
ENV_VARS=$(cat stack_env.json)
# Prepare JSON payload with environment variables
JSON_PAYLOAD=$(jq -n --arg stackFileContent "$STACK_FILE_CONTENT" --argjson pullImage true --argjson env "$ENV_VARS" \
'{stackFileContent: $stackFileContent, pullImage: $pullImage, env: $env}')
echo "About to push the following JSON payload:"
echo $JSON_PAYLOAD
# Update stack in Portainer (this redeploys it)
DEPLOY_RESPONSE=$(curl -X PUT "$PORTAINER_API_URL/stacks/$STACK_ID?endpointId=$ENDPOINT_ID" \
-H "X-API-Key: $PORTAINER_TOKEN" \
-H "Content-Type: application/json" \
--data "$JSON_PAYLOAD")
echo "Redeployed stack in Portainer. Response:"
echo $DEPLOY_RESPONSE
- name: Status check
run: |
echo "📋 This job's status is ${{ job.status }}. Make sure you delete the init file to avoid issues."
+81
View File
@@ -0,0 +1,81 @@
name: Build Android APK
on:
workflow_dispatch:
inputs:
tag:
description: 'Version tag (e.g. 2.0.0)'
required: false
default: '2.0.0'
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android platform tools
run: |
sdkmanager "platforms;android-34" "build-tools;34.0.0"
- name: Install Capacitor dependencies
working-directory: apps/android
run: npm install
- name: Add Android platform
working-directory: apps/android
run: npx cap add android || echo "Android platform already exists"
- name: Sync Capacitor
working-directory: apps/android
run: npx cap sync android
- name: Make gradlew executable
working-directory: apps/android/android
run: chmod +x gradlew
- name: Build debug APK
working-directory: apps/android/android
run: ./gradlew assembleDebug
- name: Rename APK
run: |
VERSION="${{ github.event.inputs.tag }}"
cp apps/android/android/app/build/outputs/apk/debug/app-debug.apk \
"dibby-wemo-$VERSION-android.apk"
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: dibby-wemo-android-apk
path: dibby-wemo-*.apk
retention-days: 30
- name: Upload to GitHub Release
if: github.event.inputs.tag != ''
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ github.event.inputs.tag }}
files: dibby-wemo-*.apk
fail_on_unmatched_files: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+43
View File
@@ -0,0 +1,43 @@
name: Build & Push Docker Image
on:
workflow_dispatch:
inputs:
tag:
description: 'Docker image tag (e.g. 2.0.0)'
required: true
default: '2.0.0'
jobs:
build-docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/k0rb3nd4ll4s/dibby-wemo-manager:${{ github.event.inputs.tag }}
ghcr.io/k0rb3nd4ll4s/dibby-wemo-manager:latest
cache-from: type=gha
cache-to: type=gha,mode=max
+78
View File
@@ -0,0 +1,78 @@
name: Build & Upload Linux Packages
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to upload assets to (e.g. v2.0.0)'
required: true
default: 'v2.0.0'
jobs:
build-linux:
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Linux build tools
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq rpm fakeroot dpkg libarchive-tools
- name: Install workspace dependencies
run: npm install --legacy-peer-deps
- name: Install app-builder to system PATH
run: |
# The binary is bundled in the npm package for the current platform.
# Copy it to /usr/local/bin so USE_SYSTEM_APP_BUILDER=true can find it.
BINARY=$(node -e "console.log(require('./node_modules/app-builder-bin').appBuilderPath)")
echo "app-builder binary: $BINARY"
chmod +x "$BINARY"
sudo cp "$BINARY" /usr/local/bin/app-builder
app-builder --version
- name: Vite build
run: npx electron-vite build
working-directory: apps/desktop
- name: Bundle standalone scheduler
run: node scripts/bundle-standalone.js
working-directory: apps/desktop
- name: Build Linux x64 packages
run: npx electron-builder --linux --x64 --publish never
working-directory: apps/desktop
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
USE_SYSTEM_APP_BUILDER: "true"
- name: Build Linux arm64 packages
run: npx electron-builder --linux --arm64 --publish never
working-directory: apps/desktop
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
USE_SYSTEM_APP_BUILDER: "true"
- name: List build output
run: ls -lh apps/desktop/dist/
- name: Upload Linux packages to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag }}
files: |
apps/desktop/dist/*.AppImage
apps/desktop/dist/*.deb
apps/desktop/dist/*.rpm
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+52
View File
@@ -0,0 +1,52 @@
name: Build & Upload macOS Package
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to upload assets to (e.g. v2.0.0)'
required: true
default: 'v2.0.0'
jobs:
build-mac:
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install workspace dependencies
run: npm install --legacy-peer-deps
- name: Vite build
run: npx electron-vite build
working-directory: apps/desktop
- name: Bundle standalone scheduler
run: node scripts/bundle-standalone.js
working-directory: apps/desktop
- name: Build macOS packages
run: npx electron-builder --mac --publish never
working-directory: apps/desktop
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: List build output
run: ls -lh apps/desktop/dist/
- name: Upload macOS packages to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag }}
files: apps/desktop/dist/*.dmg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+89
View File
@@ -0,0 +1,89 @@
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
name: Build Images and Deploy
run-name: ${{ gitea.actor }} is building new PROD images and redeploying the existing stack 🚀
on:
push:
# not working right now https://github.com/actions/runner/issues/2324
# paths-ignore:
# - **.yml
branches:
- main
env:
STACK_NAME: dibbly
DOT_ENV: ${{ secrets.PROD_ENV }}
PORTAINER_TOKEN: ${{ vars.PORTAINER_TOKEN }}
PORTAINER_API_URL: https://portainer.dev.nervesocket.com/api
ENDPOINT_NAME: "mini" #sometimes "primary"
IMAGE_TAG: "reg.dev.nervesocket.com/dibbly:latest"
jobs:
Update-PROD-Stack:
runs-on: ubuntu-latest
steps:
# if: contains(github.event.pull_request.head.ref, 'init-stack')
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and push PROD Docker image
run: |
echo $DOT_ENV | base64 -d > .env
docker buildx build --push -f Dockerfile -t $IMAGE_TAG .
- name: Get the endpoint ID
# Usually ID is 1, but you can get it from the API. Only skip this if you are VERY sure.
run: |
ENDPOINT_ID=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/endpoints" | jq -r ".[] | select(.Name==\"$ENDPOINT_NAME\") | .Id")
echo "ENDPOINT_ID=$ENDPOINT_ID" >> $GITHUB_ENV
echo "Got stack Endpoint ID: $ENDPOINT_ID"
- name: Fetch stack ID from Portainer
run: |
STACK_ID=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks" | jq -r ".[] | select(.Name==\"$STACK_NAME\" and .EndpointId==$ENDPOINT_ID) | .Id")
echo "STACK_ID=$STACK_ID" >> $GITHUB_ENV
echo "Got stack ID: $STACK_ID matched with Endpoint ID: $ENDPOINT_ID"
- name: Fetch Stack
run: |
# Get the stack details (including env vars)
STACK_DETAILS=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks/$STACK_ID")
# Extract environment variables from the stack
echo "$STACK_DETAILS" | jq -r '.Env' > stack_env.json
echo "Existing stack environment variables:"
cat stack_env.json
- name: Redeploy stack in Portainer
run: |
# Read stack file content
STACK_FILE_CONTENT=$(echo "$(<web-compose.yml )")
# Read existing environment variables from the fetched stack
ENV_VARS=$(cat stack_env.json)
# Prepare JSON payload with environment variables
JSON_PAYLOAD=$(jq -n --arg stackFileContent "$STACK_FILE_CONTENT" --argjson pullImage true --argjson env "$ENV_VARS" \
'{stackFileContent: $stackFileContent, pullImage: $pullImage, env: $env}')
echo "About to push the following JSON payload:"
echo $JSON_PAYLOAD
# Update stack in Portainer (this redeploys it)
DEPLOY_RESPONSE=$(curl -X PUT "$PORTAINER_API_URL/stacks/$STACK_ID?endpointId=$ENDPOINT_ID" \
-H "X-API-Key: $PORTAINER_TOKEN" \
-H "Content-Type: application/json" \
--data "$JSON_PAYLOAD")
echo "Redeployed stack in Portainer. Response:"
echo $DEPLOY_RESPONSE
- name: Status check
run: |
echo "📋 This job's status is ${{ job.status }}. Make sure you delete the init file to avoid issues."
+1
View File
@@ -8,6 +8,7 @@ dist/
out/ out/
build/ build/
*.blockmap *.blockmap
*.tgz
builder-debug.yml builder-debug.yml
builder-effective-config.yaml builder-effective-config.yaml
+36
View File
@@ -0,0 +1,36 @@
FROM node:20-alpine
LABEL maintainer="SRS IT" \
org.opencontainers.image.title="Dibby Wemo Manager" \
org.opencontainers.image.description="Headless Belkin Wemo scheduler + web remote — no cloud required" \
org.opencontainers.image.source="https://github.com/K0rb3nD4ll4S/dibby-wemo-manager"
WORKDIR /app
# Copy package manifest first for layer caching
COPY docker/package.json ./package.json
# Install production dependencies
RUN npm install --production
# Copy application code
COPY packages/homebridge-plugin/lib ./lib
COPY docker/server.js ./server.js
# Copy mobile web UI and icon
COPY apps/desktop/resources/web ./web
COPY apps/desktop/resources/icon.png ./icon.png
# Persistent data volume (stores dibby-wemo.json config + rules)
VOLUME /data
ENV DATA_DIR=/data \
PORT=3456
EXPOSE 3456
# NOTE: Wemo SSDP discovery requires --network host on Linux Docker hosts.
# On macOS Docker Desktop, host networking is not supported — add devices
# manually via the web UI or the REST API instead.
CMD ["node", "server.js"]
+53 -19
View File
@@ -6,7 +6,7 @@ Dibby Wemo Manager gives you full local control of Belkin Wemo smart switches an
| Component | Description | | Component | Description |
|---|---| |---|---|
| 🖥️ **Desktop App** | Windows Electron app — device dashboard, power control, scheduling | | 🖥️ **Desktop App** | Cross-platform Electron app (Windows + Linux) — device dashboard, power control, scheduling |
| 🏠 **Homebridge Plugin** | HomeKit integration with custom scheduling UI inside Homebridge | | 🏠 **Homebridge Plugin** | HomeKit integration with custom scheduling UI inside Homebridge |
Both share the same local-network Wemo protocol (UPnP/SOAP) and the same DWM scheduling engine. No Belkin account, no cloud dependency, no internet required. Both share the same local-network Wemo protocol (UPnP/SOAP) and the same DWM scheduling engine. No Belkin account, no cloud dependency, no internet required.
@@ -16,12 +16,12 @@ Both share the same local-network Wemo protocol (UPnP/SOAP) and the same DWM sch
## Repository Layout ## Repository Layout
``` ```
wemo-manager/ dibby-wemo-manager/
├── apps/ ├── apps/
│ └── desktop/ # Electron desktop app (Windows) │ └── desktop/ # Electron desktop app (Windows + Linux)
├── packages/ ├── packages/
│ ├── homebridge-plugin/ # homebridge-dibby-wemo Homebridge plugin │ ├── homebridge-plugin/ # homebridge-dibby-wemo Homebridge plugin
│ └── wemo-core/ # Shared Wemo protocol helpers │ └── wemo-core/ # Shared Wemo protocol helpers (internal)
└── package.json # npm workspaces root └── package.json # npm workspaces root
``` ```
@@ -29,14 +29,24 @@ wemo-manager/
## Quick Start ## Quick Start
### Desktop App (Windows) ### Desktop App
Download the latest installer from [Releases](../../releases): Download the latest installer from [Releases](../../releases):
**Windows:**
- **`Dibby Wemo Manager Setup 2.0.0.exe`** — NSIS installer (recommended) - **`Dibby Wemo Manager Setup 2.0.0.exe`** — NSIS installer (recommended)
- **`Dibby Wemo Manager 2.0.0.exe`** — Portable single-file executable - **`Dibby Wemo Manager 2.0.0.exe`** — Portable single-file executable
Run the installer, launch the app. Wemo devices are discovered automatically via SSDP on your local network. **Linux (x64):**
- **`Dibby Wemo Manager-2.0.0.AppImage`** — Universal AppImage, runs anywhere
- **`dibby-wemo-manager_2.0.0_amd64.deb`** — Debian / Ubuntu
- **`dibby-wemo-manager-2.0.0.x86_64.rpm`** — Fedora / RHEL
**Linux (ARM64 — Raspberry Pi 4/5):**
- **`Dibby Wemo Manager-2.0.0-arm64.AppImage`**
- **`dibby-wemo-manager_2.0.0_arm64.deb`**
Run the installer (Windows) or AppImage (Linux). Wemo devices are discovered automatically via SSDP on your local network.
### Homebridge Plugin ### Homebridge Plugin
@@ -74,10 +84,14 @@ Restart Homebridge. Devices appear in HomeKit automatically.
- **Always On** — enforce a device stays on; auto-corrects within 10 seconds - **Always On** — enforce a device stays on; auto-corrects within 10 seconds
- **Trigger** — IFTTT-style: when device A changes state, control device B - **Trigger** — IFTTT-style: when device A changes state, control device B
- **Native firmware rules** — read, toggle and delete rules stored on the Wemo device itself - **Native firmware rules** — read, toggle and delete rules stored on the Wemo device itself
- **Standalone service** — Windows background service that enforces rules even when the GUI is closed - **Background scheduler** — keeps rules firing even when the GUI is closed
- Windows: native Windows service (`DibbyWemoService`)
- Linux: background process, runs while app is in system tray
- **Web remote** — optional local web interface accessible from your phone - **Web remote** — optional local web interface accessible from your phone
- **Sunrise/sunset support** — location-aware scheduling via city search - **Sunrise/sunset support** — location-aware scheduling via city search
**Platforms:** Windows 10+ (x64) · Linux x64 · Linux ARM64 (Raspberry Pi 4/5)
### 🏠 Homebridge Plugin ### 🏠 Homebridge Plugin
- All Wemo devices registered as **HomeKit switches** - All Wemo devices registered as **HomeKit switches**
@@ -141,13 +155,17 @@ The Wemo device stores rules in a SQLite database inside a ZIP archive:
The DWM (Dibby Wemo Manager) scheduler is a Node.js process that: The DWM (Dibby Wemo Manager) scheduler is a Node.js process that:
- Loads rules from a JSON store (`dibby-wemo.json`) - Loads rules from a JSON store
- Ticks every **30 seconds**, reloading rules on each tick (live edits take effect without restart) - Ticks every **30 seconds**, reloading rules on each tick (live edits take effect without restart)
- Pre-schedules events within a **65-second look-ahead window** - Pre-schedules events within a **65-second look-ahead window**
- On startup, catches up any rules missed within the last **10 minutes** - On startup, catches up any rules missed within the last **10 minutes**
- Runs a **health monitor** every 10 seconds for AlwaysOn and Trigger rules - Runs a **health monitor** every 10 seconds for AlwaysOn and Trigger rules
- Writes a **heartbeat** to the store on every tick so the UI can show scheduler status - Writes a **heartbeat** to the store on every tick so the UI can show scheduler status
### Shared Core
`packages/wemo-core` contains shared constants and utilities (day numbers, time conversions, sunrise/sunset calculator) used by both the desktop app and the Homebridge plugin without duplication. It is an internal npm workspace package, not published to npm.
--- ---
## Development ## Development
@@ -157,10 +175,10 @@ The DWM (Dibby Wemo Manager) scheduler is a Node.js process that:
- Node.js ≥ 18 - Node.js ≥ 18
- npm ≥ 9 - npm ≥ 9
### Install dependencies ### Install all dependencies
```bash ```bash
# From repo root # From repo root — installs all workspaces
npm install npm install
``` ```
@@ -171,16 +189,27 @@ cd apps/desktop
npm run dev npm run dev
``` ```
### Desktop App — build Windows installer ### Desktop App — build
```bash ```bash
# Windows installer + portable exe
cd apps/desktop cd apps/desktop
npm run build:win npm run build:win
# Linux AppImage + .deb + .rpm (x64)
cd apps/desktop
npm run build:linux
# Linux ARM64 (Raspberry Pi)
cd apps/desktop
npm run build:linux:arm64
# Windows x64 + Linux x64 in one command
cd apps/desktop
npm run build:all
``` ```
Output in `apps/desktop/dist/`: Output in `apps/desktop/dist/`.
- `Dibby Wemo Manager Setup 2.0.0.exe` — NSIS installer
- `Dibby Wemo Manager 2.0.0.exe` — portable EXE
### Homebridge Plugin — install locally ### Homebridge Plugin — install locally
@@ -197,11 +226,16 @@ Then restart Homebridge.
Each [GitHub Release](../../releases) includes: Each [GitHub Release](../../releases) includes:
| File | Description | | File | OS | Description |
|---|---| |---|---|---|
| `Dibby Wemo Manager Setup 2.0.0.exe` | Windows NSIS installer (recommended) | | `Dibby Wemo Manager Setup 2.0.0.exe` | Windows | NSIS installer (recommended) |
| `Dibby Wemo Manager 2.0.0.exe` | Windows portable executable | | `Dibby Wemo Manager 2.0.0.exe` | Windows | Portable executable |
| `homebridge-dibby-wemo-1.0.0.tgz` | Homebridge plugin npm package | | `Dibby Wemo Manager-2.0.0.AppImage` | Linux x64 | Universal AppImage |
| `dibby-wemo-manager_2.0.0_amd64.deb` | Linux x64 | Debian / Ubuntu package |
| `dibby-wemo-manager-2.0.0.x86_64.rpm` | Linux x64 | Fedora / RHEL package |
| `Dibby Wemo Manager-2.0.0-arm64.AppImage` | Linux ARM64 | Raspberry Pi 4/5 AppImage |
| `dibby-wemo-manager_2.0.0_arm64.deb` | Linux ARM64 | Raspberry Pi OS package |
| `homebridge-dibby-wemo-1.0.0.tgz` | Any | Homebridge plugin npm package |
--- ---
+22
View File
@@ -0,0 +1,22 @@
{
"appId": "com.dibby.wemo",
"appName": "Dibby Wemo",
"webDir": "www",
"server": {
"androidScheme": "https"
},
"android": {
"allowMixedContent": true,
"captureInput": true,
"webContentsDebuggingEnabled": false
},
"plugins": {
"SplashScreen": {
"launchAutoHide": true,
"launchShowDuration": 1000,
"backgroundColor": "#0d1b27",
"androidSplashResourceName": "splash",
"showSpinner": false
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "dibby-wemo-android",
"version": "2.0.0",
"description": "Dibby Wemo Manager Android companion app",
"private": true,
"scripts": {
"sync": "cap sync",
"open": "cap open android",
"build:apk": "cap sync && cd android && ./gradlew assembleDebug",
"build:release": "cap sync && cd android && ./gradlew assembleRelease"
},
"dependencies": {
"@capacitor/android": "^6.0.0",
"@capacitor/core": "^6.0.0"
},
"devDependencies": {
"@capacitor/cli": "^6.0.0"
}
}
File diff suppressed because it is too large Load Diff
+104 -27
View File
@@ -1,8 +1,10 @@
# Dibby Wemo Manager — Desktop App # Dibby Wemo Manager — Desktop App
**Windows desktop application for local Belkin Wemo control.** **Cross-platform desktop application for local Belkin Wemo control.**
Full device dashboard, power control, scheduling engine, Windows background service, and optional web remote — all communicating directly with your Wemo devices over your local network. No Belkin cloud account required. Full device dashboard, power control, scheduling engine, and optional web remote — all communicating directly with your Wemo devices over your local network. No Belkin cloud account required.
On Windows the scheduler can run as a background service so rules keep firing after the GUI is closed. Linux uses a background process instead.
--- ---
@@ -10,6 +12,8 @@ Full device dashboard, power control, scheduling engine, Windows background serv
Download the latest release from [GitHub Releases](../../releases): Download the latest release from [GitHub Releases](../../releases):
### Windows
| File | Description | | File | Description |
|---|---| |---|---|
| `Dibby Wemo Manager Setup 2.0.0.exe` | **NSIS installer** — recommended, installs to Program Files, adds Start Menu shortcut | | `Dibby Wemo Manager Setup 2.0.0.exe` | **NSIS installer** — recommended, installs to Program Files, adds Start Menu shortcut |
@@ -17,6 +21,32 @@ Download the latest release from [GitHub Releases](../../releases):
Run the installer or portable exe. The app opens and immediately begins discovering Wemo devices on your network. Run the installer or portable exe. The app opens and immediately begins discovering Wemo devices on your network.
### Linux
| File | Description |
|---|---|
| `Dibby Wemo Manager-2.0.0.AppImage` | **AppImage** — universal, runs on any modern Linux distro. No install needed. |
| `dibby-wemo-manager_2.0.0_amd64.deb` | **Debian / Ubuntu** package |
| `dibby-wemo-manager-2.0.0.x86_64.rpm` | **Fedora / RHEL / openSUSE** package |
| `Dibby Wemo Manager-2.0.0-arm64.AppImage` | **AppImage (ARM64)** — Raspberry Pi 4/5, Apple Silicon VMs |
| `dibby-wemo-manager_2.0.0_arm64.deb` | **Debian ARM64** — Raspberry Pi OS |
**AppImage:**
```bash
chmod +x "Dibby Wemo Manager-2.0.0.AppImage"
./"Dibby Wemo Manager-2.0.0.AppImage"
```
**Debian / Ubuntu (.deb):**
```bash
sudo dpkg -i dibby-wemo-manager_2.0.0_amd64.deb
```
**Fedora / RHEL (.rpm):**
```bash
sudo rpm -i dibby-wemo-manager-2.0.0.x86_64.rpm
```
--- ---
## Features ## Features
@@ -57,39 +87,48 @@ Read and manage rules stored directly on the Wemo device's own firmware:
> Wemo Dimmer V2 (WDS060) with newer RTOS firmware does not support firmware rule editing. > Wemo Dimmer V2 (WDS060) with newer RTOS firmware does not support firmware rule editing.
### 🛠️ Windows Background Service
The DWM scheduler can run as a **Windows service** (`DibbyWemoService`) so rules continue to fire even when the GUI is closed or the user logs out.
- Install/uninstall the service from the app's System tab
- The service reads rules from the shared data directory and syncs automatically when rules are saved in the GUI
- Service uses `node-windows` for reliable Windows service registration
### 🌐 Web Remote ### 🌐 Web Remote
Optional local web interface accessible from any device on your network (phone, tablet, another PC): Optional local web interface accessible from any device on your network (phone, tablet, another PC):
- View device status - View device status
- Toggle devices on/off - Toggle devices on/off
- Manage DWM rules
- QR code for easy mobile access - QR code for easy mobile access
- Configurable port; firewall rule created automatically (UAC prompt) - Configurable port; firewall rule created automatically on Windows (UAC prompt)
### 📍 Sunrise/Sunset Scheduling ### 📍 Sunrise/Sunset Scheduling
Set your city in the Settings tab. Schedule rules can then use local sunrise and sunset times as start/end points. Set your city in the Settings tab. Schedule rules can then use local sunrise and sunset times as start/end points.
### 🛠️ Background Scheduler
The DWM scheduler continues running rules even when the main window is closed.
**Windows** — installs as a native **Windows Service** (`DibbyWemoService`) via `node-windows`:
- Install/uninstall from the System tab
- The service reads rules from `C:\ProgramData\DibbyWemoManager\dwm-rules.json`
- Syncs automatically when rules are saved in the GUI
**Linux** — the scheduler runs as a background process spawned from the main Electron process. It continues running while the app is in the system tray.
--- ---
## Data Storage ## Data Storage
All app data is stored in `%APPDATA%\DibbyWemoManager\` (typically `C:\Users\<you>\AppData\Roaming\DibbyWemoManager\`): All app data is stored in the OS user-data directory:
| OS | Path |
|---|---|
| Windows | `%APPDATA%\DibbyWemoManager\` |
| Linux | `~/.config/DibbyWemoManager/` |
| File | Description | | File | Description |
|---|---| |---|---|
| `wemo-manager.json` | App settings, discovered devices, DWM rules | | `wemo-manager.json` | App settings, discovered devices, DWM rules |
| `dwm-rules.json` | DWM rules shared with the Windows background service | | `dwm-rules.json` | DWM rules shared with the background scheduler |
The standalone service reads `C:\ProgramData\DibbyWemoManager\dwm-rules.json`. The GUI syncs rules to this location after every create, update, or delete. On Windows the standalone service reads `C:\ProgramData\DibbyWemoManager\dwm-rules.json`. The GUI syncs rules to this location after every create, update, or delete.
--- ---
@@ -102,7 +141,7 @@ Electron Main Process
├── store.js — JSON persistence layer ├── store.js — JSON persistence layer
├── firewall.js — Windows Firewall rule management (elevated) ├── firewall.js — Windows Firewall rule management (elevated)
├── web-server.js — Express web remote server ├── web-server.js — Express web remote server
├── service-manager.js— node-windows service install/uninstall ├── service-manager.js— node-windows service install/uninstall (Windows)
└── ipc/ └── ipc/
├── devices.ipc.js ├── devices.ipc.js
├── rules.ipc.js ├── rules.ipc.js
@@ -116,7 +155,7 @@ Electron Renderer (React 18 + Zustand)
├── AllRulesTab — Native firmware rules per device ├── AllRulesTab — Native firmware rules per device
└── Settings — location, service, web remote config └── Settings — location, service, web remote config
Standalone Service (scheduler-standalone.js) Standalone Scheduler (scheduler-standalone.js)
└── Runs headless; reads dwm-rules.json; same scheduling logic └── Runs headless; reads dwm-rules.json; same scheduling logic
``` ```
@@ -141,7 +180,7 @@ Native firmware rules are stored in a SQLite database (`temppluginRules.db`) ins
- Node.js ≥ 18 - Node.js ≥ 18
- npm ≥ 9 - npm ≥ 9
- Windows (for Windows builds) - OS-specific toolchain (see below)
### Install dependencies ### Install dependencies
@@ -163,29 +202,67 @@ npm run dev
Opens the Electron app with hot-reload for the renderer. Opens the Electron app with hot-reload for the renderer.
### Production build ### Production builds
#### Windows (NSIS installer + portable exe)
Run on a Windows machine:
```bash ```bash
cd apps/desktop cd apps/desktop
npm run build:win npm run build:win
``` ```
This: Output in `apps/desktop/dist/`:
1. Compiles the renderer with `electron-vite` - `Dibby Wemo Manager Setup 2.0.0.exe` — NSIS installer
2. Bundles the standalone service script - `Dibby Wemo Manager 2.0.0.exe` — portable exe
3. Runs `electron-builder` to produce the NSIS installer and portable exe
Output appears in `apps/desktop/dist/`. > **Code signing:** The build config expects a PFX certificate at `resources/srsit-codesign.pfx`. Remove the `win.certificateFile` entry from `package.json` if you don't have a certificate.
> **Code signing:** The build configuration expects a PFX certificate at `resources/srsit-codesign.pfx`. Remove the `win.certificateFile` entry from `package.json` if you don't have a certificate. #### Linux x64 (AppImage + .deb + .rpm)
Run on a Linux machine or in WSL2 / CI:
```bash
cd apps/desktop
npm run build:linux
```
Output in `apps/desktop/dist/`:
- `Dibby Wemo Manager-2.0.0.AppImage`
- `dibby-wemo-manager_2.0.0_amd64.deb`
- `dibby-wemo-manager-2.0.0.x86_64.rpm`
#### Linux ARM64 (Raspberry Pi / Apple Silicon)
```bash
cd apps/desktop
npm run build:linux:arm64
```
Output in `apps/desktop/dist/`:
- `Dibby Wemo Manager-2.0.0-arm64.AppImage`
- `dibby-wemo-manager_2.0.0_arm64.deb`
#### All targets at once
```bash
cd apps/desktop
npm run build:all
```
Builds Windows x64 and Linux x64 targets in sequence. Requires the build host to have `wine` installed (for Windows cross-compilation on Linux), or run each command on its native OS.
--- ---
## Requirements ## Requirements
- Windows 10 or later (x64) | Component | Windows | Linux |
- Node.js ≥ 18 (only needed for building from source) |---|---|---|
- Wemo devices on the same LAN | OS | Windows 10 or later (x64) | Any modern distro (x64 or ARM64) |
| Node.js | ≥ 18 (build only) | ≥ 18 (build only) |
| Runtime deps | None — bundled | `libgtk-3`, `libnss3`, `libxss1` (auto via .deb) |
| Wemo devices | Same LAN | Same LAN |
--- ---
+16 -6
View File
@@ -5,6 +5,7 @@
"private": true, "private": true,
"description": "Belkin Wemo device manager local control, no cloud required", "description": "Belkin Wemo device manager local control, no cloud required",
"author": "SRS IT", "author": "SRS IT",
"homepage": "https://github.com/K0rb3nD4ll4S/dibby-wemo-manager",
"main": "out/main/index.js", "main": "out/main/index.js",
"scripts": { "scripts": {
"dev": "electron-vite dev", "dev": "electron-vite dev",
@@ -18,13 +19,15 @@
"dependencies": { "dependencies": {
"adm-zip": "^0.5.14", "adm-zip": "^0.5.14",
"axios": "^1.7.0", "axios": "^1.7.0",
"node-windows": "^1.0.0-beta.8",
"sql.js": "^1.12.0", "sql.js": "^1.12.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"ws": "^8.18.0", "ws": "^8.18.0",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"xmlbuilder2": "^4.0.3" "xmlbuilder2": "^4.0.3"
}, },
"optionalDependencies": {
"node-windows": "^1.0.0-beta.8"
},
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"electron": "33.4.11", "electron": "33.4.11",
@@ -38,9 +41,20 @@
"build": { "build": {
"appId": "com.srsit.dibbywemomanager", "appId": "com.srsit.dibbywemomanager",
"productName": "Dibby Wemo Manager", "productName": "Dibby Wemo Manager",
"npmRebuild": false,
"directories": { "directories": {
"output": "dist" "output": "dist"
}, },
"mac": {
"target": [
{ "target": "dmg", "arch": ["x64", "arm64"] }
],
"icon": "resources/icon.png",
"category": "public.app-category.utilities"
},
"dmg": {
"title": "Dibby Wemo Manager"
},
"win": { "win": {
"target": [ "target": [
{ {
@@ -69,11 +83,7 @@
"createDesktopShortcut": true "createDesktopShortcut": true
}, },
"linux": { "linux": {
"target": [ "target": ["AppImage", "deb", "rpm"],
{ "target": "AppImage", "arch": ["x64"] },
{ "target": "deb", "arch": ["x64"] },
{ "target": "rpm", "arch": ["x64"] }
],
"icon": "resources/icon.png", "icon": "resources/icon.png",
"category": "Utility", "category": "Utility",
"synopsis": "Belkin Wemo device manager — local control, no cloud required", "synopsis": "Belkin Wemo device manager — local control, no cloud required",
+1 -1
View File
@@ -104,7 +104,7 @@
<img src="icon.png" alt="Dibby Wemo Manager" /> <img src="icon.png" alt="Dibby Wemo Manager" />
<div class="header-text"> <div class="header-text">
<h1>Dibby Wemo Manager</h1> <h1>Dibby Wemo Manager</h1>
<div class="version">Version 2.0</div> <div class="version">Version 2.0.0</div>
</div> </div>
</div> </div>
<p class="desc"> <p class="desc">
+397 -7
View File
@@ -134,6 +134,62 @@
.icon-btn:hover { border-color: var(--accent); color: var(--accent); } .icon-btn:hover { border-color: var(--accent); color: var(--accent); }
.icon-btn.del:hover { border-color: var(--danger); color: var(--danger); } .icon-btn.del:hover { border-color: var(--danger); color: var(--danger); }
/* ── Brightness Slider ── */
.brightness-control {
background: var(--bg);
border-radius: 8px;
padding: 8px;
border: 1px solid var(--border);
}
.brightness-slider {
flex: 1;
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, var(--off) 0%, var(--on) 100%);
outline: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.brightness-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--card);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: all .15s;
}
.brightness-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0,0,0,0.3);
}
.brightness-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--card);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: all .15s;
}
.brightness-slider::-moz-range-thumb:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0,0,0,0.3);
}
.brightness-slider:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.brightness-value {
font-weight: 600;
color: var(--accent);
}
/* ── Modal ── */ /* ── Modal ── */
.modal-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); .modal-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55);
z-index: 200; align-items: flex-end; justify-content: center; } z-index: 200; align-items: flex-end; justify-content: center; }
@@ -196,6 +252,9 @@
<div id="page-devices" class="page active"> <div id="page-devices" class="page active">
<div class="toolbar"> <div class="toolbar">
<h2>Devices</h2> <h2>Devices</h2>
<div style="flex:1"></div>
<button class="btn btn-ghost btn-sm" onclick="refreshDeviceStates()">⟳ Refresh</button>
<button class="btn btn-ghost btn-sm" onclick="openAddDeviceModal()"> Add IP</button>
<button class="btn btn-primary" id="btn-discover" onclick="discoverDevices()">⟳ Scan</button> <button class="btn btn-primary" id="btn-discover" onclick="discoverDevices()">⟳ Scan</button>
</div> </div>
<div id="page-header-devices" class="page-header"> <div id="page-header-devices" class="page-header">
@@ -257,7 +316,13 @@
<table style="font-size:12px;color:var(--text2);width:100%;border-collapse:collapse;line-height:1.8;"> <table style="font-size:12px;color:var(--text2);width:100%;border-collapse:collapse;line-height:1.8;">
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/devices</td><td>List devices</td></tr> <tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/devices</td><td>List devices</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/discover</td><td>Scan network</td></tr> <tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/discover</td><td>Scan network</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/add</td><td>Add device manually <code style="background:var(--bg);padding:1px 4px;border-radius:3px;">{"host":"192.168.1.100","port":49153}</code></td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/devices/:ip/:port/info</td><td>Get device information</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/:ip/:port/state</td><td>Toggle power <code style="background:var(--bg);padding:1px 4px;border-radius:3px;">{"on":true}</code></td></tr> <tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/:ip/:port/state</td><td>Toggle power <code style="background:var(--bg);padding:1px 4px;border-radius:3px;">{"on":true}</code></td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/devices/:ip/:port/brightness</td><td>Get brightness (dimmer only)</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/:ip/:port/brightness</td><td>Set brightness <code style="background:var(--bg);padding:1px 4px;border-radius:3px;">{"brightness":50}</code></td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/devices/:ip/:port/rules</td><td>Get Wemo device rules</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">PUT /api/devices/:ip/:port/rules/:id</td><td>Update Wemo rule</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/dwm-rules</td><td>List DWM rules</td></tr> <tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/dwm-rules</td><td>List DWM rules</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/dwm-rules</td><td>Create rule</td></tr> <tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/dwm-rules</td><td>Create rule</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">PUT /api/dwm-rules/:id</td><td>Update rule</td></tr> <tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">PUT /api/dwm-rules/:id</td><td>Update rule</td></tr>
@@ -436,6 +501,52 @@
</div> </div>
</div> </div>
<!-- ── Add Device Modal ── -->
<div class="modal-backdrop" id="modal-add-device" onclick="closeAddDeviceModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<span class="modal-title">Add Wemo Device Manually</span>
<button class="modal-close" onclick="closeAddDeviceModal()">×</button>
</div>
<div class="form-group">
<label class="form-label">IP Address</label>
<input class="form-input" id="add-device-host" type="text" placeholder="192.168.1.100" />
</div>
<div class="form-group">
<label class="form-label">Port (optional)</label>
<input class="form-input" id="add-device-port" type="number" placeholder="49153" min="1" max="65535" />
<div style="font-size:12px;color:var(--text2);margin-top:4px;">Default: 49153 (try 49152-49156 if needed)</div>
</div>
<div id="add-device-error" style="display:none;color:var(--danger);font-size:13px;margin-bottom:10px;"></div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeAddDeviceModal()">Cancel</button>
<button class="btn btn-primary" id="btn-add-device" onclick="addDeviceManually()">Add Device</button>
</div>
</div>
</div>
<!-- ── Device Info Modal ── -->
<div class="modal-backdrop" id="modal-device-info" onclick="closeDeviceInfoModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<span class="modal-title">Device Information</span>
<button class="modal-close" onclick="closeDeviceInfoModal()">×</button>
</div>
<div id="device-info-content">
<div style="text-align:center;color:var(--text2);padding:20px;">Loading device information…</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="closeDeviceInfoModal()">Close</button>
</div>
</div>
</div>
<!-- ── Delete Confirm ── --> <!-- ── Delete Confirm ── -->
<div class="confirm-backdrop" id="confirm-delete"> <div class="confirm-backdrop" id="confirm-delete">
<div class="confirm-box"> <div class="confirm-box">
@@ -495,23 +606,54 @@ async function api(method, path, body) {
// ── WebSocket ────────────────────────────────────────────────────────────── // ── WebSocket ──────────────────────────────────────────────────────────────
function connectWS() { function connectWS() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}`); const wsUrl = `${proto}//${location.host}`;
console.log(`[DWM] Connecting WebSocket to: ${wsUrl}`);
try {
ws = new WebSocket(wsUrl);
} catch (err) {
console.error('[DWM] WebSocket creation failed:', err);
return;
}
ws.onopen = () => { ws.onopen = () => {
console.log('[DWM] WebSocket connected');
document.querySelectorAll('.ws-dot').forEach((d) => d.classList.add('connected')); document.querySelectorAll('.ws-dot').forEach((d) => d.classList.add('connected'));
document.querySelectorAll('[id^="ws-label"]').forEach((l) => l.textContent = 'Connected'); document.querySelectorAll('[id^="ws-label"]').forEach((l) => l.textContent = 'Connected');
}; };
ws.onclose = () => { ws.onclose = () => {
console.log('[DWM] WebSocket disconnected, reconnecting...');
document.querySelectorAll('.ws-dot').forEach((d) => d.classList.remove('connected')); document.querySelectorAll('.ws-dot').forEach((d) => d.classList.remove('connected'));
document.querySelectorAll('[id^="ws-label"]').forEach((l) => l.textContent = 'Reconnecting…'); document.querySelectorAll('[id^="ws-label"]').forEach((l) => l.textContent = 'Reconnecting…');
setTimeout(connectWS, 4000); setTimeout(connectWS, 4000);
}; };
ws.onerror = (error) => {
console.error('[DWM] WebSocket error:', error);
document.querySelectorAll('.ws-dot').forEach((d) => d.classList.remove('connected'));
document.querySelectorAll('[id^="ws-label"]').forEach((l) => l.textContent = 'Connection Error');
// Show user-friendly error message
if (error.type === 'error' && error.message.includes('SSL')) {
toast('WebSocket SSL Error: Try accessing via domain name or check certificate', 'error');
} else if (error.message) {
toast(`WebSocket Error: ${error.message}`, 'error');
} else {
toast('WebSocket connection failed - try refreshing the page', 'error');
}
};
ws.onmessage = (e) => { ws.onmessage = (e) => {
try { try {
const { type, data } = JSON.parse(e.data); const { type, data } = JSON.parse(e.data);
if (type === 'scheduler-fired') appendLog(data); if (type === 'scheduler-fired') appendLog(data);
if (type === 'scheduler-status') applySchedStatus(data); if (type === 'scheduler-status') applySchedStatus(data);
if (type === 'scheduler-health') appendLog({ success: data.online, msg: `[health] ${data.msg}` }); if (type === 'scheduler-health') appendLog({ success: data.online, msg: `[health] ${data.msg}` });
} catch {} } catch (err) {
console.error('[DWM] WebSocket message parse error:', err);
}
}; };
} }
@@ -559,32 +701,125 @@ function renderDevices() {
el.innerHTML = `<div class="empty"><div class="icon">📡</div><p>No devices found.<br>Tap <strong>Scan</strong> to search.</p></div>`; el.innerHTML = `<div class="empty"><div class="icon">📡</div><p>No devices found.<br>Tap <strong>Scan</strong> to search.</p></div>`;
return; return;
} }
// Check if there are WebSocket connection issues
const wsConnected = ws && ws.readyState === WebSocket.OPEN;
if (!wsConnected) {
el.innerHTML = `<div class="empty"><div class="icon">⚠️</div><p>WebSocket connection issue detected.<br>Device states may not be accurate.<br>Try refreshing the page.</p></div>`;
return;
}
el.innerHTML = devices.map((d, i) => { el.innerHTML = devices.map((d, i) => {
const name = d.friendlyName || d.name || d.host; const name = d.friendlyName || d.name || d.host;
const isDimmer = d.isDimmer || false;
const icon = isDimmer ? '🔆' : '💡';
return ` return `
<div class="card" id="dev-${i}"> <div class="card" id="dev-${i}">
<div style="font-size:24px;flex-shrink:0">💡</div> <div style="font-size:24px;flex-shrink:0">${icon}</div>
<div class="card-body"> <div class="card-body">
<div class="card-name">${esc(name)}</div> <div class="card-name">${esc(name)}</div>
<div class="card-meta">${esc(d.host)}:${d.port} <div class="card-meta">${esc(d.host)}:${d.port}
${d.productModel ? ' · ' + esc(d.productModel) : ''} ${d.productModel ? ' · ' + esc(d.productModel) : ''}
${isDimmer ? ' · Dimmer' : ''}
</div>
${isDimmer ? `
<div class="brightness-control" style="margin-top:8px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:var(--text2);">🔅</span>
<input type="range" class="brightness-slider" id="bright-${i}"
min="0" max="100" value="50"
oninput="updateBrightnessPreview(${i}, this.value)"
onchange="setBrightness(${i}, this.value)">
<span class="brightness-value" id="bright-val-${i}" style="font-size:12px;color:var(--text2);min-width:30px;">50%</span>
</div> </div>
</div> </div>
` : ''}
</div>
<div class="rule-actions">
<label class="toggle" id="dtog-${i}" onclick="toggleDevice(${i},event)"> <label class="toggle" id="dtog-${i}" onclick="toggleDevice(${i},event)">
<input type="checkbox" id="dchk-${i}"> <input type="checkbox" id="dchk-${i}">
<span class="track"></span> <span class="track"></span>
<span class="thumb"></span> <span class="thumb"></span>
</label> </label>
<button class="icon-btn" onclick="openDeviceInfoModal(${i})" title="Device Info">️</button>
</div>
</div>`; </div>`;
}).join(''); }).join('');
// fetch current state for each
devices.forEach((d, i) => { // fetch current state for each device
devices.forEach(async (d, i) => {
// Skip fetching state if WebSocket is not connected
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.log(`[DWM] Skipping device state fetch due to WebSocket connection issues`);
return;
}
try {
// For dimmer devices, fetch both brightness and state
if (d.isDimmer) {
// Fetch both brightness and state in parallel to avoid race conditions
const [brightnessData, stateData] = await Promise.allSettled([
api('GET', `/api/devices/${d.host}/${d.port}/brightness`),
api('GET', `/api/devices/${d.host}/${d.port}/state`) api('GET', `/api/devices/${d.host}/${d.port}/state`)
.then((on) => { const c = document.getElementById('dchk-'+i); if (c) c.checked = !!on; }) ]);
.catch(() => {});
// Handle brightness data
if (brightnessData.status === 'fulfilled' && brightnessData.value.brightness !== undefined) {
const slider = document.getElementById('bright-'+i);
const value = document.getElementById('bright-val-'+i);
if (slider && value) {
slider.value = brightnessData.value.brightness;
value.textContent = Math.round(brightnessData.value.brightness) + '%';
}
}
// Handle state data (this should set the toggle correctly)
if (stateData.status === 'fulfilled') {
const checkbox = document.getElementById('dchk-'+i);
if (checkbox) {
// stateData.value is an object {on: false}, need to access the 'on' property
const actualState = !!stateData.value.on;
checkbox.checked = actualState;
console.log(`[DWM] Device ${d.host}:${d.port} - BinaryState: ${actualState ? 'ON' : 'OFF'}, Last brightness: ${brightnessData.value?.brightness || 'N/A'}%`);
console.log(`[DWM] Setting checkbox.checked to: ${actualState}`);
console.log(`[DWM] Raw stateData.value:`, stateData.value);
// Force a DOM update to ensure the change takes effect
checkbox.dispatchEvent(new Event('change'));
}
}
} else {
// For non-dimmer devices, just fetch binary state
const stateResult = await api('GET', `/api/devices/${d.host}/${d.port}/state`);
const checkbox = document.getElementById('dchk-'+i);
if (checkbox) {
const shouldBeChecked = !!stateResult;
checkbox.checked = shouldBeChecked;
console.log(`[DWM] Non-dimmer device ${d.host}:${d.port} - State: ${stateResult ? 'ON' : 'OFF'}, Setting checkbox to: ${shouldBeChecked}`);
// Force a DOM update to ensure the change takes effect
checkbox.dispatchEvent(new Event('change'));
}
}
} catch (err) {
console.error(`[DWM] Error fetching state for device ${d.host}:${d.port}:`, err);
}
}); });
} }
// ── Refresh Device States ───────────────────────────────────────────────────
async function refreshDeviceStates() {
if (!devices.length) return;
// Show loading state
toast('Refreshing device states...', 'info');
// Re-render devices to refresh their states
renderDevices();
toast('Device states refreshed', 'success');
}
async function toggleDevice(i, e) { async function toggleDevice(i, e) {
e.preventDefault(); e.preventDefault();
const dev = devices[i]; const dev = devices[i];
@@ -603,6 +838,37 @@ async function toggleDevice(i, e) {
} }
} }
// ── Brightness control ───────────────────────────────────────────────────────
function updateBrightnessPreview(i, value) {
const valueEl = document.getElementById('bright-val-' + i);
if (valueEl) {
valueEl.textContent = Math.round(value) + '%';
}
}
async function setBrightness(i, value) {
const dev = devices[i];
const slider = document.getElementById('bright-' + i);
if (!dev || !slider) return;
slider.disabled = true;
try {
await api('POST', `/api/devices/${dev.host}/${dev.port}/brightness`, { brightness: Number(value) });
// Update power toggle state based on brightness
const chk = document.getElementById('dchk-' + i);
if (chk) {
chk.checked = Number(value) > 0;
}
toast(`Brightness set to ${Math.round(value)}%`, 'success');
} catch (err) {
toast(`Failed to set brightness: ${err.message}`, 'error');
} finally {
slider.disabled = false;
}
}
// ── Rules list ───────────────────────────────────────────────────────────── // ── Rules list ─────────────────────────────────────────────────────────────
async function loadRules() { async function loadRules() {
try { try {
@@ -842,6 +1108,130 @@ function closeRuleModal(e) {
document.getElementById('modal-rule').classList.remove('open'); document.getElementById('modal-rule').classList.remove('open');
} }
// ── Add Device Modal ───────────────────────────────────────────────────────
function openAddDeviceModal() {
document.getElementById('modal-add-device').classList.add('open');
document.getElementById('add-device-error').style.display = 'none';
document.getElementById('add-device-host').value = '';
document.getElementById('add-device-port').value = '';
setTimeout(() => document.getElementById('add-device-host').focus(), 100);
}
function closeAddDeviceModal(e) {
if (e && e.target !== document.getElementById('modal-add-device')) return;
document.getElementById('modal-add-device').classList.remove('open');
}
async function addDeviceManually() {
const errEl = document.getElementById('add-device-error');
const btn = document.getElementById('btn-add-device');
errEl.style.display = 'none';
const host = document.getElementById('add-device-host').value.trim();
const port = document.getElementById('add-device-port').value.trim();
if (!host) {
errEl.textContent = 'IP address is required';
errEl.style.display = 'block';
return;
}
// Basic IP validation
const ipPattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (!ipPattern.test(host)) {
errEl.textContent = 'Please enter a valid IP address';
errEl.style.display = 'block';
return;
}
const portNum = port ? parseInt(port, 10) : 49153;
if (portNum < 1 || portNum > 65535) {
errEl.textContent = 'Port must be between 1 and 65535';
errEl.style.display = 'block';
return;
}
btn.disabled = true;
btn.textContent = 'Adding…';
try {
const device = await api('POST', '/api/devices/add', { host, port: portNum });
toast(`Successfully added ${device.friendlyName || device.host}`, 'success');
closeAddDeviceModal();
await loadDevices(); // Refresh device list
} catch (err) {
errEl.textContent = err.message || 'Failed to add device';
errEl.style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = 'Add Device';
}
}
// ── Device Info Modal ───────────────────────────────────────────────────────
let currentDeviceInfo = null;
async function openDeviceInfoModal(i) {
const dev = devices[i];
if (!dev) return;
currentDeviceInfo = dev;
document.getElementById('modal-device-info').classList.add('open');
const content = document.getElementById('device-info-content');
content.innerHTML = '<div style="text-align:center;color:var(--text2);padding:20px;">Loading device information…</div>';
try {
const info = await api('GET', `/api/devices/${dev.host}/${dev.port}/info`);
renderDeviceInfo(info, dev);
} catch (err) {
content.innerHTML = `
<div style="text-align:center;color:var(--danger);padding:20px;">
<div style="font-size:24px;margin-bottom:10px;">⚠️</div>
<div>Failed to load device information</div>
<div style="font-size:12px;margin-top:8px;">${err.message}</div>
</div>
`;
}
}
function closeDeviceInfoModal(e) {
if (e && e.target !== document.getElementById('modal-device-info')) return;
document.getElementById('modal-device-info').classList.remove('open');
currentDeviceInfo = null;
}
function renderDeviceInfo(info, dev) {
const content = document.getElementById('device-info-content');
const fields = [
{ label: 'Device Name', value: info.friendlyName || dev.friendlyName || 'Unknown' },
{ label: 'IP Address', value: dev.host },
{ label: 'Port', value: dev.port },
{ label: 'Product Model', value: info.productModel || 'Unknown' },
{ label: 'Model Description', value: info.modelDescription || 'Unknown' },
{ label: 'Firmware Version', value: info.firmwareVersion || 'Unknown' },
{ label: 'UDN', value: info.udn || 'Unknown' },
{ label: 'Device Type', value: info.deviceType || 'Unknown' },
{ label: 'Manufacturer', value: info.manufacturer || 'Unknown' },
{ label: 'Is Dimmer', value: dev.isDimmer ? 'Yes' : 'No' },
{ label: 'Serial Number', value: info.serialNumber || 'Unknown' },
{ label: 'MAC Address', value: info.macAddress || 'Unknown' }
];
content.innerHTML = `
<div style="display:grid;gap:12px;">
${fields.map(field => `
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);">
<span style="font-size:13px;color:var(--text2);font-weight:600;">${field.label}</span>
<span style="font-size:13px;color:var(--text);font-family:monospace;word-break:break-all;">${field.value}</span>
</div>
`).join('')}
</div>
`;
}
async function saveRule() { async function saveRule() {
const errEl = document.getElementById('modal-rule-error'); const errEl = document.getElementById('modal-rule-error');
errEl.style.display = 'none'; errEl.style.display = 'none';
+109 -39
View File
@@ -18,6 +18,7 @@
const wemo = require('./wemo'); const wemo = require('./wemo');
const store = require('./store'); const store = require('./store');
const { sunTimes: calcSunTimes } = require('./core/sun');
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -61,6 +62,8 @@ class LocalScheduler {
this._onHealth = null; // ({host, port, name, online, msg}) health event callback this._onHealth = null; // ({host, port, name, online, msg}) health event callback
this._deviceHealth = new Map(); // 'host:port' → true | false this._deviceHealth = new Map(); // 'host:port' → true | false
this._triggerStates = new Map(); // 'host:port' → last known boolean state (for Trigger rules) this._triggerStates = new Map(); // 'host:port' → last known boolean state (for Trigger rules)
this._countdownStates = new Map(); // 'host:port' → last known boolean state (for Countdown rules)
this._countdownTimers = new Map(); // 'deviceKey-ruleId' → {timer, wantOn}
this._healthTimer = null; this._healthTimer = null;
this._startedAt = null; this._startedAt = null;
} }
@@ -126,6 +129,29 @@ class LocalScheduler {
const schedule = []; const schedule = [];
const rules = store.getDwmRules(); const rules = store.getDwmRules();
// Compute today's sunrise/sunset once — used to resolve sun-based start/end times
const loc = store.getLocation();
const todaySun = (loc?.lat != null && loc?.lng != null)
? calcSunTimes(loc.lat, loc.lng)
: null;
/**
* Convert a raw stored time value to seconds-from-midnight.
* -2 = sunrise sentinel, -3 = sunset sentinel (set by RuleEditor.jsx saveDwm).
* offsetMins is added/subtracted from the sun time (e.g. "30 min before sunset").
* Returns null if the time cannot be resolved (no location, polar day/night, or no time set).
*/
const resolveSecs = (rawSecs, type, offsetMins) => {
const offsetSecs = (offsetMins ?? 0) * 60;
if (type === 'sunset' || rawSecs === -3) {
return todaySun?.sunset != null ? todaySun.sunset + offsetSecs : null;
}
if (type === 'sunrise' || rawSecs === -2) {
return todaySun?.sunrise != null ? todaySun.sunrise + offsetSecs : null;
}
return rawSecs >= 0 ? rawSecs : null;
};
for (const rule of rules) { for (const rule of rules) {
if (!rule.enabled) continue; if (!rule.enabled) continue;
@@ -134,9 +160,12 @@ class LocalScheduler {
// ── Away Mode — handled by the randomisation loop, not pre-computed entries ── // ── Away Mode — handled by the randomisation loop, not pre-computed entries ──
if (rule.type === 'Away') { if (rule.type === 'Away') {
const startSecs = Number(rule.startTime ?? -1); const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset);
const endSecs = Number(rule.endTime ?? -1); const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset);
if (startSecs < 0) continue; // sun-based — skip for now if (startSecs === null) continue; // no location set or polar day/night
const awayStartAction = Number(rule.startAction ?? 1);
const awayEndAction = Number(rule.endAction ?? 0);
for (const dayId of (rule.days ?? [])) { for (const dayId of (rule.days ?? [])) {
const td0 = rule.targetDevices?.[0]; // for status display only const td0 = rule.targetDevices?.[0]; // for status display only
@@ -148,11 +177,11 @@ class LocalScheduler {
targetPort: td0?.port ?? 0, targetPort: td0?.port ?? 0,
dayId: Number(dayId), dayId: Number(dayId),
targetSecs: startSecs, targetSecs: startSecs,
action: 1, action: awayStartAction,
isAwayStart: true, isAwayStart: true,
}); });
// Window-end entry: stops the away loop // Window-end entry: stops the away loop
if (endSecs >= 0) { if (endSecs !== null && endSecs >= 0) {
schedule.push({ schedule.push({
ruleId: rule.id + '-away-end', ruleId: rule.id + '-away-end',
ruleName: rule.name, ruleName: rule.name,
@@ -160,7 +189,7 @@ class LocalScheduler {
targetPort: td0?.port ?? 0, targetPort: td0?.port ?? 0,
dayId: Number(dayId), dayId: Number(dayId),
targetSecs: endSecs, targetSecs: endSecs,
action: 0, action: awayEndAction,
isAwayEnd: true, isAwayEnd: true,
awayRuleId: rule.id, awayRuleId: rule.id,
}); });
@@ -169,38 +198,15 @@ class LocalScheduler {
continue; continue;
} }
// ── Countdown with active window ───────────────────────────────────── // ── Countdown — handled by the health-monitor state-change poll ─────
if (rule.type === 'Countdown') { if (rule.type === 'Countdown') continue;
const windowStart = Number(rule.windowStart ?? -1);
const windowEnd = Number(rule.windowEnd ?? -1);
if (windowStart < 0 || !(rule.windowDays?.length)) continue;
const crossesMidnight = windowEnd >= 0 && windowEnd < windowStart;
for (const dayId of rule.windowDays) {
for (const td of (rule.targetDevices ?? [])) {
if (!td.host || !td.port) continue;
schedule.push({ ruleId: rule.id, ruleName: rule.name,
targetHost: td.host, targetPort: td.port,
dayId: Number(dayId), targetSecs: windowStart, action: 1 });
if (windowEnd >= 0) {
const offDayId = crossesMidnight ? (Number(dayId) % 7) + 1 : Number(dayId);
schedule.push({ ruleId: rule.id + '-wend', ruleName: rule.name,
targetHost: td.host, targetPort: td.port,
dayId: offDayId, targetSecs: windowEnd, action: 0 });
}
}
}
continue;
}
// ── Schedule / other time-based rules ──────────────────────────────── // ── Schedule / other time-based rules ────────────────────────────────
const startSecs = Number(rule.startTime ?? -1); const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset);
const endSecs = Number(rule.endTime ?? -1); const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset);
const startAction = Number(rule.startAction ?? 1); const startAction = Number(rule.startAction ?? 1);
const endAction = Number(rule.endAction ?? -1); const endAction = Number(rule.endAction ?? -1);
if (startSecs < 0) continue; if (startSecs === null) continue; // no location set, polar day/night, or no time defined
for (const dayId of (rule.days ?? [])) { for (const dayId of (rule.days ?? [])) {
for (const td of (rule.targetDevices ?? [])) { for (const td of (rule.targetDevices ?? [])) {
@@ -210,7 +216,7 @@ class LocalScheduler {
targetHost: td.host, targetPort: td.port, targetHost: td.host, targetPort: td.port,
dayId: Number(dayId), targetSecs: startSecs, action: startAction }); dayId: Number(dayId), targetSecs: startSecs, action: startAction });
} }
if (endSecs > 0 && endAction >= 0) { if (endSecs !== null && endSecs > 0 && endAction >= 0) {
schedule.push({ ruleId: rule.id, ruleName: rule.name, schedule.push({ ruleId: rule.id, ruleName: rule.name,
targetHost: td.host, targetPort: td.port, targetHost: td.host, targetPort: td.port,
dayId: Number(dayId), targetSecs: endSecs, action: endAction }); dayId: Number(dayId), targetSecs: endSecs, action: endAction });
@@ -330,13 +336,15 @@ class LocalScheduler {
this._awayLoops.delete(ruleId); this._awayLoops.delete(ruleId);
if (forceOff) { if (forceOff) {
const endAction = Number(loop.rule.endAction ?? 0);
const turnOn = endAction === 1;
for (const td of loop.devices) { for (const td of loop.devices) {
wemo.setBinaryState(td.host, td.port, false).catch(() => {}); wemo.setBinaryState(td.host, td.port, turnOn).catch(() => {});
} }
this._onFire?.({ this._onFire?.({
success: true, success: true,
msg: `"${loop.rule.name}" Away Mode window ended — all devices OFF`, msg: `"${loop.rule.name}" Away Mode window ended — all devices ${turnOn ? 'ON' : 'OFF'}`,
entry: { action: 0 }, entry: { action: endAction },
}); });
} }
} }
@@ -431,6 +439,7 @@ class LocalScheduler {
const allRules = store.getDwmRules(); const allRules = store.getDwmRules();
const alwaysOnSet = new Set(); // keys with an active AlwaysOn rule const alwaysOnSet = new Set(); // keys with an active AlwaysOn rule
const triggerSrcSet = new Set(); // keys that are trigger source devices const triggerSrcSet = new Set(); // keys that are trigger source devices
const countdownDevMap = new Map(); // deviceKey → [{rule, td}]
const addDev = (td) => { const addDev = (td) => {
if (!td?.host || !td?.port) return; if (!td?.host || !td?.port) return;
@@ -450,7 +459,12 @@ class LocalScheduler {
} }
for (const td of (rule.targetDevices ?? [])) { for (const td of (rule.targetDevices ?? [])) {
const k = addDev(td); const k = addDev(td);
if (k && rule.type === 'AlwaysOn') alwaysOnSet.add(k); if (!k) continue;
if (rule.type === 'AlwaysOn') alwaysOnSet.add(k);
if (rule.type === 'Countdown') {
if (!countdownDevMap.has(k)) countdownDevMap.set(k, []);
countdownDevMap.get(k).push({ rule, td });
}
} }
} }
@@ -494,6 +508,60 @@ class LocalScheduler {
} }
} }
// ── Countdown — fire only when state matches condition and within window ──
if (countdownDevMap.has(key)) {
const prevState = this._countdownStates.get(key);
this._countdownStates.set(key, isOn);
if (prevState !== undefined && prevState !== isOn) {
const nowSecs = secondsFromMidnight(new Date());
for (const { rule, td } of countdownDevMap.get(key)) {
const condition = rule.countdownAction ?? 'on_to_off';
const triggered = condition === 'on_to_off' ? isOn : !isOn;
if (!triggered) continue;
// Check active window (if defined)
const winStart = Number(rule.windowStart ?? -1);
const winEnd = Number(rule.windowEnd ?? -1);
if (winStart >= 0 && winEnd >= 0) {
const crossesMidnight = winEnd < winStart;
const inWindow = crossesMidnight
? (nowSecs >= winStart || nowSecs <= winEnd)
: (nowSecs >= winStart && nowSecs <= winEnd);
if (!inWindow) continue;
} else if (winStart >= 0) {
if (nowSecs < winStart) continue;
}
const timerKey = `${key}-${rule.id}`;
const existing = this._countdownTimers.get(timerKey);
if (existing) { clearTimeout(existing.timer); this._countdownTimers.delete(timerKey); }
const wantOn = condition === 'off_to_on';
const durationMs = (Number(rule.countdownTime) || 60) * 1000;
const label = wantOn ? 'ON' : 'OFF';
const mins = Math.round(durationMs / 60000);
this._onFire?.({ success: true,
msg: `"${rule.name}" countdown started — will turn ${label} in ${mins} min (${td.host})`,
entry: { action: wantOn ? 1 : 0 } });
const timer = setTimeout(async () => {
this._countdownTimers.delete(timerKey);
try {
await wemo.setBinaryState(td.host, td.port, wantOn);
this._onFire?.({ success: true,
msg: `"${rule.name}" countdown elapsed → ${label} (${td.host}) ✓`,
entry: { action: wantOn ? 1 : 0 } });
} catch (e2) {
this._onFire?.({ success: false,
msg: `"${rule.name}" countdown elapsed → ${label} FAILED: ${e2.message}`,
entry: { action: wantOn ? 1 : 0 } });
}
}, durationMs);
this._countdownTimers.set(timerKey, { timer, wantOn });
}
}
}
} catch (e) { } catch (e) {
this._deviceHealth.set(key, false); this._deviceHealth.set(key, false);
if (wasOnline !== false) { if (wasOnline !== false) {
@@ -602,6 +670,8 @@ class LocalScheduler {
for (const t of this._timers) clearTimeout(t); for (const t of this._timers) clearTimeout(t);
this._timers = []; this._timers = [];
if (this._tickTimer) { clearTimeout(this._tickTimer); this._tickTimer = null; } if (this._tickTimer) { clearTimeout(this._tickTimer); this._tickTimer = null; }
for (const { timer } of this._countdownTimers.values()) clearTimeout(timer);
this._countdownTimers.clear();
} }
_scheduleUpcoming() { _scheduleUpcoming() {
@@ -78,6 +78,7 @@ function dwmRuleToForm(rule, defaultDeviceUdn) {
endAction: rule.endAction ?? -1, endAction: rule.endAction ?? -1,
countdownMins: rule.countdownTime ? Math.round(rule.countdownTime / 60) : 60, countdownMins: rule.countdownTime ? Math.round(rule.countdownTime / 60) : 60,
countdownTime: rule.countdownTime ?? 3600, countdownTime: rule.countdownTime ?? 3600,
countdownAction: rule.countdownAction ?? 'on_to_off',
// Countdown active window // Countdown active window
windowEnabled: rule.windowStart >= 0 && rule.windowStart != null, windowEnabled: rule.windowStart >= 0 && rule.windowStart != null,
windowStartTime: rule.windowStart >= 0 ? secsToHHMM(rule.windowStart) : '', windowStartTime: rule.windowStart >= 0 ? secsToHHMM(rule.windowStart) : '',
@@ -445,6 +446,7 @@ export default function RuleEditor({ rule, device, isDwm = false, onSave, onClos
startOffset: form.startOffset ?? 0, startOffset: form.startOffset ?? 0,
endOffset: form.endOffset ?? 0, endOffset: form.endOffset ?? 0,
countdownTime: form.countdownTime ?? 3600, countdownTime: form.countdownTime ?? 3600,
countdownAction: form.countdownAction ?? 'on_to_off',
windowStart, windowStart,
windowEnd, windowEnd,
windowDays, windowDays,
@@ -126,6 +126,30 @@ export default function AwayModeEditor({ form, onChange, sunTimes }) {
configured window. The DWM scheduler handles all randomisation while the app is running. configured window. The DWM scheduler handles all randomisation while the app is running.
</div> </div>
{/* Start / End action */}
<div style={{ display: 'flex', gap: 16, marginBottom: 4 }}>
<div className="form-group" style={{ flex: 1 }}>
<label>Window Start Action</label>
<select
value={form.startAction ?? 1}
onChange={(e) => onChange({ ...form, startAction: Number(e.target.value) })}
>
<option value={1}>Turn ON</option>
<option value={0}>Turn OFF</option>
</select>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>Window End Action</label>
<select
value={form.endAction ?? 0}
onChange={(e) => onChange({ ...form, endAction: Number(e.target.value) })}
>
<option value={0}>Turn OFF</option>
<option value={1}>Turn ON</option>
</select>
</div>
</div>
{/* Active days */} {/* Active days */}
<div className="form-group"> <div className="form-group">
<label>Active Days</label> <label>Active Days</label>
@@ -3,6 +3,7 @@ import DayPicker from '../DayPicker';
export default function CountdownEditor({ form, onChange }) { export default function CountdownEditor({ form, onChange }) {
const mins = form.countdownMins ?? 60; const mins = form.countdownMins ?? 60;
const countdownAction = form.countdownAction ?? 'on_to_off';
const windowEnabled = form.windowEnabled ?? false; const windowEnabled = form.windowEnabled ?? false;
const windowStartTime = form.windowStartTime ?? ''; const windowStartTime = form.windowStartTime ?? '';
const windowEndTime = form.windowEndTime ?? ''; const windowEndTime = form.windowEndTime ?? '';
@@ -18,14 +19,26 @@ export default function CountdownEditor({ form, onChange }) {
return ( return (
<> <>
<div className="notice notice-info"> {/* Condition */}
The device automatically turns off after the countdown completes. <div className="form-group">
The countdown starts when the device is manually turned on (or at window start, if an active window is set below). <label>Condition</label>
<select
value={countdownAction}
onChange={(e) => onChange({ ...form, countdownAction: e.target.value })}
>
<option value="on_to_off">If device turns ON auto-OFF after duration</option>
<option value="off_to_on">If device turns OFF auto-ON after duration</option>
</select>
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>
{countdownAction === 'on_to_off'
? 'When the device is turned ON, it will automatically turn OFF after the countdown.'
: 'When the device is turned OFF, it will automatically turn ON after the countdown.'}
</div>
</div> </div>
{/* Countdown duration */} {/* Countdown duration */}
<div className="form-group"> <div className="form-group">
<label>Turn off after (minutes)</label> <label>Duration (minutes)</label>
<input <input
type="number" min="1" max="1440" type="number" min="1" max="1440"
value={mins} value={mins}
@@ -59,9 +72,8 @@ export default function CountdownEditor({ form, onChange }) {
{windowEnabled && ( {windowEnabled && (
<> <>
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12 }}> <p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12 }}>
The scheduler will turn the device <strong>ON</strong> at the window start and The countdown only activates when the device state changes within this time window.
<strong> OFF</strong> at the window end. The countdown auto-off fires in between. State changes outside the window are ignored.
Use this to prevent the timer rule conflicting with other rules outside these hours.
</p> </p>
{/* Window times */} {/* Window times */}
@@ -88,7 +100,6 @@ export default function CountdownEditor({ form, onChange }) {
{windowStartTime && windowEndTime && crossesMidnight() && ( {windowStartTime && windowEndTime && crossesMidnight() && (
<div className="notice notice-info" style={{ marginBottom: 12, fontSize: 12 }}> <div className="notice notice-info" style={{ marginBottom: 12, fontSize: 12 }}>
🌙 Window crosses midnight ends at <strong>{windowEndTime}</strong> the <strong>next day</strong>. 🌙 Window crosses midnight ends at <strong>{windowEndTime}</strong> the <strong>next day</strong>.
The OFF command fires on the following calendar day.
</div> </div>
)} )}
+66
View File
@@ -0,0 +1,66 @@
version: '3.8'
services:
dibbly-web:
image: reg.dev.nervesocket.com/dibbly:latest
container_name: dibbly-wemo-manager-prod
restart: always
ports:
- "3456:3456"
volumes:
- dibbly-data:/data
- dibbly-logs:/app/logs
environment:
- DATA_DIR=/data
- PORT=3456
- NODE_ENV=production
networks:
- dibbly-network
# Use host networking on Linux for Wemo SSDP discovery
# Uncomment the line below if running on Linux
# network_mode: host
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3456/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
memory: 256M
reservations:
memory: 128M
# Optional: Reverse proxy for SSL termination
nginx:
image: nginx:alpine
container_name: dibbly-nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
networks:
- dibbly-network
depends_on:
- dibbly-web
profiles:
- with-nginx
volumes:
dibbly-data:
driver: local
dibbly-logs:
driver: local
networks:
dibbly-network:
driver: bridge
+14
View File
@@ -0,0 +1,14 @@
{
"name": "dibby-wemo-docker",
"version": "2.0.0",
"description": "Dibby Wemo Manager — headless scheduler + web remote",
"main": "server.js",
"dependencies": {
"adm-zip": "^0.5.14",
"axios": "^1.7.0",
"sql.js": "^1.12.0",
"ws": "^8.18.0",
"xml2js": "^0.6.2",
"xmlbuilder2": "^4.0.3"
}
}
+380
View File
@@ -0,0 +1,380 @@
'use strict';
/**
* Dibby Wemo Manager — Docker entry point
*
* Runs the DWM scheduler + REST/WebSocket API server without Electron.
* Configure via environment variables:
* DATA_DIR — path to persistent data directory (default: /data)
* PORT — HTTP port (default: 3456)
*/
const http = require('http');
const path = require('path');
const fs = require('fs');
const os = require('os');
const DwmStore = require('./lib/store');
const DwmScheduler = require('./lib/scheduler');
const wemo = require('./lib/wemo-client');
const DATA_DIR = process.env.DATA_DIR || '/data';
const PORT = parseInt(process.env.PORT || '3456', 10);
const WEB_DIR = path.join(__dirname, 'web');
// ── Bootstrap ─────────────────────────────────────────────────────────────────
const store = new DwmStore(DATA_DIR);
const scheduler = new DwmScheduler({ store, wemoClient: wemo, log: console });
let _wss = null;
function broadcast(type, data) {
if (!_wss) return;
const msg = JSON.stringify({ type, data });
for (const client of _wss.clients) {
if (client.readyState === 1 /* OPEN */) client.send(msg);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function getLocalIP() {
for (const iface of Object.values(os.networkInterfaces())) {
for (const addr of iface) {
if (addr.family === 'IPv4' && !addr.internal) return addr.address;
}
}
return 'localhost';
}
function json(res, data, status = 200) {
const body = JSON.stringify(data);
res.writeHead(status, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
});
res.end(body);
}
function jsonErr(res, msg, status = 500) {
json(res, { error: msg }, status);
}
// ── Request router ────────────────────────────────────────────────────────────
async function handleRequest(req, res) {
const url = req.url.split('?')[0];
const method = req.method.toUpperCase();
if (method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE',
'Access-Control-Allow-Headers': 'Content-Type',
});
res.end();
return;
}
const body = await new Promise((resolve) => {
if (method !== 'POST' && method !== 'PUT') return resolve({});
let raw = '';
req.on('data', (c) => { raw += c; });
req.on('end', () => { try { resolve(JSON.parse(raw)); } catch { resolve({}); } });
});
try {
// ── Debug endpoint ─────────────────────────────────────────────────────
if (url === '/api/debug/test-device' && method === 'POST') {
const { host, port } = body;
if (!host) {
return jsonErr(res, 'Host is required', 400);
}
const devicePort = port ? parseInt(port, 10) : 49153;
console.log(`[DWM] Debug test for ${host}:${devicePort}`);
const results = {};
// Test 1: HTTP connection to setup.xml
try {
const setupUrl = `http://${host}:${devicePort}/setup.xml`;
console.log(`[DWM] Testing setup.xml URL: ${setupUrl}`);
const response = await axios.get(setupUrl, {
timeout: 5000,
httpAgent: NO_KEEPALIVE
});
results.setupXml = {
status: response.status,
dataLength: response.data.length,
success: response.status === 200
};
console.log(`[DWM] setup.xml response:`, results.setupXml);
} catch (err) {
results.setupXml = {
success: false,
error: err.message,
code: err.code
};
console.log(`[DWM] setup.xml failed:`, err.message);
}
// Test 2: Try to get device info
try {
console.log(`[DWM] Testing getDeviceInfo`);
const deviceInfo = await wemo.getDeviceInfo(host, devicePort);
results.deviceInfo = {
success: true,
data: deviceInfo
};
console.log(`[DWM] getDeviceInfo successful:`, deviceInfo);
} catch (err) {
results.deviceInfo = {
success: false,
error: err.message
};
console.log(`[DWM] getDeviceInfo failed:`, err.message);
}
// Test 3: Try to get binary state
try {
console.log(`[DWM] Testing getBinaryState`);
const state = await wemo.getBinaryState(host, devicePort);
results.binaryState = {
success: true,
state: state
};
console.log(`[DWM] getBinaryState successful:`, state);
} catch (err) {
results.binaryState = {
success: false,
error: err.message
};
console.log(`[DWM] getBinaryState failed:`, err.message);
}
return json(res, results);
}
// ── Devices ────────────────────────────────────────────────────────────
if (url === '/api/devices' && method === 'GET') {
const devices = store.getDevices();
// Add dimmer detection to each device
const devicesWithDimmerInfo = devices.map(device => ({
...device,
isDimmer: wemo.isDimmerDevice ? wemo.isDimmerDevice(device) : false
}));
return json(res, devicesWithDimmerInfo);
}
if (url === '/api/devices/discover' && method === 'POST') {
const saved = store.getDevices();
const manual = saved.map((d) => ({ host: d.host, port: d.port }));
const devices = await wemo.discoverDevices(8000, manual);
store.saveDevices(devices);
return json(res, devices);
}
if (url === '/api/devices/add' && method === 'POST') {
const { host, port } = body;
if (!host) {
return jsonErr(res, 'Host is required', 400);
}
const devicePort = port ? parseInt(port, 10) : 49153;
const manualEntry = { host, port: devicePort };
console.log(`[DWM] Attempting to add device manually: ${host}:${devicePort}`);
try {
// First try direct device info fetch
let device = null;
try {
console.log(`[DWM] Trying direct device info fetch for ${host}:${devicePort}`);
device = await wemo.getDeviceInfo(host, devicePort);
if (device) {
device.host = host;
device.port = devicePort;
device.manual = true; // Mark as manually added
console.log(`[DWM] Direct fetch successful:`, device);
}
} catch (directErr) {
console.log(`[DWM] Direct fetch failed:`, directErr.message);
}
// If direct fetch failed, try discovery with manual entry
if (!device) {
console.log(`[DWM] Trying discovery with manual entry`);
const devices = await wemo.discoverDevices(5000, [manualEntry]);
if (devices.length > 0) {
device = devices[0];
device.manual = true; // Mark as manually added
console.log(`[DWM] Discovery successful:`, device);
}
}
if (device) {
// Add to existing devices
const allDevices = [...store.getDevices(), ...[device]];
store.saveDevices(allDevices);
console.log(`[DWM] Device added successfully: ${device.friendlyName || device.host}`);
return json(res, device, 201);
} else {
console.log(`[DWM] No device found at ${host}:${devicePort}`);
return jsonErr(res, `No Wemo device found at ${host}:${devicePort}. Check IP address and port.`, 404);
}
} catch (err) {
console.log(`[DWM] Error adding device:`, err);
return jsonErr(res, `Failed to connect: ${err.message}`, 500);
}
}
const stateMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/state$/);
if (stateMatch) {
const [, host, port] = stateMatch;
if (method === 'GET') {
const on = await wemo.getBinaryState(host, Number(port));
return json(res, { on });
}
if (method === 'POST') {
await wemo.setBinaryState(host, Number(port), !!body.on);
return json(res, { ok: true });
}
}
// ── Device Information ───────────────────────────────────────────────────
const deviceInfoMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/info$/);
if (deviceInfoMatch && method === 'GET') {
const [, host, port] = deviceInfoMatch;
try {
const info = await wemo.getDeviceInfo(host, Number(port));
return json(res, info);
} catch (err) {
return jsonErr(res, `Failed to get device info: ${err.message}`, 500);
}
}
// ── Brightness control ───────────────────────────────────────────────────
const brightnessMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/brightness$/);
if (brightnessMatch) {
const [, host, port] = brightnessMatch;
if (method === 'GET') {
const brightness = await wemo.getBrightness(host, Number(port));
return json(res, { brightness });
}
if (method === 'POST') {
await wemo.setBrightness(host, Number(port), body.brightness);
return json(res, { ok: true });
}
}
// ── DWM Rules ──────────────────────────────────────────────────────────
if (url === '/api/dwm-rules') {
if (method === 'GET') return json(res, store.getDwmRules());
if (method === 'POST') {
const rule = store.createDwmRule(body);
scheduler.reload();
return json(res, rule, 201);
}
}
const ruleMatch = url.match(/^\/api\/dwm-rules\/(.+)$/);
if (ruleMatch) {
const id = ruleMatch[1];
if (method === 'PUT') {
const updated = store.updateDwmRule(id, body);
scheduler.reload();
return json(res, updated);
}
if (method === 'DELETE') {
store.deleteDwmRule(id);
scheduler.reload();
return json(res, { ok: true });
}
}
// ── Wemo Device Rules ──────────────────────────────────────────────────
const wemoRulesMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/rules$/);
if (wemoRulesMatch && method === 'GET') {
const [, host, port] = wemoRulesMatch;
return json(res, await wemo.fetchRules(host, Number(port)));
}
const wemoRuleMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/rules\/(\d+)$/);
if (wemoRuleMatch && method === 'PUT') {
const [, host, port, ruleId] = wemoRuleMatch;
await wemo.updateRule(host, Number(port), Number(ruleId), body);
return json(res, { ok: true });
}
// ── Scheduler ──────────────────────────────────────────────────────────
if (url === '/api/scheduler/status' && method === 'GET') {
return json(res, scheduler.getStatus());
}
// ── Static assets ──────────────────────────────────────────────────────
if (url === '/icon.png' && method === 'GET') {
const file = path.join(__dirname, 'icon.png');
fs.readFile(file, (e, data) => {
if (e) { res.writeHead(404); res.end(); return; }
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(data);
});
return;
}
// Fallback → serve mobile web UI
const file = path.join(WEB_DIR, 'index.html');
fs.readFile(file, (e, data) => {
if (e) { res.writeHead(404); res.end('Web UI not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(data);
});
} catch (e) {
jsonErr(res, e.message);
}
}
// ── Start ─────────────────────────────────────────────────────────────────────
async function main() {
// Ensure data directory exists
fs.mkdirSync(DATA_DIR, { recursive: true });
await scheduler.start();
console.log(`[DWM] Scheduler started — data: ${DATA_DIR}`);
const server = http.createServer(handleRequest);
let WebSocketServer;
try { WebSocketServer = require('ws').WebSocketServer || require('ws').Server; } catch {}
if (WebSocketServer) {
_wss = new WebSocketServer({ server });
_wss.on('connection', (ws) => {
ws.send(JSON.stringify({ type: 'scheduler-status', data: scheduler.getStatus() }));
});
scheduler.onFire = (event) => broadcast('scheduler-fired', event);
scheduler.onStatus = (status) => broadcast('scheduler-status', status);
}
server.listen(PORT, '0.0.0.0', () => {
console.log(`[DWM] Web Remote: http://${getLocalIP()}:${PORT}`);
});
process.on('SIGTERM', () => { server.close(); process.exit(0); });
process.on('SIGINT', () => { server.close(); process.exit(0); });
}
main().catch((e) => { console.error('[DWM] Fatal:', e); process.exit(1); });
+189
View File
@@ -0,0 +1,189 @@
# Web Deployment Guide
This guide covers deploying Dibby Wemo Manager as a web service using Docker containers.
## Quick Start
### Using Docker Compose (Recommended)
1. **Build and deploy with one command:**
```bash
# Linux/macOS
./scripts/web-deploy.sh
# Windows
powershell -ExecutionPolicy Bypass -File scripts/web-deploy.ps1
```
2. **Or manually:**
```bash
# Build the Docker image
docker build -t reg.dev.nervesocket.com/dibbly:latest .
# Start with Docker Compose
docker-compose -f web-compose.yml up -d
```
3. **Access the web interface:**
- Local: http://localhost:3456
- Mobile: http://YOUR_IP:3456
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `DATA_DIR` | `/data` | Persistent data directory for device configs and rules |
| `PORT` | `3456` | HTTP port for the web interface |
### Docker Compose Options
The `web-compose.yml` includes several production-ready features:
- **Persistent Data**: Uses Docker volumes to store device configurations and rules
- **Health Checks**: Automatic monitoring of service health
- **Restart Policy**: Automatically restarts if the service crashes
- **Network Isolation**: Runs in a dedicated Docker network
### Networking Considerations
#### Wemo Device Discovery
Wemo devices use SSDP (Simple Service Discovery Protocol) which requires special networking:
- **Linux**: Use `network_mode: host` for automatic device discovery
- **macOS/Windows**: Host networking isn't supported - add devices manually via the web UI
To enable host networking on Linux, uncomment this line in `web-compose.yml`:
```yaml
network_mode: host
```
#### Port Configuration
The service runs on port 3456 by default. To change it:
1. Update the port mapping in `web-compose.yml`
2. Set the `PORT` environment variable
3. Restart the service
## Management
### View Logs
```bash
docker-compose -f web-compose.yml logs -f
```
### Stop Service
```bash
docker-compose -f web-compose.yml down
```
### Update Service
```bash
# Pull latest image and restart
docker-compose -f web-compose.yml pull
docker-compose -f web-compose.yml up -d
```
### Backup Data
```bash
# Backup persistent data
docker run --rm -v dibbly-data:/data -v $(pwd):/backup alpine tar czf /backup/dibbly-backup.tar.gz -C /data .
# Restore data
docker run --rm -v dibbly-data:/data -v $(pwd):/backup alpine tar xzf /backup/dibbly-backup.tar.gz -C /data
```
## Web Interface Features
The web interface provides full parity with the desktop application:
- **Device Management**: Discover, add, and control Wemo devices
- **Scheduling**: Create and manage device schedules
- **Real-time Updates**: WebSocket-based live status updates
- **Mobile Optimized**: Responsive design for phones and tablets
- **Dark Mode**: Automatic theme detection
### API Endpoints
The service exposes a REST API for integration:
- `GET /api/devices` - List all devices
- `POST /api/devices/discover` - Discover new devices
- `GET/POST /api/devices/{host}/{port}/state` - Control device state
- `GET/POST/PUT/DELETE /api/dwm-rules` - Manage scheduling rules
- `GET /api/scheduler/status` - Get scheduler status
## Troubleshooting
### Common Issues
1. **Devices not discovered automatically**
- On macOS/Windows, add devices manually via the web UI
- On Linux, ensure host networking is enabled
2. **Service not accessible**
- Check if port 3456 is available
- Verify Docker is running
- Check firewall settings
3. **Data persistence issues**
- Ensure the Docker volume `dibbly-data` exists
- Check permissions on the data directory
### Health Checks
The service includes built-in health checks. Monitor status:
```bash
docker-compose -f web-compose.yml ps
```
### Performance
For optimal performance:
- Use host networking on Linux for faster device discovery
- Ensure adequate disk space for data persistence
- Monitor memory usage with multiple devices
## Security
### Network Security
- The service binds to `0.0.0.0` by default
- Consider using a reverse proxy (nginx/traefik) for production
- Implement authentication if exposing to the internet
### Data Protection
- Device configurations are stored in `/data`
- Regular backups are recommended
- Consider encrypting the data volume in production
## Production Deployment
For production environments, consider:
1. **Reverse Proxy**: Use nginx or Traefik for SSL termination
2. **Authentication**: Add authentication layer
3. **Monitoring**: Implement proper logging and monitoring
4. **Backups**: Automated backup strategy
5. **High Availability**: Multiple instances with load balancing
Example nginx configuration:
```nginx
server {
listen 443 ssl;
server_name your-domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3456;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
+60
View File
@@ -0,0 +1,60 @@
events {
worker_connections 1024;
}
http {
upstream dibbly {
server dibbly-web:3456;
}
# HTTP redirect to HTTPS
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
# HTTPS server
server {
listen 443 ssl http2;
server_name _;
# SSL configuration
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
# Proxy to Dibby service
location / {
proxy_pass http://dibbly;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeout settings
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
}
+7807
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "wemo-manager",
"version": "2.0.0",
"private": true,
"workspaces": [
"packages/*",
"apps/*"
],
"scripts": {
"dev": "npm run dev --workspace=apps/desktop",
"build": "npm run build --workspace=apps/desktop",
"install:all": "npm install"
}
}
@@ -220,9 +220,32 @@
<div class="flex-row" style="margin-bottom:12px"> <div class="flex-row" style="margin-bottom:12px">
<h2 style="margin:0">DWM Automation Rules</h2> <h2 style="margin:0">DWM Automation Rules</h2>
<div class="spacer"></div> <div class="spacer"></div>
<button class="btn btn-ghost btn-sm" id="btn-export-dwm" title="Export all rules to JSON file">⬇ Export</button>
<button class="btn btn-ghost btn-sm" id="btn-import-dwm" title="Import rules from JSON file">⬆ Import</button>
<input type="file" id="dwm-import-file" accept=".json" style="display:none" />
<button class="btn btn-primary" id="btn-add-dwm">+ Add Rule</button> <button class="btn btn-primary" id="btn-add-dwm">+ Add Rule</button>
</div> </div>
<!-- Import preview panel (hidden until file chosen) -->
<div id="dwm-import-panel" style="display:none;margin-bottom:16px;padding:14px 16px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.18);border-radius:8px">
<div class="flex-row" style="margin-bottom:10px">
<strong id="dwm-import-title" style="font-size:0.92rem">Import Rules</strong>
<div class="spacer"></div>
<button class="btn btn-ghost btn-sm" id="btn-import-cancel">✕ Cancel</button>
</div>
<div id="dwm-import-list" style="max-height:180px;overflow-y:auto;margin-bottom:12px;font-size:0.82rem;color:#9ca3af"></div>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:12px;font-size:0.85rem">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="radio" name="dwm-import-mode" value="merge" checked /> Merge <span style="color:#9ca3af;font-size:0.78rem">(skip existing names)</span>
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="radio" name="dwm-import-mode" value="replace" /> Replace <span style="color:#fca5a5;font-size:0.78rem">(delete all current rules first)</span>
</label>
</div>
<div id="dwm-import-status" style="font-size:0.82rem;margin-bottom:8px;min-height:18px"></div>
<button class="btn btn-primary" id="btn-import-confirm">⬆ Import Rules</button>
</div>
<!-- Scheduler heartbeat bar --> <!-- 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)"> <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-dot" style="width:10px;height:10px;border-radius:50%;background:#6b7280;flex-shrink:0"></span>
@@ -316,12 +339,40 @@
<div class="flex-row"> <div class="flex-row">
<div class="form-group" style="flex:1"> <div class="form-group" style="flex:1">
<label>Start Time</label> <label>Start Time</label>
<select id="dwm-start-type" style="margin-bottom:6px">
<option value="fixed">Fixed Time</option>
<option value="sunrise">Sunrise</option>
<option value="sunset">Sunset</option>
</select>
<div id="dwm-start-fixed">
<input type="text" id="dwm-start-time" placeholder="e.g. 8:30 PM" /> <input type="text" id="dwm-start-time" placeholder="e.g. 8:30 PM" />
</div> </div>
<div id="dwm-start-sun" style="display:none">
<div style="display:flex;align-items:center;gap:6px">
<input type="number" id="dwm-start-offset" placeholder="0" style="width:70px" />
<span style="font-size:0.8rem;color:#9ca3af">min (+ after, before)</span>
</div>
<div id="dwm-start-preview" style="font-size:0.78rem;color:#4ade80;margin-top:4px"></div>
</div>
</div>
<div class="form-group" style="flex:1"> <div class="form-group" style="flex:1">
<label>End Time (optional)</label> <label>End Time (optional)</label>
<select id="dwm-end-type" style="margin-bottom:6px">
<option value="fixed">Fixed Time</option>
<option value="sunrise">Sunrise</option>
<option value="sunset">Sunset</option>
</select>
<div id="dwm-end-fixed">
<input type="text" id="dwm-end-time" placeholder="e.g. 11:00 PM" /> <input type="text" id="dwm-end-time" placeholder="e.g. 11:00 PM" />
</div> </div>
<div id="dwm-end-sun" style="display:none">
<div style="display:flex;align-items:center;gap:6px">
<input type="number" id="dwm-end-offset" placeholder="0" style="width:70px" />
<span style="font-size:0.8rem;color:#9ca3af">min (+ after, before)</span>
</div>
<div id="dwm-end-preview" style="font-size:0.78rem;color:#4ade80;margin-top:4px"></div>
</div>
</div>
</div> </div>
<div class="flex-row"> <div class="flex-row">
<div class="form-group" style="flex:1"> <div class="form-group" style="flex:1">
@@ -343,10 +394,29 @@
</div> </div>
<div id="dwm-countdown-fields" style="display:none"> <div id="dwm-countdown-fields" style="display:none">
<div class="form-group">
<label>Condition</label>
<select id="dwm-countdown-action">
<option value="on_to_off">If device turns ON → auto-OFF after duration</option>
<option value="off_to_on">If device turns OFF → auto-ON after duration</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label>Countdown Duration (minutes)</label> <label>Countdown Duration (minutes)</label>
<input type="number" id="dwm-countdown-mins" min="1" max="1440" placeholder="60" /> <input type="number" id="dwm-countdown-mins" min="1" max="1440" placeholder="60" />
</div> </div>
<div class="flex-row">
<div class="form-group" style="flex:1">
<label>Active Window Start</label>
<input type="text" id="dwm-countdown-window-start" placeholder="e.g. 9:00 AM" />
</div>
<div class="form-group" style="flex:1">
<label>Active Window End</label>
<input type="text" id="dwm-countdown-window-end" placeholder="e.g. 4:00 AM" />
<div style="font-size:0.75rem;color:var(--muted);margin-top:3px">End before start = next day</div>
</div>
</div>
<div style="font-size:0.75rem;color:var(--muted);margin-bottom:8px">Leave window blank to run at any time.</div>
</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"> <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">
@@ -12,6 +12,7 @@ let _wemoRules = null; // { rules, ruleDevices, targets } for selecte
let _editingDwmId = null; // null = create, string = update let _editingDwmId = null; // null = create, string = update
let _selectedDwmDays = new Set(); let _selectedDwmDays = new Set();
let _pendingLocation = null; // { lat, lng, label } let _pendingLocation = null; // { lat, lng, label }
let _todaySunTimes = null; // { sunrise, sunset } seconds from midnight
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tabs // Tabs
@@ -195,7 +196,11 @@ function dwmRuleSummary(r) {
} }
if (r.type === 'Countdown') { if (r.type === 'Countdown') {
const mins = r.countdownTime ? Math.round(r.countdownTime / 60) : null; const mins = r.countdownTime ? Math.round(r.countdownTime / 60) : null;
return mins ? `${mins} min auto-off` : ''; const cond = r.countdownAction === 'off_to_on' ? 'OFF→ON' : 'ON→OFF';
const win = (r.windowStart >= 0 && r.windowEnd >= 0)
? ` · ${secsToHHMM(r.windowStart)}${secsToHHMM(r.windowEnd)}`
: (r.windowStart >= 0 ? ` · from ${secsToHHMM(r.windowStart)}` : '');
return mins ? `${mins} min · ${cond}${win}` : '—';
} }
const days = dayLabel(r.days); const days = dayLabel(r.days);
const devs = (r.targetDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || 'no targets'; const devs = (r.targetDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || 'no targets';
@@ -273,6 +278,139 @@ function deleteDwmRule(id) {
setTimeout(() => row.remove(), 5000); setTimeout(() => row.remove(), 5000);
} }
// ── Export ────────────────────────────────────────────────────────────────────
document.getElementById('btn-export-dwm').addEventListener('click', async () => {
try {
const rules = await homebridge.request('/rules/export');
if (!rules || !rules.length) { showStatus('dwm-rules-status', 'No rules to export.', 'warn'); return; }
const blob = new Blob([JSON.stringify(rules, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const date = new Date().toISOString().slice(0, 10);
a.href = url;
a.download = `dwm-rules-${date}.json`;
a.click();
URL.revokeObjectURL(url);
showStatus('dwm-rules-status', `Exported ${rules.length} rule${rules.length !== 1 ? 's' : ''}.`, 'success');
} catch (e) {
showStatus('dwm-rules-status', 'Export failed: ' + e.message, 'error');
}
});
// ── Import ────────────────────────────────────────────────────────────────────
let _importRules = []; // parsed rules waiting for confirmation
document.getElementById('btn-import-dwm').addEventListener('click', () => {
document.getElementById('dwm-import-file').value = ''; // reset so same file can be re-selected
document.getElementById('dwm-import-file').click();
});
document.getElementById('dwm-import-file').addEventListener('change', (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target.result);
const rules = Array.isArray(parsed) ? parsed : parsed.rules ?? [];
if (!rules.length) throw new Error('No rules found in file');
_importRules = rules;
document.getElementById('dwm-import-title').textContent =
`Import ${rules.length} rule${rules.length !== 1 ? 's' : ''} from "${file.name}"`;
// Build preview list
const listEl = document.getElementById('dwm-import-list');
listEl.innerHTML = rules.map((r) =>
`<div style="padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.06)">` +
`<span style="color:#e2e8f0">${esc(r.name ?? '(unnamed)')}</span> ` +
`<span style="color:#6b7280;font-size:0.75rem">${esc(r.type ?? '')}</span></div>`
).join('');
document.getElementById('dwm-import-status').textContent = '';
document.getElementById('dwm-import-panel').style.display = '';
document.getElementById('btn-import-confirm').disabled = false;
} catch (err) {
showStatus('dwm-rules-status', 'Import failed: ' + err.message, 'error');
}
};
reader.readAsText(file);
});
document.getElementById('btn-import-cancel').addEventListener('click', () => {
document.getElementById('dwm-import-panel').style.display = 'none';
_importRules = [];
});
document.getElementById('btn-import-confirm').addEventListener('click', async () => {
if (!_importRules.length) return;
const mode = document.querySelector('input[name="dwm-import-mode"]:checked')?.value ?? 'merge';
const statusEl = document.getElementById('dwm-import-status');
const btn = document.getElementById('btn-import-confirm');
btn.disabled = true;
statusEl.style.color = '#9ca3af';
statusEl.textContent = 'Importing…';
try {
const res = await homebridge.request('/rules/import', { rules: _importRules, mode });
document.getElementById('dwm-import-panel').style.display = 'none';
_importRules = [];
await loadDwmRules();
const msg = mode === 'replace'
? `Replaced all rules — imported ${res.imported}.`
: `Imported ${res.imported} rule${res.imported !== 1 ? 's' : ''}${res.skipped ? `, skipped ${res.skipped} (name already exists)` : ''}.`;
showStatus('dwm-rules-status', msg, 'success');
} catch (e) {
btn.disabled = false;
statusEl.style.color = '#fca5a5';
statusEl.textContent = 'Import failed: ' + e.message;
}
});
// ── Sun-time helpers ─────────────────────────────────────────────────────────
function secsToAmPm(secs) {
if (secs == null || secs < 0) return '—';
const h24 = Math.floor(secs / 3600) % 24;
const m = Math.floor((secs % 3600) / 60);
const ap = h24 < 12 ? 'AM' : 'PM';
const h12 = h24 % 12 || 12;
return `${h12}:${String(m).padStart(2, '0')} ${ap}`;
}
function updateSunTypeVisibility() {
for (const side of ['start', 'end']) {
const type = document.getElementById(`dwm-${side}-type`)?.value ?? 'fixed';
const isSun = type === 'sunrise' || type === 'sunset';
document.getElementById(`dwm-${side}-fixed`).style.display = isSun ? 'none' : '';
document.getElementById(`dwm-${side}-sun`).style.display = isSun ? '' : 'none';
updateSunPreview(side);
}
}
function updateSunPreview(side) {
const previewEl = document.getElementById(`dwm-${side}-preview`);
if (!previewEl) return;
const type = document.getElementById(`dwm-${side}-type`)?.value;
const offset = parseInt(document.getElementById(`dwm-${side}-offset`)?.value ?? '0', 10) || 0;
if (!_todaySunTimes || (type !== 'sunrise' && type !== 'sunset')) { previewEl.textContent = ''; return; }
const baseSecs = type === 'sunrise' ? _todaySunTimes.sunrise : _todaySunTimes.sunset;
if (baseSecs == null) { previewEl.textContent = 'No sun data for location'; return; }
const fireSecs = baseSecs + offset * 60;
const baseStr = secsToAmPm(baseSecs);
const fireStr = secsToAmPm(fireSecs);
const offStr = offset !== 0 ? ` (${offset > 0 ? '+' : ''}${offset} min)` : '';
previewEl.textContent = `Today's ${type}: ${baseStr} → fires ${fireStr}${offStr}`;
}
// Wire up type dropdowns and offset inputs for live preview
['start', 'end'].forEach((side) => {
document.getElementById(`dwm-${side}-type`)?.addEventListener('change', updateSunTypeVisibility);
document.getElementById(`dwm-${side}-offset`)?.addEventListener('input', () => updateSunPreview(side));
});
document.getElementById('btn-add-dwm').addEventListener('click', () => openDwmEdit(null)); document.getElementById('btn-add-dwm').addEventListener('click', () => openDwmEdit(null));
// ── DWM Inline Form ─────────────────────────────────────────────────────────── // ── DWM Inline Form ───────────────────────────────────────────────────────────
@@ -298,12 +436,19 @@ function openDwmEdit(id) {
document.getElementById('dwm-name').value = r.name ?? ''; document.getElementById('dwm-name').value = r.name ?? '';
document.getElementById('dwm-type').value = r.type ?? 'Schedule'; document.getElementById('dwm-type').value = r.type ?? 'Schedule';
document.getElementById('dwm-enabled').checked = r.enabled !== false; document.getElementById('dwm-enabled').checked = r.enabled !== false;
document.getElementById('dwm-start-time').value = secsToHHMM(r.startTime); document.getElementById('dwm-start-type').value = r.startType || 'fixed';
document.getElementById('dwm-end-time').value = secsToHHMM(r.endTime); document.getElementById('dwm-start-offset').value = String(r.startOffset ?? 0);
document.getElementById('dwm-start-time').value = (r.startType === 'fixed' && r.startTime >= 0) ? secsToHHMM(r.startTime) : '';
document.getElementById('dwm-end-type').value = r.endType || 'fixed';
document.getElementById('dwm-end-offset').value = String(r.endOffset ?? 0);
document.getElementById('dwm-end-time').value = (r.endType === 'fixed' && r.endTime > 0) ? secsToHHMM(r.endTime) : '';
document.getElementById('dwm-start-action').value = String(r.startAction ?? 1); document.getElementById('dwm-start-action').value = String(r.startAction ?? 1);
document.getElementById('dwm-end-action').value = String(r.endAction ?? -1); document.getElementById('dwm-end-action').value = String(r.endAction ?? -1);
document.getElementById('dwm-countdown-mins').value = document.getElementById('dwm-countdown-mins').value =
r.countdownTime ? String(Math.round(r.countdownTime / 60)) : ''; r.countdownTime ? String(Math.round(r.countdownTime / 60)) : '';
document.getElementById('dwm-countdown-action').value = r.countdownAction ?? 'on_to_off';
document.getElementById('dwm-countdown-window-start').value = r.windowStart >= 0 ? secsToHHMM(r.windowStart) : '';
document.getElementById('dwm-countdown-window-end').value = r.windowEnd >= 0 ? secsToHHMM(r.windowEnd) : '';
_selectedDwmDays = new Set((r.days ?? []).map(Number)); _selectedDwmDays = new Set((r.days ?? []).map(Number));
@@ -328,11 +473,18 @@ function openDwmEdit(id) {
document.getElementById('dwm-name').value = ''; document.getElementById('dwm-name').value = '';
document.getElementById('dwm-type').value = 'Schedule'; document.getElementById('dwm-type').value = 'Schedule';
document.getElementById('dwm-enabled').checked = true; document.getElementById('dwm-enabled').checked = true;
document.getElementById('dwm-start-type').value = 'fixed';
document.getElementById('dwm-start-offset').value = '0';
document.getElementById('dwm-start-time').value = ''; document.getElementById('dwm-start-time').value = '';
document.getElementById('dwm-end-type').value = 'fixed';
document.getElementById('dwm-end-offset').value = '0';
document.getElementById('dwm-end-time').value = ''; document.getElementById('dwm-end-time').value = '';
document.getElementById('dwm-start-action').value = '1'; document.getElementById('dwm-start-action').value = '1';
document.getElementById('dwm-end-action').value = '-1'; document.getElementById('dwm-end-action').value = '-1';
document.getElementById('dwm-countdown-mins').value = ''; document.getElementById('dwm-countdown-mins').value = '';
document.getElementById('dwm-countdown-action').value = 'on_to_off';
document.getElementById('dwm-countdown-window-start').value = '';
document.getElementById('dwm-countdown-window-end').value = '';
document.getElementById('dwm-trigger-src').value = ''; document.getElementById('dwm-trigger-src').value = '';
document.getElementById('dwm-trigger-event').value = 'any'; document.getElementById('dwm-trigger-event').value = 'any';
document.getElementById('dwm-trigger-action').value = 'on'; document.getElementById('dwm-trigger-action').value = 'on';
@@ -342,6 +494,7 @@ function openDwmEdit(id) {
updateDwmDayButtons(); updateDwmDayButtons();
updateDwmTypeFields(); updateDwmTypeFields();
updateSunTypeVisibility();
document.getElementById('dwm-list-view').style.display = 'none'; document.getElementById('dwm-list-view').style.display = 'none';
document.getElementById('dwm-form-panel').style.display = ''; document.getElementById('dwm-form-panel').style.display = '';
window.scrollTo(0, 0); window.scrollTo(0, 0);
@@ -462,11 +615,36 @@ document.getElementById('dwm-form-save-btn').addEventListener('click', async ()
const mins = Number(document.getElementById('dwm-countdown-mins').value); const mins = Number(document.getElementById('dwm-countdown-mins').value);
if (!mins || mins < 1) { showModalError('Enter countdown duration in minutes'); return; } if (!mins || mins < 1) { showModalError('Enter countdown duration in minutes'); return; }
rule.countdownTime = mins * 60; rule.countdownTime = mins * 60;
rule.countdownAction = document.getElementById('dwm-countdown-action').value;
const winStart = hhmmToSecs(document.getElementById('dwm-countdown-window-start').value);
const winEnd = hhmmToSecs(document.getElementById('dwm-countdown-window-end').value);
rule.windowStart = winStart >= 0 ? winStart : -1;
rule.windowEnd = winEnd >= 0 ? winEnd : -1;
} else { } else {
const startSecs = hhmmToSecs(document.getElementById('dwm-start-time').value); const startType = document.getElementById('dwm-start-type').value;
if (startSecs < 0) { showModalError('Enter a valid start time (HH:MM)'); return; } const startOffset = parseInt(document.getElementById('dwm-start-offset').value ?? '0', 10) || 0;
const endType = document.getElementById('dwm-end-type').value;
const endOffset = parseInt(document.getElementById('dwm-end-offset').value ?? '0', 10) || 0;
let startSecs;
if (startType === 'sunrise') { startSecs = -2; }
else if (startType === 'sunset') { startSecs = -3; }
else {
startSecs = hhmmToSecs(document.getElementById('dwm-start-time').value);
if (startSecs < 0) { showModalError('Enter a valid start time (e.g. 8:30 PM)'); return; }
}
let endSecs;
if (endType === 'sunrise') { endSecs = -2; }
else if (endType === 'sunset') { endSecs = -3; }
else { endSecs = hhmmToSecs(document.getElementById('dwm-end-time').value); }
rule.startTime = startSecs; rule.startTime = startSecs;
rule.endTime = hhmmToSecs(document.getElementById('dwm-end-time').value); rule.startType = startType;
rule.startOffset = startOffset;
rule.endTime = endSecs;
rule.endType = endType;
rule.endOffset = endOffset;
rule.startAction = Number(document.getElementById('dwm-start-action').value); rule.startAction = Number(document.getElementById('dwm-start-action').value);
rule.endAction = Number(document.getElementById('dwm-end-action').value); rule.endAction = Number(document.getElementById('dwm-end-action').value);
} }
@@ -764,5 +942,7 @@ document.querySelectorAll('.tab-btn').forEach((btn) => {
await loadDwmRules(); await loadDwmRules();
await loadLocation(); await loadLocation();
refreshWemoDeviceSelect(); refreshWemoDeviceSelect();
startHeartbeatPolling(); // start immediately (DWM tab is not default, but still useful) startHeartbeatPolling();
// Fetch today's sun times in background — used by rule editor previews
homebridge.request('/sun-times').then((st) => { _todaySunTimes = st; }).catch(() => {});
})(); })();
@@ -28,6 +28,7 @@ const path = require('path');
const DwmStore = require('../lib/store'); const DwmStore = require('../lib/store');
const wemoClient = require('../lib/wemo-client'); const wemoClient = require('../lib/wemo-client');
const axios = require('axios'); const axios = require('axios');
const { sunTimes: calcSunTimes } = require('../lib/sun');
class DibbyWemoUiServer extends HomebridgePluginUiServer { class DibbyWemoUiServer extends HomebridgePluginUiServer {
constructor() { constructor() {
@@ -44,8 +45,8 @@ class DibbyWemoUiServer extends HomebridgePluginUiServer {
this.onRequest('/devices/discover', async ({ timeout } = {}) => { this.onRequest('/devices/discover', async ({ timeout } = {}) => {
const ms = typeof timeout === 'number' ? timeout : 10_000; const ms = typeof timeout === 'number' ? timeout : 10_000;
const devices = await wemoClient.discoverDevices(ms); const devices = await wemoClient.discoverDevices(ms);
// Persist updated list // Merge into cached list — previously known devices stay even if not found this scan
this._store.saveDevices(devices.map((d) => ({ this._store.mergeDevices(devices.map((d) => ({
host: d.host, host: d.host,
port: d.port, port: d.port,
udn: d.udn ?? `${d.host}:${d.port}`, udn: d.udn ?? `${d.host}:${d.port}`,
@@ -53,7 +54,8 @@ class DibbyWemoUiServer extends HomebridgePluginUiServer {
productModel: d.productModel ?? 'Wemo Device', productModel: d.productModel ?? 'Wemo Device',
firmwareVersion: d.firmwareVersion ?? null, firmwareVersion: d.firmwareVersion ?? null,
}))); })));
return devices; // Return the full merged list so the UI shows all known devices
return this._store.getDevices();
}); });
this.onRequest('/devices/state', async ({ host, port }) => { this.onRequest('/devices/state', async ({ host, port }) => {
@@ -83,6 +85,35 @@ class DibbyWemoUiServer extends HomebridgePluginUiServer {
return { ok: true }; return { ok: true };
}); });
this.onRequest('/rules/export', async () => {
return this._store.getDwmRules();
});
this.onRequest('/rules/import', async ({ rules, mode }) => {
if (!Array.isArray(rules) || rules.length === 0) throw new Error('No valid rules found in import data');
if (mode === 'replace') {
for (const r of this._store.getDwmRules()) this._store.deleteDwmRule(r.id);
}
const existing = this._store.getDwmRules();
const existingNames = new Set(existing.map((r) => (r.name ?? '').toLowerCase()));
let imported = 0, skipped = 0;
for (const rule of rules) {
// Strip old identity fields — store will assign fresh id + timestamps
const { id: _id, createdAt: _ca, updatedAt: _ua, ...ruleData } = rule;
if (mode === 'merge' && existingNames.has((ruleData.name ?? '').toLowerCase())) {
skipped++;
continue;
}
this._store.createDwmRule(ruleData);
imported++;
}
return { ok: true, imported, skipped };
});
// ── Scheduler heartbeat ─────────────────────────────────────────────────── // ── Scheduler heartbeat ───────────────────────────────────────────────────
this.onRequest('/scheduler/status', async () => { this.onRequest('/scheduler/status', async () => {
const hb = this._store.getHeartbeat(); const hb = this._store.getHeartbeat();
@@ -122,6 +153,13 @@ class DibbyWemoUiServer extends HomebridgePluginUiServer {
return this._store.getLocation(); return this._store.getLocation();
}); });
this.onRequest('/sun-times', async () => {
const loc = this._store.getLocation();
if (!loc?.lat || !loc?.lng) return { sunrise: null, sunset: null };
try { return calcSunTimes(loc.lat, loc.lng); }
catch { return { sunrise: null, sunset: null }; }
});
this.onRequest('/location/set', async (loc) => { this.onRequest('/location/set', async (loc) => {
this._store.setLocation(loc); this._store.setLocation(loc);
return { ok: true }; return { ok: true };
+18 -7
View File
@@ -114,25 +114,36 @@ class WemoPlatform {
this.log.info(`Found ${discovered.length} Wemo device(s)`); this.log.info(`Found ${discovered.length} Wemo device(s)`);
// Save discovered device list for the custom UI // Merge discovered devices into the cached list — keep offline devices too
this._store.saveDevices(discovered.map((d) => ({ const freshForStore = discovered.map((d) => ({
host: d.host, host: d.host,
port: d.port, port: d.port,
udn: d.udn ?? `${d.host}:${d.port}`, udn: d.udn ?? `${d.host}:${d.port}`,
friendlyName: d.friendlyName ?? d.host, friendlyName: d.friendlyName ?? d.host,
productModel: d.productModel ?? 'Wemo Device', productModel: d.productModel ?? 'Wemo Device',
firmwareVersion: d.firmwareVersion ?? null, firmwareVersion: d.firmwareVersion ?? null,
}))); }));
const allKnown = this._store.mergeDevices(freshForStore);
// Register newly discovered devices in HomeKit
for (const device of discovered) { for (const device of discovered) {
this._registerDevice(device, pollInterval); this._registerDevice(device, pollInterval);
} }
// Remove stale accessories (devices no longer discovered) // Register previously cached devices that weren't discovered (may be offline)
const activeUUIDs = new Set(discovered.map((d) => this._uuidForDevice(d))); // so HomeKit still knows about them
const discoveredUDNs = new Set(discovered.map((d) => d.udn ?? `${d.host}:${d.port}`));
for (const cached of allKnown) {
if (!discoveredUDNs.has(cached.udn)) {
this.log.info(`Device offline/not found during discovery, keeping cached: ${cached.friendlyName}`);
this._registerDevice(cached, pollInterval);
}
}
// Remove orphaned accessories — those with no device context at all
for (const [uuid, acc] of this._accessories) { for (const [uuid, acc] of this._accessories) {
if (!activeUUIDs.has(uuid)) { if (!acc.context?.device?.host) {
this.log.info('Removing stale accessory: ' + acc.displayName); this.log.info('Removing orphaned accessory (no device context): ' + acc.displayName);
this._handlers.get(uuid)?.stopPolling(); this._handlers.get(uuid)?.stopPolling();
this._handlers.delete(uuid); this._handlers.delete(uuid);
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [acc]); this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [acc]);
+120 -42
View File
@@ -19,11 +19,37 @@
* await scheduler.start(); * await scheduler.start();
*/ */
const { sunTimes: calcSunTimes } = require('./sun');
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
/** Wemo DayID: 1=Mon … 7=Sun. JS getDay(): 0=Sun … 6=Sat. */ /** Wemo DayID: 1=Mon … 7=Sun. JS getDay(): 0=Sun … 6=Sat. */
function jsToWemoDayId(jsDay) { return jsDay === 0 ? 7 : jsDay; } function jsToWemoDayId(jsDay) { return jsDay === 0 ? 7 : jsDay; }
/**
* Resolve a stored startTime/endTime value to actual seconds-from-midnight.
* -2 = sunrise sentinel, -3 = sunset sentinel.
* offsetMins is added to the sun time (negative = before).
* Returns null if unresolvable (no location, polar day/night, or no time set).
*/
function resolveSecs(rawSecs, type, offsetMins, todaySun) {
const offsetSecs = (offsetMins ?? 0) * 60;
if (type === 'sunset' || rawSecs === -3) {
return todaySun?.sunset != null ? todaySun.sunset + offsetSecs : null;
}
if (type === 'sunrise' || rawSecs === -2) {
return todaySun?.sunrise != null ? todaySun.sunrise + offsetSecs : null;
}
return rawSecs >= 0 ? rawSecs : null;
}
/** Compute today's sunrise/sunset from the store's saved location. Returns null if not set. */
function getTodaySun(store) {
const loc = store.getLocation?.();
if (!loc?.lat || !loc?.lng) return null;
try { return calcSunTimes(loc.lat, loc.lng); } catch { return null; }
}
function secondsFromMidnight(date) { function secondsFromMidnight(date) {
return date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds(); return date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds();
} }
@@ -74,6 +100,8 @@ class DwmScheduler {
this._onHealth = null; // ({host, port, name, online, msg}) health event callback this._onHealth = null; // ({host, port, name, online, msg}) health event callback
this._deviceHealth = new Map(); // 'host:port' → true | false this._deviceHealth = new Map(); // 'host:port' → true | false
this._triggerStates = new Map(); // 'host:port' → last known boolean state (for Trigger rules) this._triggerStates = new Map(); // 'host:port' → last known boolean state (for Trigger rules)
this._countdownStates = new Map(); // 'host:port' → last known boolean state (for Countdown rules)
this._countdownTimers = new Map(); // 'deviceKey-ruleId' → {timer, wantOn}
this._healthTimer = null; this._healthTimer = null;
this._startedAt = null; this._startedAt = null;
} }
@@ -150,6 +178,7 @@ class DwmScheduler {
_loadSchedule() { _loadSchedule() {
const schedule = []; const schedule = [];
const rules = this._store.getDwmRules(); const rules = this._store.getDwmRules();
const todaySun = getTodaySun(this._store);
for (const rule of rules) { for (const rule of rules) {
if (!rule.enabled) continue; if (!rule.enabled) continue;
@@ -159,9 +188,11 @@ class DwmScheduler {
// Away Mode // Away Mode
if (rule.type === 'Away') { if (rule.type === 'Away') {
const startSecs = Number(rule.startTime ?? -1); const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset, todaySun);
const endSecs = Number(rule.endTime ?? -1); const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset, todaySun);
if (startSecs < 0) continue; if (startSecs === null) continue;
const awayStartAction = Number(rule.startAction ?? 1);
const awayEndAction = Number(rule.endAction ?? 0);
for (const dayId of (rule.days ?? [])) { for (const dayId of (rule.days ?? [])) {
const td0 = rule.targetDevices?.[0]; const td0 = rule.targetDevices?.[0];
@@ -169,50 +200,29 @@ class DwmScheduler {
ruleId: rule.id, ruleName: rule.name, ruleId: rule.id, ruleName: rule.name,
targetHost: td0?.host ?? '', targetPort: td0?.port ?? 0, targetHost: td0?.host ?? '', targetPort: td0?.port ?? 0,
dayId: Number(dayId), targetSecs: startSecs, dayId: Number(dayId), targetSecs: startSecs,
action: 1, isAwayStart: true, action: awayStartAction, isAwayStart: true,
}); });
if (endSecs >= 0) { if (endSecs !== null && endSecs >= 0) {
schedule.push({ schedule.push({
ruleId: rule.id + '-away-end', ruleName: rule.name, ruleId: rule.id + '-away-end', ruleName: rule.name,
targetHost: td0?.host ?? '', targetPort: td0?.port ?? 0, targetHost: td0?.host ?? '', targetPort: td0?.port ?? 0,
dayId: Number(dayId), targetSecs: endSecs, dayId: Number(dayId), targetSecs: endSecs,
action: 0, isAwayEnd: true, awayRuleId: rule.id, action: awayEndAction, isAwayEnd: true, awayRuleId: rule.id,
}); });
} }
} }
continue; continue;
} }
// Countdown with active window // Countdown — handled entirely by the health-monitor state-change poll
if (rule.type === 'Countdown') { if (rule.type === 'Countdown') continue;
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 // Schedule / time-based
const startSecs = Number(rule.startTime ?? -1); const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset, todaySun);
const endSecs = Number(rule.endTime ?? -1); const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset, todaySun);
const startAction = Number(rule.startAction ?? 1); const startAction = Number(rule.startAction ?? 1);
const endAction = Number(rule.endAction ?? -1); const endAction = Number(rule.endAction ?? -1);
if (startSecs < 0) continue; if (startSecs === null) continue;
for (const dayId of (rule.days ?? [])) { for (const dayId of (rule.days ?? [])) {
for (const td of (rule.targetDevices ?? [])) { for (const td of (rule.targetDevices ?? [])) {
@@ -222,7 +232,7 @@ class DwmScheduler {
targetHost: td.host, targetPort: td.port, targetHost: td.host, targetPort: td.port,
dayId: Number(dayId), targetSecs: startSecs, action: startAction }); dayId: Number(dayId), targetSecs: startSecs, action: startAction });
} }
if (endSecs > 0 && endAction >= 0) { if (endSecs !== null && endSecs > 0 && endAction >= 0) {
schedule.push({ ruleId: rule.id, ruleName: rule.name, schedule.push({ ruleId: rule.id, ruleName: rule.name,
targetHost: td.host, targetPort: td.port, targetHost: td.host, targetPort: td.port,
dayId: Number(dayId), targetSecs: endSecs, action: endAction }); dayId: Number(dayId), targetSecs: endSecs, action: endAction });
@@ -243,17 +253,18 @@ class DwmScheduler {
const nowSecs = secondsFromMidnight(now); const nowSecs = secondsFromMidnight(now);
const todayId = jsToWemoDayId(now.getDay()); const todayId = jsToWemoDayId(now.getDay());
const rules = this._store.getDwmRules(); const rules = this._store.getDwmRules();
const todaySun = getTodaySun(this._store);
for (const rule of rules) { for (const rule of rules) {
if (!rule.enabled || rule.type !== 'Away') continue; if (!rule.enabled || rule.type !== 'Away') continue;
if (this._awayLoops.has(rule.id)) continue; if (this._awayLoops.has(rule.id)) continue;
const startSecs = Number(rule.startTime ?? -1); const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset, todaySun);
const endSecs = Number(rule.endTime ?? -1); const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset, todaySun);
if (startSecs < 0) continue; if (startSecs === null) continue;
if (!(rule.days ?? []).includes(todayId)) continue; if (!(rule.days ?? []).includes(todayId)) continue;
const inWindow = endSecs >= 0 const inWindow = endSecs !== null && endSecs >= 0
? (startSecs <= endSecs ? (nowSecs >= startSecs && nowSecs < endSecs) ? (startSecs <= endSecs ? (nowSecs >= startSecs && nowSecs < endSecs)
: (nowSecs >= startSecs || nowSecs < endSecs)) : (nowSecs >= startSecs || nowSecs < endSecs))
: nowSecs >= startSecs; : nowSecs >= startSecs;
@@ -269,7 +280,9 @@ class DwmScheduler {
const devices = (rule.targetDevices ?? []).filter(td => td.host && td.port); const devices = (rule.targetDevices ?? []).filter(td => td.host && td.port);
if (!devices.length) return; if (!devices.length) return;
const loop = { rule, devices, endSecs: Number(rule.endTime ?? -1), timer: null, isOn: false }; const todaySun = getTodaySun(this._store);
const resolvedEnd = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset, todaySun);
const loop = { rule, devices, endSecs: resolvedEnd ?? -1, timer: null, isOn: false };
this._awayLoops.set(rule.id, loop); this._awayLoops.set(rule.id, loop);
this._awayStep(rule.id, true); this._awayStep(rule.id, true);
} }
@@ -314,12 +327,14 @@ class DwmScheduler {
if (loop.timer) clearTimeout(loop.timer); if (loop.timer) clearTimeout(loop.timer);
this._awayLoops.delete(ruleId); this._awayLoops.delete(ruleId);
if (forceOff) { if (forceOff) {
const endAction = Number(loop.rule.endAction ?? 0);
const turnOn = endAction === 1;
for (const td of loop.devices) { for (const td of loop.devices) {
this._wemo.setBinaryState(td.host, td.port, false).catch(() => {}); this._wemo.setBinaryState(td.host, td.port, turnOn).catch(() => {});
} }
this._emit({ success: true, this._emit({ success: true,
msg: `"${loop.rule.name}" Away Mode window ended — all devices OFF`, msg: `"${loop.rule.name}" Away Mode window ended — all devices ${turnOn ? 'ON' : 'OFF'}`,
entry: { action: 0 } }); entry: { action: endAction } });
} }
} }
@@ -365,6 +380,8 @@ class DwmScheduler {
for (const t of this._timers) clearTimeout(t); for (const t of this._timers) clearTimeout(t);
this._timers = []; this._timers = [];
if (this._tickTimer) { clearTimeout(this._tickTimer); this._tickTimer = null; } if (this._tickTimer) { clearTimeout(this._tickTimer); this._tickTimer = null; }
for (const { timer } of this._countdownTimers.values()) clearTimeout(timer);
this._countdownTimers.clear();
} }
_scheduleUpcoming() { _scheduleUpcoming() {
@@ -501,6 +518,7 @@ class DwmScheduler {
const allRules = this._store.getDwmRules(); const allRules = this._store.getDwmRules();
const alwaysOnSet = new Set(); // keys with an active AlwaysOn rule const alwaysOnSet = new Set(); // keys with an active AlwaysOn rule
const triggerSrcSet = new Set(); // keys that are trigger source devices const triggerSrcSet = new Set(); // keys that are trigger source devices
const countdownDevMap = new Map(); // deviceKey → [{rule, td}]
const addDev = (td) => { const addDev = (td) => {
if (!td?.host || !td?.port) return; if (!td?.host || !td?.port) return;
@@ -520,7 +538,12 @@ class DwmScheduler {
} }
for (const td of (rule.targetDevices ?? [])) { for (const td of (rule.targetDevices ?? [])) {
const k = addDev(td); const k = addDev(td);
if (k && rule.type === 'AlwaysOn') alwaysOnSet.add(k); if (!k) continue;
if (rule.type === 'AlwaysOn') alwaysOnSet.add(k);
if (rule.type === 'Countdown') {
if (!countdownDevMap.has(k)) countdownDevMap.set(k, []);
countdownDevMap.get(k).push({ rule, td });
}
} }
} }
@@ -564,6 +587,61 @@ class DwmScheduler {
} }
} }
// ── Countdown — fire only when state matches condition and within window ──
if (countdownDevMap.has(key)) {
const prevState = this._countdownStates.get(key);
this._countdownStates.set(key, isOn);
if (prevState !== undefined && prevState !== isOn) {
const nowSecs = secondsFromMidnight(new Date());
for (const { rule, td } of countdownDevMap.get(key)) {
const condition = rule.countdownAction ?? 'on_to_off';
const triggered = condition === 'on_to_off' ? isOn : !isOn;
if (!triggered) continue; // state doesn't match this rule's condition
// Check active window (if defined)
const winStart = Number(rule.windowStart ?? -1);
const winEnd = Number(rule.windowEnd ?? -1);
if (winStart >= 0 && winEnd >= 0) {
const crossesMidnight = winEnd < winStart;
const inWindow = crossesMidnight
? (nowSecs >= winStart || nowSecs <= winEnd)
: (nowSecs >= winStart && nowSecs <= winEnd);
if (!inWindow) continue; // outside active window
} else if (winStart >= 0) {
if (nowSecs < winStart) continue;
}
const timerKey = `${key}-${rule.id}`;
// Cancel any pending timer for this device+rule
const existing = this._countdownTimers.get(timerKey);
if (existing) { clearTimeout(existing.timer); this._countdownTimers.delete(timerKey); }
const wantOn = condition === 'off_to_on'; // on_to_off → turn OFF; off_to_on → turn ON
const durationMs = (Number(rule.countdownTime) || 60) * 1000;
const label = wantOn ? 'ON' : 'OFF';
const mins = Math.round(durationMs / 60000);
this._emit({ success: true,
msg: `"${rule.name}" countdown started — will turn ${label} in ${mins} min (${td.host})`,
entry: { action: wantOn ? 1 : 0 } });
const timer = setTimeout(async () => {
this._countdownTimers.delete(timerKey);
try {
await this._wemo.setBinaryState(td.host, td.port, wantOn);
this._emit({ success: true,
msg: `"${rule.name}" countdown elapsed → ${label} (${td.host}) ✓`,
entry: { action: wantOn ? 1 : 0 } });
} catch (e2) {
this._emit({ success: false,
msg: `"${rule.name}" countdown elapsed → ${label} FAILED: ${e2.message}`,
entry: { action: wantOn ? 1 : 0 } });
}
}, durationMs);
this._countdownTimers.set(timerKey, { timer, wantOn });
}
}
}
} catch (e) { } catch (e) {
this._deviceHealth.set(key, false); this._deviceHealth.set(key, false);
if (wasOnline !== false) { if (wasOnline !== false) {
+27
View File
@@ -56,6 +56,33 @@ class DwmStore {
getDeviceGroups() { return this._load().deviceGroups ?? []; } getDeviceGroups() { return this._load().deviceGroups ?? []; }
saveDeviceGroups(groups) { const d = this._load(); d.deviceGroups = groups; this._save(d); } saveDeviceGroups(groups) { const d = this._load(); d.deviceGroups = groups; this._save(d); }
/**
* Merge freshly discovered devices into the cached list.
* - Existing devices are updated with fresh data (host/port/name/firmware).
* - Previously cached devices NOT in the new scan are kept as-is (offline ≠ removed).
* - Newly found devices are appended.
* Returns the merged list.
*/
mergeDevices(fresh) {
const d = this._load();
const cached = d.devices ?? [];
const byUdn = new Map(cached.map((dev) => [dev.udn, dev]));
for (const f of fresh) {
const udn = f.udn ?? `${f.host}:${f.port}`;
if (byUdn.has(udn)) {
// Update existing entry with latest network data
byUdn.set(udn, { ...byUdn.get(udn), ...f, udn });
} else {
byUdn.set(udn, { ...f, udn });
}
}
d.devices = Array.from(byUdn.values());
this._save(d);
return d.devices;
}
// ── Disabled-rule backups ───────────────────────────────────────────────── // ── Disabled-rule backups ─────────────────────────────────────────────────
getDisabledRules() { return this._load().disabledRules ?? {}; } getDisabledRules() { return this._load().disabledRules ?? {}; }
@@ -122,6 +122,108 @@ async function setBinaryState(host, port, on) {
await soapWithFallback(host, port, BE_URL, BE_SVC, 'SetBinaryState', { BinaryState: on ? '1' : '0' }); await soapWithFallback(host, port, BE_URL, BE_SVC, 'SetBinaryState', { BinaryState: on ? '1' : '0' });
} }
// ---------------------------------------------------------------------------
// Dimmer control
// ---------------------------------------------------------------------------
async function getBrightness(host, port) {
try {
const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetBinaryState');
const raw = String(res['BinaryState'] ?? '0');
console.log(`[DWM] Full BinaryState response object:`, res);
console.log(`[DWM] Raw BinaryState string: "${raw}"`);
// For dimmers, check if brightness is available as a separate parameter
// This matches the Python pywemo implementation
if (res.brightness !== undefined) {
const brightness = parseInt(res.brightness, 10);
console.log(`[DWM] Brightness from separate parameter: ${brightness}`);
return !isNaN(brightness) ? brightness : null;
}
// Fallback: Check if BinaryState contains brightness info in format: "1|brightness|..."
// Example: "1|50|0" where 50 is the brightness level (0-100)
if (raw.includes('|')) {
const parts = raw.split('|');
console.log(`[DWM] BinaryState parts:`, parts);
if (parts.length >= 2) {
const brightness = parseInt(parts[1], 10);
console.log(`[DWM] Brightness from pipe format: ${brightness}`);
return !isNaN(brightness) ? brightness : null;
}
}
// If device is on but no brightness info, assume 100%
// If device is off, return 0
const isOn = raw === '1' || raw === '8';
console.log(`[DWM] No brightness info, using binary state: ${isOn ? 100 : 0} (raw: "${raw}")`);
return isOn ? 100 : 0;
} catch (err) {
console.log(`[DWM] getBrightness failed: ${err.message}, falling back to binary state`);
// Fallback for non-dimmer devices
const isOn = await getBinaryState(host, port);
return isOn ? 100 : 0;
}
}
async function setBrightness(host, port, brightness) {
// Brightness should be 0-100
const level = Math.max(0, Math.min(100, Math.round(brightness)));
console.log(`[DWM] Setting brightness for ${host}:${port} to ${level}%`);
if (level === 0) {
// Turn off the device
console.log(`[DWM] Brightness 0, turning device off`);
await setBinaryState(host, port, false);
} else {
// For dimmers, use the correct format: BinaryState=1, brightness=level
// This matches the Python pywemo implementation
try {
console.log(`[DWM] Trying dimmer format: BinaryState=1, brightness=${level}`);
await soapWithFallback(host, port, BE_URL, BE_SVC, 'SetBinaryState', {
BinaryState: '1',
brightness: level.toString()
});
console.log(`[DWM] Dimmer brightness set successfully`);
} catch (err) {
console.log(`[DWM] Dimmer format failed: ${err.message}, falling back to on/off`);
// Fallback for non-dimmer devices - just turn on
await setBinaryState(host, port, true);
}
}
}
function isDimmerDevice(deviceInfo) {
if (!deviceInfo) return false;
const { productModel, modelDescription, udn, deviceType } = deviceInfo;
console.log(`[DWM] Checking if device is dimmer:`, {
productModel,
modelDescription,
udn,
deviceType
});
// Check various indicators that this is a dimmer
const isDimmer = (
(productModel && productModel.toLowerCase().includes('dimmer')) ||
(modelDescription && modelDescription.toLowerCase().includes('dimmer')) ||
(udn && udn.toLowerCase().includes('dimmer')) ||
(deviceType && deviceType.toLowerCase().includes('dimmer')) ||
(productModel && productModel.includes('WDS060')) ||
(modelDescription && modelDescription.includes('Dimmer')) ||
(udn && udn.includes('Dimmer-1_0')) ||
(productModel && productModel.includes('WDS')) ||
(modelDescription && modelDescription.toLowerCase().includes('light') && modelDescription.toLowerCase().includes('dim'))
);
console.log(`[DWM] Dimmer detection result: ${isDimmer}`);
return isDimmer;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Device info // Device info
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -478,6 +580,9 @@ function _insertNewRule(db, ruleId, ruleData) {
module.exports = { module.exports = {
getBinaryState, getBinaryState,
setBinaryState, setBinaryState,
getBrightness,
setBrightness,
isDimmerDevice,
getDeviceInfo, getDeviceInfo,
discoverDevices, discoverDevices,
fetchRules, fetchRules,
+996
View File
@@ -0,0 +1,996 @@
{
"name": "homebridge-dibby-wemo",
"version": "file:../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "homebridge-dibby-wemo",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@homebridge/plugin-ui-utils": "^2.2.0",
"adm-zip": "^0.5.14",
"axios": "^1.7.0",
"sql.js": "^1.12.0",
"xml2js": "^0.6.2",
"xmlbuilder2": "^4.0.3"
},
"devDependencies": {
"homebridge": "^1.8.0"
},
"engines": {
"homebridge": ">=1.6.0",
"node": ">=18"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/@homebridge/ciao": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@homebridge/ciao/-/ciao-1.3.5.tgz",
"integrity": "sha512-f7MAw7YuoEYgJEQ1VyRcLHGuVmCpmXi65GVR8CAtPWPqIZf/HFr4vHzVpOfQMpEQw9Pt5uh07guuLt5HE8ruog==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"fast-deep-equal": "^3.1.3",
"source-map-support": "^0.5.21",
"tslib": "^2.8.1"
},
"bin": {
"ciao-bcs": "lib/bonjour-conformance-testing.js"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/@homebridge/dbus-native": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@homebridge/dbus-native/-/dbus-native-0.7.3.tgz",
"integrity": "sha512-21RywQjjJPssTNdz9gZtKquShzlIz8hDQ1SGAYmOWdoRwSsi+znCt10LAjDSXB8AVVMwothTm0KQx2KJSBhLAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"event-stream": "^4.0.1",
"hexy": "^0.3.5",
"long": "^5.3.2",
"minimist": "^1.2.8",
"safe-buffer": "^5.1.2",
"xml2js": "^0.6.2"
},
"bin": {
"dbus2js": "bin/dbus2js.js"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/@homebridge/plugin-ui-utils": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@homebridge/plugin-ui-utils/-/plugin-ui-utils-2.2.2.tgz",
"integrity": "sha512-qXHK3DLBE1/CC5ATxM1w7XJikUfg3cUReU3hcOGhNpelgsYlgJUp303NFZQnvOw6t5/shKm7DvD4Xb8pEPdj2Q==",
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"dev": true,
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/@oozcitak/dom": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz",
"integrity": "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "^2.0.2",
"@oozcitak/url": "^3.0.0",
"@oozcitak/util": "^10.0.0"
},
"engines": {
"node": ">=20.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/@oozcitak/infra": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-2.0.2.tgz",
"integrity": "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==",
"license": "MIT",
"dependencies": {
"@oozcitak/util": "^10.0.0"
},
"engines": {
"node": ">=20.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/@oozcitak/url": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-3.0.0.tgz",
"integrity": "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "^2.0.2",
"@oozcitak/util": "^10.0.0"
},
"engines": {
"node": ">=20.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/@oozcitak/util": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-10.0.0.tgz",
"integrity": "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==",
"license": "MIT",
"engines": {
"node": ">=20.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/adm-zip": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/axios": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/bonjour-hap": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/bonjour-hap/-/bonjour-hap-3.10.0.tgz",
"integrity": "sha512-2/jKd5X4WgvRQXIjwMtc26nT1cuD0DbAwmVJ9vq/IVvWUNxL7Tce3valQUXnkQ0yL38/jSmrXAUL2nPGZB9hGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"multicast-dns": "^7.2.5",
"multicast-dns-service-types": "^1.1.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/commander": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/dns-packet": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
"integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@leichtgewicht/ip-codec": "^2.0.1"
},
"engines": {
"node": ">=6"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/duplexer": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
"dev": true,
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/event-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-4.0.1.tgz",
"integrity": "sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"duplexer": "^0.1.1",
"from": "^0.1.7",
"map-stream": "0.0.7",
"pause-stream": "^0.0.11",
"split": "^1.0.1",
"stream-combiner": "^0.2.2",
"through": "^2.3.8"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/fast-srp-hap": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fast-srp-hap/-/fast-srp-hap-2.0.4.tgz",
"integrity": "sha512-lHRYYaaIbMrhZtsdGTwPN82UbqD9Bv8QfOlKs+Dz6YRnByZifOh93EYmf2iEWFtkOEIqR2IK8cFD0UN5wLIWBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.17.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/from": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
"integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==",
"dev": true,
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/fs-extra": {
"version": "11.3.4",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
"integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/futoin-hkdf": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.5.3.tgz",
"integrity": "sha512-SewY5KdMpaoCeh7jachEWFsh1nNlaDjNHZXWqL5IGwtpEYHTgkr2+AMCgNwKWkcc0wpSYrZfR7he4WdmHFtDxQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/hap-nodejs": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/hap-nodejs/-/hap-nodejs-0.14.2.tgz",
"integrity": "sha512-hjV6WkV8xDQS6khGt2aIRMyt5yp+VHBUwZmPZH+thkBQH41VqDLfxUaGqX4M0yqvKxTpVWSQbSdldBLCCwIztA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@homebridge/ciao": "^1.3.5",
"@homebridge/dbus-native": "^0.7.3",
"bonjour-hap": "^3.10.0",
"debug": "^4.4.3",
"fast-srp-hap": "^2.0.4",
"futoin-hkdf": "^1.5.3",
"node-persist": "^0.0.12",
"source-map-support": "^0.5.21",
"tslib": "^2.8.1",
"tweetnacl": "^1.0.3"
},
"engines": {
"node": "^18 || ^20 || ^22 || ^24"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/hexy": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.3.5.tgz",
"integrity": "sha512-UCP7TIZPXz5kxYJnNOym+9xaenxCLor/JyhKieo8y8/bJWunGh9xbhy3YrgYJUQ87WwfXGm05X330DszOfINZw==",
"dev": true,
"license": "MIT",
"bin": {
"hexy": "bin/hexy_cmd.js"
},
"engines": {
"node": ">=10.4"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/homebridge": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/homebridge/-/homebridge-1.11.3.tgz",
"integrity": "sha512-r6Fy8CVDH2OrhaHjiBMoVTyxvuSaTIVnNRnM7HiFPx5AeWW7Q7hkIwuctz9Ls46iR4+elushD4YoZkvdBUwNgg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"chalk": "4.1.2",
"commander": "13.1.0",
"fs-extra": "11.3.4",
"hap-nodejs": "0.14.2",
"qrcode-terminal": "0.12.0",
"semver": "7.7.4",
"source-map-support": "0.5.21"
},
"bin": {
"homebridge": "bin/homebridge"
},
"engines": {
"node": "^18.15.0 || ^20.7.0 || ^22 || ^24"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"dev": true,
"license": "Apache-2.0"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/map-stream": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz",
"integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==",
"dev": true,
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/multicast-dns": {
"version": "7.2.5",
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
"integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dns-packet": "^5.2.2",
"thunky": "^1.0.2"
},
"bin": {
"multicast-dns": "cli.js"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/multicast-dns-service-types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz",
"integrity": "sha512-cnAsSVxIDsYt0v7HmC0hWZFwwXSh+E6PgCrREDuN/EsjgLwA5XRmlMHhSiDPrt6HxY1gTivEa/Zh7GtODoLevQ==",
"dev": true,
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/node-persist": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/node-persist/-/node-persist-0.0.12.tgz",
"integrity": "sha512-Fbia3FYnURzaql53wLu0t19dmAwQg/tXT6O7YPmdwNwysNKEyFmgoT2BQlPD3XXQnYeiQVNvR5lfvufGwKuxhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"mkdirp": "~0.5.1",
"q": "~1.1.1"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/pause-stream": {
"version": "0.0.11",
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
"integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==",
"dev": true,
"license": [
"MIT",
"Apache2"
],
"dependencies": {
"through": "~2.3"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/q": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/q/-/q-1.1.2.tgz",
"integrity": "sha512-ROtylwux7Vkc4C07oKE/ReigUmb33kVoLtcR4SJ1QVqwaZkBEDL3vX4/kwFzIERQ5PfCl0XafbU8u2YUhyGgVA==",
"deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6.0",
"teleport": ">=0.2.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/qrcode-terminal": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
"integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
"dev": true,
"bin": {
"qrcode-terminal": "bin/qrcode-terminal.js"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/split": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz",
"integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==",
"dev": true,
"license": "MIT",
"dependencies": {
"through": "2"
},
"engines": {
"node": "*"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/stream-combiner": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz",
"integrity": "sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"duplexer": "~0.1.1",
"through": "~2.3.4"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true,
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/thunky": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
"dev": true,
"license": "MIT"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"dev": true,
"license": "Unlicense"
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"../../../../../../../Claude/Wemo/wemo-manager/packages/homebridge-plugin/node_modules/xmlbuilder2": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz",
"integrity": "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==",
"license": "MIT",
"dependencies": {
"@oozcitak/dom": "^2.0.2",
"@oozcitak/infra": "^2.0.2",
"@oozcitak/util": "^10.0.0",
"js-yaml": "^4.1.1"
},
"engines": {
"node": ">=20.0"
}
}
}
}
+118
View File
@@ -0,0 +1,118 @@
# @wemo-manager/core
**Shared Wemo protocol constants and utilities used by both the Dibby Wemo Manager desktop app and the Homebridge plugin.**
This is an internal package within the `dibby-wemo-manager` monorepo. It is not published to npm — both packages reference it via npm workspaces.
---
## What's in here
### Constants
| Export | Description |
|---|---|
| `DAY_NUMBERS` | Map of day names → Wemo day numbers (Monday=1 … Sunday=7) |
| `DAY_NAMES` | Map of Wemo day numbers → full names |
| `DAY_SHORT` | Map of Wemo day numbers → abbreviated names (Mon, Tue…) |
| `RULE_TYPES` | Wemo firmware rule type strings (Schedule, Away, Countdown, Long Press) |
| `ACTIONS` | StartAction / EndAction numeric codes (ON=1, OFF=0, TOGGLE=2, NONE=-1) |
| `NETWORK_STATUS` | GetNetworkStatus response codes |
| `RESET_CODES` | ReSetup action codes (clear data, factory reset, clear WiFi) |
| `RD_DEFAULTS` | Default field values for a new RULEDEVICES row |
| `SUN_CODES` | Sentinel values for sunrise (2) and sunset (3) in StartTime/EndTime |
### Helper functions
| Function | Signature | Description |
|---|---|---|
| `namesToDayNumbers` | `(names: string[]) => number[]` | Convert day name array to sorted Wemo day numbers |
| `dayNumbersToNames` | `(numbers: number[]) => string[]` | Convert day numbers to full name array |
| `dayNumbersToShort` | `(numbers: number[]) => string[]` | Convert day numbers to abbreviated name array |
| `timeToSecs` | `(hhmm: string) => number` | Parse `"HH:MM"` to seconds from midnight |
| `secsToHHMM` | `(secs: number) => string` | Format seconds from midnight to `"HH:MM"` |
| `sunTimes` | `(lat, lng, date?) => { sunrise, sunset }` | Calculate sunrise/sunset as seconds from midnight |
---
## Wemo day number convention
Wemo devices use **1-based day numbers**, not bitmasks:
| Number | Day |
|---|---|
| 1 | Monday |
| 2 | Tuesday |
| 3 | Wednesday |
| 4 | Thursday |
| 5 | Friday |
| 6 | Saturday |
| 7 | Sunday |
Multi-day rules have one `RULEDEVICES` row **per day** — not a single row with a bitmask.
---
## Sun time sentinel codes
When a rule's `StartTime` or `EndTime` is set to one of these values, the scheduler resolves it to the actual sunrise/sunset time for the configured location:
| Constant | Value | Meaning |
|---|---|---|
| `SUN_CODES.SUNRISE` | `-2` | Use today's sunrise time |
| `SUN_CODES.SUNSET` | `-3` | Use today's sunset time |
---
## Usage
```js
const {
DAY_NUMBERS,
namesToDayNumbers,
secsToHHMM,
sunTimes,
SUN_CODES,
} = require('@wemo-manager/core');
// Convert user-selected days to Wemo day numbers
const days = namesToDayNumbers(['Monday', 'Wednesday', 'Friday']);
// → [1, 3, 5]
// Format a time stored as seconds from midnight
console.log(secsToHHMM(75600)); // "21:00" (9 PM)
// Check if a StartTime is a sunrise/sunset sentinel
if (startTime === SUN_CODES.SUNRISE) {
const { sunrise } = sunTimes(lat, lng);
startTime = sunrise;
}
```
---
## File structure
```
packages/wemo-core/
├── package.json
└── src/
├── index.js — re-exports everything from sun.js and types.js
├── types.js — constants + day/time helper functions
└── sun.js — NOAA sunrise/sunset calculator (pure JS, no deps)
```
---
## Notes
- **No external dependencies** — pure JavaScript, no npm packages required.
- `sun.js` implements the NOAA Solar Calculator algorithm (Jean Meeus, *Astronomical Algorithms*). It returns `null` for each value during polar day/polar night.
- `secsToHHMM` returns `"00:00"` for negative input (used for no-time sentinel values like `1`).
- The DWM scheduler in both the desktop app and the Homebridge plugin uses `SUN_CODES` to resolve rule times at tick time, so sunrise/sunset-based rules automatically adjust every day.
---
## License
MIT
+51
View File
@@ -0,0 +1,51 @@
# Dibby Wemo Manager - Web Deployment Script (PowerShell)
# This script builds and deploys the web version using Docker Compose
Write-Host "🚀 Deploying Dibby Wemo Manager (Web Version)..." -ForegroundColor Green
# Check if Docker is running
try {
docker info > $null 2>&1
} catch {
Write-Host "❌ Docker is not running. Please start Docker Desktop first." -ForegroundColor Red
exit 1
}
# Build the Docker image
Write-Host "📦 Building Docker image..." -ForegroundColor Blue
docker build -t reg.dev.nervesocket.com/dibbly:latest .
# Stop existing container if running
Write-Host "🛑 Stopping existing container..." -ForegroundColor Yellow
docker-compose -f web-compose.yml down 2>$null
# Start the service
Write-Host "🔄 Starting web service..." -ForegroundColor Blue
docker-compose -f web-compose.yml up -d
# Wait for service to be ready
Write-Host "⏳ Waiting for service to be ready..." -ForegroundColor Yellow
Start-Sleep -Seconds 10
# Check if service is running
$serviceStatus = docker-compose -f web-compose.yml ps
if ($serviceStatus -match "Up") {
Write-Host "✅ Dibby Wemo Manager is now running!" -ForegroundColor Green
Write-Host ""
Write-Host "🌐 Access the web interface at:" -ForegroundColor Cyan
Write-Host " http://localhost:3456"
Write-Host ""
Write-Host "📱 Mobile-friendly URL:" -ForegroundColor Cyan
$localIP = (Get-NetIPAddress -AddressFamily IPv4 -InterfaceAlias "Ethernet*","Wi-Fi*" | Where-Object { $_.IPAddress -notlike "169.*" -and $_.IPAddress -notlike "127.*" } | Select-Object -First 1).IPAddress
Write-Host " http://$($localIP):3456"
Write-Host ""
Write-Host "📊 View logs:" -ForegroundColor Cyan
Write-Host " docker-compose -f web-compose.yml logs -f"
Write-Host ""
Write-Host "🛑 Stop service:" -ForegroundColor Cyan
Write-Host " docker-compose -f web-compose.yml down"
} else {
Write-Host "❌ Failed to start the service. Check logs:" -ForegroundColor Red
docker-compose -f web-compose.yml logs
exit 1
}
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Dibby Wemo Manager - Web Deployment Script
# This script builds and deploys the web version using Docker Compose
set -e
echo "🚀 Deploying Dibby Wemo Manager (Web Version)..."
# Check if Docker is running
if ! docker info > /dev/null 2>&1; then
echo "❌ Docker is not running. Please start Docker first."
exit 1
fi
# Build the Docker image (if needed)
echo "📦 Building Docker image..."
docker build -t reg.dev.nervesocket.com/dibbly:latest .
# Stop existing container if running
echo "🛑 Stopping existing container..."
docker-compose -f web-compose.yml down || true
# Start the service
echo "🔄 Starting web service..."
docker-compose -f web-compose.yml up -d
# Wait for service to be ready
echo "⏳ Waiting for service to be ready..."
sleep 10
# Check if service is running
if docker-compose -f web-compose.yml ps | grep -q "Up"; then
echo "✅ Dibby Wemo Manager is now running!"
echo ""
echo "🌐 Access the web interface at:"
echo " http://localhost:3456"
echo ""
echo "📱 Mobile-friendly URL:"
echo " http://$(hostname -I | awk '{print $1}'):3456"
echo ""
echo "📊 View logs:"
echo " docker-compose -f web-compose.yml logs -f"
echo ""
echo "🛑 Stop service:"
echo " docker-compose -f web-compose.yml down"
else
echo "❌ Failed to start the service. Check logs:"
docker-compose -f web-compose.yml logs
exit 1
fi
+24
View File
@@ -0,0 +1,24 @@
version: '3.8'
services:
dibbly-web:
image: reg.dev.nervesocket.com/dibbly:latest
container_name: dibbly-wemo-manager
restart: unless-stopped
ports:
- "3456:3456"
volumes:
- dibbly-data:/data
environment:
- DATA_DIR=/data
- PORT=3456
networks:
- dibbly-network
volumes:
dibbly-data:
driver: local
networks:
dibbly-network:
driver: bridge