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 (
+
+ 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.'}
+
+
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.