From 2db7d5f4d0dd897486114b0a736e006d255f45e2 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 25 Aug 2026 20:44:50 +0000 Subject: [PATCH] Add kiosk wall-panel control view Full-screen /kiosk route, separate from the staff SPA: dedicated long-lived kiosk login (own password, hashed, mints its own hnf_session cookie locally via CENTRAL_AUTH_SECRET -- no central auth changes needed), scrollable grid of small mode/setpoint blocks per live-controllable device (shelly_trv, mhi_mqtt, daikin), tap-to-edit modal reusing the existing per-type control panels, and a type-aware bulk multiselect (separate TRV/AC sections, only applied to matching selected devices, TRV commands staggered). - backend/src/routes/kiosk.js: public POST /api/kiosk/login - backend/src/routes/settings.js: admin set/check kiosk password (bcrypt) - backend/src/routes/config.js: exclude/protect kiosk_password_hash - frontend/src/pages/Kiosk.tsx + components/Kiosk*: the kiosk UI - reuses GET /api/status for the combined device list -- no new status endpoint needed since the kiosk token now also carries the 'view' cap Co-Authored-By: Claude Sonnet 5 --- backend/package.json | 1 + backend/src/db.js | 6 + backend/src/index.js | 2 + backend/src/routes/config.js | 9 +- backend/src/routes/kiosk.js | 71 +++++++ backend/src/routes/settings.js | 20 ++ docker-compose.yml | 4 + frontend/src/App.tsx | 39 ++-- frontend/src/api.ts | 58 ++++++ frontend/src/components/KioskBulkPanel.tsx | 131 ++++++++++++ frontend/src/components/KioskDeviceBlock.tsx | 57 ++++++ frontend/src/components/KioskModal.tsx | 55 +++++ frontend/src/index.css | 56 ++++++ frontend/src/pages/Kiosk.tsx | 201 +++++++++++++++++++ frontend/src/pages/Settings.tsx | 54 ++++- frontend/src/types.ts | 7 + 16 files changed, 757 insertions(+), 14 deletions(-) create mode 100644 backend/src/routes/kiosk.js create mode 100644 frontend/src/components/KioskBulkPanel.tsx create mode 100644 frontend/src/components/KioskDeviceBlock.tsx create mode 100644 frontend/src/components/KioskModal.tsx create mode 100644 frontend/src/pages/Kiosk.tsx diff --git a/backend/package.json b/backend/package.json index 568575e..8f03662 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,7 @@ "@fastify/cors": "^9.0.1", "@fastify/multipart": "^8.3.0", "@fastify/static": "^7.0.4", + "bcryptjs": "^2.4.3", "fastify": "^4.28.1", "jose": "^5.9.6", "luxon": "^3.5.0", diff --git a/backend/src/db.js b/backend/src/db.js index d7dff48..6e10059 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -272,6 +272,12 @@ async function seedDefaults() { maintenance_api_key: '', hvac_defaults: DEFAULT_HVAC_TEMPLATE, history_retention_days: 4, // how long device_readings rows are kept — see pruneOldReadings() + // Bcrypt hash of the kiosk wall-panel's login password — never the plaintext. + // NULL until an admin sets one via Settings -> Kiosk (see routes/kiosk.js / + // routes/settings.js). Deliberately excluded from GET /api/config's response + // (routes/config.js) and from the generic PUT /api/config/:key path — the only + // way to change it is POST /api/settings/kiosk-password, which hashes server-side. + kiosk_password_hash: null, } for (const [key, value] of Object.entries(defaults)) { await pool.query( diff --git a/backend/src/index.js b/backend/src/index.js index 2ac10da..4071da7 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -18,6 +18,7 @@ import { overrideRoutes } from './routes/override.js' import { configRoutes } from './routes/config.js' import { settingsRoutes } from './routes/settings.js' import { mhiGatewayRoutes } from './routes/mhi-gateways.js' +import { kioskRoutes } from './routes/kiosk.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const UPLOADS_DIR = join(__dirname, '..', 'uploads') @@ -46,6 +47,7 @@ await app.register(overrideRoutes) await app.register(configRoutes) await app.register(settingsRoutes) await app.register(mhiGatewayRoutes) +await app.register(kioskRoutes) try { await initDb() diff --git a/backend/src/routes/config.js b/backend/src/routes/config.js index 5933161..cc494a4 100644 --- a/backend/src/routes/config.js +++ b/backend/src/routes/config.js @@ -9,12 +9,19 @@ export async function configRoutes(app) { app.addHook('preHandler', requireAuth) app.get('/api/config', { preHandler: requireCap('view') }, async () => { - const { rows } = await pool.query('SELECT key, value FROM config ORDER BY key') + // kiosk_password_hash is deliberately never returned here — it's a bcrypt hash, + // not a secret to display, but there's no reason to hand it to every viewer + // either. See routes/settings.js for the "is a kiosk password set?" boolean. + const { rows } = await pool.query(`SELECT key, value FROM config WHERE key != 'kiosk_password_hash' ORDER BY key`) return Object.fromEntries(rows.map(r => [r.key, r.value])) }) app.put('/api/config/:key', { preHandler: requireCap('settings') }, async (req, reply) => { const { key } = req.params + // Blocked here so the kiosk password can only ever be set via + // POST /api/settings/kiosk-password, which hashes it server-side — this generic + // key/value route would otherwise let it be overwritten with raw plaintext. + if (key === 'kiosk_password_hash') return reply.status(403).send({ error: 'Use POST /api/settings/kiosk-password instead' }) const { value } = req.body || {} if (value === undefined) return reply.status(400).send({ error: 'value required' }) await pool.query( diff --git a/backend/src/routes/kiosk.js b/backend/src/routes/kiosk.js new file mode 100644 index 0000000..3a707ad --- /dev/null +++ b/backend/src/routes/kiosk.js @@ -0,0 +1,71 @@ +import { SignJWT } from 'jose' +import bcrypt from 'bcryptjs' +import { getConfig } from '../db.js' + +const DOMAIN = process.env.DOMAIN || 'localhost' +const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '') + +// Same shape as central auth's own cookieOpts() (auth/src/routes/auth.js) — +// Secure only when the request genuinely arrived over HTTPS (NPM's +// X-Forwarded-Proto), domain scoped the same way, so this cookie behaves +// identically to one central auth issued. +function cookieOpts(request) { + const proto = request.headers['x-forwarded-proto'] || request.protocol + return { + httpOnly: true, + secure: proto === 'https', + sameSite: 'lax', + domain: DOMAIN === 'localhost' ? undefined : `.${DOMAIN}`, + path: '/', + maxAge: KIOSK_SESSION_SECONDS, + } +} + +// ~10 years — deliberately near-permanent. This is the wall-panel kiosk's whole +// point: enter the password once per tablet, never again (see project_hvac_kiosk +// memory / kickoff brief). If CENTRAL_AUTH_SECRET is ever rotated, every kiosk +// tablet's cookie invalidates at once and needs re-entering — an accepted, +// expected consequence of shared-secret signing, not a bug. +const KIOSK_SESSION_SECONDS = 60 * 60 * 24 * 3650 + +// hvac's own requireAuth (../auth.js) verifies the hnf_session cookie itself +// against CENTRAL_AUTH_SECRET — it never calls back to central auth at request +// time. So hvac's backend, holding that same secret, can mint a valid session +// token directly. No central-auth changes needed at all; the resulting cookie +// is indistinguishable from one central auth issued, just narrower in scope. +export async function kioskRoutes(app) { + // Deliberately outside any requireAuth hook — this IS the login route. + app.post('/api/kiosk/login', async (req, reply) => { + const { password } = req.body || {} + if (!password || typeof password !== 'string') { + return reply.status(400).send({ error: 'Password required' }) + } + + const hash = await getConfig('kiosk_password_hash', null) + if (!hash) { + return reply.status(503).send({ error: 'Kiosk login has not been set up yet — set a Kiosk Password under Settings first.' }) + } + + const ok = await bcrypt.compare(password, hash) + if (!ok) return reply.status(401).send({ error: 'Incorrect password' }) + + // Deliberately narrow: 'view' + 'control' only — never 'settings' or + // 'manage_devices', so a token that lives on a wall-mounted tablet can + // never reach Settings, device assignment, or anything admin. + const token = await new SignJWT({ + apps: ['hvac'], + caps: ['hvac:view', 'hvac:control'], + is_admin: false, + offsite_allowed: true, // it's a physical on-site kiosk — no IP-based onsite check needed + name: 'Kiosk', + }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject('hvac-kiosk') + .setIssuedAt() + .setExpirationTime(`${KIOSK_SESSION_SECONDS}s`) + .sign(secret) + + reply.setCookie('hnf_session', token, cookieOpts(req)) + return { ok: true } + }) +} diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index 024e9a9..accb328 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -1,6 +1,8 @@ +import bcrypt from 'bcryptjs' import { requireAuth, requireCap } from '../auth.js' import { testConnection } from '../lib/newbook.js' import { isConnected as mqttConnected } from '../lib/mqtt.js' +import { getConfig, setConfig } from '../db.js' // Settings-page-backing endpoints that don't fit the generic config.js key/value // store: connection tests and the MQTT broker's live status. The Home Assistant @@ -22,4 +24,22 @@ export async function settingsRoutes(app) { : 'Not connected — the shared MQTT broker (LXC 104) may not be provisioned yet, or hvac-backend has no credentials in settings.', } }) + + // Kiosk wall-panel login (see routes/kiosk.js) — the password itself is never + // readable back, only whether one has been set, so the Settings page can show + // a "configured" state without exposing the hash to the browser. + app.get('/api/settings/kiosk-password-status', { preHandler: requireCap('settings') }, async () => { + const hash = await getConfig('kiosk_password_hash', null) + return { configured: !!hash } + }) + + app.post('/api/settings/kiosk-password', { preHandler: requireCap('settings') }, async (req, reply) => { + const { password } = req.body || {} + if (!password || typeof password !== 'string' || password.length < 6) { + return reply.status(400).send({ error: 'Password must be at least 6 characters' }) + } + const hash = await bcrypt.hash(password, 12) + await setConfig('kiosk_password_hash', hash) + return { ok: true } + }) } diff --git a/docker-compose.yml b/docker-compose.yml index bf707b7..ebd00b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,10 @@ services: environment: - DATABASE_URL=${DATABASE_URL} - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} + # Only needed for the kiosk login (routes/kiosk.js), which mints its own + # hnf_session cookie locally — matches central auth's own cookie domain + # scoping (auth/src/routes/auth.js cookieOpts()) so the two are interchangeable. + - DOMAIN=${DOMAIN} - SETTINGS_URL=${SETTINGS_URL} - SETTINGS_SECRET=${SETTINGS_SECRET} - APP_SLUG=hvac diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 94e3bdd..3916ea4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Navigate, Outlet } 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 Devices from './pages/Devices' import Settings from './pages/Settings' +import Kiosk from './pages/Kiosk' import { can } from './types' function Home() { @@ -15,21 +16,35 @@ function Home() { return

You don't have access to any HVAC pages yet.

} +// The normal staff SPA shell — everything except /kiosk goes through the +// central-auth AuthGate + sidebar Layout, same as before this route existed. +function StaffShell() { + return ( + + + + + + ) +} + export default function App() { return ( <> - - - - } /> - } /> - } /> - } /> - } /> - - - + + {/* Kiosk wall-panel: its own full-screen shell, own login (a dedicated + kiosk password, not central auth) — deliberately outside StaffShell, + see components/Kiosk* and routes/kiosk.js on the backend. */} + } /> + }> + } /> + } /> + } /> + } /> + } /> + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 94ff85f..aa934e1 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -173,3 +173,61 @@ export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean export function fetchMqttStatus(): Promise<{ connected: boolean; note: string }> { return request('/settings/mqtt-status') } +export function fetchKioskPasswordStatus(): Promise<{ configured: boolean }> { + return request('/settings/kiosk-password-status') +} +export function setKioskPassword(password: string): Promise<{ ok: boolean }> { + return request('/settings/kiosk-password', { method: 'POST', body: JSON.stringify({ password }) }) +} + +// ── Kiosk (wall-panel control view) ────────────────────────────────────── +// Deliberately separate from request() above: a 401 here means "the kiosk's +// long-lived session lapsed (or was never logged in)" — the right response is +// re-showing the on-screen password prompt, NOT redirecting the whole tablet +// to the staff portal's /login like every other page in this app does. +export class KioskSessionError extends Error {} + +async function krequest(path: string, opts: RequestInit = {}): Promise { + const res = await fetch(`${BASE}${path}`, { + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...opts.headers }, + ...opts, + }) + if (res.status === 401) throw new KioskSessionError('Kiosk not authenticated') + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.error || `Request failed: ${res.status}`) + } + return res.json() +} + +export function kioskLogin(password: string): Promise<{ ok: boolean }> { + return krequest('/kiosk/login', { method: 'POST', body: JSON.stringify({ password }) }) +} +export function kioskFetchStatus(): Promise<{ + mqtt_connected: boolean + weather_peak_c: number | null + weather_tier: string | null + active_ac_template: ActiveAcTemplate + zones: ZoneStatus[] +}> { + return krequest('/status') +} +export function kioskFetchTrvStatus(deviceId: number): Promise { + return krequest(`/devices/${deviceId}/trv-status`) +} +export function kioskSendTrvControl(deviceId: number, body: { tempC: number }): Promise<{ ok: boolean }> { + return krequest(`/devices/${deviceId}/trv-control`, { method: 'POST', body: JSON.stringify(body) }) +} +export function kioskFetchMhiStatus(deviceId: number): Promise { + return krequest(`/devices/${deviceId}/mhi-status`) +} +export function kioskSendMhiControl(deviceId: number, body: { onOff?: boolean; mode?: string; tempC?: number; fanSpeed?: string }): Promise<{ ok: boolean }> { + return krequest(`/devices/${deviceId}/mhi-control`, { method: 'POST', body: JSON.stringify(body) }) +} +export function kioskFetchDaikinStatus(deviceId: number): Promise { + return krequest(`/devices/${deviceId}/daikin-status`) +} +export function kioskSendDaikinControl(deviceId: number, body: { onOff?: boolean; mode?: string; tempC?: number; fanSpeed?: string }): Promise<{ ok: boolean }> { + return krequest(`/devices/${deviceId}/daikin-control`, { method: 'POST', body: JSON.stringify(body) }) +} diff --git a/frontend/src/components/KioskBulkPanel.tsx b/frontend/src/components/KioskBulkPanel.tsx new file mode 100644 index 0000000..51920d2 --- /dev/null +++ b/frontend/src/components/KioskBulkPanel.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react' +import { Power, Minus, Plus } from 'lucide-react' +import { MHI_MODES, MHI_FAN_SPEEDS } from '../types' +import type { DeviceSummary } from '../types' +import { kioskSendTrvControl, kioskSendMhiControl, kioskSendDaikinControl } from '../api' + +// TRV commands are staggered, not fired in parallel — these are battery/deep-sleep +// Shelly valves going through hvac's own retry/backoff pipeline server-side +// (setTargetWithRetry, backend/src/lib/mqtt.js); the old retired system paced bulk +// valve commands ~10s apart for the same reason. A kiosk bulk-edit is a rarer, +// human-triggered action rather than a scheduled mass push, so this uses a +// shorter but still real stagger rather than firing every TRV command at once. +const TRV_STAGGER_MS = 2500 + +interface Props { + devices: DeviceSummary[] // the full selected set, mixed types + onDone: () => void +} + +// Type-aware bulk edit — TRV and AC control logic genuinely differ (TRV is just a +// target temp; AC has mode/setpoint/fan), so this shows a separate section per +// device type present in the selection rather than one lowest-common-denominator +// form. Each section's Apply only touches the matching selected devices. +export default function KioskBulkPanel({ devices, onDone }: Props) { + const trvIds = devices.filter(d => d.device_type === 'shelly_trv').map(d => d.id) + const mhiIds = devices.filter(d => d.device_type === 'mhi_mqtt').map(d => d.id) + const daikinIds = devices.filter(d => d.device_type === 'daikin').map(d => d.id) + const acCount = mhiIds.length + daikinIds.length + + const [trvTemp, setTrvTemp] = useState(21) + const [acOnOff, setAcOnOff] = useState(true) + const [acMode, setAcMode] = useState('cool') + const [acTemp, setAcTemp] = useState(22) + const [acFan, setAcFan] = useState('medium') + + const [applyingTrv, setApplyingTrv] = useState(false) + const [applyingAc, setApplyingAc] = useState(false) + const [results, setResults] = useState([]) + + async function applyTrv() { + setApplyingTrv(true) + let failed = 0 + for (const id of trvIds) { + try { + const res = await kioskSendTrvControl(id, { tempC: trvTemp }) + if (!res.ok) failed++ + } catch { + failed++ + } + await new Promise(r => setTimeout(r, TRV_STAGGER_MS)) + } + setApplyingTrv(false) + setResults(r => [...r, `TRVs: ${trvIds.length - failed}/${trvIds.length} updated${failed ? ' — some failed' : ''}`]) + } + + async function applyAc() { + setApplyingAc(true) + const patch = { onOff: acOnOff, mode: acMode, tempC: acTemp, fanSpeed: acFan } + const outcomes = await Promise.all([ + ...mhiIds.map(id => kioskSendMhiControl(id, patch).catch(() => ({ ok: false }))), + ...daikinIds.map(id => kioskSendDaikinControl(id, patch).catch(() => ({ ok: false }))), + ]) + const okCount = outcomes.filter(o => o.ok).length + setApplyingAc(false) + setResults(r => [...r, `AC units: ${okCount}/${acCount} updated${okCount < acCount ? ' — some failed' : ''}`]) + } + + return ( +
+

+ {devices.length} device{devices.length === 1 ? '' : 's'} selected + {trvIds.length > 0 && acCount > 0 ? ' — TRV and AC settings apply separately, only to matching devices.' : ''} +

+ + {trvIds.length > 0 && ( +
+
TRV settings ({trvIds.length})
+
+
+ + {trvTemp.toFixed(1)}°C + +
+ +
+
+ )} + + {acCount > 0 && ( +
+
AC settings ({acCount})
+
+ + +
+ + {acTemp.toFixed(1)}°C + +
+ + +
+
+ )} + + {results.length > 0 && ( +
+ {results.map((r, i) =>
{r}
)} +
+ )} + +
+ +
+
+ ) +} diff --git a/frontend/src/components/KioskDeviceBlock.tsx b/frontend/src/components/KioskDeviceBlock.tsx new file mode 100644 index 0000000..bba0295 --- /dev/null +++ b/frontend/src/components/KioskDeviceBlock.tsx @@ -0,0 +1,57 @@ +import { Thermometer, Snowflake, Flame, Fan, Droplet, Heater, Check } from 'lucide-react' +import type { DeviceSummary } from '../types' + +const MODE_ICONS: Record = { + cool: Snowflake, + heat: Flame, + fan: Fan, + dry: Droplet, + auto: Fan, +} + +function fmtTemp(t: string | number | null | undefined) { + return t != null ? `${Number(t).toFixed(1)}°C` : '—' +} + +interface Props { + device: DeviceSummary + zoneName: string + selectionMode: boolean + selected: boolean + onTap: () => void +} + +// Small summary block — mode/power + setpoint at a glance, tap for the full +// control modal (or toggle selection, in bulk-select mode). Deliberately reads +// from the same live-status cache /api/status already returns (see Kiosk.tsx), +// not a per-device round trip — this is what makes a scrollable grid of many of +// these cheap to poll. +export default function KioskDeviceBlock({ device, zoneName, selectionMode, selected, onTap }: Props) { + const isTrv = device.device_type === 'shelly_trv' + const ModeIcon = isTrv ? Heater : (MODE_ICONS[device.current_mode || ''] || Fan) + const on = isTrv ? !!device.heating_active : device.power_state === 'on' + + return ( +
+ {selectionMode && ( +
{selected && }
+ )} +
+ {zoneName} + {device.location && · {device.location}} +
+
+ + {fmtTemp(device.current_target_temp)} +
+
+ {fmtTemp(device.current_room_temp)} + {!isTrv && device.fan_speed && {device.fan_speed}} +
+
+ ) +} diff --git a/frontend/src/components/KioskModal.tsx b/frontend/src/components/KioskModal.tsx new file mode 100644 index 0000000..29c3f5b --- /dev/null +++ b/frontend/src/components/KioskModal.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from 'react' +import { X } from 'lucide-react' + +// Auto-dismiss back to the device grid after this long with no interaction — same +// pattern as weather's DayModal (this is also a kiosk: a modal left open by +// whoever last tapped it shouldn't sit there indefinitely). Longer than weather's +// 20s since dialling in a setpoint takes more thought than glancing at a forecast. +const IDLE_MS = 30_000 + +interface KioskModalProps { + title: string + onClose: () => void + children: React.ReactNode + wide?: boolean +} + +export default function KioskModal({ title, onClose, children, wide }: KioskModalProps) { + const [resetTick, setResetTick] = useState(0) + const timerRef = useRef | null>(null) + + useEffect(() => { + function reset() { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(onClose, IDLE_MS) + setResetTick(t => t + 1) + } + reset() + // pointerdown covers touch + mouse + stylus in one; deliberately no mousemove. + const events = ['pointerdown', 'keydown', 'wheel'] as const + events.forEach(e => window.addEventListener(e, reset)) + window.addEventListener('scroll', reset, true) // capture — scroll doesn't bubble + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + events.forEach(e => window.removeEventListener(e, reset)) + window.removeEventListener('scroll', reset, true) + } + }, [onClose]) + + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+ {children} +
+
+
+
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 307bf82..8d877df 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -430,4 +430,60 @@ table.data tr:last-child td { border-bottom: none; } .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); } + +/* ── Kiosk wall-panel view ─────────────────────────────────── */ +/* Deliberately NOT modelled on weather's fixed-grid no-scroll layout — a device + grid can outgrow one screen the way a single day's forecast never does, so + this is a normal scrollable page, just full-screen and touch-sized. */ +.kiosk-shell { height: 100dvh; display: flex; flex-direction: column; background: var(--body-bg); } +.kiosk-center { align-items: center; justify-content: center; } +.kiosk-login-card { + background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); + box-shadow: var(--shadow-md); padding: 28px 26px; width: 100%; max-width: 340px; text-align: center; +} +.kiosk-login-card input { + border: 1px solid var(--card-border); border-radius: 8px; padding: 10px 12px; font-size: 16px; +} + +.kiosk-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; background: var(--navy); color: var(--text); flex-shrink: 0; +} +.kiosk-header-title { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; color: var(--gold); } + +.kiosk-error { margin: 12px 16px 0; } + +.kiosk-grid-wrap { flex: 1; overflow-y: auto; padding: 16px; } +.kiosk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; } + +.kiosk-block { + position: relative; cursor: pointer; background: var(--card-bg); + border: 1px solid var(--card-border); border-radius: var(--radius); + box-shadow: var(--shadow-sm); padding: 12px; min-height: 96px; + display: flex; flex-direction: column; gap: 6px; user-select: none; +} +.kiosk-block.on { border-color: var(--app-primary); } +.kiosk-block.selected { outline: 2px solid var(--gold); outline-offset: -2px; } +.kiosk-block-check { + position: absolute; top: 8px; right: 8px; width: 20px; height: 20px; border-radius: 5px; + border: 1.5px solid var(--card-border); display: flex; align-items: center; justify-content: center; + color: var(--navy); background: var(--card-bg); +} +.kiosk-block.selected .kiosk-block-check { background: var(--gold); border-color: var(--gold); color: var(--navy); } +.kiosk-block-zone { font-size: 12px; font-weight: 700; color: var(--text-dark); padding-right: 22px; } +.kiosk-block-location { font-weight: 500; color: var(--text-mid); } +.kiosk-block-main { display: flex; align-items: center; gap: 8px; font-size: 20px; font-weight: 700; color: var(--text-dark); } +.kiosk-block.on .kiosk-block-main { color: var(--app-primary); } +.kiosk-block-sub { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--text-mid); } +.kiosk-block-fan { margin-left: auto; text-transform: capitalize; } + +.kiosk-bulk-bar { + position: sticky; bottom: 0; display: flex; align-items: center; gap: 12px; + padding: 12px 18px; background: var(--navy); color: var(--text); flex-shrink: 0; +} + +/* 30s duration must match KioskModal.tsx's IDLE_MS */ +.kiosk-modal-timeout-track { height: 3px; background: var(--card-border); border-radius: 2px; margin-top: 18px; overflow: hidden; } +.kiosk-modal-timeout-bar { height: 100%; width: 100%; background: var(--gold); transform-origin: left; animation: kiosk-timeout-shrink 30s linear forwards; } +@keyframes kiosk-timeout-shrink { from { transform: scaleX(1); } to { transform: scaleX(0); } } .sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; } diff --git a/frontend/src/pages/Kiosk.tsx b/frontend/src/pages/Kiosk.tsx new file mode 100644 index 0000000..9dc7c74 --- /dev/null +++ b/frontend/src/pages/Kiosk.tsx @@ -0,0 +1,201 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Thermometer, CheckSquare, X } from 'lucide-react' +import { kioskLogin, kioskFetchStatus, KioskSessionError } from '../api' +import type { DeviceSummary, ZoneStatus } from '../types' +import { KIOSK_DEVICE_TYPES } from '../types' +import KioskDeviceBlock from '../components/KioskDeviceBlock' +import KioskModal from '../components/KioskModal' +import KioskBulkPanel from '../components/KioskBulkPanel' +import TrvControlPanel from '../components/TrvControlPanel' +import MhiControlPanel from '../components/MhiControlPanel' +import DaikinControlPanel from '../components/DaikinControlPanel' + +// Control surface, not a passive display — far shorter than weather's 5-minute +// precedent, but not so aggressive it hammers the backend given the underlying +// data is already push/poll-cached (MQTT for TRV/MHI, a 60s poller for Daikin). +const POLL_MS = 12_000 + +interface FlatDevice { device: DeviceSummary; zoneName: string } + +function flattenDevices(zones: ZoneStatus[]): FlatDevice[] { + const out: FlatDevice[] = [] + for (const z of zones) { + for (const d of z.devices) { + if (KIOSK_DEVICE_TYPES.includes(d.device_type)) out.push({ device: d, zoneName: z.name }) + } + } + return out.sort((a, b) => a.zoneName.localeCompare(b.zoneName) || (a.device.location || '').localeCompare(b.device.location || '')) +} + +// The wall-panel kiosk view — a dedicated route/login, deliberately outside the +// normal staff AuthGate/Layout (see App.tsx). One master panel: every live- +// controllable device (shelly_trv / mhi_mqtt / daikin) in one scrollable grid of +// small blocks; tap a block for the full control modal, or toggle Select for +// type-aware bulk editing. See project_hvac_kiosk memory for the design decisions +// behind this (access model, layout, bulk-edit mechanism). +export default function Kiosk() { + const [authState, setAuthState] = useState<'checking' | 'login' | 'authed'>('checking') + const [password, setPassword] = useState('') + const [loginError, setLoginError] = useState('') + const [loginLoading, setLoginLoading] = useState(false) + + const [flatDevices, setFlatDevices] = useState([]) + const [loadError, setLoadError] = useState('') + + const [selectionMode, setSelectionMode] = useState(false) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [editingDevice, setEditingDevice] = useState(null) + const [bulkOpen, setBulkOpen] = useState(false) + + const pollRef = useRef | null>(null) + + const load = useCallback(() => { + kioskFetchStatus() + .then(s => { + setFlatDevices(flattenDevices(s.zones)) + setLoadError('') + setAuthState('authed') + }) + .catch(e => { + if (e instanceof KioskSessionError) { setAuthState('login'); return } + setLoadError(e instanceof Error ? e.message : 'Failed to load device status') + }) + }, []) + + useEffect(() => { load() }, [load]) + + useEffect(() => { + if (authState !== 'authed') { + if (pollRef.current) clearInterval(pollRef.current) + return + } + pollRef.current = setInterval(load, POLL_MS) + return () => { if (pollRef.current) clearInterval(pollRef.current) } + }, [authState, load]) + + async function handleLogin(e: React.FormEvent) { + e.preventDefault() + setLoginLoading(true); setLoginError('') + try { + await kioskLogin(password) + setPassword('') + load() + } catch (e) { + setLoginError(e instanceof Error ? e.message : 'Login failed') + } finally { + setLoginLoading(false) + } + } + + function toggleSelectionMode() { + setSelectionMode(m => !m) + setSelectedIds(new Set()) + } + + function tapDevice(d: DeviceSummary) { + if (selectionMode) { + setSelectedIds(prev => { + const next = new Set(prev) + if (next.has(d.id)) next.delete(d.id); else next.add(d.id) + return next + }) + } else { + setEditingDevice(d) + } + } + + function closeBulk() { + setBulkOpen(false) + setSelectionMode(false) + setSelectedIds(new Set()) + load() + } + + const selectedDevices = flatDevices.filter(fd => selectedIds.has(fd.device.id)).map(fd => fd.device) + const editingZoneName = editingDevice ? flatDevices.find(fd => fd.device.id === editingDevice.id)?.zoneName : '' + + if (authState === 'checking') { + return

Loading…

+ } + + if (authState === 'login') { + return ( +
+
+ +

HVAC Kiosk

+
+ setPassword(e.target.value)} + placeholder="Kiosk password" autoFocus required autoComplete="off" + /> + {loginError &&
{loginError}
} + +
+
+
+ ) + } + + return ( +
+
+ + HVAC Kiosk + + +
+ + {loadError &&
{loadError}
} + +
+
+ {flatDevices.map(({ device, zoneName }) => ( + tapDevice(device)} + /> + ))} + {flatDevices.length === 0 && !loadError && ( +

No live-controllable devices assigned to a zone yet.

+ )} +
+
+ + {selectionMode && selectedIds.size > 0 && ( +
+ {selectedIds.size} selected + + +
+ )} + + {editingDevice && ( + { setEditingDevice(null); load() }} + > + {editingDevice.device_type === 'shelly_trv' && } + {editingDevice.device_type === 'mhi_mqtt' && } + {editingDevice.device_type === 'daikin' && } + + )} + + {bulkOpen && ( + + + + )} +
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index e8af462..dff7735 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react' -import { Wifi, WifiOff } from 'lucide-react' +import { Wifi, WifiOff, MonitorCog } from 'lucide-react' import { fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook, fetchConfig, updateConfig, fetchMqttStatus, fetchStatus, createZone, + fetchKioskPasswordStatus, setKioskPassword, } from '../api' import type { NewbookSite, AppConfig, HvacDefaults, HvacAcTemplate, HvacWeatherTier } from '../types' import { DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS } from '../types' @@ -41,6 +42,12 @@ export default function Settings() { const [publicAreaName, setPublicAreaName] = useState('') const [creatingZone, setCreatingZone] = useState(false) + const [kioskConfigured, setKioskConfigured] = useState(null) + const [kioskPassword, setKioskPasswordInput] = useState('') + const [kioskSaving, setKioskSaving] = useState(false) + const [kioskMsg, setKioskMsg] = useState('') + const [kioskError, setKioskError] = useState('') + function loadSites() { fetchNewbookSites() .then(res => setSites(res.sites)) @@ -64,6 +71,7 @@ export default function Settings() { setDefaults({ ...DEFAULT_HVAC_TEMPLATE, ...cfg.hvac_defaults, ac_occupied_tiers: cfg.hvac_defaults?.ac_occupied_tiers ?? DEFAULT_HVAC_TEMPLATE.ac_occupied_tiers }) }).catch(() => {}) fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).catch(() => {}) + fetchKioskPasswordStatus().then(s => setKioskConfigured(s.configured)).catch(() => {}) fetchStatus() .then(s => { setWeatherPeakC(s.weather_peak_c); setWeatherTier(s.weather_tier) }) .catch(() => {}) @@ -165,6 +173,20 @@ export default function Settings() { } } + async function handleSaveKioskPassword() { + setKioskSaving(true); setKioskError(''); setKioskMsg('') + try { + await setKioskPassword(kioskPassword) + setKioskPasswordInput('') + setKioskConfigured(true) + setKioskMsg('Kiosk password saved — enter it once on each kiosk tablet at /hvac/kiosk.') + } catch (e) { + setKioskError(e instanceof Error ? e.message : 'Save failed') + } finally { + setKioskSaving(false) + } + } + async function handleCreatePublicArea(e: React.FormEvent) { e.preventDefault() if (!publicAreaName.trim()) return @@ -442,6 +464,36 @@ export default function Settings() {
+
Kiosk Wall Panel
+

+ A dedicated login for the full-screen wall-panel control view at /hvac/kiosk — separate + from staff logins, meant to be entered once per tablet and left signed in indefinitely (see{' '} + the kiosk itself). Scoped to + view + control only — it can never reach this Settings page or device assignment. +

+ {kioskError &&
{kioskError}
} + {kioskMsg &&
{kioskMsg}
} +
+ + + {kioskConfigured == null ? 'Checking…' : kioskConfigured ? 'A kiosk password is configured.' : 'No kiosk password set yet — the kiosk view cannot be logged into until one is set below.'} + +
+
+
+ + setKioskPasswordInput(e.target.value)} + placeholder="At least 6 characters" autoComplete="new-password" + /> +
+
+
+ +
+
Maintenance Integration

Not yet available — maintenance needs its own Settings → API Keys page first. diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2cd257d..dd98690 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -26,6 +26,13 @@ export const DEVICE_TYPE_LABELS: Record = { // value only, ready for its driver (currently just 'midea'). export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'home_assistant', 'daikin'] +// Device types the kiosk wall-panel actually shows/controls — deliberately +// narrower than IMPLEMENTED_DEVICE_TYPES (excludes home_assistant, which has no +// kiosk-friendly control shape yet). See project_hvac_kiosk memory's "v1 device +// scope" decision — widen this if/when mitsubishi_cn105/midea_mqtt/hitachi_ir land. +export const KIOSK_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'daikin'] +export const KIOSK_AC_DEVICE_TYPES: DeviceType[] = ['mhi_mqtt', 'daikin'] + // TRV temps/offsets and aircon template fields are all nullable on a zone — NULL means // "inherit the matching field from hvac_defaults" (see HvacDefaults below); a non-null // value is that room's explicit override. Set via ZoneDetailModal's per-field toggles.