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:
SRS IT
2026-03-28 20:20:32 -04:00
parent b200c45385
commit 951d4c4eaa
4 changed files with 170 additions and 30 deletions
@@ -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 };