diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 59a35d87..2e242f1f 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -9,3 +9,13 @@ import './styles/base.css'; // Register custom elements import './components/AppShell.js'; import './components/StateCard.js'; +import './pages/Dashboard.js'; + +// Mount the Dashboard inside the AppShell's slot so the empty `
` +// layout actually shows something on first paint. +window.addEventListener('DOMContentLoaded', () => { + const shell = document.querySelector('hc-app-shell'); + if (shell && !shell.querySelector('hc-dashboard')) { + shell.appendChild(document.createElement('hc-dashboard')); + } +}); diff --git a/frontend/src/pages/Dashboard.ts b/frontend/src/pages/Dashboard.ts new file mode 100644 index 00000000..8ea3a93b --- /dev/null +++ b/frontend/src/pages/Dashboard.ts @@ -0,0 +1,143 @@ +/** + * Dashboard page — fetches HOMECORE state + config from the backend and + * populates the `` slot with a grid of ``. + * + * Auth: reads bearer from `localStorage["homecore.token"]`, the + * `?token=` query string, or `HOMECORE_TOKEN` `` tag — in that + * order. Falls back to the literal "dev-token" in DEV-mode backends + * (any non-empty bearer is accepted when HOMECORE_TOKENS is unset). + */ + +import { LitElement, html, css } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import { HomecoreClient } from '../api/client.js'; +import type { ApiConfig, StateView } from '../api/types.js'; + +function resolveToken(): string { + if (typeof localStorage !== 'undefined') { + const stored = localStorage.getItem('homecore.token'); + if (stored) return stored; + } + const url = new URL(window.location.href); + const qs = url.searchParams.get('token'); + if (qs) return qs; + const meta = document.querySelector('meta[name="homecore-token"]'); + if (meta?.content) return meta.content; + return 'dev-token'; +} + +@customElement('hc-dashboard') +export class Dashboard extends LitElement { + static styles = css` + :host { + display: block; + padding: 24px; + color: var(--hc-fg, #e6e9ec); + font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); + } + .meta { + display: flex; + gap: 16px; + flex-wrap: wrap; + color: var(--hc-fg-dim, #8a93a0); + font-size: 14px; + margin-bottom: 16px; + } + .meta strong { color: var(--hc-fg, #e6e9ec); } + .grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; + } + .empty, + .err { + padding: 24px; + border: 1px dashed var(--hc-border, #2a323e); + border-radius: 8px; + text-align: center; + color: var(--hc-fg-dim, #8a93a0); + } + .err { + border-color: #b35a5a; + color: #f0c0c0; + text-align: left; + font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); + font-size: 13px; + white-space: pre-wrap; + } + `; + + @state() private states: StateView[] = []; + @state() private config: ApiConfig | null = null; + @state() private error: string | null = null; + @state() private loading = true; + + private client = new HomecoreClient({ token: resolveToken() }); + private pollTimer: number | undefined; + + connectedCallback(): void { + super.connectedCallback(); + void this.refresh(); + this.pollTimer = window.setInterval(() => void this.refresh(), 5000); + } + + disconnectedCallback(): void { + if (this.pollTimer !== undefined) window.clearInterval(this.pollTimer); + super.disconnectedCallback(); + } + + private async refresh(): Promise { + try { + const [cfg, states] = await Promise.all([ + this.client.getConfig(), + this.client.getStates(), + ]); + this.config = cfg; + this.states = states; + this.error = null; + } catch (e) { + this.error = e instanceof Error ? e.message : String(e); + } finally { + this.loading = false; + } + } + + render() { + if (this.error) { + return html`
backend unreachable — ${this.error}\n\n + hint: make sure homecore-server is running on :8123 and that + the token in localStorage["homecore.token"] is accepted. +
`; + } + if (this.loading) { + return html`
loading HOMECORE state…
`; + } + const v = this.config?.version ?? '?'; + const loc = this.config?.location_name ?? 'Home'; + return html` +
+ ${loc} + HOMECORE v${v} + ${this.states.length} entities +
+ ${this.states.length === 0 + ? html`
+ No entities registered yet. Run + bash scripts/homecore-seed.sh to populate + ~10 demo entities, or connect a plugin / integration. +
` + : html`
+ ${this.states.map( + (s) => html`` + )} +
`} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'hc-dashboard': Dashboard; + } +} diff --git a/scripts/homecore-seed.sh b/scripts/homecore-seed.sh new file mode 100644 index 00000000..64b3bdee --- /dev/null +++ b/scripts/homecore-seed.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# homecore-seed.sh — populate the empty HOMECORE state machine with a +# representative cross-section of entities so the web UI renders +# useful content right after `homecore-server` boots. +# +# When homecore-server starts with no plugins loaded and no +# integrations enabled, its state machine is empty by design — the +# web UI shows "No entities registered yet". This script POSTs ~10 +# real-looking entities via the HA-compat REST surface. +# +# Where the numbers come from: +# - sensor.living_room_presence / _motion / bedroom_breathing_rate / +# bedroom_heart_rate are pulled live from the RuView sensing-server +# (RUVIEW_URL/api/v1/vitals/12/latest) when reachable. +# - Other entities use plausible literals. +# +# Usage: +# bash scripts/homecore-seed.sh +# HOMECORE_URL=http://localhost:8123 HOMECORE_TOKEN=dev-token bash scripts/homecore-seed.sh +# RUVIEW_URL=http://ruv-mac-mini:3000 bash scripts/homecore-seed.sh # live numbers +# +# Idempotent: re-running just updates the values. + +set -euo pipefail + +URL="${HOMECORE_URL:-http://127.0.0.1:8123}" +TOKEN="${HOMECORE_TOKEN:-dev-token}" +RUVIEW_URL="${RUVIEW_URL:-http://localhost:3000}" + +post() { + local entity_id="$1"; shift + local body="$1"; shift + curl -fsS -X POST "$URL/api/states/$entity_id" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$body" >/dev/null && echo " set $entity_id" +} + +# Pull a live snapshot from the RuView sensing-server (optional). +ruview_snapshot="{}" +if curl -fsS --max-time 2 "$RUVIEW_URL/api/v1/vitals/12/latest" -o /tmp/ruview-vitals.json 2>/dev/null; then + ruview_snapshot=$(cat /tmp/ruview-vitals.json) + echo "Pulled live RuView snapshot from $RUVIEW_URL" +else + echo "RuView snapshot unreachable — using defaults (set RUVIEW_URL to your sensing-server to pull live values)" +fi + +get_num() { + local key="$1" default="$2" + echo "$ruview_snapshot" | python3 -c " +import sys, json +try: + d = json.loads(sys.stdin.read()) + v = d.get('$key') + print(v if v is not None else '$default') +except Exception: + print('$default') +" 2>/dev/null || echo "$default" +} + +presence=$(get_num presence false) +breathing=$(get_num breathing_rate_bpm 14.5) +heart_rate=$(get_num heartrate_bpm 68.0) +motion=$(get_num motion 0.0) + +echo +echo "Seeding HOMECORE at $URL ..." + +post sensor.living_room_presence "{\"state\": \"$presence\", \"attributes\": {\"friendly_name\": \"Living Room Presence\", \"device_class\": \"occupancy\", \"source\": \"RuView ESP32-C6 BFLD\"}}" +post sensor.living_room_motion_score "{\"state\": \"$motion\", \"attributes\": {\"friendly_name\": \"Living Room Motion Score\", \"unit_of_measurement\": \"score\", \"icon\": \"mdi:motion-sensor\"}}" +post sensor.bedroom_breathing_rate "{\"state\": \"$breathing\", \"attributes\": {\"friendly_name\": \"Bedroom Breathing Rate\", \"unit_of_measurement\": \"BPM\", \"device_class\": \"frequency\", \"source\": \"Seeed MR60BHA2 mmWave\"}}" +post sensor.bedroom_heart_rate "{\"state\": \"$heart_rate\", \"attributes\": {\"friendly_name\": \"Bedroom Heart Rate\", \"unit_of_measurement\": \"BPM\", \"device_class\": \"frequency\", \"source\": \"Seeed MR60BHA2 mmWave\"}}" +post light.kitchen_ceiling '{"state": "on", "attributes": {"friendly_name": "Kitchen Ceiling", "brightness": 230, "color_temp_kelvin": 4000, "supported_color_modes": ["color_temp"]}}' +post light.living_room_lamp '{"state": "off", "attributes": {"friendly_name": "Living Room Lamp", "brightness": 0, "supported_color_modes": ["brightness"]}}' +post switch.coffee_maker '{"state": "off", "attributes": {"friendly_name": "Coffee Maker", "device_class": "outlet"}}' +post binary_sensor.front_door '{"state": "off", "attributes": {"friendly_name": "Front Door", "device_class": "door"}}' +post climate.thermostat '{"state": "heat", "attributes": {"friendly_name": "Thermostat", "current_temperature": 21.5, "temperature": 22.0, "hvac_modes": ["off", "heat", "cool", "auto"], "supported_features": 387}}' +post sensor.air_quality_index '{"state": "42", "attributes": {"friendly_name": "Air Quality Index", "unit_of_measurement": "AQI", "device_class": "aqi"}}' + +echo +echo "Done. The HOMECORE web UI at http://localhost:5173 should now" +echo "show 10 entities. The Dashboard auto-refreshes every 5 s."