Scaffold lights app: Tasmota MQTT control, schedules with sunrise/sunset triggers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
371582cbd6
41 changed files with 10283 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
uploads/
|
||||||
|
*.log
|
||||||
7
backend/Dockerfile
Normal file
7
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
COPY src ./src
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
1209
backend/package-lock.json
generated
Normal file
1209
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
backend/package.json
Normal file
18
backend/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "hnf-lights-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "node --watch src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^9.4.0",
|
||||||
|
"@fastify/cors": "^9.0.1",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"jose": "^5.9.6",
|
||||||
|
"mqtt": "^5.10.3",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"suncalc": "^1.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
backend/src/auth.js
Normal file
57
backend/src/auth.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { jwtVerify } from 'jose'
|
||||||
|
import { isOnsite } from './ip-check.js'
|
||||||
|
|
||||||
|
const APP_SLUG = process.env.APP_SLUG || 'lights'
|
||||||
|
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
|
||||||
|
|
||||||
|
export async function requireAuth(request, reply) {
|
||||||
|
const token = request.cookies?.hnf_session
|
||||||
|
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
|
||||||
|
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
const { payload: p } = await jwtVerify(token, secret)
|
||||||
|
payload = p
|
||||||
|
} catch {
|
||||||
|
return reply.status(401).send({ error: 'Invalid session' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.apps?.includes(APP_SLUG)) {
|
||||||
|
return reply.status(403).send({ error: 'No permission for this app' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.offsite_allowed) {
|
||||||
|
const clientIP = request.headers['x-real-ip'] || request.ip
|
||||||
|
if (!(await isOnsite(clientIP))) {
|
||||||
|
return reply.status(403).send({ error: 'Access restricted to site network' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = `${APP_SLUG}:`
|
||||||
|
let caps
|
||||||
|
if (Array.isArray(payload.caps)) {
|
||||||
|
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
|
||||||
|
} else {
|
||||||
|
// Legacy token — grant all non-settings caps until re-login
|
||||||
|
caps = ['view', 'control', 'schedules', 'manage_devices']
|
||||||
|
}
|
||||||
|
|
||||||
|
request.user = {
|
||||||
|
email: payload.sub,
|
||||||
|
name: payload.name,
|
||||||
|
is_admin: payload.is_admin ?? false,
|
||||||
|
caps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasCap(request, cap) {
|
||||||
|
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireCap(cap) {
|
||||||
|
return async (request, reply) => {
|
||||||
|
if (!hasCap(request, cap)) {
|
||||||
|
return reply.status(403).send({ error: `Missing capability: ${cap}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
backend/src/db.js
Normal file
83
backend/src/db.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import pg from 'pg'
|
||||||
|
|
||||||
|
const { Pool } = pg
|
||||||
|
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||||
|
|
||||||
|
export async function initDb() {
|
||||||
|
await pool.query(`
|
||||||
|
-- One row per controllable relay. A multi-relay Tasmota device is several
|
||||||
|
-- rows sharing a mqtt_topic with different channels. mqtt_topic is the
|
||||||
|
-- device's Tasmota "Topic" (the %topic% in cmnd/%topic%/POWER).
|
||||||
|
CREATE TABLE IF NOT EXISTS light_devices (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
location TEXT,
|
||||||
|
mqtt_topic TEXT NOT NULL,
|
||||||
|
channel INT NOT NULL DEFAULT 1,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
state TEXT, -- 'on' | 'off' | NULL (unknown)
|
||||||
|
online BOOLEAN, -- from the Tasmota LWT; NULL until first seen
|
||||||
|
state_at TIMESTAMPTZ,
|
||||||
|
last_manual_at TIMESTAMPTZ, -- last manual/physical change; a schedule trigger older than this does not override it
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (mqtt_topic, channel)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS light_groups (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS light_group_members (
|
||||||
|
group_id INT NOT NULL REFERENCES light_groups(id) ON DELETE CASCADE,
|
||||||
|
device_id INT NOT NULL REFERENCES light_devices(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (group_id, device_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- target_type/target_id point at a device or group (no FK — the delete
|
||||||
|
-- routes remove a target's schedules explicitly). days uses 0=Sun..6=Sat in
|
||||||
|
-- site-local time. fixed schedules use time_of_day; sunrise/sunset use
|
||||||
|
-- offset_minutes (negative = before). last_fired_on is the site-local date
|
||||||
|
-- (YYYY-MM-DD) it last ran, so a restart never double-fires.
|
||||||
|
CREATE TABLE IF NOT EXISTS light_schedules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
target_type TEXT NOT NULL, -- device | group
|
||||||
|
target_id INT NOT NULL,
|
||||||
|
action TEXT NOT NULL, -- on | off
|
||||||
|
trigger_type TEXT NOT NULL, -- fixed | sunrise | sunset
|
||||||
|
time_of_day TEXT, -- 'HH:MM' when fixed
|
||||||
|
offset_minutes INT NOT NULL DEFAULT 0,
|
||||||
|
days INT[] NOT NULL DEFAULT '{0,1,2,3,4,5,6}',
|
||||||
|
last_fired_on TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS light_events (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
device_id INT,
|
||||||
|
device_name TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL, -- on | off
|
||||||
|
source TEXT NOT NULL, -- manual | schedule | reconcile | physical
|
||||||
|
detail TEXT -- user email or schedule name
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS light_events_at_idx ON light_events (at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
for (const [key, value] of [['latitude', ''], ['longitude', ''], ['timezone', 'Europe/London']]) {
|
||||||
|
await pool.query(`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`, [key, value])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSettings() {
|
||||||
|
const { rows } = await pool.query('SELECT key, value FROM app_settings')
|
||||||
|
return Object.fromEntries(rows.map(r => [r.key, r.value]))
|
||||||
|
}
|
||||||
38
backend/src/index.js
Normal file
38
backend/src/index.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import cookie from '@fastify/cookie'
|
||||||
|
import cors from '@fastify/cors'
|
||||||
|
import { initDb } from './db.js'
|
||||||
|
import { connect as connectMqtt } from './lib/mqtt.js'
|
||||||
|
import { startEngine } from './lib/engine.js'
|
||||||
|
import { deviceRoutes } from './routes/devices.js'
|
||||||
|
import { scheduleRoutes } from './routes/schedules.js'
|
||||||
|
import { settingsRoutes } from './routes/settings.js'
|
||||||
|
|
||||||
|
const app = Fastify({ logger: true, trustProxy: true })
|
||||||
|
const startedAt = Date.now()
|
||||||
|
|
||||||
|
await app.register(cookie)
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: process.env.CORS_ORIGIN || false,
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) }))
|
||||||
|
|
||||||
|
await app.register(deviceRoutes)
|
||||||
|
await app.register(scheduleRoutes)
|
||||||
|
await app.register(settingsRoutes)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initDb()
|
||||||
|
|
||||||
|
// The broker may be unreachable at boot — connect() handles that with its own
|
||||||
|
// backoff and never blocks startup.
|
||||||
|
connectMqtt().catch(err => app.log.warn(`MQTT connect failed at startup: ${err.message}`))
|
||||||
|
|
||||||
|
startEngine()
|
||||||
|
await app.listen({ port: 3001, host: '0.0.0.0' })
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
80
backend/src/ip-check.js
Normal file
80
backend/src/ip-check.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import dns from 'dns/promises'
|
||||||
|
|
||||||
|
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
|
||||||
|
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
|
||||||
|
const TTL = 5 * 60 * 1000
|
||||||
|
const cache = new Map()
|
||||||
|
|
||||||
|
const PUBLIC_IP_URLS = [
|
||||||
|
'https://api.ipify.org',
|
||||||
|
'https://ifconfig.co/ip',
|
||||||
|
'https://icanhazip.com',
|
||||||
|
]
|
||||||
|
|
||||||
|
function normalizeIP(ip) {
|
||||||
|
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIPv4(s) {
|
||||||
|
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipInCidr(ip, cidr) {
|
||||||
|
const [range, bits] = cidr.split('/')
|
||||||
|
if (!isIPv4(ip) || !isIPv4(range)) return false
|
||||||
|
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
|
||||||
|
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
|
||||||
|
return (toInt(ip) & mask) === (toInt(range) & mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPublicIP() {
|
||||||
|
for (const url of PUBLIC_IP_URLS) {
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), 4000)
|
||||||
|
const res = await fetch(url, { signal: ctrl.signal })
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (!res.ok) continue
|
||||||
|
const ip = (await res.text()).trim()
|
||||||
|
if (isIPv4(ip)) return ip
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDynamic(key, resolver) {
|
||||||
|
const hit = cache.get(key)
|
||||||
|
if (hit && Date.now() < hit.expiry) return hit.ip
|
||||||
|
const ip = await resolver()
|
||||||
|
if (ip) {
|
||||||
|
cache.set(key, { ip, expiry: Date.now() + TTL })
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
return hit ? hit.ip : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isOnsite(requestIP) {
|
||||||
|
if (matchers.length === 0 || matchers.includes('disabled')) return true
|
||||||
|
const ip = normalizeIP(requestIP)
|
||||||
|
if (!ip) return false
|
||||||
|
|
||||||
|
for (const m of matchers) {
|
||||||
|
if (m === 'auto') {
|
||||||
|
const pub = await resolveDynamic('auto', fetchPublicIP)
|
||||||
|
if (pub && ip === pub) return true
|
||||||
|
} else if (m.includes('/')) {
|
||||||
|
if (ipInCidr(ip, m)) return true
|
||||||
|
} else if (/[a-zA-Z]/.test(m)) {
|
||||||
|
const resolved = await resolveDynamic(m, async () => {
|
||||||
|
try { return (await dns.resolve4(m))[0] } catch { return null }
|
||||||
|
})
|
||||||
|
if (resolved && ip === resolved) return true
|
||||||
|
} else {
|
||||||
|
if (ip === m) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
33
backend/src/lib/control.js
Normal file
33
backend/src/lib/control.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
import { getDevice, publishPower } from './mqtt.js'
|
||||||
|
import { recordEvent } from './events.js'
|
||||||
|
|
||||||
|
const STAGGER_MS = 150
|
||||||
|
|
||||||
|
// The single path every power change goes through: publish, log, and — for
|
||||||
|
// manual changes — stamp last_manual_at so a later reconcile won't undo it.
|
||||||
|
export async function setPower(deviceId, state, source, detail = null) {
|
||||||
|
const device = getDevice(deviceId)
|
||||||
|
if (!device) throw new Error('Device not found or inactive')
|
||||||
|
const changing = device.state !== state
|
||||||
|
publishPower(device, state)
|
||||||
|
if (source === 'manual') {
|
||||||
|
await pool.query('UPDATE light_devices SET last_manual_at = NOW() WHERE id = $1', [deviceId])
|
||||||
|
}
|
||||||
|
if (changing) await recordEvent(device, state, source, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPowerMany(deviceIds, state, source, detail = null) {
|
||||||
|
const failed = []
|
||||||
|
for (const id of deviceIds) {
|
||||||
|
try { await setPower(id, state, source, detail) } catch (err) { failed.push({ id, error: err.message }) }
|
||||||
|
await new Promise(r => setTimeout(r, STAGGER_MS))
|
||||||
|
}
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveTargetDevices(targetType, targetId) {
|
||||||
|
if (targetType === 'device') return [targetId]
|
||||||
|
const { rows } = await pool.query('SELECT device_id FROM light_group_members WHERE group_id = $1', [targetId])
|
||||||
|
return rows.map(r => r.device_id)
|
||||||
|
}
|
||||||
123
backend/src/lib/engine.js
Normal file
123
backend/src/lib/engine.js
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
// Schedule engine. Two jobs:
|
||||||
|
// 1. tick(): fire any schedule whose trigger time passed within the grace
|
||||||
|
// window and hasn't run today.
|
||||||
|
// 2. reconcile(): work out what each device SHOULD be right now from the
|
||||||
|
// schedules and correct it — used on startup, when a device comes back
|
||||||
|
// online, and on demand. A manual or physical change newer than the latest
|
||||||
|
// trigger is respected ("override until the next schedule event").
|
||||||
|
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'
|
||||||
|
|
||||||
|
const TICK_MS = 20 * 1000
|
||||||
|
const FIRE_GRACE_MS = 15 * 60 * 1000 // older misses are left to reconcile
|
||||||
|
const LOOKBACK_DAYS = 7
|
||||||
|
|
||||||
|
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)
|
||||||
|
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('; '))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tick() {
|
||||||
|
if (!isConnected()) return
|
||||||
|
const settings = await getSettings()
|
||||||
|
const tz = settings.timezone || 'Europe/London'
|
||||||
|
const now = new Date()
|
||||||
|
const today = localParts(now, tz)
|
||||||
|
const { rows } = await pool.query('SELECT * FROM light_schedules WHERE enabled = TRUE')
|
||||||
|
for (const s of rows) {
|
||||||
|
if (!s.days.includes(today.weekday)) continue
|
||||||
|
const t = triggerTime(s, today, settings)
|
||||||
|
if (!t) continue
|
||||||
|
const age = now - t
|
||||||
|
if (age >= 0 && age < FIRE_GRACE_MS) {
|
||||||
|
s._today = ymd(today)
|
||||||
|
await fire(s, t).catch(err => console.error('[engine] fire failed:', err.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest trigger at or before `now` among the schedules that apply to a device.
|
||||||
|
function latestTrigger(schedules, settings, now) {
|
||||||
|
const tz = settings.timezone || 'Europe/London'
|
||||||
|
const today = localParts(now, tz)
|
||||||
|
let best = null
|
||||||
|
for (let back = 0; back <= LOOKBACK_DAYS; back++) {
|
||||||
|
const day = dayOffset(today, -back, tz)
|
||||||
|
for (const s of schedules) {
|
||||||
|
if (!s.days.includes(day.weekday)) continue
|
||||||
|
const t = triggerTime(s, day, settings)
|
||||||
|
if (t && t <= now && (!best || t > best.at)) best = { at: t, action: s.action, name: s.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedules that apply to a device, directly or through any group it is in.
|
||||||
|
async function schedulesFor(deviceId) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT s.* FROM light_schedules s
|
||||||
|
WHERE s.enabled = TRUE AND (
|
||||||
|
(s.target_type = 'device' AND s.target_id = $1) OR
|
||||||
|
(s.target_type = 'group' AND s.target_id IN (SELECT group_id FROM light_group_members WHERE device_id = $1))
|
||||||
|
)`,
|
||||||
|
[deviceId]
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconcile(deviceIds = null) {
|
||||||
|
if (!isConnected()) return { applied: 0 }
|
||||||
|
const settings = await getSettings()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
let applied = 0
|
||||||
|
for (const id of ids) {
|
||||||
|
const device = getDevice(id)
|
||||||
|
if (!device || device.state === null || device.online === false) continue
|
||||||
|
const want = latestTrigger(await schedulesFor(id), settings, now)
|
||||||
|
if (!want || want.action === device.state) continue
|
||||||
|
const { rows: [row] } = await pool.query('SELECT last_manual_at FROM light_devices WHERE id = $1', [id])
|
||||||
|
if (row?.last_manual_at && row.last_manual_at > want.at) continue
|
||||||
|
console.log(`[engine] reconcile: ${device.name} is ${device.state}, schedule "${want.name}" says ${want.action}`)
|
||||||
|
await setPower(id, want.action, 'reconcile', want.name).catch(err => console.error('[engine] reconcile failed:', err.message))
|
||||||
|
applied++
|
||||||
|
}
|
||||||
|
return { applied }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startEngine() {
|
||||||
|
// A device announcing itself online may have missed triggers while it was
|
||||||
|
// down; give its state message a moment to land before comparing.
|
||||||
|
setOnlineHandler(ids => setTimeout(() => reconcile(ids).catch(() => {}), 5000))
|
||||||
|
setInterval(() => tick().catch(err => console.error('[engine] tick failed:', err.message)), TICK_MS)
|
||||||
|
// Startup catch-up once the broker has delivered the initial device states.
|
||||||
|
setTimeout(() => reconcile().catch(() => {}), 20000)
|
||||||
|
setInterval(() => pruneEvents().catch(() => {}), 24 * 3600 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next time a schedule will fire (for display), searching the coming week.
|
||||||
|
export function nextRun(s, settings, now = new Date()) {
|
||||||
|
const tz = settings.timezone || 'Europe/London'
|
||||||
|
const today = localParts(now, tz)
|
||||||
|
for (let ahead = 0; ahead <= 7; ahead++) {
|
||||||
|
const day = dayOffset(today, ahead, tz)
|
||||||
|
if (!s.days.includes(day.weekday)) continue
|
||||||
|
const t = triggerTime(s, day, settings)
|
||||||
|
if (t && t > now) return t
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
14
backend/src/lib/events.js
Normal file
14
backend/src/lib/events.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
|
||||||
|
const RETENTION_DAYS = 90
|
||||||
|
|
||||||
|
export async function recordEvent(device, action, source, detail = null) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO light_events (device_id, device_name, action, source, detail) VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[device.id, device.name, action, source, detail]
|
||||||
|
).catch(err => console.error('[events] insert failed:', err.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneEvents() {
|
||||||
|
await pool.query(`DELETE FROM light_events WHERE at < NOW() - ($1 || ' days')::interval`, [String(RETENTION_DAYS)])
|
||||||
|
}
|
||||||
214
backend/src/lib/mqtt.js
Normal file
214
backend/src/lib/mqtt.js
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
// Tasmota over MQTT. Credentials come from the settings service's service
|
||||||
|
// client "lights-backend" (settings → MQTT Clients → "service consumer"), same
|
||||||
|
// runtime-fetch pattern as hvac. Reconnect/backoff is owned here, never blocks
|
||||||
|
// startup, and every public function degrades when the broker is unreachable.
|
||||||
|
//
|
||||||
|
// Tasmota topic layout (defaults): commands go to cmnd/<topic>/POWER[n]; the
|
||||||
|
// device answers on stat/<topic>/RESULT and reports periodically on
|
||||||
|
// tele/<topic>/STATE; tele/<topic>/LWT is retained "Online"/"Offline".
|
||||||
|
import mqtt from 'mqtt'
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
import { recordEvent } from './events.js'
|
||||||
|
|
||||||
|
const CLIENT_NAME = 'lights-backend'
|
||||||
|
const BROKER_RECONNECT_BACKOFF_MS = [30, 60, 120, 300, 600, 1800].map(s => s * 1000)
|
||||||
|
const PENDING_MS = 15000
|
||||||
|
|
||||||
|
let client = null
|
||||||
|
let connecting = false
|
||||||
|
let reconnectAttempt = 0
|
||||||
|
let devices = new Map() // id -> device row (live state cached here)
|
||||||
|
let topicMap = new Map() // mqtt_topic -> [device]
|
||||||
|
const discovered = new Map() // unregistered topic -> { topic, online, channels, last_seen }
|
||||||
|
const pending = new Map() // device id -> expiry ms; our own command, so its echo isn't a "physical" change
|
||||||
|
let onlineHandler = null
|
||||||
|
|
||||||
|
export function setOnlineHandler(fn) { onlineHandler = fn }
|
||||||
|
|
||||||
|
async function getCredentials() {
|
||||||
|
const url = `${process.env.SETTINGS_URL}/settings/api/internal/mqtt-client/${CLIENT_NAME}`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching MQTT credentials for "${CLIENT_NAME}"`)
|
||||||
|
const s = await res.json()
|
||||||
|
if (!s.host || !s.username || !s.password) throw new Error('MQTT broker credentials not configured')
|
||||||
|
return { host: s.host, port: s.port || 1883, username: s.username, password: s.password }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshDevices() {
|
||||||
|
const { rows } = await pool.query('SELECT * FROM light_devices WHERE active = TRUE')
|
||||||
|
const next = new Map()
|
||||||
|
for (const r of rows) {
|
||||||
|
// Keep the in-memory state if we already have it — the DB copy is only ever
|
||||||
|
// behind it, never ahead.
|
||||||
|
const prev = devices.get(r.id)
|
||||||
|
next.set(r.id, prev ? { ...r, state: prev.state, online: prev.online } : r)
|
||||||
|
}
|
||||||
|
devices = next
|
||||||
|
topicMap = new Map()
|
||||||
|
for (const d of devices.values()) {
|
||||||
|
if (!topicMap.has(d.mqtt_topic)) topicMap.set(d.mqtt_topic, [])
|
||||||
|
topicMap.get(d.mqtt_topic).push(d)
|
||||||
|
discovered.delete(d.mqtt_topic)
|
||||||
|
}
|
||||||
|
if (client?.connected) queryAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDevice(id) { return devices.get(id) }
|
||||||
|
|
||||||
|
function queryAll() {
|
||||||
|
for (const topic of topicMap.keys()) client.publish(`cmnd/${topic}/State`, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelValue(obj, channel) {
|
||||||
|
const v = channel === 1 ? (obj.POWER1 ?? obj.POWER) : obj[`POWER${channel}`]
|
||||||
|
return typeof v === 'string' ? v.toUpperCase() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelsIn(obj) {
|
||||||
|
const out = []
|
||||||
|
if (obj.POWER !== undefined || obj.POWER1 !== undefined) out.push(1)
|
||||||
|
for (let n = 2; n <= 8; n++) if (obj[`POWER${n}`] !== undefined) out.push(n)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyState(device, value, now) {
|
||||||
|
const next = value === 'ON' ? 'on' : value === 'OFF' ? 'off' : null
|
||||||
|
if (!next) return
|
||||||
|
const prev = device.state
|
||||||
|
device.state = next
|
||||||
|
if (prev === next) {
|
||||||
|
pool.query('UPDATE light_devices SET state_at = $1 WHERE id = $2', [now, device.id]).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const expected = pending.get(device.id)
|
||||||
|
const ours = expected && expected > Date.now()
|
||||||
|
pending.delete(device.id)
|
||||||
|
// A change we didn't ask for (wall switch, Tasmota's own timer, the web UI)
|
||||||
|
// counts as a manual override, so the next reconcile doesn't fight it.
|
||||||
|
const physical = prev !== null && !ours
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE light_devices SET state = $1, state_at = $2${physical ? ', last_manual_at = NOW()' : ''} WHERE id = $3`,
|
||||||
|
[next, now, device.id]
|
||||||
|
).catch(err => console.error('[mqtt] state update failed:', err.message))
|
||||||
|
if (physical) await recordEvent(device, next, 'physical')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMessage(topic, message) {
|
||||||
|
const [prefix, devTopic, kind] = topic.split('/')
|
||||||
|
const text = message.toString().trim()
|
||||||
|
const list = topicMap.get(devTopic)
|
||||||
|
|
||||||
|
if (kind === 'LWT') {
|
||||||
|
const online = text.toLowerCase() === 'online'
|
||||||
|
if (!list) {
|
||||||
|
const d = discovered.get(devTopic) || { topic: devTopic, channels: [1] }
|
||||||
|
discovered.set(devTopic, { ...d, online, last_seen: new Date() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const cameOnline = online && list.some(d => d.online !== true)
|
||||||
|
for (const d of list) d.online = online
|
||||||
|
await pool.query('UPDATE light_devices SET online = $1 WHERE mqtt_topic = $2', [online, devTopic]).catch(() => {})
|
||||||
|
if (cameOnline) {
|
||||||
|
client?.publish(`cmnd/${devTopic}/State`, '')
|
||||||
|
onlineHandler?.(list.map(d => d.id))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefix === 'tele' ? kind !== 'STATE' : kind !== 'RESULT') return
|
||||||
|
let obj
|
||||||
|
try { obj = JSON.parse(text) } catch { return }
|
||||||
|
if (!obj || typeof obj !== 'object') return
|
||||||
|
const channels = channelsIn(obj)
|
||||||
|
if (!channels.length) return
|
||||||
|
|
||||||
|
if (!list) {
|
||||||
|
if (discovered.size < 200 || discovered.has(devTopic)) {
|
||||||
|
const d = discovered.get(devTopic) || { topic: devTopic, online: null }
|
||||||
|
discovered.set(devTopic, { ...d, channels, last_seen: new Date() })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const now = new Date()
|
||||||
|
for (const d of list) {
|
||||||
|
const v = channelValue(obj, d.channel)
|
||||||
|
if (v) await applyState(d, v, now)
|
||||||
|
}
|
||||||
|
// A state message from a device we thought offline proves it's alive.
|
||||||
|
if (list.some(d => d.online === false)) {
|
||||||
|
for (const d of list) d.online = true
|
||||||
|
pool.query('UPDATE light_devices SET online = TRUE WHERE mqtt_topic = $1', [devTopic]).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (connecting) return
|
||||||
|
const delay = BROKER_RECONNECT_BACKOFF_MS[Math.min(reconnectAttempt, BROKER_RECONNECT_BACKOFF_MS.length - 1)]
|
||||||
|
reconnectAttempt++
|
||||||
|
console.warn(`[mqtt] broker unreachable — retrying in ${delay / 1000}s`)
|
||||||
|
setTimeout(() => { connect().catch(() => {}) }, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connect() {
|
||||||
|
if (connecting || client?.connected) return
|
||||||
|
connecting = true
|
||||||
|
try {
|
||||||
|
const creds = await getCredentials()
|
||||||
|
await refreshDevices()
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const c = mqtt.connect(`mqtt://${creds.host}:${creds.port}`, {
|
||||||
|
username: creds.username,
|
||||||
|
password: creds.password,
|
||||||
|
clientId: `${CLIENT_NAME}-${Math.random().toString(16).slice(2, 8)}`,
|
||||||
|
connectTimeout: 10000,
|
||||||
|
reconnectPeriod: 0, // we own reconnect/backoff ourselves
|
||||||
|
})
|
||||||
|
|
||||||
|
c.on('connect', () => {
|
||||||
|
client = c
|
||||||
|
reconnectAttempt = 0
|
||||||
|
connecting = false
|
||||||
|
console.log('[mqtt] connected to broker')
|
||||||
|
c.subscribe(['tele/+/LWT', 'tele/+/STATE', 'stat/+/RESULT'])
|
||||||
|
queryAll()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
c.on('message', (topic, message) => {
|
||||||
|
handleMessage(topic, message).catch(err => console.error('[mqtt] message handling failed:', err.message))
|
||||||
|
})
|
||||||
|
c.on('error', err => console.error('[mqtt] connection error:', err.message))
|
||||||
|
c.on('close', () => {
|
||||||
|
if (client === c) client = null
|
||||||
|
connecting = false
|
||||||
|
scheduleReconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!client) { c.end(true); connecting = false; reject(new Error('MQTT connect timed out')) }
|
||||||
|
}, 12000)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
connecting = false
|
||||||
|
console.error('[mqtt] could not connect (broker/credentials unavailable):', err.message)
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConnected() { return !!client?.connected }
|
||||||
|
|
||||||
|
export function listDiscovered() {
|
||||||
|
return [...discovered.values()].sort((a, b) => a.topic.localeCompare(b.topic))
|
||||||
|
}
|
||||||
|
|
||||||
|
// state: 'on' | 'off'. Throws if the broker isn't connected so callers can tell
|
||||||
|
// the user instead of silently recording a command that never went out.
|
||||||
|
export function publishPower(device, state) {
|
||||||
|
if (!client?.connected) throw new Error('MQTT broker not connected')
|
||||||
|
pending.set(device.id, Date.now() + PENDING_MS)
|
||||||
|
const cmd = device.channel > 1 ? `POWER${device.channel}` : 'POWER'
|
||||||
|
client.publish(`cmnd/${device.mqtt_topic}/${cmd}`, state === 'on' ? 'ON' : 'OFF')
|
||||||
|
}
|
||||||
79
backend/src/lib/time.js
Normal file
79
backend/src/lib/time.js
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import SunCalc from 'suncalc'
|
||||||
|
|
||||||
|
const PART_FMT = new Map()
|
||||||
|
function fmt(tz) {
|
||||||
|
if (!PART_FMT.has(tz)) {
|
||||||
|
PART_FMT.set(tz, new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short',
|
||||||
|
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return PART_FMT.get(tz)
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAYS = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }
|
||||||
|
|
||||||
|
// Wall-clock parts of an instant in the site timezone.
|
||||||
|
export function localParts(date, tz) {
|
||||||
|
const p = Object.fromEntries(fmt(tz).formatToParts(date).map(x => [x.type, x.value]))
|
||||||
|
return {
|
||||||
|
y: Number(p.year), m: Number(p.month), d: Number(p.day),
|
||||||
|
hh: Number(p.hour), mm: Number(p.minute), weekday: WEEKDAYS[p.weekday],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ymd({ y, m, d }) {
|
||||||
|
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetMs(ts, tz) {
|
||||||
|
const t = ts - (ts % 60000)
|
||||||
|
const p = localParts(new Date(t), tz)
|
||||||
|
return Date.UTC(p.y, p.m - 1, p.d, p.hh, p.mm) - t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site-local wall-clock time -> UTC instant (handles DST by re-checking the offset once).
|
||||||
|
export function zonedToUtc(y, m, d, hh, mm, tz) {
|
||||||
|
const guess = Date.UTC(y, m - 1, d, hh, mm)
|
||||||
|
let ts = guess - offsetMs(guess, tz)
|
||||||
|
ts = guess - offsetMs(ts, tz)
|
||||||
|
return new Date(ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validTimezone(tz) {
|
||||||
|
try { fmt(tz); return true } catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The instant a schedule fires on the given site-local calendar date, or null
|
||||||
|
// (sun trigger with no location configured, or no sunrise/sunset that day).
|
||||||
|
export function triggerTime(s, day, settings) {
|
||||||
|
const tz = settings.timezone || 'Europe/London'
|
||||||
|
if (s.trigger_type === 'fixed') {
|
||||||
|
const [hh, mm] = (s.time_of_day || '').split(':').map(Number)
|
||||||
|
if (!Number.isInteger(hh) || !Number.isInteger(mm)) return null
|
||||||
|
return zonedToUtc(day.y, day.m, day.d, hh, mm, tz)
|
||||||
|
}
|
||||||
|
const lat = parseFloat(settings.latitude)
|
||||||
|
const lon = parseFloat(settings.longitude)
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
|
||||||
|
const t = sunTimes(day, settings)?.[s.trigger_type]
|
||||||
|
if (!t) return null
|
||||||
|
return new Date(t.getTime() + (s.offset_minutes || 0) * 60000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sunTimes(day, settings) {
|
||||||
|
const lat = parseFloat(settings.latitude)
|
||||||
|
const lon = parseFloat(settings.longitude)
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
|
||||||
|
const noon = zonedToUtc(day.y, day.m, day.d, 12, 0, settings.timezone || 'Europe/London')
|
||||||
|
const t = SunCalc.getTimes(noon, lat, lon)
|
||||||
|
const ok = d => (d instanceof Date && !isNaN(d) ? d : null)
|
||||||
|
return { sunrise: ok(t.sunrise), sunset: ok(t.sunset) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site-local calendar day `offset` days from `from` (offset may be negative).
|
||||||
|
export function dayOffset(from, offset, tz) {
|
||||||
|
const noon = zonedToUtc(from.y, from.m, from.d, 12, 0, tz)
|
||||||
|
const p = localParts(new Date(noon.getTime() + offset * 86400000), tz)
|
||||||
|
return { y: p.y, m: p.m, d: p.d, weekday: p.weekday }
|
||||||
|
}
|
||||||
152
backend/src/routes/devices.js
Normal file
152
backend/src/routes/devices.js
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
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 { 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`
|
||||||
|
)
|
||||||
|
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
|
||||||
|
FROM light_groups g LEFT JOIN light_group_members m ON m.group_id = g.id GROUP BY g.id ORDER BY g.name`
|
||||||
|
)
|
||||||
|
return { devices, groups }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deviceRoutes(app) {
|
||||||
|
app.addHook('preHandler', requireAuth)
|
||||||
|
|
||||||
|
app.get('/api/state', { preHandler: requireCap('view') }, async () => {
|
||||||
|
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 }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── manual control ──
|
||||||
|
app.post('/api/devices/:id/power', { preHandler: requireCap('control') }, async (req, reply) => {
|
||||||
|
const state = req.body?.state
|
||||||
|
if (state !== 'on' && state !== 'off') return reply.status(400).send({ error: 'state must be on or off' })
|
||||||
|
try {
|
||||||
|
await setPower(Number(req.params.id), state, 'manual', req.user.email)
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(e.message.includes('not connected') ? 503 : 404).send({ error: e.message })
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/groups/:id/power', { preHandler: requireCap('control') }, async (req, reply) => {
|
||||||
|
const state = req.body?.state
|
||||||
|
if (state !== 'on' && state !== 'off') return reply.status(400).send({ error: 'state must be on or off' })
|
||||||
|
if (!isConnected()) return reply.status(503).send({ error: 'MQTT broker not connected' })
|
||||||
|
const ids = await resolveTargetDevices('group', Number(req.params.id))
|
||||||
|
const failed = await setPowerMany(ids, state, 'manual', req.user.email)
|
||||||
|
return { ok: failed.length === 0, failed }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/reconcile', { preHandler: requireCap('control') }, async (req, reply) => {
|
||||||
|
if (!isConnected()) return reply.status(503).send({ error: 'MQTT broker not connected' })
|
||||||
|
return reconcile()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── devices ──
|
||||||
|
app.get('/api/devices/discovered', { preHandler: requireCap('manage_devices') }, async () => listDiscovered())
|
||||||
|
|
||||||
|
app.post('/api/devices', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||||
|
const { name, location, mqtt_topic, channel } = req.body || {}
|
||||||
|
const ch = Number(channel) || 1
|
||||||
|
if (!name?.trim()) return reply.status(400).send({ error: 'Name is required' })
|
||||||
|
if (!TOPIC_RE.test(mqtt_topic || '')) return reply.status(400).send({ error: 'Tasmota topic is required (letters, numbers, - _ .)' })
|
||||||
|
if (!Number.isInteger(ch) || ch < 1 || ch > 8) return reply.status(400).send({ error: 'Channel must be 1-8' })
|
||||||
|
try {
|
||||||
|
const { rows: [row] } = await pool.query(
|
||||||
|
`INSERT INTO light_devices (name, location, mqtt_topic, channel) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||||
|
[name.trim(), location?.trim() || null, mqtt_topic, ch]
|
||||||
|
)
|
||||||
|
await refreshDevices()
|
||||||
|
return row
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === '23505') return reply.status(409).send({ error: 'That topic + channel is already registered' })
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/api/devices/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||||
|
const { name, location, mqtt_topic, channel, active } = req.body || {}
|
||||||
|
if (mqtt_topic !== undefined && !TOPIC_RE.test(mqtt_topic)) return reply.status(400).send({ error: 'Invalid Tasmota topic' })
|
||||||
|
try {
|
||||||
|
const { rows: [row] } = await pool.query(
|
||||||
|
`UPDATE light_devices SET
|
||||||
|
name = COALESCE($1, name),
|
||||||
|
location = CASE WHEN $2::boolean THEN $3 ELSE location END,
|
||||||
|
mqtt_topic = COALESCE($4, mqtt_topic),
|
||||||
|
channel = COALESCE($5, channel),
|
||||||
|
active = COALESCE($6, active)
|
||||||
|
WHERE id = $7 RETURNING *`,
|
||||||
|
[name?.trim() || null, location !== undefined, location?.trim() || null, mqtt_topic ?? null, channel ?? null, active ?? null, req.params.id]
|
||||||
|
)
|
||||||
|
if (!row) return reply.status(404).send({ error: 'Not found' })
|
||||||
|
await refreshDevices()
|
||||||
|
return row
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === '23505') return reply.status(409).send({ error: 'That topic + channel is already registered' })
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/devices/:id', { preHandler: requireCap('manage_devices') }, async (req) => {
|
||||||
|
await pool.query(`DELETE FROM light_schedules WHERE target_type = 'device' AND target_id = $1`, [req.params.id])
|
||||||
|
await pool.query('DELETE FROM light_devices WHERE id = $1', [req.params.id])
|
||||||
|
await refreshDevices()
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── groups ──
|
||||||
|
async function setMembers(groupId, deviceIds) {
|
||||||
|
await pool.query('DELETE FROM light_group_members WHERE group_id = $1', [groupId])
|
||||||
|
for (const id of deviceIds) {
|
||||||
|
await pool.query('INSERT INTO light_group_members (group_id, device_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [groupId, id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/groups', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||||
|
const { name, device_ids } = req.body || {}
|
||||||
|
if (!name?.trim()) return reply.status(400).send({ error: 'Name is required' })
|
||||||
|
try {
|
||||||
|
const { rows: [row] } = await pool.query('INSERT INTO light_groups (name) VALUES ($1) RETURNING *', [name.trim()])
|
||||||
|
await setMembers(row.id, Array.isArray(device_ids) ? device_ids.map(Number) : [])
|
||||||
|
return row
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === '23505') return reply.status(409).send({ error: 'A group with that name already exists' })
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/api/groups/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||||
|
const { name, device_ids } = req.body || {}
|
||||||
|
const { rows: [row] } = await pool.query(
|
||||||
|
'UPDATE light_groups SET name = COALESCE($1, name) WHERE id = $2 RETURNING *', [name?.trim() || null, req.params.id]
|
||||||
|
).catch(e => { if (e.code === '23505') return { rows: [] }; throw e })
|
||||||
|
if (!row) return reply.status(404).send({ error: 'Not found (or name already used)' })
|
||||||
|
if (Array.isArray(device_ids)) await setMembers(row.id, device_ids.map(Number))
|
||||||
|
return row
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/groups/:id', { preHandler: requireCap('manage_devices') }, async (req) => {
|
||||||
|
await pool.query(`DELETE FROM light_schedules WHERE target_type = 'group' AND target_id = $1`, [req.params.id])
|
||||||
|
await pool.query('DELETE FROM light_groups WHERE id = $1', [req.params.id])
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── activity log ──
|
||||||
|
app.get('/api/events', { preHandler: requireCap('view') }, async (req) => {
|
||||||
|
const limit = Math.min(Number(req.query.limit) || 100, 500)
|
||||||
|
const { rows } = await pool.query('SELECT * FROM light_events ORDER BY at DESC LIMIT $1', [limit])
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
}
|
||||||
75
backend/src/routes/schedules.js
Normal file
75
backend/src/routes/schedules.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
|
import { pool, getSettings } from '../db.js'
|
||||||
|
import { nextRun } from '../lib/engine.js'
|
||||||
|
|
||||||
|
function validate(b, partial = false) {
|
||||||
|
const out = {}
|
||||||
|
const need = (cond, msg) => { if (!cond) throw Object.assign(new Error(msg), { statusCode: 400 }) }
|
||||||
|
if (!partial || b.name !== undefined) { need(b.name?.trim(), 'Name is required'); out.name = b.name.trim() }
|
||||||
|
if (!partial || b.target_type !== undefined) { need(['device', 'group'].includes(b.target_type), 'target_type must be device or group'); out.target_type = b.target_type }
|
||||||
|
if (!partial || b.target_id !== undefined) { need(Number.isInteger(Number(b.target_id)), 'target_id is required'); out.target_id = Number(b.target_id) }
|
||||||
|
if (!partial || b.action !== undefined) { need(['on', 'off'].includes(b.action), 'action must be on or off'); out.action = b.action }
|
||||||
|
if (!partial || b.trigger_type !== undefined) { need(['fixed', 'sunrise', 'sunset'].includes(b.trigger_type), 'trigger_type must be fixed, sunrise or sunset'); out.trigger_type = b.trigger_type }
|
||||||
|
if (b.time_of_day !== undefined) { need(/^([01]\d|2[0-3]):[0-5]\d$/.test(b.time_of_day || ''), 'time_of_day must be HH:MM'); out.time_of_day = b.time_of_day }
|
||||||
|
if (b.offset_minutes !== undefined) {
|
||||||
|
const o = Number(b.offset_minutes)
|
||||||
|
need(Number.isInteger(o) && o >= -720 && o <= 720, 'offset_minutes must be a whole number between -720 and 720')
|
||||||
|
out.offset_minutes = o
|
||||||
|
}
|
||||||
|
if (b.days !== undefined) {
|
||||||
|
need(Array.isArray(b.days) && b.days.length > 0 && b.days.every(d => Number.isInteger(d) && d >= 0 && d <= 6), 'days must be a non-empty list of 0-6')
|
||||||
|
out.days = [...new Set(b.days)].sort()
|
||||||
|
}
|
||||||
|
if (b.enabled !== undefined) out.enabled = !!b.enabled
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scheduleRoutes(app) {
|
||||||
|
app.addHook('preHandler', requireAuth)
|
||||||
|
|
||||||
|
app.setErrorHandler((err, req, reply) => {
|
||||||
|
if (err.statusCode === 400) return reply.status(400).send({ error: err.message })
|
||||||
|
req.log.error(err)
|
||||||
|
reply.status(err.statusCode || 500).send({ error: err.message })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/schedules', { preHandler: requireCap('view') }, async () => {
|
||||||
|
const settings = await getSettings()
|
||||||
|
const { rows } = await pool.query(`SELECT * FROM light_schedules ORDER BY trigger_type, time_of_day NULLS LAST, name`)
|
||||||
|
return rows.map(s => ({ ...s, next_run: s.enabled ? nextRun(s, settings) : null }))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/schedules', { preHandler: requireCap('schedules') }, async (req) => {
|
||||||
|
const v = validate(req.body || {})
|
||||||
|
if (v.trigger_type === 'fixed' && !v.time_of_day) throw Object.assign(new Error('time_of_day is required for fixed schedules'), { statusCode: 400 })
|
||||||
|
const { rows: [row] } = await pool.query(
|
||||||
|
`INSERT INTO light_schedules (name, enabled, target_type, target_id, action, trigger_type, time_of_day, offset_minutes, days)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||||
|
[v.name, v.enabled ?? true, v.target_type, v.target_id, v.action, v.trigger_type,
|
||||||
|
v.trigger_type === 'fixed' ? v.time_of_day : null, v.trigger_type === 'fixed' ? 0 : (v.offset_minutes ?? 0),
|
||||||
|
v.days ?? [0, 1, 2, 3, 4, 5, 6]]
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/api/schedules/:id', { preHandler: requireCap('schedules') }, async (req, reply) => {
|
||||||
|
const v = validate(req.body || {}, true)
|
||||||
|
const { rows: [cur] } = await pool.query('SELECT * FROM light_schedules WHERE id = $1', [req.params.id])
|
||||||
|
if (!cur) return reply.status(404).send({ error: 'Not found' })
|
||||||
|
const m = { ...cur, ...v }
|
||||||
|
if (m.trigger_type === 'fixed') { m.offset_minutes = 0; if (!m.time_of_day) return reply.status(400).send({ error: 'time_of_day is required for fixed schedules' }) }
|
||||||
|
else m.time_of_day = null
|
||||||
|
// Editing a trigger re-arms it, so moving a time later today still fires.
|
||||||
|
const { rows: [row] } = await pool.query(
|
||||||
|
`UPDATE light_schedules SET name=$1, enabled=$2, target_type=$3, target_id=$4, action=$5, trigger_type=$6,
|
||||||
|
time_of_day=$7, offset_minutes=$8, days=$9, last_fired_on=NULL WHERE id=$10 RETURNING *`,
|
||||||
|
[m.name, m.enabled, m.target_type, m.target_id, m.action, m.trigger_type, m.time_of_day, m.offset_minutes, m.days, req.params.id]
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/schedules/:id', { preHandler: requireCap('schedules') }, async (req) => {
|
||||||
|
await pool.query('DELETE FROM light_schedules WHERE id = $1', [req.params.id])
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
42
backend/src/routes/settings.js
Normal file
42
backend/src/routes/settings.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
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'])
|
||||||
|
|
||||||
|
export async function settingsRoutes(app) {
|
||||||
|
app.addHook('preHandler', requireAuth)
|
||||||
|
|
||||||
|
app.get('/api/settings', { preHandler: requireCap('settings') }, async () => {
|
||||||
|
const { rows } = await pool.query('SELECT key, value, updated_at FROM app_settings ORDER BY key')
|
||||||
|
return { settings: rows }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/settings', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||||
|
const { settings } = req.body || {}
|
||||||
|
if (!Array.isArray(settings)) return reply.status(400).send({ error: 'settings must be an array' })
|
||||||
|
for (const { key, value } of settings) {
|
||||||
|
if (!ALLOWED_KEYS.has(key)) continue
|
||||||
|
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 === '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())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
|
||||||
|
[key, v]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/settings/mqtt-status', { preHandler: requireCap('settings') }, async () => ({
|
||||||
|
connected: isConnected(),
|
||||||
|
note: isConnected()
|
||||||
|
? 'Connected to the MQTT broker.'
|
||||||
|
: 'Not connected — create a service client named "lights-backend" under Settings → MQTT Clients, or check the broker (LXC 104) is reachable.',
|
||||||
|
}))
|
||||||
|
}
|
||||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
security_opt:
|
||||||
|
- apparmor=unconfined
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
||||||
|
- SETTINGS_URL=${SETTINGS_URL}
|
||||||
|
- SETTINGS_SECRET=${SETTINGS_SECRET}
|
||||||
|
- APP_SLUG=lights
|
||||||
|
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
args:
|
||||||
|
VITE_HOTEL_NAME: ${VITE_HOTEL_NAME}
|
||||||
|
security_opt:
|
||||||
|
- apparmor=unconfined
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-3080}:80"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
driver: bridge
|
||||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
ARG VITE_HOTEL_NAME
|
||||||
|
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html/lights
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
18
frontend/index.html
Normal file
18
frontend/index.html
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Lighting" />
|
||||||
|
<link rel="apple-touch-icon" href="/lights/icons/icon-192.png" />
|
||||||
|
<meta name="theme-color" content="#b45309" />
|
||||||
|
<title>Lighting</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
frontend/nginx.conf
Normal file
40
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
client_max_body_size 12m;
|
||||||
|
|
||||||
|
location /lights/api/auth/ {
|
||||||
|
proxy_pass http://10.10.10.101:3001/api/auth/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /lights/api/ {
|
||||||
|
proxy_pass http://backend:3001/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
add_header Cache-Control "no-store";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /lights/health {
|
||||||
|
proxy_pass http://backend:3001/health;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* /lights/.*\.(js|css|png|ico|svg|woff2?)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /lights/ {
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
try_files $uri /lights/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = / {
|
||||||
|
return 301 /lights/;
|
||||||
|
}
|
||||||
|
}
|
||||||
6243
frontend/package-lock.json
generated
Normal file
6243
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "hnf-lights-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.1",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.5",
|
||||||
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
41
frontend/src/App.tsx
Normal file
41
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import AuthGate, { useAuth } from './components/AuthGate'
|
||||||
|
import IosInstallHint from './components/IosInstallHint'
|
||||||
|
import Layout from './components/Layout'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import Schedules from './pages/Schedules'
|
||||||
|
import Devices from './pages/Devices'
|
||||||
|
import Log from './pages/Log'
|
||||||
|
import Settings from './pages/Settings'
|
||||||
|
import { can } from './types'
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
if (can(user, 'view')) return <Navigate to="/dashboard" replace />
|
||||||
|
if (can(user, 'manage_devices')) return <Navigate to="/devices" replace />
|
||||||
|
if (can(user, 'settings')) return <Navigate to="/settings" replace />
|
||||||
|
return <div className="page"><p className="muted">You don't have access to any Lighting pages yet.</p></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BrowserRouter basename="/lights">
|
||||||
|
<AuthGate>
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/schedules" element={<Schedules />} />
|
||||||
|
<Route path="/devices" element={<Devices />} />
|
||||||
|
<Route path="/log" element={<Log />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
</AuthGate>
|
||||||
|
</BrowserRouter>
|
||||||
|
<IosInstallHint />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
frontend/src/api.ts
Normal file
54
frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import type {
|
||||||
|
StateResponse, LightDevice, LightGroup, Schedule, ScheduleInput, DiscoveredDevice, LightEvent, AppSetting, PowerState,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const BASE = '/lights/api'
|
||||||
|
|
||||||
|
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||||||
|
...opts,
|
||||||
|
})
|
||||||
|
if (res.status === 401) {
|
||||||
|
;(window.top ?? window).location.href = '/login'
|
||||||
|
throw new Error('Unauthenticated')
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
throw new Error(err.error || `Request failed: ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (method: string, body: unknown): RequestInit => ({ method, body: JSON.stringify(body) })
|
||||||
|
|
||||||
|
// State + control
|
||||||
|
export const fetchState = (): Promise<StateResponse> => request('/state')
|
||||||
|
export const setDevicePower = (id: number, state: PowerState) => request(`/devices/${id}/power`, json('POST', { state }))
|
||||||
|
export const setGroupPower = (id: number, state: PowerState) => request(`/groups/${id}/power`, json('POST', { state }))
|
||||||
|
export const reapplySchedules = (): Promise<{ applied: number }> => request('/reconcile', { method: 'POST' })
|
||||||
|
|
||||||
|
// Devices
|
||||||
|
export interface DeviceInput { name?: string; location?: string | null; mqtt_topic?: string; channel?: number; active?: boolean }
|
||||||
|
export const fetchDiscovered = (): Promise<DiscoveredDevice[]> => request('/devices/discovered')
|
||||||
|
export const createDevice = (body: DeviceInput): Promise<LightDevice> => request('/devices', json('POST', body))
|
||||||
|
export const updateDevice = (id: number, body: DeviceInput): Promise<LightDevice> => request(`/devices/${id}`, json('PATCH', body))
|
||||||
|
export const deleteDevice = (id: number) => request(`/devices/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Groups
|
||||||
|
export const createGroup = (name: string, device_ids: number[]): Promise<LightGroup> => request('/groups', json('POST', { name, device_ids }))
|
||||||
|
export const updateGroup = (id: number, name: string, device_ids: number[]): Promise<LightGroup> => request(`/groups/${id}`, json('PATCH', { name, device_ids }))
|
||||||
|
export const deleteGroup = (id: number) => request(`/groups/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Schedules
|
||||||
|
export const fetchSchedules = (): Promise<Schedule[]> => request('/schedules')
|
||||||
|
export const createSchedule = (body: ScheduleInput): Promise<Schedule> => request('/schedules', json('POST', body))
|
||||||
|
export const updateSchedule = (id: number, body: Partial<ScheduleInput>): Promise<Schedule> => request(`/schedules/${id}`, json('PATCH', body))
|
||||||
|
export const deleteSchedule = (id: number) => request(`/schedules/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Log + settings
|
||||||
|
export const fetchEvents = (limit = 200): Promise<LightEvent[]> => request(`/events?limit=${limit}`)
|
||||||
|
export const getSettings = (): Promise<{ settings: AppSetting[] }> => request('/settings')
|
||||||
|
export const saveSettings = (settings: { key: string; value: string }[]) => request('/settings', json('PUT', { settings }))
|
||||||
|
export const fetchMqttStatus = (): Promise<{ connected: boolean; note: string }> => request('/settings/mqtt-status')
|
||||||
169
frontend/src/components/AuthGate.tsx
Normal file
169
frontend/src/components/AuthGate.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { useEffect, useRef, useState, createContext, useContext } from 'react'
|
||||||
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
function getInactivityMs(): number | null {
|
||||||
|
if (window.matchMedia('(display-mode: standalone)').matches) return null
|
||||||
|
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins='))
|
||||||
|
if (!c) return null
|
||||||
|
const mins = parseInt(c.split('=')[1])
|
||||||
|
return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only bounce to the central login when actually embedded in the portal shell.
|
||||||
|
// A directly-opened browser tab must never navigate away from its own scope.
|
||||||
|
function isEmbedded() {
|
||||||
|
return window.top !== window
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthCtx { user: User }
|
||||||
|
const Ctx = createContext<AuthCtx | null>(null)
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(Ctx)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/verify?app=lights', { credentials: 'include' })
|
||||||
|
.then(async r => {
|
||||||
|
if (r.ok) {
|
||||||
|
setUser(await r.json())
|
||||||
|
setState('authed')
|
||||||
|
} else if (isEmbedded()) {
|
||||||
|
window.top!.location.href = `/login?from=${encodeURIComponent('/app/lights')}`
|
||||||
|
} else {
|
||||||
|
setState('login')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { if (!isEmbedded()) setState('login') })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Inactivity auto-logout — configurable per device (Admin Settings → Device).
|
||||||
|
useEffect(() => {
|
||||||
|
const ms = getInactivityMs()
|
||||||
|
if (state !== 'authed' || !ms) return
|
||||||
|
const timeoutMs: number = ms
|
||||||
|
|
||||||
|
async function forceLogout() {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||||
|
setUser(null)
|
||||||
|
setState('login')
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = setTimeout(forceLogout, timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const
|
||||||
|
events.forEach(e => window.addEventListener(e, reset, { passive: true }))
|
||||||
|
reset()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
events.forEach(e => window.removeEventListener(e, reset))
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
async function login(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
if (!res.ok) { setError('Invalid email or password'); return }
|
||||||
|
const verify = await fetch('/api/auth/verify?app=lights', { credentials: 'include' })
|
||||||
|
if (verify.ok) {
|
||||||
|
setUser(await verify.json())
|
||||||
|
setState('authed')
|
||||||
|
} else {
|
||||||
|
setError("You don't have access to this app.")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Connection error — please try again')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'checking') {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
|
||||||
|
}}>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'login') {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
|
||||||
|
background: 'var(--navy-dark)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--navy)', borderRadius: 'var(--radius)',
|
||||||
|
padding: '2rem', width: '100%', maxWidth: '360px',
|
||||||
|
border: '1px solid var(--surface-2)',
|
||||||
|
}}>
|
||||||
|
<h1 style={{ fontSize: '1.4rem', marginBottom: '0.25rem', color: 'var(--gold)' }}>
|
||||||
|
Lighting
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: '1.5rem' }}>
|
||||||
|
{import.meta.env.VITE_HOTEL_NAME}
|
||||||
|
</p>
|
||||||
|
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
<input
|
||||||
|
type="email" value={email} onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder="Email" required autoComplete="email"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="Password" required autoComplete="current-password"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
|
||||||
|
<button type="submit" disabled={loading} style={{
|
||||||
|
background: loading ? 'var(--surface-2)' : 'var(--gold)',
|
||||||
|
color: loading ? 'var(--text-muted)' : 'var(--navy-dark)',
|
||||||
|
border: 'none', borderRadius: '6px', padding: '0.625rem',
|
||||||
|
fontSize: '1rem', fontWeight: 600, marginTop: '0.25rem',
|
||||||
|
}}>
|
||||||
|
{loading ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Ctx.Provider value={{ user: user! }}>
|
||||||
|
{children}
|
||||||
|
</Ctx.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
|
||||||
|
borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem',
|
||||||
|
fontSize: '1rem', width: '100%', outline: 'none',
|
||||||
|
}
|
||||||
89
frontend/src/components/IosInstallHint.tsx
Normal file
89
frontend/src/components/IosInstallHint.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Share, ExternalLink, X } from 'lucide-react'
|
||||||
|
|
||||||
|
function isIos() {
|
||||||
|
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||||
|
// iPadOS 13+ reports as 'MacIntel' but has touch support, unlike a real Mac
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStandalone() {
|
||||||
|
return window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
(window.navigator as unknown as { standalone?: boolean }).standalone === true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safari's UA also matches "Safari", so this checks for the other iOS
|
||||||
|
// browsers/in-app webviews that spoof it — none of them can add to the
|
||||||
|
// home screen; only Safari itself can.
|
||||||
|
function isNonSafariIosBrowser() {
|
||||||
|
return /CriOS|FxiOS|EdgiOS|OPiOS|mercury|GSA|DuckDuckGo|Instagram|FBAN|FBAV|Line\//.test(navigator.userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS has no `beforeinstallprompt` API — Safari never fires it, so the
|
||||||
|
* portal's Install button (?install=1) does nothing there. This shows the
|
||||||
|
* manual steps instead: Share -> Add to Home Screen in Safari, or a prompt
|
||||||
|
* to switch to Safari first if the page was opened in another browser/app.
|
||||||
|
*/
|
||||||
|
export default function IosInstallHint() {
|
||||||
|
const [mode, setMode] = useState<'safari' | 'other' | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isIos() || isStandalone()) return
|
||||||
|
if (!new URLSearchParams(window.location.search).has('install')) return
|
||||||
|
setMode(isNonSafariIosBrowser() ? 'other' : 'safari')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!mode) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 9999,
|
||||||
|
background: 'rgba(15,15,32,0.55)',
|
||||||
|
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
|
||||||
|
padding: '0 16px 24px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%', maxWidth: '360px',
|
||||||
|
background: 'var(--card-bg)', borderRadius: 'var(--radius)',
|
||||||
|
border: '1px solid var(--card-border)', boxShadow: 'var(--shadow-md)',
|
||||||
|
padding: '20px', textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode(null)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '10px', right: '10px',
|
||||||
|
background: 'none', border: 'none', color: 'var(--text-mid)',
|
||||||
|
padding: '4px', cursor: 'pointer', lineHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
width: '44px', height: '44px', margin: '0 auto 12px',
|
||||||
|
borderRadius: '50%', background: 'var(--gold)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{mode === 'safari'
|
||||||
|
? <Share size={22} strokeWidth={1.75} color="var(--navy)" />
|
||||||
|
: <ExternalLink size={22} strokeWidth={1.75} color="var(--navy)" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'safari' ? (
|
||||||
|
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
|
||||||
|
To install this app, tap the <strong>Share</strong> icon in Safari's
|
||||||
|
toolbar, then choose <strong>Add to Home Screen</strong>.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
|
||||||
|
iOS only allows installing apps from <strong>Safari</strong>. Open this
|
||||||
|
page in Safari, then tap Share → <strong>Add to Home Screen</strong>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
frontend/src/components/Layout.tsx
Normal file
74
frontend/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
|
import { Lightbulb, LayoutGrid, CalendarClock, Cpu, History, Settings, Menu, LogOut } from 'lucide-react'
|
||||||
|
import { useAuth } from './AuthGate'
|
||||||
|
import { can } from '../types'
|
||||||
|
|
||||||
|
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ to: '/dashboard', label: 'Control', icon: LayoutGrid, cap: 'view' },
|
||||||
|
{ to: '/schedules', label: 'Schedules', icon: CalendarClock, cap: 'view' },
|
||||||
|
{ to: '/devices', label: 'Devices', icon: Cpu, cap: 'manage_devices' },
|
||||||
|
{ to: '/log', label: 'Activity', icon: History, cap: 'view' },
|
||||||
|
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const items = NAV.filter(n => can(user, n.cap))
|
||||||
|
const location = useLocation()
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch('/lights/api/auth/logout', { method: 'POST', credentials: 'include' })
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { setMenuOpen(false) }, [location.pathname])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`app-shell${menuOpen ? ' menu-open' : ''}`}>
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-logo">
|
||||||
|
<Lightbulb size={18} strokeWidth={1.75} />
|
||||||
|
Lighting
|
||||||
|
</div>
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
{items.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
|
||||||
|
<Icon {...ICON_PROPS} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="sidebar-user" style={{ whiteSpace: 'normal' }}>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--text)', fontSize: '12px', marginBottom: '2px' }}>{user.name}</div>
|
||||||
|
<div style={{ fontSize: '11px', marginBottom: '8px' }}>{user.email}</div>
|
||||||
|
<button onClick={logout} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '6px',
|
||||||
|
background: 'none', border: 'none', color: 'inherit',
|
||||||
|
fontSize: '12px', padding: 0, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<LogOut size={13} strokeWidth={1.75} />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{menuOpen && <div className="menu-backdrop" onClick={() => setMenuOpen(false)} />}
|
||||||
|
|
||||||
|
<header className="top-bar">
|
||||||
|
<button className="top-bar-burger" onClick={() => setMenuOpen(o => !o)}>
|
||||||
|
<Menu size={20} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<Lightbulb size={18} strokeWidth={1.75} color="var(--gold)" />
|
||||||
|
<span className="top-bar-title">Lighting</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="page-content">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
370
frontend/src/index.css
Normal file
370
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
/* Stack design system tokens — include verbatim in every app */
|
||||||
|
:root {
|
||||||
|
--navy: #1a1a2e;
|
||||||
|
--navy-dark: #0f0f20;
|
||||||
|
--gold: #c9a84c;
|
||||||
|
--gold-light: #e8c96d;
|
||||||
|
--surface: rgba(255,255,255,0.07);
|
||||||
|
--surface-2: rgba(255,255,255,0.08);
|
||||||
|
--text: rgba(255,255,255,0.88);
|
||||||
|
--text-muted: rgba(255,255,255,0.48);
|
||||||
|
--body-bg: #f4f5f7;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--card-border: #e4e8ee;
|
||||||
|
--text-dark: #1e293b;
|
||||||
|
--text-mid: #64748b;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
|
||||||
|
--danger: #dc2626;
|
||||||
|
--radius: 10px;
|
||||||
|
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
body { background: var(--body-bg); color: var(--text-dark); font-family: var(--font); }
|
||||||
|
|
||||||
|
/* App theme + semantic tokens */
|
||||||
|
:root {
|
||||||
|
--app-primary: #b45309;
|
||||||
|
--app-primary-light: #0891b2;
|
||||||
|
|
||||||
|
--sev-warning: #d97706;
|
||||||
|
--sev-critical: #dc2626;
|
||||||
|
--sev-ok: #16a34a;
|
||||||
|
|
||||||
|
--danger-bg: #fef2f2;
|
||||||
|
--warn-bg: #fffbeb;
|
||||||
|
--ok-bg: #f0fdf4;
|
||||||
|
|
||||||
|
--sidebar-w: 240px;
|
||||||
|
--topbar-h: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html, body, #root { height: 100%; margin: 0; font-size: 14px; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 2px; }
|
||||||
|
|
||||||
|
/* ── App shell ─────────────────────────────────────────────── */
|
||||||
|
.app-shell { display: flex; height: 100vh; overflow: hidden; }
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-w);
|
||||||
|
background: var(--navy);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.sidebar-logo {
|
||||||
|
padding: 20px 16px 12px;
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sidebar-logo svg { opacity: .8; }
|
||||||
|
.sidebar-nav { flex: 1; padding: 8px 0; }
|
||||||
|
.sidebar-nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13.5px;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
|
||||||
|
.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
|
||||||
|
.sidebar-user {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--surface-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
display: none;
|
||||||
|
height: var(--topbar-h);
|
||||||
|
background: var(--navy);
|
||||||
|
color: var(--text);
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.top-bar-title { flex: 1; font-size: 15px; font-weight: 600; color: var(--gold); }
|
||||||
|
|
||||||
|
.page-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; bottom: 0;
|
||||||
|
z-index: 200;
|
||||||
|
transform: translateX(calc(-1 * var(--sidebar-w)));
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
.app-shell.menu-open .sidebar { transform: translateX(0); }
|
||||||
|
.top-bar { display: flex; }
|
||||||
|
.app-shell { flex-direction: column; }
|
||||||
|
.field-row { flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
z-index: 199;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-burger {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page chrome ───────────────────────────────────────────── */
|
||||||
|
.page { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; }
|
||||||
|
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||||
|
.page-header h1 { font-size: 18px; margin: 0; flex: 1; }
|
||||||
|
|
||||||
|
/* ── Buttons ───────────────────────────────────────────────── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-dark);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font);
|
||||||
|
transition: background .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--text-mid); }
|
||||||
|
.btn:disabled { opacity: .5; cursor: default; }
|
||||||
|
.btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
|
||||||
|
.btn-primary:hover { background: var(--gold-light); border-color: var(--gold-light); }
|
||||||
|
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||||
|
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 8px; }
|
||||||
|
|
||||||
|
/* ── Forms ─────────────────────────────────────────────────── */
|
||||||
|
.field { margin-bottom: 12px; }
|
||||||
|
.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; }
|
||||||
|
.field input[type="text"], .field input[type="email"], .field input[type="date"],
|
||||||
|
.field input[type="number"], .field select, .field textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--text-dark);
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.field textarea { min-height: 72px; resize: vertical; }
|
||||||
|
.field-row { display: flex; gap: 12px; }
|
||||||
|
.field-row > .field { flex: 1; }
|
||||||
|
.field-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; }
|
||||||
|
.field-check input { width: 16px; height: 16px; accent-color: var(--gold); }
|
||||||
|
.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; }
|
||||||
|
|
||||||
|
/* ── Cards ─────────────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Asset grid / cards ────────────────────────────────────── */
|
||||||
|
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||||
|
.asset-card {
|
||||||
|
border-left: 4px solid var(--sev-ok);
|
||||||
|
}
|
||||||
|
.asset-card.sev-warning { border-left-color: var(--sev-warning); }
|
||||||
|
.asset-card.sev-critical { border-left-color: var(--sev-critical); }
|
||||||
|
.asset-card-title { font-weight: 600; font-size: 14px; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; justify-content: space-between; }
|
||||||
|
.asset-card-meta { font-size: 12px; color: var(--text-mid); margin-bottom: 8px; }
|
||||||
|
.asset-field-list { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.asset-field-row { display: flex; justify-content: space-between; gap: 8px; font-size: 12.5px; }
|
||||||
|
.asset-field-key { color: var(--text-mid); }
|
||||||
|
.asset-field-value { font-weight: 600; color: var(--text-dark); }
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Badges ────────────────────────────────────────────────── */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge-sev-warning { background: var(--sev-warning); }
|
||||||
|
.badge-sev-critical { background: var(--sev-critical); }
|
||||||
|
.badge-sev-ok { background: var(--sev-ok); }
|
||||||
|
|
||||||
|
.badge-status-open { background: var(--sev-critical); }
|
||||||
|
.badge-status-acknowledged { background: var(--sev-warning); }
|
||||||
|
.badge-status-resolved { background: var(--sev-ok); }
|
||||||
|
|
||||||
|
.badge-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
color: var(--text-mid);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal ─────────────────────────────────────────────────── */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15,15,32,.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 12px;
|
||||||
|
z-index: 100;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 680px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: auto 0;
|
||||||
|
}
|
||||||
|
.modal-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.modal-header h2 { font-size: 16px; margin: 0; flex: 1; }
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-mid);
|
||||||
|
padding: 2px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ── Photos ────────────────────────────────────────────────── */
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.spin { animation: spin .75s linear infinite; }
|
||||||
|
|
||||||
|
/* ── Tables ────────────────────────────────────────────────── */
|
||||||
|
.table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
|
||||||
|
table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
table.data th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--text-mid);
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border); vertical-align: top; }
|
||||||
|
table.data tr:last-child td { border-bottom: none; }
|
||||||
|
table.data tr.clickable { cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── Misc ──────────────────────────────────────────────────── */
|
||||||
|
.empty-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; }
|
||||||
|
.error-banner {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.ok-banner {
|
||||||
|
background: var(--ok-bg);
|
||||||
|
border: 1px solid var(--sev-ok);
|
||||||
|
color: var(--sev-ok);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.warn-banner {
|
||||||
|
background: var(--warn-bg);
|
||||||
|
border: 1px solid var(--sev-warning);
|
||||||
|
color: var(--sev-warning);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; }
|
||||||
|
.muted { color: var(--text-mid); }
|
||||||
|
|
||||||
|
/* Sidebar scrollbar */
|
||||||
|
.sidebar::-webkit-scrollbar, .sidebar-nav::-webkit-scrollbar { width: 4px; }
|
||||||
|
.sidebar::-webkit-scrollbar-track, .sidebar-nav::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.sidebar::-webkit-scrollbar-thumb, .sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; }
|
||||||
|
.sidebar::-webkit-scrollbar-thumb:hover, .sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
|
||||||
|
.sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }
|
||||||
|
|
||||||
|
/* ── Lights ─────────────────────────────────────────────────── */
|
||||||
|
.field input[type="time"] {
|
||||||
|
width: 100%; padding: 8px 10px; font-size: 13.5px; font-family: inherit;
|
||||||
|
border: 1px solid var(--card-border); border-radius: 8px; background: var(--card-bg); color: var(--text-dark);
|
||||||
|
}
|
||||||
|
.tile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
||||||
|
.tile {
|
||||||
|
background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm); padding: 12px 14px; display: flex; flex-direction: column; gap: 8px;
|
||||||
|
}
|
||||||
|
.tile.on { border-color: var(--gold); }
|
||||||
|
.tile.offline { opacity: .55; }
|
||||||
|
.tile-top { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.tile-name { font-weight: 600; font-size: 13.5px; flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.tile-meta { font-size: 11.5px; color: var(--text-mid); }
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-mid); flex-shrink: 0; }
|
||||||
|
.dot.online { background: var(--sev-ok); }
|
||||||
|
.dot.offline { background: var(--sev-critical); }
|
||||||
|
.switch {
|
||||||
|
position: relative; width: 44px; height: 24px; border-radius: 12px; border: none; cursor: pointer;
|
||||||
|
background: var(--card-border); flex-shrink: 0; transition: background .15s;
|
||||||
|
}
|
||||||
|
.switch::after {
|
||||||
|
content: ''; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%;
|
||||||
|
background: #fff; box-shadow: var(--shadow-sm); transition: transform .15s;
|
||||||
|
}
|
||||||
|
.switch.on { background: var(--gold); }
|
||||||
|
.switch.on::after { transform: translateX(20px); }
|
||||||
|
.switch:disabled { opacity: .5; cursor: default; }
|
||||||
|
.day-picker { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.day-picker button {
|
||||||
|
width: 38px; height: 32px; border-radius: 8px; border: 1px solid var(--card-border);
|
||||||
|
background: var(--card-bg); color: var(--text-dark); font-size: 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.day-picker button.sel { background: var(--navy); border-color: var(--navy); color: #fff; }
|
||||||
|
.check-list { display: flex; flex-direction: column; gap: 6px; max-height: 240px; overflow-y: auto; padding: 4px 0; }
|
||||||
|
.sun-strip { display: flex; gap: 16px; align-items: center; font-size: 13px; color: var(--text-mid); flex-wrap: wrap; }
|
||||||
|
.sun-strip span { display: inline-flex; align-items: center; gap: 5px; }
|
||||||
19
frontend/src/main.tsx
Normal file
19
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
// When opened from the portal's install button (?install=1), trigger the
|
||||||
|
// PWA install prompt as soon as the browser offers it (Chrome/Edge only).
|
||||||
|
if (new URLSearchParams(window.location.search).has('install')) {
|
||||||
|
window.addEventListener('beforeinstallprompt', e => {
|
||||||
|
e.preventDefault()
|
||||||
|
;(e as Event & { prompt: () => Promise<void> }).prompt()
|
||||||
|
}, { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
138
frontend/src/pages/Dashboard.tsx
Normal file
138
frontend/src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Sunrise, Sunset, WifiOff, RefreshCw, Lightbulb, LightbulbOff } from 'lucide-react'
|
||||||
|
import { useAuth } from '../components/AuthGate'
|
||||||
|
import { fetchState, setDevicePower, setGroupPower, reapplySchedules } from '../api'
|
||||||
|
import type { StateResponse, LightDevice, PowerState } from '../types'
|
||||||
|
import { can } from '../types'
|
||||||
|
|
||||||
|
const POLL_MS = 5000
|
||||||
|
|
||||||
|
function fmtTime(iso: string | null, tz: string) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: tz })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const canControl = can(user, 'control')
|
||||||
|
const [data, setData] = useState<StateResponse | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
|
const timer = useRef<number>()
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try { setData(await fetchState()); setError('') }
|
||||||
|
catch (e) { setError(e instanceof Error ? e.message : 'Failed to load') }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
timer.current = window.setInterval(load, POLL_MS)
|
||||||
|
return () => window.clearInterval(timer.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function act(key: string, fn: () => Promise<unknown>) {
|
||||||
|
setBusy(key); setError('')
|
||||||
|
try { await fn() } catch (e) { setError(e instanceof Error ? e.message : 'Command failed') }
|
||||||
|
// The device confirms over MQTT a moment after the command.
|
||||||
|
setTimeout(() => { load(); setBusy(null) }, 1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reapply() {
|
||||||
|
setError(''); setMsg('')
|
||||||
|
try {
|
||||||
|
const { applied } = await reapplySchedules()
|
||||||
|
setMsg(applied ? `Corrected ${applied} light${applied === 1 ? '' : 's'} to match their schedules` : 'Everything already matches its schedule')
|
||||||
|
setTimeout(() => setMsg(''), 4000)
|
||||||
|
load()
|
||||||
|
} catch (e) { setError(e instanceof Error ? e.message : 'Failed') }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return <div className="page">{error ? <div className="error-banner">{error}</div> : <p className="muted">Loading…</p>}</div>
|
||||||
|
|
||||||
|
const byId = new Map(data.devices.map(d => [d.id, d]))
|
||||||
|
const active = data.devices.filter(d => d.active)
|
||||||
|
const locations = [...new Set(active.map(d => d.location || 'Other'))]
|
||||||
|
|
||||||
|
function DeviceTile({ d }: { d: LightDevice }) {
|
||||||
|
const on = d.state === 'on'
|
||||||
|
const offline = d.online === false
|
||||||
|
return (
|
||||||
|
<div className={`tile${on ? ' on' : ''}${offline ? ' offline' : ''}`}>
|
||||||
|
<div className="tile-top">
|
||||||
|
<span className={`dot${d.online === true ? ' online' : offline ? ' offline' : ''}`} />
|
||||||
|
<span className="tile-name">{d.name}</span>
|
||||||
|
<button
|
||||||
|
className={`switch${on ? ' on' : ''}`}
|
||||||
|
disabled={!canControl || busy === `d${d.id}`}
|
||||||
|
aria-label={`Turn ${d.name} ${on ? 'off' : 'on'}`}
|
||||||
|
onClick={() => act(`d${d.id}`, () => setDevicePower(d.id, on ? 'off' : 'on'))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="tile-meta">
|
||||||
|
{offline ? <><WifiOff size={11} strokeWidth={1.75} /> Offline</> : d.state === null ? 'State unknown' : on ? 'On' : 'Off'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupBtn = (id: number, state: PowerState, label: string) => (
|
||||||
|
<button className="btn btn-sm" disabled={!canControl || busy === `g${id}`}
|
||||||
|
onClick={() => act(`g${id}`, () => setGroupPower(id, state))}>{label}</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Lighting</h1>
|
||||||
|
<div className="sun-strip">
|
||||||
|
<span><Sunrise size={15} strokeWidth={1.75} /> {fmtTime(data.sun?.sunrise ?? null, data.timezone)}</span>
|
||||||
|
<span><Sunset size={15} strokeWidth={1.75} /> {fmtTime(data.sun?.sunset ?? null, data.timezone)}</span>
|
||||||
|
</div>
|
||||||
|
{canControl && (
|
||||||
|
<button className="btn btn-sm" onClick={reapply} title="Set every light to what its schedules say it should be right now (manual changes made since the last schedule event are kept)">
|
||||||
|
<RefreshCw size={13} strokeWidth={1.75} /> Re-apply schedules
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
{msg && <div className="ok-banner">{msg}</div>}
|
||||||
|
{!data.mqtt_connected && <div className="warn-banner">The lighting controller can't reach the MQTT broker — switches below may be out of date and commands will fail.</div>}
|
||||||
|
{!data.sun && <div className="warn-banner">No site location set, so sunrise/sunset schedules won't run. Add latitude/longitude in Settings.</div>}
|
||||||
|
|
||||||
|
{data.groups.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="section-title">Groups</div>
|
||||||
|
<div className="tile-grid">
|
||||||
|
{data.groups.map(g => {
|
||||||
|
const members = g.device_ids.map(id => byId.get(id)).filter((d): d is LightDevice => !!d)
|
||||||
|
const onCount = members.filter(d => d.state === 'on').length
|
||||||
|
return (
|
||||||
|
<div key={g.id} className={`tile${onCount ? ' on' : ''}`}>
|
||||||
|
<div className="tile-top">
|
||||||
|
<span className="tile-name">{g.name}</span>
|
||||||
|
{onCount ? <Lightbulb size={16} strokeWidth={1.75} color="var(--gold)" /> : <LightbulbOff size={16} strokeWidth={1.75} color="var(--text-mid)" />}
|
||||||
|
</div>
|
||||||
|
<div className="tile-meta">{onCount} of {members.length} on</div>
|
||||||
|
{canControl && <div style={{ display: 'flex', gap: 6 }}>{groupBtn(g.id, 'on', 'All on')}{groupBtn(g.id, 'off', 'All off')}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active.length === 0 && <div className="empty-state">No lights set up yet. Add them under Devices.</div>}
|
||||||
|
{locations.map(loc => (
|
||||||
|
<div key={loc}>
|
||||||
|
<div className="section-title">{loc}</div>
|
||||||
|
<div className="tile-grid">
|
||||||
|
{active.filter(d => (d.location || 'Other') === loc).map(d => <DeviceTile key={d.id} d={d} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
196
frontend/src/pages/Devices.tsx
Normal file
196
frontend/src/pages/Devices.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Plus, Pencil, Trash2, X, Radar } from 'lucide-react'
|
||||||
|
import { fetchState, fetchDiscovered, createDevice, updateDevice, deleteDevice, createGroup, updateGroup, deleteGroup } from '../api'
|
||||||
|
import type { StateResponse, LightDevice, LightGroup, DiscoveredDevice } from '../types'
|
||||||
|
|
||||||
|
interface DeviceForm { id: number | null; name: string; location: string; mqtt_topic: string; channel: number }
|
||||||
|
interface GroupForm { id: number | null; name: string; device_ids: number[] }
|
||||||
|
|
||||||
|
export default function Devices() {
|
||||||
|
const [data, setData] = useState<StateResponse | null>(null)
|
||||||
|
const [discovered, setDiscovered] = useState<DiscoveredDevice[]>([])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [deviceForm, setDeviceForm] = useState<DeviceForm | null>(null)
|
||||||
|
const [groupForm, setGroupForm] = useState<GroupForm | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
fetchState().then(setData).catch(e => setError(e.message))
|
||||||
|
fetchDiscovered().then(setDiscovered).catch(() => {})
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
const t = window.setInterval(load, 10000)
|
||||||
|
return () => window.clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function run(fn: () => Promise<unknown>, done: () => void) {
|
||||||
|
setSaving(true); setError('')
|
||||||
|
try { await fn(); done(); load() }
|
||||||
|
catch (e) { setError(e instanceof Error ? e.message : 'Failed') }
|
||||||
|
finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveDevice = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!deviceForm) return
|
||||||
|
const body = { name: deviceForm.name, location: deviceForm.location || null, mqtt_topic: deviceForm.mqtt_topic.trim(), channel: deviceForm.channel }
|
||||||
|
run(() => deviceForm.id ? updateDevice(deviceForm.id, body) : createDevice(body), () => setDeviceForm(null))
|
||||||
|
}
|
||||||
|
const saveGroup = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!groupForm) return
|
||||||
|
run(() => groupForm.id ? updateGroup(groupForm.id, groupForm.name, groupForm.device_ids) : createGroup(groupForm.name, groupForm.device_ids), () => setGroupForm(null))
|
||||||
|
}
|
||||||
|
const removeDevice = (d: LightDevice) => {
|
||||||
|
if (confirm(`Remove "${d.name}"? Any schedules targeting it are deleted too.`)) run(() => deleteDevice(d.id), () => {})
|
||||||
|
}
|
||||||
|
const removeGroup = (g: LightGroup) => {
|
||||||
|
if (confirm(`Delete group "${g.name}"? Any schedules targeting it are deleted too (the lights themselves are kept).`)) run(() => deleteGroup(g.id), () => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return <div className="page">{error ? <div className="error-banner">{error}</div> : <p className="muted">Loading…</p>}</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Devices</h1>
|
||||||
|
<button className="btn btn-primary" onClick={() => setDeviceForm({ id: null, name: '', location: '', mqtt_topic: '', channel: 1 })}>
|
||||||
|
<Plus size={14} strokeWidth={1.75} /> Add light
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
{discovered.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="section-title"><Radar size={13} strokeWidth={1.75} style={{ verticalAlign: -2 }} /> Found on the broker, not added yet</div>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data">
|
||||||
|
<tbody>
|
||||||
|
{discovered.map(d => (
|
||||||
|
<tr key={d.topic}>
|
||||||
|
<td><code>{d.topic}</code></td>
|
||||||
|
<td className="muted">{d.channels.length > 1 ? `${d.channels.length} relays` : '1 relay'}</td>
|
||||||
|
<td className="muted">{d.online === false ? 'Offline' : 'Online'}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>
|
||||||
|
<button className="btn btn-sm" onClick={() => setDeviceForm({ id: null, name: d.topic, location: '', mqtt_topic: d.topic, channel: d.channels[0] ?? 1 })}>
|
||||||
|
<Plus size={13} strokeWidth={1.75} /> Add
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="section-title">Lights</div>
|
||||||
|
{data.devices.length === 0 ? <div className="empty-state">No lights yet. Add one, or pick one found on the broker above.</div> : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data">
|
||||||
|
<thead><tr><th>Name</th><th>Location</th><th>Tasmota topic</th><th>Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{data.devices.map(d => (
|
||||||
|
<tr key={d.id} style={{ opacity: d.active ? 1 : 0.5 }}>
|
||||||
|
<td>{d.name}</td>
|
||||||
|
<td>{d.location ?? '—'}</td>
|
||||||
|
<td><code>{d.mqtt_topic}{d.channel > 1 ? ` · relay ${d.channel}` : ''}</code></td>
|
||||||
|
<td>{!d.active ? 'Disabled' : d.online === false ? 'Offline' : d.state ? d.state.toUpperCase() : 'Unknown'}</td>
|
||||||
|
<td style={{ whiteSpace: 'nowrap', textAlign: 'right' }}>
|
||||||
|
<button className="btn btn-sm" onClick={() => setDeviceForm({ id: d.id, name: d.name, location: d.location ?? '', mqtt_topic: d.mqtt_topic, channel: d.channel })}><Pencil size={13} strokeWidth={1.75} /></button>{' '}
|
||||||
|
<button className="btn btn-sm" onClick={() => removeDevice(d)}><Trash2 size={13} strokeWidth={1.75} /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="section-title" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span>Groups</span>
|
||||||
|
<button className="btn btn-sm" onClick={() => setGroupForm({ id: null, name: '', device_ids: [] })}><Plus size={13} strokeWidth={1.75} /> Add group</button>
|
||||||
|
</div>
|
||||||
|
{data.groups.length === 0 ? <div className="empty-state">Groups let one button or schedule control several lights, e.g. "Car park" or "Reception".</div> : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data">
|
||||||
|
<tbody>
|
||||||
|
{data.groups.map(g => (
|
||||||
|
<tr key={g.id}>
|
||||||
|
<td>{g.name}</td>
|
||||||
|
<td className="muted">{g.device_ids.map(id => data.devices.find(d => d.id === id)?.name).filter(Boolean).join(', ') || 'No lights'}</td>
|
||||||
|
<td style={{ whiteSpace: 'nowrap', textAlign: 'right' }}>
|
||||||
|
<button className="btn btn-sm" onClick={() => setGroupForm({ id: g.id, name: g.name, device_ids: g.device_ids })}><Pencil size={13} strokeWidth={1.75} /></button>{' '}
|
||||||
|
<button className="btn btn-sm" onClick={() => removeGroup(g)}><Trash2 size={13} strokeWidth={1.75} /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deviceForm && (
|
||||||
|
<div className="modal-overlay" onClick={() => setDeviceForm(null)}>
|
||||||
|
<form className="modal" onClick={e => e.stopPropagation()} onSubmit={saveDevice}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>{deviceForm.id ? 'Edit light' : 'Add light'}</h2>
|
||||||
|
<button type="button" className="modal-close" onClick={() => setDeviceForm(null)}><X size={18} strokeWidth={1.75} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>Name</label>
|
||||||
|
<input type="text" required value={deviceForm.name} onChange={e => setDeviceForm({ ...deviceForm, name: e.target.value })} placeholder="Car park – north post" />
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>Location</label>
|
||||||
|
<input type="text" value={deviceForm.location} onChange={e => setDeviceForm({ ...deviceForm, location: e.target.value })} placeholder="Car park" />
|
||||||
|
<div className="field-hint">Lights are grouped by location on the control screen.</div>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field"><label>Tasmota topic</label>
|
||||||
|
<input type="text" required value={deviceForm.mqtt_topic} onChange={e => setDeviceForm({ ...deviceForm, mqtt_topic: e.target.value })} placeholder="tasmota_A1B2C3" />
|
||||||
|
<div className="field-hint">The switch's MQTT "Topic" (Tasmota → Configuration → MQTT).</div>
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>Relay</label>
|
||||||
|
<input type="number" min={1} max={8} required value={deviceForm.channel} onChange={e => setDeviceForm({ ...deviceForm, channel: Number(e.target.value) })} />
|
||||||
|
<div className="field-hint">1 unless it's a multi-relay switch.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="btn" onClick={() => setDeviceForm(null)}>Cancel</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupForm && (
|
||||||
|
<div className="modal-overlay" onClick={() => setGroupForm(null)}>
|
||||||
|
<form className="modal" onClick={e => e.stopPropagation()} onSubmit={saveGroup}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>{groupForm.id ? 'Edit group' : 'Add group'}</h2>
|
||||||
|
<button type="button" className="modal-close" onClick={() => setGroupForm(null)}><X size={18} strokeWidth={1.75} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>Name</label>
|
||||||
|
<input type="text" required value={groupForm.name} onChange={e => setGroupForm({ ...groupForm, name: e.target.value })} placeholder="Car park" />
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>Lights in this group</label>
|
||||||
|
<div className="check-list">
|
||||||
|
{data.devices.map(d => (
|
||||||
|
<label key={d.id} className="field-check">
|
||||||
|
<input type="checkbox" checked={groupForm.device_ids.includes(d.id)}
|
||||||
|
onChange={e => setGroupForm({ ...groupForm, device_ids: e.target.checked ? [...groupForm.device_ids, d.id] : groupForm.device_ids.filter(x => x !== d.id) })} />
|
||||||
|
{d.name}{d.location ? <span className="muted"> · {d.location}</span> : null}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="btn" onClick={() => setGroupForm(null)}>Cancel</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
frontend/src/pages/Log.tsx
Normal file
43
frontend/src/pages/Log.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { fetchEvents } from '../api'
|
||||||
|
import type { LightEvent } from '../types'
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<LightEvent['source'], string> = {
|
||||||
|
manual: 'Manual', schedule: 'Schedule', reconcile: 'Catch-up', physical: 'Wall switch / device',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Log() {
|
||||||
|
const [events, setEvents] = useState<LightEvent[]>([])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = () => fetchEvents().then(setEvents).catch(e => setError(e.message))
|
||||||
|
load()
|
||||||
|
const t = window.setInterval(load, 15000)
|
||||||
|
return () => window.clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header"><h1>Activity</h1></div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
{events.length === 0 ? <div className="empty-state">Nothing has changed yet.</div> : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data">
|
||||||
|
<thead><tr><th>When</th><th>Light</th><th>Change</th><th>Cause</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{events.map(ev => (
|
||||||
|
<tr key={ev.id}>
|
||||||
|
<td>{new Date(ev.at).toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}</td>
|
||||||
|
<td>{ev.device_name}</td>
|
||||||
|
<td>{ev.action === 'on' ? 'Turned on' : 'Turned off'}</td>
|
||||||
|
<td>{SOURCE_LABELS[ev.source]}{ev.detail ? <span className="muted"> · {ev.detail}</span> : null}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
217
frontend/src/pages/Schedules.tsx
Normal file
217
frontend/src/pages/Schedules.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Plus, Pencil, Trash2, X, Sunrise, Sunset, Clock } from 'lucide-react'
|
||||||
|
import { useAuth } from '../components/AuthGate'
|
||||||
|
import { fetchSchedules, fetchState, createSchedule, updateSchedule, deleteSchedule } from '../api'
|
||||||
|
import type { Schedule, ScheduleInput, StateResponse, TriggerType, PowerState } from '../types'
|
||||||
|
import { can, DAY_LABELS } from '../types'
|
||||||
|
|
||||||
|
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6]
|
||||||
|
const BLANK: ScheduleInput = {
|
||||||
|
name: '', enabled: true, target_type: 'group', target_id: 0, action: 'on',
|
||||||
|
trigger_type: 'sunset', time_of_day: '18:00', offset_minutes: 0, days: ALL_DAYS,
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeTrigger(s: Schedule) {
|
||||||
|
if (s.trigger_type === 'fixed') return s.time_of_day ?? ''
|
||||||
|
const base = s.trigger_type === 'sunrise' ? 'Sunrise' : 'Sunset'
|
||||||
|
if (!s.offset_minutes) return base
|
||||||
|
return `${base} ${s.offset_minutes < 0 ? '−' : '+'} ${Math.abs(s.offset_minutes)} min`
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeDays(days: number[]) {
|
||||||
|
if (days.length === 7) return 'Every day'
|
||||||
|
if (days.length === 5 && [1, 2, 3, 4, 5].every(d => days.includes(d))) return 'Mon–Fri'
|
||||||
|
if (days.length === 2 && days.includes(0) && days.includes(6)) return 'Weekends'
|
||||||
|
return days.map(d => DAY_LABELS[d]).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Schedules() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const canEdit = can(user, 'schedules')
|
||||||
|
const [schedules, setSchedules] = useState<Schedule[]>([])
|
||||||
|
const [state, setState] = useState<StateResponse | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [editing, setEditing] = useState<{ id: number | null; form: ScheduleInput } | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
fetchSchedules().then(setSchedules).catch(e => setError(e.message))
|
||||||
|
fetchState().then(setState).catch(() => {})
|
||||||
|
}
|
||||||
|
useEffect(load, [])
|
||||||
|
|
||||||
|
const targetName = (s: { target_type: string; target_id: number }) => {
|
||||||
|
if (!state) return '…'
|
||||||
|
const list = s.target_type === 'group' ? state.groups : state.devices
|
||||||
|
return list.find(t => t.id === s.target_id)?.name ?? '(deleted)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
const first = state?.groups[0] ? { target_type: 'group' as const, target_id: state.groups[0].id }
|
||||||
|
: { target_type: 'device' as const, target_id: state?.devices[0]?.id ?? 0 }
|
||||||
|
setEditing({ id: null, form: { ...BLANK, ...first } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(s: Schedule) {
|
||||||
|
setEditing({
|
||||||
|
id: s.id,
|
||||||
|
form: {
|
||||||
|
name: s.name, enabled: s.enabled, target_type: s.target_type, target_id: s.target_id, action: s.action,
|
||||||
|
trigger_type: s.trigger_type, time_of_day: s.time_of_day ?? '18:00', offset_minutes: s.offset_minutes, days: s.days,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!editing) return
|
||||||
|
setSaving(true); setError('')
|
||||||
|
try {
|
||||||
|
const f = editing.form
|
||||||
|
const body: ScheduleInput = f.trigger_type === 'fixed'
|
||||||
|
? { ...f, offset_minutes: 0 } : { ...f, time_of_day: undefined }
|
||||||
|
if (editing.id) await updateSchedule(editing.id, body); else await createSchedule(body)
|
||||||
|
setEditing(null); load()
|
||||||
|
} catch (err) { setError(err instanceof Error ? err.message : 'Save failed') }
|
||||||
|
finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(s: Schedule) {
|
||||||
|
await updateSchedule(s.id, { enabled: !s.enabled }).catch(e => setError(e.message))
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(s: Schedule) {
|
||||||
|
if (!confirm(`Delete schedule "${s.name}"?`)) return
|
||||||
|
await deleteSchedule(s.id).catch(e => setError(e.message))
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = (patch: Partial<ScheduleInput>) => setEditing(ed => ed && { ...ed, form: { ...ed.form, ...patch } })
|
||||||
|
const targets = editing && state ? (editing.form.target_type === 'group' ? state.groups : state.devices) : []
|
||||||
|
const TriggerIcon = ({ t }: { t: TriggerType }) =>
|
||||||
|
t === 'sunrise' ? <Sunrise size={14} strokeWidth={1.75} /> : t === 'sunset' ? <Sunset size={14} strokeWidth={1.75} /> : <Clock size={14} strokeWidth={1.75} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Schedules</h1>
|
||||||
|
{canEdit && <button className="btn btn-primary" onClick={openNew}><Plus size={14} strokeWidth={1.75} /> New schedule</button>}
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
{state && !state.sun && schedules.some(s => s.trigger_type !== 'fixed') && (
|
||||||
|
<div className="warn-banner">Sunrise/sunset schedules won't run until a site location is set in Settings.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schedules.length === 0 ? <div className="empty-state">No schedules yet.</div> : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data">
|
||||||
|
<thead><tr><th>Name</th><th>Does</th><th>When</th><th>Days</th><th>Next run</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{schedules.map(s => (
|
||||||
|
<tr key={s.id} style={{ opacity: s.enabled ? 1 : 0.5 }}>
|
||||||
|
<td>{s.name}</td>
|
||||||
|
<td>Turn <strong>{targetName(s)}</strong> {s.action}</td>
|
||||||
|
<td><span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}><TriggerIcon t={s.trigger_type} />{describeTrigger(s)}</span></td>
|
||||||
|
<td>{describeDays(s.days)}</td>
|
||||||
|
<td>{s.next_run ? new Date(s.next_run).toLocaleString('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit', timeZone: state?.timezone }) : '—'}</td>
|
||||||
|
<td style={{ whiteSpace: 'nowrap', textAlign: 'right' }}>
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<button className={`switch${s.enabled ? ' on' : ''}`} onClick={() => toggle(s)} aria-label="Enable or disable" style={{ marginRight: 8, verticalAlign: 'middle' }} />
|
||||||
|
<button className="btn btn-sm" onClick={() => openEdit(s)}><Pencil size={13} strokeWidth={1.75} /></button>{' '}
|
||||||
|
<button className="btn btn-sm" onClick={() => remove(s)}><Trash2 size={13} strokeWidth={1.75} /></button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div className="modal-overlay" onClick={() => setEditing(null)}>
|
||||||
|
<form className="modal" onClick={e => e.stopPropagation()} onSubmit={save}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>{editing.id ? 'Edit schedule' : 'New schedule'}</h2>
|
||||||
|
<button type="button" className="modal-close" onClick={() => setEditing(null)}><X size={18} strokeWidth={1.75} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" required value={editing.form.name} onChange={e => set({ name: e.target.value })} placeholder="Car park on at dusk" />
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label>Turn</label>
|
||||||
|
<select value={editing.form.action} onChange={e => set({ action: e.target.value as PowerState })}>
|
||||||
|
<option value="on">On</option><option value="off">Off</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Target type</label>
|
||||||
|
<select value={editing.form.target_type} onChange={e => {
|
||||||
|
const t = e.target.value as 'device' | 'group'
|
||||||
|
const list = state ? (t === 'group' ? state.groups : state.devices) : []
|
||||||
|
set({ target_type: t, target_id: list[0]?.id ?? 0 })
|
||||||
|
}}>
|
||||||
|
<option value="group">Group</option><option value="device">Single light</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Target</label>
|
||||||
|
<select required value={editing.form.target_id} onChange={e => set({ target_id: Number(e.target.value) })}>
|
||||||
|
{targets.length === 0 && <option value="">None available</option>}
|
||||||
|
{targets.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label>When</label>
|
||||||
|
<select value={editing.form.trigger_type} onChange={e => set({ trigger_type: e.target.value as TriggerType })}>
|
||||||
|
<option value="fixed">At a fixed time</option>
|
||||||
|
<option value="sunrise">Sunrise</option>
|
||||||
|
<option value="sunset">Sunset</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{editing.form.trigger_type === 'fixed' ? (
|
||||||
|
<div className="field">
|
||||||
|
<label>Time</label>
|
||||||
|
<input type="time" required value={editing.form.time_of_day ?? ''} onChange={e => set({ time_of_day: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="field">
|
||||||
|
<label>Offset (minutes)</label>
|
||||||
|
<input type="number" min={-720} max={720} value={editing.form.offset_minutes ?? 0} onChange={e => set({ offset_minutes: Number(e.target.value) })} />
|
||||||
|
<div className="field-hint">Negative = before, positive = after</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Days</label>
|
||||||
|
<div className="day-picker">
|
||||||
|
{DAY_LABELS.map((label, d) => (
|
||||||
|
<button type="button" key={d} className={editing.form.days.includes(d) ? 'sel' : ''}
|
||||||
|
onClick={() => set({ days: editing.form.days.includes(d) ? editing.form.days.filter(x => x !== d) : [...editing.form.days, d].sort() })}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="field-check">
|
||||||
|
<input type="checkbox" checked={editing.form.enabled} onChange={e => set({ enabled: e.target.checked })} /> Enabled
|
||||||
|
</label>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="btn" onClick={() => setEditing(null)}>Cancel</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving || editing.form.days.length === 0 || !editing.form.target_id}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
frontend/src/pages/Settings.tsx
Normal file
79
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Wifi, WifiOff } from 'lucide-react'
|
||||||
|
import { getSettings, saveSettings, fetchMqttStatus } from '../api'
|
||||||
|
|
||||||
|
const SETTING_LABELS: Record<string, { label: string; hint: string; placeholder?: string }> = {
|
||||||
|
latitude: {
|
||||||
|
label: 'Latitude',
|
||||||
|
hint: 'Site latitude in decimal degrees (e.g. 50.2). Needed for sunrise/sunset schedules.',
|
||||||
|
placeholder: '50.2',
|
||||||
|
},
|
||||||
|
longitude: {
|
||||||
|
label: 'Longitude',
|
||||||
|
hint: 'Site longitude in decimal degrees — negative is west (e.g. -5.1).',
|
||||||
|
placeholder: '-5.1',
|
||||||
|
},
|
||||||
|
timezone: {
|
||||||
|
label: 'Timezone',
|
||||||
|
hint: 'IANA timezone name. Decides what a fixed time like 18:00 means, and handles daylight saving.',
|
||||||
|
placeholder: 'Europe/London',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [mqttConnected, setMqttConnected] = useState<boolean | null>(null)
|
||||||
|
const [mqttNote, setMqttNote] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSettings()
|
||||||
|
.then(({ settings }) => setValues(Object.fromEntries(settings.map(s => [s.key, s.value ?? '']))))
|
||||||
|
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load settings'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true); setError(''); setMsg('')
|
||||||
|
try {
|
||||||
|
await saveSettings(Object.entries(values).map(([key, value]) => ({ key, value })))
|
||||||
|
setMsg('Settings saved'); setTimeout(() => setMsg(''), 2500)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Save failed')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="page"><p className="muted">Loading…</p></div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page" style={{ maxWidth: 640 }}>
|
||||||
|
<div className="page-header"><h1>Settings</h1></div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
{msg && <div className="ok-banner">{msg}</div>}
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 16, display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||||
|
{mqttConnected ? <Wifi size={18} strokeWidth={1.75} color="var(--sev-ok)" /> : <WifiOff size={18} strokeWidth={1.75} color="var(--sev-critical)" />}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13.5 }}>MQTT broker: {mqttConnected === null ? 'checking…' : mqttConnected ? 'connected' : 'not connected'}</div>
|
||||||
|
<div className="field-hint">{mqttNote}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.entries(SETTING_LABELS).map(([key, meta]) => (
|
||||||
|
<div className="field" key={key}>
|
||||||
|
<label>{meta.label}</label>
|
||||||
|
<input type="text" value={values[key] ?? ''} placeholder={meta.placeholder}
|
||||||
|
onChange={e => setValues({ ...values, [key]: e.target.value })} />
|
||||||
|
<div className="field-hint">{meta.hint}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className="btn btn-primary" disabled={saving} onClick={handleSave}>{saving ? 'Saving…' : 'Save Settings'}</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
frontend/src/types.ts
Normal file
96
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
export type PowerState = 'on' | 'off'
|
||||||
|
export type TriggerType = 'fixed' | 'sunrise' | 'sunset'
|
||||||
|
|
||||||
|
export interface LightDevice {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
location: string | null
|
||||||
|
mqtt_topic: string
|
||||||
|
channel: number
|
||||||
|
active: boolean
|
||||||
|
state: PowerState | null
|
||||||
|
online: boolean | null
|
||||||
|
state_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LightGroup {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
device_ids: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SunTimes {
|
||||||
|
sunrise: string | null
|
||||||
|
sunset: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateResponse {
|
||||||
|
devices: LightDevice[]
|
||||||
|
groups: LightGroup[]
|
||||||
|
mqtt_connected: boolean
|
||||||
|
sun: SunTimes | null
|
||||||
|
timezone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Schedule {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
target_type: 'device' | 'group'
|
||||||
|
target_id: number
|
||||||
|
action: PowerState
|
||||||
|
trigger_type: TriggerType
|
||||||
|
time_of_day: string | null
|
||||||
|
offset_minutes: number
|
||||||
|
days: number[]
|
||||||
|
next_run: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleInput {
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
target_type: 'device' | 'group'
|
||||||
|
target_id: number
|
||||||
|
action: PowerState
|
||||||
|
trigger_type: TriggerType
|
||||||
|
time_of_day?: string
|
||||||
|
offset_minutes?: number
|
||||||
|
days: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoveredDevice {
|
||||||
|
topic: string
|
||||||
|
online: boolean | null
|
||||||
|
channels: number[]
|
||||||
|
last_seen: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LightEvent {
|
||||||
|
id: number
|
||||||
|
at: string
|
||||||
|
device_id: number | null
|
||||||
|
device_name: string
|
||||||
|
action: PowerState
|
||||||
|
source: 'manual' | 'schedule' | 'reconcile' | 'physical'
|
||||||
|
detail: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSetting {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
is_admin: boolean
|
||||||
|
caps: string[] // bare slugs — verify?app=lights strips the prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
export function can(user: User, cap: string): boolean {
|
||||||
|
return user.is_admin || user.caps.includes(cap)
|
||||||
|
}
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
31
frontend/vite.config.ts
Normal file
31
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/lights/',
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
manifest: {
|
||||||
|
name: 'Lighting',
|
||||||
|
short_name: 'Lighting',
|
||||||
|
start_url: '/lights/',
|
||||||
|
scope: '/lights/',
|
||||||
|
display: 'standalone',
|
||||||
|
theme_color: '#b45309',
|
||||||
|
background_color: '#b45309',
|
||||||
|
icons: [
|
||||||
|
{ src: '/lights/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: '/lights/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
navigateFallback: '/lights/index.html',
|
||||||
|
navigateFallbackDenylist: [/\/api\//],
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
43
seed-app.js
Normal file
43
seed-app.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Run from lights/ dir: DATABASE_URL=... node seed-app.js
|
||||||
|
import pg from 'pg'
|
||||||
|
|
||||||
|
const { Pool } = pg
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
|
||||||
|
VALUES ('lights', 'Lighting', 'Tasmota smart-switch lighting — manual control plus on/off and sunrise/sunset schedules via MQTT', '/lights', 'Lightbulb', '#b45309', 'Hotel', '10.10.10.133', 3080)
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
base_path = EXCLUDED.base_path,
|
||||||
|
icon = EXCLUDED.icon,
|
||||||
|
theme_color = EXCLUDED.theme_color,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
internal_host = EXCLUDED.internal_host,
|
||||||
|
internal_port = EXCLUDED.internal_port
|
||||||
|
`)
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
|
||||||
|
SELECT a.id, c.slug, c.name, c.description, c.sort_order
|
||||||
|
FROM apps a, (VALUES
|
||||||
|
('view', 'View', 'See the lighting control screen, schedules and activity log', 1),
|
||||||
|
('control', 'Control', 'Switch lights and groups on/off manually', 2),
|
||||||
|
('schedules', 'Schedules', 'Create, edit and delete on/off and sunrise/sunset schedules', 3),
|
||||||
|
('manage_devices', 'Manage Devices', 'Add, edit and remove lights and light groups', 4),
|
||||||
|
('settings', 'Settings', 'Configure site location, timezone and view MQTT status', 5)
|
||||||
|
) AS c(slug, name, description, sort_order)
|
||||||
|
WHERE a.slug = 'lights'
|
||||||
|
ON CONFLICT (app_id, slug) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
sort_order = EXCLUDED.sort_order
|
||||||
|
`)
|
||||||
|
|
||||||
|
// No default Staff grants — access is admin-assigned per role via the portal
|
||||||
|
// (same convention as hvac/plant/wages/utilities).
|
||||||
|
|
||||||
|
console.log('lights app seeded.')
|
||||||
|
await pool.end()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue