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:
parent
f0b4ac3c41
commit
2db7d5f4d0
16 changed files with 757 additions and 14 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
71
backend/src/routes/kiosk.js
Normal file
71
backend/src/routes/kiosk.js
Normal 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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) })
|
||||||
|
}
|
||||||
|
|
|
||||||
131
frontend/src/components/KioskBulkPanel.tsx
Normal file
131
frontend/src/components/KioskBulkPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
frontend/src/components/KioskDeviceBlock.tsx
Normal file
57
frontend/src/components/KioskDeviceBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
frontend/src/components/KioskModal.tsx
Normal file
55
frontend/src/components/KioskModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
201
frontend/src/pages/Kiosk.tsx
Normal file
201
frontend/src/pages/Kiosk.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue