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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 20:44:50 +00:00
parent f0b4ac3c41
commit 2db7d5f4d0
16 changed files with 757 additions and 14 deletions

View file

@ -11,6 +11,7 @@
"@fastify/cors": "^9.0.1", "@fastify/cors": "^9.0.1",
"@fastify/multipart": "^8.3.0", "@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"bcryptjs": "^2.4.3",
"fastify": "^4.28.1", "fastify": "^4.28.1",
"jose": "^5.9.6", "jose": "^5.9.6",
"luxon": "^3.5.0", "luxon": "^3.5.0",

View file

@ -272,6 +272,12 @@ async function seedDefaults() {
maintenance_api_key: '', maintenance_api_key: '',
hvac_defaults: DEFAULT_HVAC_TEMPLATE, hvac_defaults: DEFAULT_HVAC_TEMPLATE,
history_retention_days: 4, // how long device_readings rows are kept — see pruneOldReadings() 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)) { for (const [key, value] of Object.entries(defaults)) {
await pool.query( await pool.query(

View file

@ -18,6 +18,7 @@ import { overrideRoutes } from './routes/override.js'
import { configRoutes } from './routes/config.js' import { configRoutes } from './routes/config.js'
import { settingsRoutes } from './routes/settings.js' import { settingsRoutes } from './routes/settings.js'
import { mhiGatewayRoutes } from './routes/mhi-gateways.js' import { mhiGatewayRoutes } from './routes/mhi-gateways.js'
import { kioskRoutes } from './routes/kiosk.js'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const UPLOADS_DIR = join(__dirname, '..', 'uploads') const UPLOADS_DIR = join(__dirname, '..', 'uploads')
@ -46,6 +47,7 @@ await app.register(overrideRoutes)
await app.register(configRoutes) await app.register(configRoutes)
await app.register(settingsRoutes) await app.register(settingsRoutes)
await app.register(mhiGatewayRoutes) await app.register(mhiGatewayRoutes)
await app.register(kioskRoutes)
try { try {
await initDb() await initDb()

View file

@ -9,12 +9,19 @@ export async function configRoutes(app) {
app.addHook('preHandler', requireAuth) app.addHook('preHandler', requireAuth)
app.get('/api/config', { preHandler: requireCap('view') }, async () => { 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])) return Object.fromEntries(rows.map(r => [r.key, r.value]))
}) })
app.put('/api/config/:key', { preHandler: requireCap('settings') }, async (req, reply) => { app.put('/api/config/:key', { preHandler: requireCap('settings') }, async (req, reply) => {
const { key } = req.params 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 || {} const { value } = req.body || {}
if (value === undefined) return reply.status(400).send({ error: 'value required' }) if (value === undefined) return reply.status(400).send({ error: 'value required' })
await pool.query( await pool.query(

View file

@ -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 }
})
}

View file

@ -1,6 +1,8 @@
import bcrypt from 'bcryptjs'
import { requireAuth, requireCap } from '../auth.js' import { requireAuth, requireCap } from '../auth.js'
import { testConnection } from '../lib/newbook.js' import { testConnection } from '../lib/newbook.js'
import { isConnected as mqttConnected } from '../lib/mqtt.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 // 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 // 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.', : '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 }
})
} }

View file

@ -6,6 +6,10 @@ services:
environment: environment:
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} - 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_URL=${SETTINGS_URL}
- SETTINGS_SECRET=${SETTINGS_SECRET} - SETTINGS_SECRET=${SETTINGS_SECRET}
- APP_SLUG=hvac - APP_SLUG=hvac

View file

@ -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 AuthGate, { useAuth } from './components/AuthGate'
import IosInstallHint from './components/IosInstallHint' import IosInstallHint from './components/IosInstallHint'
import Layout from './components/Layout' import Layout from './components/Layout'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import Devices from './pages/Devices' import Devices from './pages/Devices'
import Settings from './pages/Settings' import Settings from './pages/Settings'
import Kiosk from './pages/Kiosk'
import { can } from './types' import { can } from './types'
function Home() { function Home() {
@ -15,21 +16,35 @@ function Home() {
return <div className="page"><p className="muted">You don't have access to any HVAC pages yet.</p></div> return <div className="page"><p className="muted">You don't have access to any HVAC pages yet.</p></div>
} }
// 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 (
<AuthGate>
<Layout>
<Outlet />
</Layout>
</AuthGate>
)
}
export default function App() { export default function App() {
return ( return (
<> <>
<BrowserRouter basename="/hvac"> <BrowserRouter basename="/hvac">
<AuthGate> <Routes>
<Layout> {/* Kiosk wall-panel: its own full-screen shell, own login (a dedicated
<Routes> kiosk password, not central auth) — deliberately outside StaffShell,
<Route path="/" element={<Home />} /> see components/Kiosk* and routes/kiosk.js on the backend. */}
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/kiosk" element={<Kiosk />} />
<Route path="/devices" element={<Devices />} /> <Route element={<StaffShell />}>
<Route path="/settings" element={<Settings />} /> <Route path="/" element={<Home />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="/dashboard" element={<Dashboard />} />
</Routes> <Route path="/devices" element={<Devices />} />
</Layout> <Route path="/settings" element={<Settings />} />
</AuthGate> <Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter> </BrowserRouter>
<IosInstallHint /> <IosInstallHint />
</> </>

View file

@ -173,3 +173,61 @@ export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean
export function fetchMqttStatus(): Promise<{ connected: boolean; note: string }> { export function fetchMqttStatus(): Promise<{ connected: boolean; note: string }> {
return request('/settings/mqtt-status') 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<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) 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<TrvStatus> {
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<MhiStatus> {
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<DaikinStatus> {
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) })
}

View file

@ -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<string[]>([])
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 (
<div>
<p className="field-hint" style={{ marginBottom: 14 }}>
{devices.length} device{devices.length === 1 ? '' : 's'} selected
{trvIds.length > 0 && acCount > 0 ? ' — TRV and AC settings apply separately, only to matching devices.' : ''}
</p>
{trvIds.length > 0 && (
<div className="card" style={{ marginBottom: 12 }}>
<div className="section-title" style={{ marginTop: 0 }}>TRV settings ({trvIds.length})</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button className="btn btn-sm" onClick={() => setTrvTemp(t => Math.max(5, t - 0.5))}><Minus size={12} strokeWidth={1.75} /></button>
<span style={{ minWidth: 56, textAlign: 'center', fontWeight: 600 }}>{trvTemp.toFixed(1)}°C</span>
<button className="btn btn-sm" onClick={() => setTrvTemp(t => Math.min(30, t + 0.5))}><Plus size={12} strokeWidth={1.75} /></button>
</div>
<button className="btn btn-primary" disabled={applyingTrv} onClick={applyTrv}>
{applyingTrv ? 'Applying…' : `Apply to ${trvIds.length} TRV${trvIds.length === 1 ? '' : 's'}`}
</button>
</div>
</div>
)}
{acCount > 0 && (
<div className="card" style={{ marginBottom: 12 }}>
<div className="section-title" style={{ marginTop: 0 }}>AC settings ({acCount})</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<button
className="btn btn-sm"
onClick={() => setAcOnOff(o => !o)}
style={acOnOff ? { background: 'var(--gold)', borderColor: 'var(--gold)', color: 'var(--navy)' } : undefined}
>
<Power size={13} strokeWidth={1.75} /> {acOnOff ? 'On' : 'Off'}
</button>
<select value={acMode} onChange={e => setAcMode(e.target.value)}>
{MHI_MODES.map(m => <option key={m} value={m}>{m[0].toUpperCase() + m.slice(1)}</option>)}
</select>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button className="btn btn-sm" onClick={() => setAcTemp(t => Math.max(16, t - 0.5))}><Minus size={12} strokeWidth={1.75} /></button>
<span style={{ minWidth: 48, textAlign: 'center', fontWeight: 600 }}>{acTemp.toFixed(1)}°C</span>
<button className="btn btn-sm" onClick={() => setAcTemp(t => Math.min(30, t + 0.5))}><Plus size={12} strokeWidth={1.75} /></button>
</div>
<select value={acFan} onChange={e => setAcFan(e.target.value)}>
{MHI_FAN_SPEEDS.map(f => <option key={f} value={f}>{f[0].toUpperCase() + f.slice(1)}</option>)}
</select>
<button className="btn btn-primary" disabled={applyingAc} onClick={applyAc}>
{applyingAc ? 'Applying…' : `Apply to ${acCount} AC unit${acCount === 1 ? '' : 's'}`}
</button>
</div>
</div>
)}
{results.length > 0 && (
<div className="ok-banner" style={{ marginBottom: 12 }}>
{results.map((r, i) => <div key={i}>{r}</div>)}
</div>
)}
<div className="modal-actions">
<button className="btn" onClick={onDone}>Done</button>
</div>
</div>
)
}

View file

@ -0,0 +1,57 @@
import { Thermometer, Snowflake, Flame, Fan, Droplet, Heater, Check } from 'lucide-react'
import type { DeviceSummary } from '../types'
const MODE_ICONS: Record<string, typeof Snowflake> = {
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 (
<div
className={`kiosk-block${selected ? ' selected' : ''}${on ? ' on' : ''}`}
onClick={onTap}
role="button"
>
{selectionMode && (
<div className="kiosk-block-check">{selected && <Check size={14} strokeWidth={2.5} />}</div>
)}
<div className="kiosk-block-zone">
{zoneName}
{device.location && <span className="kiosk-block-location"> · {device.location}</span>}
</div>
<div className="kiosk-block-main">
<ModeIcon size={22} strokeWidth={1.75} />
<span className="kiosk-block-setpoint">{fmtTemp(device.current_target_temp)}</span>
</div>
<div className="kiosk-block-sub">
<Thermometer size={12} strokeWidth={1.75} /> {fmtTemp(device.current_room_temp)}
{!isTrv && device.fan_speed && <span className="kiosk-block-fan">{device.fan_speed}</span>}
</div>
</div>
)
}

View file

@ -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<ReturnType<typeof setTimeout> | 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 (
<div className="modal-overlay" onClick={onClose}>
<div className={`modal${wide ? ' modal-lg' : ''}`} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{title}</h2>
<button className="modal-close" onClick={onClose} aria-label="Close">
<X size={20} strokeWidth={1.75} />
</button>
</div>
{children}
<div className="kiosk-modal-timeout-track">
<div className="kiosk-modal-timeout-bar" key={resetTick} />
</div>
</div>
</div>
)
}

View file

@ -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-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, .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::-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; } .sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }

View file

@ -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<FlatDevice[]>([])
const [loadError, setLoadError] = useState('')
const [selectionMode, setSelectionMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [editingDevice, setEditingDevice] = useState<DeviceSummary | null>(null)
const [bulkOpen, setBulkOpen] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | 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 <div className="kiosk-shell kiosk-center"><p className="muted">Loading…</p></div>
}
if (authState === 'login') {
return (
<div className="kiosk-shell kiosk-center">
<div className="kiosk-login-card">
<Thermometer size={28} strokeWidth={1.5} style={{ color: 'var(--gold)', marginBottom: 8 }} />
<h1 style={{ fontSize: '1.3rem', margin: '0 0 1.25rem' }}>HVAC Kiosk</h1>
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Kiosk password" autoFocus required autoComplete="off"
/>
{loginError && <div className="error-banner">{loginError}</div>}
<button className="btn btn-primary" type="submit" disabled={loginLoading}>
{loginLoading ? 'Signing in…' : 'Unlock'}
</button>
</form>
</div>
</div>
)
}
return (
<div className="kiosk-shell">
<header className="kiosk-header">
<span className="kiosk-header-title">
<Thermometer size={18} strokeWidth={1.75} /> HVAC Kiosk
</span>
<button className={`btn btn-sm${selectionMode ? ' btn-primary' : ''}`} onClick={toggleSelectionMode}>
<CheckSquare size={14} strokeWidth={1.75} /> {selectionMode ? 'Cancel select' : 'Select'}
</button>
</header>
{loadError && <div className="error-banner kiosk-error">{loadError}</div>}
<div className="kiosk-grid-wrap">
<div className="kiosk-grid">
{flatDevices.map(({ device, zoneName }) => (
<KioskDeviceBlock
key={device.id}
device={device}
zoneName={zoneName}
selectionMode={selectionMode}
selected={selectedIds.has(device.id)}
onTap={() => tapDevice(device)}
/>
))}
{flatDevices.length === 0 && !loadError && (
<p className="muted">No live-controllable devices assigned to a zone yet.</p>
)}
</div>
</div>
{selectionMode && selectedIds.size > 0 && (
<div className="kiosk-bulk-bar">
<span>{selectedIds.size} selected</span>
<button className="btn btn-primary" onClick={() => setBulkOpen(true)}>Edit selected</button>
<button className="btn btn-sm" onClick={() => setSelectedIds(new Set())} aria-label="Clear selection">
<X size={13} strokeWidth={1.75} />
</button>
</div>
)}
{editingDevice && (
<KioskModal
title={editingDevice.location ? `${editingZoneName} — ${editingDevice.location}` : editingZoneName || 'Device'}
onClose={() => { setEditingDevice(null); load() }}
>
{editingDevice.device_type === 'shelly_trv' && <TrvControlPanel deviceId={editingDevice.id} canControl={true} />}
{editingDevice.device_type === 'mhi_mqtt' && <MhiControlPanel deviceId={editingDevice.id} canControl={true} />}
{editingDevice.device_type === 'daikin' && <DaikinControlPanel deviceId={editingDevice.id} canControl={true} />}
</KioskModal>
)}
{bulkOpen && (
<KioskModal title={`Edit ${selectedDevices.length} selected`} wide onClose={closeBulk}>
<KioskBulkPanel devices={selectedDevices} onDone={closeBulk} />
</KioskModal>
)}
</div>
)
}

View file

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Wifi, WifiOff } from 'lucide-react' import { Wifi, WifiOff, MonitorCog } from 'lucide-react'
import { import {
fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook, fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook,
fetchConfig, updateConfig, fetchMqttStatus, fetchStatus, createZone, fetchConfig, updateConfig, fetchMqttStatus, fetchStatus, createZone,
fetchKioskPasswordStatus, setKioskPassword,
} from '../api' } from '../api'
import type { NewbookSite, AppConfig, HvacDefaults, HvacAcTemplate, HvacWeatherTier } from '../types' import type { NewbookSite, AppConfig, HvacDefaults, HvacAcTemplate, HvacWeatherTier } from '../types'
import { DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS } 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 [publicAreaName, setPublicAreaName] = useState('')
const [creatingZone, setCreatingZone] = useState(false) const [creatingZone, setCreatingZone] = useState(false)
const [kioskConfigured, setKioskConfigured] = useState<boolean | null>(null)
const [kioskPassword, setKioskPasswordInput] = useState('')
const [kioskSaving, setKioskSaving] = useState(false)
const [kioskMsg, setKioskMsg] = useState('')
const [kioskError, setKioskError] = useState('')
function loadSites() { function loadSites() {
fetchNewbookSites() fetchNewbookSites()
.then(res => setSites(res.sites)) .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 }) setDefaults({ ...DEFAULT_HVAC_TEMPLATE, ...cfg.hvac_defaults, ac_occupied_tiers: cfg.hvac_defaults?.ac_occupied_tiers ?? DEFAULT_HVAC_TEMPLATE.ac_occupied_tiers })
}).catch(() => {}) }).catch(() => {})
fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).catch(() => {}) fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).catch(() => {})
fetchKioskPasswordStatus().then(s => setKioskConfigured(s.configured)).catch(() => {})
fetchStatus() fetchStatus()
.then(s => { setWeatherPeakC(s.weather_peak_c); setWeatherTier(s.weather_tier) }) .then(s => { setWeatherPeakC(s.weather_peak_c); setWeatherTier(s.weather_tier) })
.catch(() => {}) .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) { async function handleCreatePublicArea(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!publicAreaName.trim()) return if (!publicAreaName.trim()) return
@ -442,6 +464,36 @@ export default function Settings() {
</div> </div>
</div> </div>
<div className="section-title">Kiosk Wall Panel</div>
<p className="field-hint" style={{ marginBottom: 10 }}>
A dedicated login for the full-screen wall-panel control view at <code>/hvac/kiosk</code> — separate
from staff logins, meant to be entered once per tablet and left signed in indefinitely (see{' '}
<MonitorCog size={12} strokeWidth={1.75} style={{ verticalAlign: '-1px' }} /> the kiosk itself). Scoped to
view + control only — it can never reach this Settings page or device assignment.
</p>
{kioskError && <div className="error-banner">{kioskError}</div>}
{kioskMsg && <div className="ok-banner">{kioskMsg}</div>}
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<MonitorCog size={16} strokeWidth={1.75} color={kioskConfigured ? 'var(--health-healthy)' : 'var(--text-mid)'} />
<span>
{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.'}
</span>
</div>
<div className="field-row" style={{ marginBottom: 12 }}>
<div className="field">
<label>{kioskConfigured ? 'New kiosk password' : 'Kiosk password'}</label>
<input
type="password" value={kioskPassword} onChange={e => setKioskPasswordInput(e.target.value)}
placeholder="At least 6 characters" autoComplete="new-password"
/>
</div>
</div>
<div style={{ marginBottom: 24 }}>
<button className="btn btn-primary" disabled={kioskSaving || kioskPassword.length < 6} onClick={handleSaveKioskPassword}>
{kioskSaving ? 'Saving…' : kioskConfigured ? 'Change kiosk password' : 'Set kiosk password'}
</button>
</div>
<div className="section-title">Maintenance Integration</div> <div className="section-title">Maintenance Integration</div>
<p className="field-hint" style={{ marginBottom: 10 }}> <p className="field-hint" style={{ marginBottom: 10 }}>
Not yet available — maintenance needs its own Settings → API Keys page first. Not yet available — maintenance needs its own Settings → API Keys page first.

View file

@ -26,6 +26,13 @@ export const DEVICE_TYPE_LABELS: Record<DeviceType, string> = {
// value only, ready for its driver (currently just 'midea'). // value only, ready for its driver (currently just 'midea').
export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'home_assistant', 'daikin'] 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 // 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 // "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. // value is that room's explicit override. Set via ZoneDetailModal's per-field toggles.