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