diff --git a/packages/homebridge-plugin/homebridge-ui/public/index.html b/packages/homebridge-plugin/homebridge-ui/public/index.html
index 8d3ca4b..b49c624 100644
--- a/packages/homebridge-plugin/homebridge-ui/public/index.html
+++ b/packages/homebridge-plugin/homebridge-ui/public/index.html
@@ -316,11 +316,39 @@
diff --git a/packages/homebridge-plugin/homebridge-ui/public/index.js b/packages/homebridge-plugin/homebridge-ui/public/index.js
index ea452f0..8685a29 100644
--- a/packages/homebridge-plugin/homebridge-ui/public/index.js
+++ b/packages/homebridge-plugin/homebridge-ui/public/index.js
@@ -12,6 +12,7 @@ let _wemoRules = null; // { rules, ruleDevices, targets } for selecte
let _editingDwmId = null; // null = create, string = update
let _selectedDwmDays = new Set();
let _pendingLocation = null; // { lat, lng, label }
+let _todaySunTimes = null; // { sunrise, sunset } seconds from midnight
// ---------------------------------------------------------------------------
// Tabs
@@ -273,6 +274,48 @@ function deleteDwmRule(id) {
setTimeout(() => row.remove(), 5000);
}
+// ── 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));
// ── DWM Inline Form ───────────────────────────────────────────────────────────
@@ -298,8 +341,12 @@ function openDwmEdit(id) {
document.getElementById('dwm-name').value = r.name ?? '';
document.getElementById('dwm-type').value = r.type ?? 'Schedule';
document.getElementById('dwm-enabled').checked = r.enabled !== false;
- document.getElementById('dwm-start-time').value = secsToHHMM(r.startTime);
- document.getElementById('dwm-end-time').value = secsToHHMM(r.endTime);
+ document.getElementById('dwm-start-type').value = r.startType || 'fixed';
+ 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-end-action').value = String(r.endAction ?? -1);
document.getElementById('dwm-countdown-mins').value =
@@ -325,11 +372,15 @@ function openDwmEdit(id) {
});
}
} else {
- document.getElementById('dwm-name').value = '';
- document.getElementById('dwm-type').value = 'Schedule';
- document.getElementById('dwm-enabled').checked = true;
- document.getElementById('dwm-start-time').value = '';
- document.getElementById('dwm-end-time').value = '';
+ document.getElementById('dwm-name').value = '';
+ document.getElementById('dwm-type').value = 'Schedule';
+ 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-end-type').value = 'fixed';
+ document.getElementById('dwm-end-offset').value = '0';
+ document.getElementById('dwm-end-time').value = '';
document.getElementById('dwm-start-action').value = '1';
document.getElementById('dwm-end-action').value = '-1';
document.getElementById('dwm-countdown-mins').value = '';
@@ -342,6 +393,7 @@ function openDwmEdit(id) {
updateDwmDayButtons();
updateDwmTypeFields();
+ updateSunTypeVisibility();
document.getElementById('dwm-list-view').style.display = 'none';
document.getElementById('dwm-form-panel').style.display = '';
window.scrollTo(0, 0);
@@ -463,10 +515,30 @@ document.getElementById('dwm-form-save-btn').addEventListener('click', async ()
if (!mins || mins < 1) { showModalError('Enter countdown duration in minutes'); return; }
rule.countdownTime = mins * 60;
} else {
- const startSecs = hhmmToSecs(document.getElementById('dwm-start-time').value);
- if (startSecs < 0) { showModalError('Enter a valid start time (HH:MM)'); return; }
+ const startType = document.getElementById('dwm-start-type').value;
+ 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.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.endAction = Number(document.getElementById('dwm-end-action').value);
}
@@ -764,5 +836,7 @@ document.querySelectorAll('.tab-btn').forEach((btn) => {
await loadDwmRules();
await loadLocation();
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(() => {});
})();
diff --git a/packages/homebridge-plugin/homebridge-ui/server.js b/packages/homebridge-plugin/homebridge-ui/server.js
index 3a336c7..21350f2 100644
--- a/packages/homebridge-plugin/homebridge-ui/server.js
+++ b/packages/homebridge-plugin/homebridge-ui/server.js
@@ -28,6 +28,7 @@ const path = require('path');
const DwmStore = require('../lib/store');
const wemoClient = require('../lib/wemo-client');
const axios = require('axios');
+const { sunTimes: calcSunTimes } = require('../lib/sun');
class DibbyWemoUiServer extends HomebridgePluginUiServer {
constructor() {
@@ -122,6 +123,13 @@ class DibbyWemoUiServer extends HomebridgePluginUiServer {
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._store.setLocation(loc);
return { ok: true };
diff --git a/packages/homebridge-plugin/lib/scheduler.js b/packages/homebridge-plugin/lib/scheduler.js
index 75e9cb1..9148a84 100644
--- a/packages/homebridge-plugin/lib/scheduler.js
+++ b/packages/homebridge-plugin/lib/scheduler.js
@@ -19,11 +19,37 @@
* await scheduler.start();
*/
+const { sunTimes: calcSunTimes } = require('./sun');
+
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Wemo DayID: 1=Mon … 7=Sun. JS getDay(): 0=Sun … 6=Sat. */
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) {
return date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds();
}
@@ -150,6 +176,7 @@ class DwmScheduler {
_loadSchedule() {
const schedule = [];
const rules = this._store.getDwmRules();
+ const todaySun = getTodaySun(this._store);
for (const rule of rules) {
if (!rule.enabled) continue;
@@ -159,9 +186,9 @@ class DwmScheduler {
// Away Mode
if (rule.type === 'Away') {
- const startSecs = Number(rule.startTime ?? -1);
- const endSecs = Number(rule.endTime ?? -1);
- if (startSecs < 0) continue;
+ const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset, todaySun);
+ const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset, todaySun);
+ if (startSecs === null) continue;
for (const dayId of (rule.days ?? [])) {
const td0 = rule.targetDevices?.[0];
@@ -171,7 +198,7 @@ class DwmScheduler {
dayId: Number(dayId), targetSecs: startSecs,
action: 1, isAwayStart: true,
});
- if (endSecs >= 0) {
+ if (endSecs !== null && endSecs >= 0) {
schedule.push({
ruleId: rule.id + '-away-end', ruleName: rule.name,
targetHost: td0?.host ?? '', targetPort: td0?.port ?? 0,
@@ -208,11 +235,11 @@ class DwmScheduler {
}
// Schedule / time-based
- const startSecs = Number(rule.startTime ?? -1);
- const endSecs = Number(rule.endTime ?? -1);
+ const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset, todaySun);
+ const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset, todaySun);
const startAction = Number(rule.startAction ?? 1);
const endAction = Number(rule.endAction ?? -1);
- if (startSecs < 0) continue;
+ if (startSecs === null) continue;
for (const dayId of (rule.days ?? [])) {
for (const td of (rule.targetDevices ?? [])) {
@@ -222,7 +249,7 @@ class DwmScheduler {
targetHost: td.host, targetPort: td.port,
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,
targetHost: td.host, targetPort: td.port,
dayId: Number(dayId), targetSecs: endSecs, action: endAction });
@@ -239,21 +266,22 @@ class DwmScheduler {
_resumeAwayLoops() {
if (!this._running) return;
- const now = new Date();
- const nowSecs = secondsFromMidnight(now);
- const todayId = jsToWemoDayId(now.getDay());
- const rules = this._store.getDwmRules();
+ const now = new Date();
+ const nowSecs = secondsFromMidnight(now);
+ const todayId = jsToWemoDayId(now.getDay());
+ const rules = this._store.getDwmRules();
+ const todaySun = getTodaySun(this._store);
for (const rule of rules) {
if (!rule.enabled || rule.type !== 'Away') continue;
if (this._awayLoops.has(rule.id)) continue;
- const startSecs = Number(rule.startTime ?? -1);
- const endSecs = Number(rule.endTime ?? -1);
- if (startSecs < 0) continue;
+ const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset, todaySun);
+ const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset, todaySun);
+ if (startSecs === null) continue;
if (!(rule.days ?? []).includes(todayId)) continue;
- const inWindow = endSecs >= 0
+ const inWindow = endSecs !== null && endSecs >= 0
? (startSecs <= endSecs ? (nowSecs >= startSecs && nowSecs < endSecs)
: (nowSecs >= startSecs || nowSecs < endSecs))
: nowSecs >= startSecs;
@@ -269,7 +297,9 @@ class DwmScheduler {
const devices = (rule.targetDevices ?? []).filter(td => td.host && td.port);
if (!devices.length) return;
- const loop = { rule, devices, endSecs: Number(rule.endTime ?? -1), timer: null, isOn: false };
+ 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._awayStep(rule.id, true);
}