Per-light schedule pause, global schedules switch, Timeline preview page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-10-14 12:40:45 +00:00
parent 371582cbd6
commit d33b2fe11d
13 changed files with 368 additions and 12 deletions

View file

@ -23,6 +23,11 @@ export async function initDb() {
UNIQUE (mqtt_topic, channel)
);
-- Per-light schedule pause: schedules skip a paused light. paused_until NULL
-- means "until someone resumes it".
ALTER TABLE light_devices ADD COLUMN IF NOT EXISTS schedules_paused BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE light_devices ADD COLUMN IF NOT EXISTS paused_until TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS light_groups (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
@ -72,7 +77,7 @@ export async function initDb() {
);
`)
for (const [key, value] of [['latitude', ''], ['longitude', ''], ['timezone', 'Europe/London']]) {
for (const [key, value] of [['latitude', ''], ['longitude', ''], ['timezone', 'Europe/London'], ['schedules_enabled', 'true']]) {
await pool.query(`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`, [key, value])
}
}

View file

@ -9,19 +9,40 @@ import { pool, getSettings } from '../db.js'
import { getDevice, isConnected, setOnlineHandler } from './mqtt.js'
import { setPower, setPowerMany, resolveTargetDevices } from './control.js'
import { pruneEvents } from './events.js'
import { localParts, ymd, triggerTime, dayOffset } from './time.js'
import { localParts, ymd, triggerTime, dayOffset, zonedToUtc } from './time.js'
const TICK_MS = 20 * 1000
const FIRE_GRACE_MS = 15 * 60 * 1000 // older misses are left to reconcile
const LOOKBACK_DAYS = 7
export const schedulesOn = settings => settings.schedules_enabled !== 'false'
const NOT_PAUSED = `NOT (schedules_paused AND (paused_until IS NULL OR paused_until > NOW()))`
// Of these devices, the ones whose schedules aren't currently paused.
async function unpaused(ids) {
if (!ids.length) return []
const { rows } = await pool.query(`SELECT id FROM light_devices WHERE id = ANY($1) AND ${NOT_PAUSED}`, [ids])
return rows.map(r => r.id)
}
// Timed pauses that have run out: clear them and let the light catch up with
// whatever its schedules say now (still subject to the manual-override rule).
async function expirePauses() {
const { rows } = await pool.query(
`UPDATE light_devices SET schedules_paused = FALSE, paused_until = NULL
WHERE schedules_paused AND paused_until IS NOT NULL AND paused_until <= NOW() RETURNING id`
)
if (rows.length) reconcile(rows.map(r => r.id)).catch(() => {})
}
async function fire(s, trigger) {
const claimed = await pool.query(
`UPDATE light_schedules SET last_fired_on = $1 WHERE id = $2 AND last_fired_on IS DISTINCT FROM $1 RETURNING id`,
[s._today, s.id]
)
if (!claimed.rowCount) return
const ids = await resolveTargetDevices(s.target_type, s.target_id)
const ids = await unpaused(await resolveTargetDevices(s.target_type, s.target_id))
console.log(`[engine] firing "${s.name}" (${s.action}) on ${ids.length} device(s), trigger ${trigger.toISOString()}`)
const failed = await setPowerMany(ids, s.action, 'schedule', s.name)
if (failed.length) console.warn(`[engine] "${s.name}": ${failed.length} command(s) failed:`, failed.map(f => f.error).join('; '))
@ -30,6 +51,8 @@ async function fire(s, trigger) {
export async function tick() {
if (!isConnected()) return
const settings = await getSettings()
await expirePauses()
if (!schedulesOn(settings)) return
const tz = settings.timezone || 'Europe/London'
const now = new Date()
const today = localParts(now, tz)
@ -78,12 +101,14 @@ async function schedulesFor(deviceId) {
export async function reconcile(deviceIds = null) {
if (!isConnected()) return { applied: 0 }
const settings = await getSettings()
if (!schedulesOn(settings)) return { applied: 0 }
const now = new Date()
let ids = deviceIds
if (!ids) {
const { rows } = await pool.query('SELECT id FROM light_devices WHERE active = TRUE')
ids = rows.map(r => r.id)
}
ids = await unpaused(ids)
let applied = 0
for (const id of ids) {
const device = getDevice(id)
@ -121,3 +146,61 @@ export function nextRun(s, settings, now = new Date()) {
}
return null
}
// What the schedules alone would do to every light across one site-local day,
// as on/off/unscheduled segments. Planned behaviour only — it ignores manual
// changes and pauses (the caller flags paused lights).
export async function previewDay(day, settings) {
const tz = settings.timezone || 'Europe/London'
const start = zonedToUtc(day.y, day.m, day.d, 0, 0, tz)
const next = dayOffset(day, 1, tz)
const end = zonedToUtc(next.y, next.m, next.d, 0, 0, tz)
const weekday = dayOffset(day, 0, tz).weekday
const { rows: devices } = await pool.query(
`SELECT id, name, location, active,
(schedules_paused AND (paused_until IS NULL OR paused_until > NOW())) AS paused
FROM light_devices WHERE active = TRUE ORDER BY location NULLS LAST, name`
)
const { rows: schedules } = await pool.query('SELECT * FROM light_schedules WHERE enabled = TRUE')
const { rows: members } = await pool.query('SELECT group_id, device_id FROM light_group_members')
const groupsOf = new Map()
for (const m of members) {
if (!groupsOf.has(m.device_id)) groupsOf.set(m.device_id, new Set())
groupsOf.get(m.device_id).add(m.group_id)
}
return devices.map(d => {
const mine = schedules.filter(s =>
(s.target_type === 'device' && s.target_id === d.id) ||
(s.target_type === 'group' && groupsOf.get(d.id)?.has(s.target_id)))
const carried = latestTrigger(mine, settings, new Date(start.getTime() - 1))
const events = []
for (const s of mine) {
if (!s.days.includes(weekday)) continue
const t = triggerTime(s, day, settings)
if (t && t >= start && t < end) events.push({ at: t, action: s.action, schedule: s.name })
}
events.sort((a, b) => a.at - b.at)
const segments = []
let cursor = start
let state = carried?.action ?? null
let by = carried?.name ?? null
for (const ev of events) {
if (ev.at > cursor) segments.push({ from: cursor, to: ev.at, state, schedule: by })
cursor = ev.at; state = ev.action; by = ev.schedule
}
segments.push({ from: cursor, to: end, state, schedule: by })
const onMinutes = Math.round(segments.filter(x => x.state === 'on').reduce((n, x) => n + (x.to - x.from), 0) / 60000)
return {
id: d.id, name: d.name, location: d.location, paused: d.paused,
scheduled: mine.length > 0, on_minutes: onMinutes,
events: events.map(e => ({ at: e.at, action: e.action, schedule: e.schedule })),
segments: segments.filter(x => x.to > x.from),
}
}).map(d => ({ ...d, day_start: start, day_end: end }))
}

View file

@ -2,14 +2,16 @@ import { requireAuth, requireCap } from '../auth.js'
import { pool, getSettings } from '../db.js'
import { refreshDevices, listDiscovered, isConnected } from '../lib/mqtt.js'
import { setPower, setPowerMany, resolveTargetDevices } from '../lib/control.js'
import { reconcile } from '../lib/engine.js'
import { reconcile, previewDay, schedulesOn } from '../lib/engine.js'
import { localParts, sunTimes } from '../lib/time.js'
const TOPIC_RE = /^[A-Za-z0-9_.-]+$/
async function loadState() {
const { rows: devices } = await pool.query(
`SELECT id, name, location, mqtt_topic, channel, active, state, online, state_at FROM light_devices ORDER BY location NULLS LAST, name`
`SELECT id, name, location, mqtt_topic, channel, active, state, online, state_at,
(schedules_paused AND (paused_until IS NULL OR paused_until > NOW())) AS schedules_paused, paused_until
FROM light_devices ORDER BY location NULLS LAST, name`
)
const { rows: groups } = await pool.query(
`SELECT g.id, g.name, COALESCE(array_agg(m.device_id) FILTER (WHERE m.device_id IS NOT NULL), '{}') AS device_ids
@ -25,7 +27,7 @@ export async function deviceRoutes(app) {
const settings = await getSettings()
const tz = settings.timezone || 'Europe/London'
const sun = sunTimes(localParts(new Date(), tz), settings)
return { ...(await loadState()), mqtt_connected: isConnected(), sun, timezone: tz }
return { ...(await loadState()), mqtt_connected: isConnected(), sun, timezone: tz, schedules_enabled: schedulesOn(settings) }
})
// ── manual control ──
@ -49,6 +51,25 @@ export async function deviceRoutes(app) {
return { ok: failed.length === 0, failed }
})
// Pause (hours = N, or omitted for "until resumed") or resume (paused: false) a
// single light's schedules. Uses the control cap — it's an operational
// override, not schedule editing.
app.post('/api/devices/:id/schedule-hold', { preHandler: requireCap('control') }, async (req, reply) => {
const { paused, hours } = req.body || {}
if (typeof paused !== 'boolean') return reply.status(400).send({ error: 'paused must be true or false' })
const h = hours == null ? null : Number(hours)
if (h !== null && !(h > 0 && h <= 24 * 30)) return reply.status(400).send({ error: 'hours must be between 0 and 720' })
const { rowCount } = await pool.query(
`UPDATE light_devices SET schedules_paused = $1,
paused_until = CASE WHEN $1 AND $2::numeric IS NOT NULL THEN NOW() + ($2::numeric * INTERVAL '1 hour') ELSE NULL END
WHERE id = $3`,
[paused, paused ? h : null, req.params.id]
)
if (!rowCount) return reply.status(404).send({ error: 'Not found' })
if (!paused && isConnected()) await reconcile([Number(req.params.id)])
return { ok: true }
})
app.post('/api/reconcile', { preHandler: requireCap('control') }, async (req, reply) => {
if (!isConnected()) return reply.status(503).send({ error: 'MQTT broker not connected' })
return reconcile()
@ -143,6 +164,23 @@ export async function deviceRoutes(app) {
return { ok: true }
})
// ── timeline preview: what the schedules plan for one day ──
app.get('/api/preview', { preHandler: requireCap('view') }, async (req, reply) => {
const settings = await getSettings()
const tz = settings.timezone || 'Europe/London'
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(req.query.date || '')
const day = m ? { y: +m[1], m: +m[2], d: +m[3] } : localParts(new Date(), tz)
if (m && (day.m < 1 || day.m > 12 || day.d < 1 || day.d > 31)) return reply.status(400).send({ error: 'Invalid date' })
const devices = await previewDay(day, settings)
return {
date: `${day.y}-${String(day.m).padStart(2, '0')}-${String(day.d).padStart(2, '0')}`,
timezone: tz,
schedules_enabled: schedulesOn(settings),
sun: sunTimes(day, settings),
devices,
}
})
// ── activity log ──
app.get('/api/events', { preHandler: requireCap('view') }, async (req) => {
const limit = Math.min(Number(req.query.limit) || 100, 500)

View file

@ -4,8 +4,9 @@ import { isConnected } from '../lib/mqtt.js'
import { validTimezone } from '../lib/time.js'
// Standard app_settings pattern (see conventions doc). Latitude/longitude drive
// the sunrise/sunset triggers; timezone decides what "18:00" and "Mon-Fri" mean.
const ALLOWED_KEYS = new Set(['latitude', 'longitude', 'timezone'])
// the sunrise/sunset triggers; timezone decides what "18:00" and "Mon-Fri" mean;
// schedules_enabled is the global kill switch for all automation.
const ALLOWED_KEYS = new Set(['latitude', 'longitude', 'timezone', 'schedules_enabled'])
export async function settingsRoutes(app) {
app.addHook('preHandler', requireAuth)
@ -23,6 +24,7 @@ export async function settingsRoutes(app) {
const v = String(value ?? '').trim()
if (key === 'latitude' && v && !(Math.abs(parseFloat(v)) <= 90)) return reply.status(400).send({ error: 'Latitude must be between -90 and 90' })
if (key === 'longitude' && v && !(Math.abs(parseFloat(v)) <= 180)) return reply.status(400).send({ error: 'Longitude must be between -180 and 180' })
if (key === 'schedules_enabled' && v !== 'true' && v !== 'false') return reply.status(400).send({ error: 'schedules_enabled must be true or false' })
if (key === 'timezone' && !validTimezone(v)) return reply.status(400).send({ error: `Unknown timezone "${v}"` })
await pool.query(
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())