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:
parent
371582cbd6
commit
d33b2fe11d
13 changed files with 368 additions and 12 deletions
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue