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>
This commit is contained in:
@@ -316,11 +316,39 @@
|
||||
<div class="flex-row">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label>Start Time</label>
|
||||
<input type="text" id="dwm-start-time" placeholder="e.g. 8:30 PM" />
|
||||
<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" />
|
||||
</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">
|
||||
<label>End Time (optional)</label>
|
||||
<input type="text" id="dwm-end-time" placeholder="e.g. 11:00 PM" />
|
||||
<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" />
|
||||
</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 class="flex-row">
|
||||
|
||||
@@ -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(() => {});
|
||||
})();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user