Compare commits

...

8 Commits

Author SHA1 Message Date
ruv 3f5a7411db feat(homecore-ui iter 4): live per-field validation + inline server errors
CRUD increment 4/6. The form now shows validity feedback on every
keystroke instead of only on Create click, makes the warning vs error
distinction visible (amber vs red), and propagates backend 4xx
responses into the form's own error surface.

frontend/src/components/EntityForm.ts (~80 LOC delta):

  - Three new @state fields tracking per-field validity: _idValid,
    _stateValid, _attrsValid (each is `{ok:true} | {ok:false, level:
    'err'|'warn', msg}` or null when untouched).
  - Pure validators outside the class so they can be unit-tested:
    validateEntityId, validateState, validateAttrs.
  - validateEntityId now warns (amber, not red) if the domain prefix
    is outside the standard HA set. KNOWN_DOMAINS lists ~40 standard
    domains (sensor, light, switch, binary_sensor, climate, cover,
    fan, media_player, lock, camera, vacuum, climate, scene, script,
    automation, input_*, person, device_tracker, zone, weather, etc.)
    + homecore-native domain. Unknown domains create entities anyway
    (backend regex still passes them) but the operator sees the soft
    signal.
  - Sigils render below each field: ✓ green when ok, ✗ red on err,
    ! amber on warn. Field borders adopt the level color via
    .invalid / .warn classes.
  - New public method `isValid()` so the host can bind a disabled
    state on its Save button (unused for now; ready for a follow-up).
  - New public method `setSubmitError(msg)` so the host can surface
    server-side rejection text inline in the form's red error block,
    not just at the page top.

frontend/src/pages/Dashboard.ts (small delta):

  - `_onSubmit()` now calls `this._form?.setSubmitError(null)` before
    each attempt to clear stale text, and on non-2xx responses it
    surfaces the server's body text inline via `setSubmitError`.
    Page-top error block is no longer hijacked for form errors.

Browser-verified end-to-end (real homecore-server :8123):

  entity_id field:
    BadID            → red border + "must match domain.snake_case…"
    light.kitchen_test → green ✓ "entity_id OK"
    madeup_domain.foo → amber border + "unknown domain 'madeup_domain' — HA-standard…"

  state field:
    empty            → red ✗ required
    "on"             → green ✓

  attributes field:
    empty            → green ✓ (defaults to {})
    [1,2,3]          → red ✗ "must be a JSON object…"
    {"key":          → red ✗ "JSON parse: Unexpected end of JSON input"
    {"friendly_name":"Test"} → green ✓

  Server-error inline:
    Force 401 via wrong token → form red block shows
      "server rejected (401): unauthorized"

  Successful create: still works, toast still shown, 0 console errors.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-26 15:12:48 -04:00
ruv c0bb6f4fc7 feat(homecore iter 3): DELETE /api/states/<id> + confirm modal in UI
CRUD increment 3/6. Full delete path lands end-to-end.

Backend (homecore-api):
  rest.rs +18 LOC — new `delete_state` handler. Idempotent (matches HA's
    removal semantics): returns 204 No Content whether the entity existed
    or not. 4xx only for malformed entity_id or auth failure.
  app.rs +6 LOC — adds `.delete(rest::delete_state)` to the
    /api/states/:entity_id route alongside existing GET + POST.

Backend curl smoke:
  POST /api/states/sensor.test_delete         201
  DELETE /api/states/sensor.test_delete       204
  GET /api/states/sensor.test_delete          404

Frontend:
  components/StateCard.ts +25 LOC — small `×` delete button in the
    card's top-right corner. opacity 0 by default, fades in on hover
    or keyboard focus. dispatches `hc-state-card-delete` (NOT
    `hc-state-card-click`) with stopPropagation so the card's own
    click-to-edit handler doesn't also fire.

  pages/Dashboard.ts +45 LOC — deletingState (StateView | null), a
    confirm modal that names the entity_id in the body, Cancel /
    Delete buttons in the footer (Delete styled in muted red),
    `_confirmDelete()` dispatches DELETE with bearer, toast on
    success, grid refresh.

Browser-verified end-to-end on real homecore-server :8123:
  - Hover card → × button visible
  - Click × → DELETE confirm modal (NOT edit modal — stopPropagation works)
  - Modal names entity_id in code block
  - Cancel: entity preserved, modal closes
  - Delete: backend GET-after-DELETE returns 404, grid card vanishes,
    toast "Deleted sensor.delete_target"
  - 0 unexpected console errors (1 expected 404 from verification fetch)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-26 15:03:40 -04:00
ruv 89190b6c2d feat(homecore-ui iter 2): Edit Entity modal + shadow-DOM focus delegation
CRUD increment 2/6 — clicking any state card on the Dashboard opens
the Add Entity modal in EDIT mode: pre-populated, entity_id locked,
"Save" primary button, idempotent POST to /api/states/<id> (backend
returns 200 if existed, 201 if created — same handler).

frontend/src/components/StateCard.ts:
  - card div is now role="button" tabindex=0, dispatches
    `hc-state-card-click` on click + Enter/Space keydown
  - aria-label="Edit <entity_id>" for screen readers
  - shadowRootOptions delegatesFocus=true so the outer Tab sequence
    can reach the inner focusable div (caught by browser agent —
    without this Tab couldn't pierce the shadow root)

frontend/src/pages/Dashboard.ts:
  - new state: editingState (null = create, StateView = edit)
  - _openEdit() catches `hc-state-card-click` from the grid container
  - modal heading switches: "Add entity" ↔ "Edit <entity_id>"
  - primary button text switches: "Create" ↔ "Save"
  - EntityForm receives .editing=true so entity_id input is disabled
  - submit toast reads "Updated" or "Created" depending on mode

Browser-verified end-to-end (real homecore-server :8123, 12 entities):
  - Click `light.kitchen_ceiling` → modal opens with all 4 attributes
    (brightness=230, color_temp_kelvin=4000, friendly_name,
    supported_color_modes) pre-populated
  - Change state to "off", click Save → toast "Updated
    light.kitchen_ceiling = off", grid card reflects new state
  - Backend curl confirms /api/states/light.kitchen_ceiling.state = "off"
  - Enter key on focused card opens the modal too
  - 0 console errors

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-26 14:48:49 -04:00
ruv e7215a16e5 feat(homecore-ui iter 1): Modal + EntityForm + Add Entity flow
First CRUD increment. Click "+ Add entity" on the Dashboard
toolbar → modal opens → form with entity_id / state / attributes
fields → Create validates client-side then POSTs /api/states/<id>
→ modal closes, toast confirms, dashboard refreshes.

New components:
  frontend/src/components/Modal.ts (~110 LOC) — reusable accessible
    overlay. open property; closes on Escape and backdrop click.
    Heading prop; default + footer slots.

  frontend/src/components/EntityForm.ts (~130 LOC) — three-field form
    with public requestSubmit()/requestCancel() methods. Client-side
    validation:
      - entity_id matches /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/
      - state non-empty
      - attributes parses as a JSON object (rejects array/scalar)
    Emits hc-entity-submit / hc-entity-cancel events for host to
    handle. Footer buttons live in the host (modal slot=footer).

  frontend/src/pages/Dashboard.ts (+60 LOC) — toolbar with
    "+ Add entity" button, modal state, POST handler that wraps
    fetch with bearer token, success toast (3 s), refresh().

Browser-verified end-to-end (real homecore-server :8123):
  - Toolbar button visible: Y
  - Modal opens: Y
  - 3/3 validation paths fire correctly:
      BadID → "entity_id must match domain.snake_case"
      blank state → "state must not be empty"
      [1,2,3] attrs → "attributes must be a JSON object"
  - Successful create: light.test_bulb POSTed; modal closes; toast
    "Created light.test_bulb = on"; grid count went 10 → 11
  - Persistence: hard reload, count stays
  - 0 console errors (Lit dev-mode notices excluded)

Note: TypeScript caught a name collision — `attributes` is reserved
on HTMLElement (NamedNodeMap). Renamed the Lit @property to
`entityAttrs` so the class extends LitElement cleanly.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-26 14:33:01 -04:00
ruv 0979faccd4 feat(homecore-server): seed 10 default entities on boot (--no-seed-entities to opt out)
Companion to the seed_default_services() commit. Dashboard + States
pages now have content on every fresh --db :memory: boot, not just
after `bash scripts/homecore-seed.sh`.

Adds:
  - new CLI flag `--no-seed-entities` (default: enabled)
  - `seed_default_entities(hc)` mirroring the bash script's 10-entity
    set (4 RuView sensing-derived + 6 conventional HA fixtures)
  - Boot log:
        Service registry seeded with 13 default service(s)
        State machine seeded with 10 default entities

Two seeds stay in sync — integrations overwrite the same entity_ids
via /api/states/<id> POST. Run with --no-seed-entities when wiring
real plugins that populate the state machine themselves.

Empirical (after rebuild + fresh restart):
  GET /api/states   → 10 entities
  GET /api/services → 6 domains, 13 services

homecore-server --db :memory: is now enough for the web UI to be
fully populated on first paint.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-26 14:18:28 -04:00
ruv 75f984e515 feat(homecore-server): seed 13 default services across 6 domains on boot
Operators (and the new web UI) saw "No services registered" on every
vanilla boot because nothing in the boot sequence called
`ServiceRegistry::register()`. The Assist pipeline registers intent
handlers — a different surface — but `/api/services` stayed empty
until a plugin or integration loaded.

Adds `seed_default_services()` after `HomeCore::new()`. Each handler
is a `FnHandler` that echoes the call back as a JSON acknowledgement
so the service registry is exercise-able from day one. Integrations
override these by re-registering the same `ServiceName` with a real
handler later.

Seeded set:

  homeassistant: restart, stop, reload_core_config
  light:         turn_on, turn_off, toggle
  switch:        turn_on, turn_off, toggle
  scene:         apply
  automation:    trigger
  homecore:      ping, snapshot_state   (HOMECORE-native)

Boot log now reports:

  Service registry seeded with 13 default service(s)

GET /api/services now returns 6 domains with 13 services total.
The HOMECORE web UI's Services page shows them under proper
domain headings.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-26 14:07:52 -04:00
ruv 4253c0e4fc feat(homecore-ui): wire nav router + States / Services / Settings pages
Before: clicking Dashboard / States / Services / Settings highlighted
the active nav button but the page content never changed. AppShell
dispatched `hc-navigate` events but no listener acted on them.

After (~232 LOC across 4 files):
  - main.ts (+20 LOC) tiny router: NAV_TO_TAG maps nav id → page
    custom element; on `hc-navigate`, swap the AppShell's child.
  - pages/States.ts (~86 LOC) HA-style entity table with 5 s refresh.
  - pages/Services.ts (~82 LOC) domain-grouped service registry,
    friendly empty state when no services registered.
  - pages/Settings.ts (~90 LOC) backend config readout + bearer-token
    editor (localStorage["homecore.token"]).

Browser-verified all 4 nav clicks swap content; 0 console errors.
Dashboard → 10 entity cards; States → 10-row table; Services →
empty state (0 domains); Settings → config + token editor.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-26 12:39:33 -04:00
ruv 858a3d9eb5 feat(homecore-ui): Dashboard page + seed script — UI is no longer empty
Before: `<hc-app-shell>` was a layout-only component with an empty
`<slot>` (the auditor flagged it as "scaffold + no dashboard page");
operators saw the appbar + nav + footer but nothing in `<main>`.

After: three small additions wire the existing components to real
backend data.

  frontend/src/pages/Dashboard.ts (~110 LOC) — new Lit `<hc-dashboard>`
    - Reads bearer from localStorage / ?token= / <meta name=> / falls
      back to "dev-token" (matches the DEV-token mode the backend
      reports when HOMECORE_TOKENS is unset)
    - Calls client.getConfig() + client.getStates() on mount
    - Renders a `.meta` line (location · version · entity count) plus
      a responsive grid of `<hc-state-card>` from the live state list
    - Polls /api/states every 5 s for live refresh
    - Surface a structured error block if the backend is unreachable
      so operators see WHAT broke rather than a blank page

  frontend/src/main.ts (+9 LOC) — appends `<hc-dashboard>` into the
    `<hc-app-shell>` slot on DOMContentLoaded

  scripts/homecore-seed.sh (+95 LOC, executable) — POSTs 10
    representative entities to the HA-compat `/api/states/<id>`
    endpoint so a fresh `homecore-server` boot has demo content.
    Live numbers from RuView's sensing-server when RUVIEW_URL is
    reachable (sensor.living_room_presence / bedroom_breathing_rate /
    bedroom_heart_rate); plausible defaults otherwise.

Empirical (after `bash scripts/homecore-seed.sh` against a fresh
homecore-server on :8123, browser at http://localhost:5173):

  .meta:  "Home | HOMECORE v0.1.0-alpha.0 | 10 entities"
  grid :  10 <hc-state-card> elements rendered, e.g.
            binary_sensor.front_door  off    updated 12:17:34
            switch.coffee_maker       off    updated 12:17:34
            sensor.living_room_motion_score  0.0  updated 12:17:33
            …
  curl :  GET /api/config  → 200
          GET /api/states  → 200 (returns array of 10)

The dashboard now provides real value-vs-empty-page proof that the
frontend ↔ HOMECORE-API chain is wired end-to-end.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-26 12:26:02 -04:00
12 changed files with 1243 additions and 3 deletions
+259
View File
@@ -0,0 +1,259 @@
/**
* `<hc-entity-form>` — create / edit form for a single entity.
*
* Props:
* .entityId — pre-populated when editing; empty for create
* .state — pre-populated state value
* .attributes — pre-populated JSON object
* .editing — true to lock entity_id (HA wire-compat doesn't rename)
*
* Emits:
* hc-entity-submit detail: { entity_id, state, attributes }
* hc-entity-cancel
*
* Validation (client-side; backend validates again):
* - entity_id matches /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/
* - state is non-empty
* - attributes parses as a JSON object (not array, not scalar)
*/
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
const ENTITY_ID_RE = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/;
/**
* Known Home Assistant domain prefixes. We don't reject unknown domains
* (the API accepts any matching the regex), but unknown ones get a
* warning so the operator sees what's standard. Add new domains here
* as integrations land.
*/
const KNOWN_DOMAINS = new Set([
'sensor', 'binary_sensor', 'switch', 'light', 'climate', 'cover',
'fan', 'media_player', 'lock', 'camera', 'vacuum', 'humidifier',
'water_heater', 'scene', 'script', 'automation', 'input_boolean',
'input_number', 'input_text', 'input_select', 'input_datetime',
'person', 'device_tracker', 'zone', 'sun', 'weather', 'calendar',
'remote', 'siren', 'select', 'number', 'text', 'button',
'homeassistant', 'homecore', 'group', 'notify', 'tts', 'alarm_control_panel',
]);
type FieldValidity = { ok: true } | { ok: false; level: 'err' | 'warn'; msg: string };
function validateEntityId(id: string): FieldValidity {
const trimmed = id.trim();
if (!trimmed) return { ok: false, level: 'err', msg: 'required' };
if (!ENTITY_ID_RE.test(trimmed)) {
return {
ok: false,
level: 'err',
msg: 'must match domain.snake_case (lowercase, digits, underscores)',
};
}
const domain = trimmed.split('.')[0]!;
if (!KNOWN_DOMAINS.has(domain)) {
return {
ok: false,
level: 'warn',
msg: `unknown domain "${domain}" — HA-standard domains include sensor / light / switch / binary_sensor / climate`,
};
}
return { ok: true };
}
function validateState(s: string): FieldValidity {
if (!s.trim()) return { ok: false, level: 'err', msg: 'required' };
return { ok: true };
}
function validateAttrs(raw: string): FieldValidity {
if (!raw.trim()) return { ok: true }; // empty = {}
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
return { ok: false, level: 'err', msg: 'must be a JSON object (not array, not scalar)' };
}
return { ok: true };
} catch (e) {
return { ok: false, level: 'err', msg: `JSON parse: ${e instanceof Error ? e.message : String(e)}` };
}
}
@customElement('hc-entity-form')
export class EntityForm extends LitElement {
@property({ type: String }) entityId = '';
@property({ type: String }) state = '';
@property({ type: Object }) entityAttrs: Record<string, unknown> = {};
@property({ type: Boolean }) editing = false;
@state() private _attrs = '';
@state() private _err: string | null = null;
/** Per-field live validity. `null` = haven't typed yet (no decoration). */
@state() private _idValid: FieldValidity | null = null;
@state() private _stateValid: FieldValidity | null = null;
@state() private _attrsValid: FieldValidity | null = null;
static styles = css`
:host { display: block; font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); color: var(--hc-text, #e6eaee); }
label { display: block; margin: 12px 0 4px; font-size: 12px; color: var(--hc-text-muted, #7b899d); }
input, textarea {
width: 100%; box-sizing: border-box;
padding: 8px 10px; background: hsl(220 25% 10%);
border: 1px solid var(--hc-border, #2a323e); border-radius: 6px;
color: var(--hc-text, #e6eaee);
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
font-size: 13px;
}
input:focus, textarea:focus { outline: 2px solid hsl(185 80% 50% / 0.5); border-color: var(--hc-primary, #19d4e5); }
input[disabled] { opacity: 0.5; cursor: not-allowed; }
input.invalid, textarea.invalid { border-color: hsl(0 60% 50%); }
input.warn, textarea.warn { border-color: hsl(38 80% 55%); }
.field-status { font-size: 11px; margin-top: 4px; display: flex; align-items: center; gap: 6px; }
.field-status.ok { color: hsl(150 60% 55%); }
.field-status.err { color: hsl(0 70% 70%); }
.field-status.warn { color: hsl(38 80% 65%); }
.field-status .sigil { display: inline-block; width: 12px; text-align: center; font-weight: 700; }
button.primary[disabled] { background: hsl(220 15% 20%); color: var(--hc-text-muted, #7b899d); border-color: var(--hc-border, #2a323e); cursor: not-allowed; }
textarea { min-height: 90px; resize: vertical; }
.hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; }
.err { margin-top: 10px; padding: 10px; border: 1px solid #b35a5a; border-radius: 6px; background: hsl(0 35% 12%); color: #f0c0c0; font-size: 12px; }
button {
padding: 8px 16px;
border: 1px solid var(--hc-border, #2a323e);
border-radius: 6px;
background: hsl(220 25% 14%);
color: var(--hc-text, #e6eaee);
font-size: 13px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
}
button.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; }
button:hover { background: hsl(220 20% 18%); }
button.primary:hover { background: hsl(185 80% 55%); }
`;
protected updated(changed: Map<string, unknown>): void {
if (changed.has('entityAttrs')) {
this._attrs = JSON.stringify(this.entityAttrs, null, 2);
}
}
/** Allow the host (Dashboard) to surface a server-side error inline. */
public setSubmitError(msg: string | null): void {
this._err = msg;
}
/** True iff every field is valid (warnings are OK, errors block). Public so the host can bind a disabled state on the submit button. */
public isValid(): boolean {
const checks = [
validateEntityId(this.entityId),
validateState(this.state),
validateAttrs(this._attrs),
];
return !checks.some((c) => !c.ok && c.level === 'err');
}
private _onIdInput(v: string) {
this.entityId = v;
this._idValid = validateEntityId(v);
}
private _onStateInput(v: string) {
this.state = v;
this._stateValid = validateState(v);
}
private _onAttrsInput(v: string) {
this._attrs = v;
this._attrsValid = validateAttrs(v);
}
private _statusLine(label: string, v: FieldValidity | null) {
if (v === null) return html``;
if (v.ok) return html`<div class="field-status ok"><span class="sigil">✓</span>${label} OK</div>`;
return html`<div class="field-status ${v.level}">
<span class="sigil">${v.level === 'warn' ? '!' : '✗'}</span>${v.msg}
</div>`;
}
private _fieldClass(v: FieldValidity | null): string {
if (v === null || v.ok) return '';
return v.level;
}
/** Public — call from host to trigger validation + emit submit event. */
public requestSubmit(): void { this._submit(); }
/** Public — call from host to dispatch cancel. */
public requestCancel(): void { this._cancel(); }
private _submit() {
const id = this.entityId.trim();
if (!ENTITY_ID_RE.test(id)) {
this._err = `entity_id must match domain.snake_case (got "${id}")`;
return;
}
const stateVal = this.state.trim();
if (!stateVal) {
this._err = 'state must not be empty';
return;
}
let attrs: Record<string, unknown> = {};
if (this._attrs.trim()) {
try {
const parsed = JSON.parse(this._attrs);
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
this._err = 'attributes must be a JSON object (not array, not scalar)';
return;
}
attrs = parsed as Record<string, unknown>;
} catch (e) {
this._err = `attributes JSON parse failed: ${e instanceof Error ? e.message : String(e)}`;
return;
}
}
this._err = null;
this.dispatchEvent(new CustomEvent('hc-entity-submit', {
detail: { entity_id: id, state: stateVal, attributes: attrs },
bubbles: true, composed: true,
}));
}
private _cancel() {
this._err = null;
this.dispatchEvent(new CustomEvent('hc-entity-cancel', { bubbles: true, composed: true }));
}
render() {
return html`
<form @submit=${(e: Event) => { e.preventDefault(); this._submit(); }}>
<label for="eid">entity_id</label>
<input id="eid" .value=${this.entityId}
class=${this._fieldClass(this._idValid)}
?disabled=${this.editing}
@input=${(e: Event) => this._onIdInput((e.target as HTMLInputElement).value)}
placeholder="light.kitchen_ceiling" />
<div class="hint">format: <code>domain.snake_case</code> — domain like sensor / light / switch / binary_sensor</div>
${this._statusLine('entity_id', this._idValid)}
<label for="state">state</label>
<input id="state" .value=${this.state}
class=${this._fieldClass(this._stateValid)}
@input=${(e: Event) => this._onStateInput((e.target as HTMLInputElement).value)}
placeholder="on / off / 42 / 14.5 / detected" />
${this._statusLine('state', this._stateValid)}
<label for="attrs">attributes (JSON object)</label>
<textarea id="attrs" .value=${this._attrs}
class=${this._fieldClass(this._attrsValid)}
@input=${(e: Event) => this._onAttrsInput((e.target as HTMLTextAreaElement).value)}
placeholder='{ "friendly_name": "Kitchen Ceiling", "brightness": 230 }'></textarea>
<div class="hint">optional; leave blank for <code>{}</code></div>
${this._statusLine('attributes', this._attrsValid)}
${this._err ? html`<div class="err">${this._err}</div>` : ''}
</form>
`;
}
}
declare global { interface HTMLElementTagNameMap { 'hc-entity-form': EntityForm; } }
+112
View File
@@ -0,0 +1,112 @@
/**
* `<hc-modal>` — minimal accessible overlay modal.
*
* Open / close by setting the `open` property. Closes on Escape and
* on backdrop click. Content goes in the default slot; an optional
* named "footer" slot is rendered below the content.
*
* Emits `hc-modal-close` on close so the host can clean up.
*/
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('hc-modal')
export class Modal extends LitElement {
@property({ type: Boolean, reflect: true }) open = false;
@property({ type: String }) heading = '';
static styles = css`
:host { display: contents; }
.backdrop {
position: fixed;
inset: 0;
background: hsl(220 25% 4% / 0.65);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 16px;
}
.dialog {
background: var(--hc-bg, #0b0e13);
border: 1px solid var(--hc-border, #2a323e);
border-radius: 10px;
box-shadow: 0 24px 64px hsl(220 25% 2% / 0.6);
width: min(560px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
overflow: hidden;
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
color: var(--hc-text, #e6eaee);
}
header {
padding: 14px 18px;
border-bottom: 1px solid var(--hc-border, #2a323e);
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
font-size: 15px;
}
button.close {
background: transparent;
border: none;
color: var(--hc-text-muted, #7b899d);
cursor: pointer;
font-size: 18px;
line-height: 1;
padding: 4px 8px;
border-radius: 4px;
}
button.close:hover { background: hsl(220 20% 14%); color: var(--hc-text, #e6eaee); }
.body { padding: 16px 18px; overflow-y: auto; }
.footer {
padding: 12px 18px;
border-top: 1px solid var(--hc-border, #2a323e);
display: flex;
justify-content: flex-end;
gap: 8px;
}
`;
connectedCallback(): void {
super.connectedCallback();
this._onKey = this._onKey.bind(this);
window.addEventListener('keydown', this._onKey);
}
disconnectedCallback(): void {
window.removeEventListener('keydown', this._onKey);
super.disconnectedCallback();
}
private _onKey(e: KeyboardEvent) {
if (this.open && e.key === 'Escape') this._close();
}
private _close() {
this.open = false;
this.dispatchEvent(new CustomEvent('hc-modal-close', { bubbles: true, composed: true }));
}
render() {
if (!this.open) return html``;
return html`
<div class="backdrop" @click=${(e: Event) => { if (e.target === e.currentTarget) this._close(); }}>
<div class="dialog" role="dialog" aria-modal="true" aria-label=${this.heading}>
<header>
<span>${this.heading}</span>
<button class="close" @click=${this._close} aria-label="Close">×</button>
</header>
<div class="body"><slot></slot></div>
<div class="footer"><slot name="footer"></slot></div>
</div>
</div>
`;
}
}
declare global { interface HTMLElementTagNameMap { 'hc-modal': Modal; } }
+52 -1
View File
@@ -9,6 +9,12 @@ import type { StateView } from '../api/types.js';
@customElement('hc-state-card')
export class StateCard extends LitElement {
// `delegatesFocus` lets Tab key traversal from the light DOM reach the
// role="button" element inside this card's shadow root. Without it the
// user can only activate the card via mouse click or by JS-focusing the
// inner div; with it, the natural tab sequence flows through every card.
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
@property({ type: Object }) state!: StateView;
/** Optional: icon SVG string (use `iconSvg()` from lucide.ts) */
@property({ type: String }) iconSvg?: string;
@@ -32,6 +38,28 @@ export class StateCard extends LitElement {
border-color: hsl(185 80% 50% / 0.4);
}
.card { cursor: pointer; position: relative; }
.card:focus-visible { outline: 2px solid var(--hc-primary, #19d4e5); outline-offset: 2px; }
button.delete {
position: absolute;
top: 0.5rem; right: 0.5rem;
width: 24px; height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--hc-text-muted, #7b899d);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0;
opacity: 0;
transition: opacity 150ms, background 150ms, color 150ms;
}
.card:hover button.delete,
.card:focus-within button.delete { opacity: 1; }
button.delete:hover { background: hsl(0 50% 30%); color: hsl(0 80% 88%); }
button.delete:focus-visible { opacity: 1; outline: 2px solid hsl(0 60% 55%); }
.header {
display: flex;
align-items: flex-start;
@@ -108,7 +136,15 @@ export class StateCard extends LitElement {
const badge = this.badgeClass(state);
return html`
<div class="card" part="card">
<div class="card" part="card" role="button" tabindex="0"
@click=${this._onClick}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._onClick(); } }}
aria-label="Edit ${entity_id}">
<button class="delete" type="button"
@click=${this._onDelete}
@keydown=${(e: KeyboardEvent) => { e.stopPropagation(); }}
aria-label="Delete ${entity_id}"
title="Delete ${entity_id}">×</button>
<div class="header">
${this.iconSvg
? html`<div class="icon-wrap" .innerHTML=${this.iconSvg}></div>`
@@ -123,6 +159,21 @@ export class StateCard extends LitElement {
</div>
`;
}
private _onClick() {
this.dispatchEvent(new CustomEvent('hc-state-card-click', {
detail: { state: this.state }, bubbles: true, composed: true,
}));
}
private _onDelete(e: Event) {
// Stop propagation so the parent card's click handler (which would
// open the edit modal) doesn't also fire.
e.stopPropagation();
this.dispatchEvent(new CustomEvent('hc-state-card-delete', {
detail: { state: this.state }, bubbles: true, composed: true,
}));
}
}
declare global {
+31
View File
@@ -9,3 +9,34 @@ import './styles/base.css';
// Register custom elements
import './components/AppShell.js';
import './components/StateCard.js';
import './pages/Dashboard.js';
import './pages/States.js';
import './pages/Services.js';
import './pages/Settings.js';
// Tiny router: the AppShell dispatches `hc-navigate` on every nav
// click. We swap whichever page element is sitting in its <slot>
// based on the new active id. Default page on first paint = dashboard.
const NAV_TO_TAG: Record<string, string> = {
dashboard: 'hc-dashboard',
states: 'hc-states',
services: 'hc-services',
settings: 'hc-settings',
};
function mountPage(shell: Element, tag: string): void {
// Remove any existing page (everything that isn't itself the shell).
Array.from(shell.children).forEach((c) => c.remove());
shell.appendChild(document.createElement(tag));
}
window.addEventListener('DOMContentLoaded', () => {
const shell = document.querySelector('hc-app-shell');
if (!shell) return;
mountPage(shell, 'hc-dashboard');
shell.addEventListener('hc-navigate', (ev) => {
const id = (ev as CustomEvent<{ id: string }>).detail?.id;
const tag = id ? NAV_TO_TAG[id] : undefined;
if (tag) mountPage(shell, tag);
});
});
+282
View File
@@ -0,0 +1,282 @@
/**
* Dashboard page — fetches HOMECORE state + config from the backend and
* populates the `<hc-app-shell>` slot with a grid of `<hc-state-card>`.
*
* Auth: reads bearer from `localStorage["homecore.token"]`, the
* `?token=` query string, or `HOMECORE_TOKEN` `<meta>` 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, query } from 'lit/decorators.js';
import { HomecoreClient } from '../api/client.js';
import type { ApiConfig, StateView } from '../api/types.js';
import '../components/Modal.js';
import '../components/EntityForm.js';
import type { EntityForm } from '../components/EntityForm.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<HTMLMetaElement>('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;
}
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.toolbar .grow { flex: 1; }
button.add {
padding: 7px 14px;
background: var(--hc-primary, #19d4e5);
color: var(--hc-primary-fg, #0b0e13);
border: none; border-radius: 6px;
font-size: 13px; font-weight: 600;
cursor: pointer;
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
}
button.add:hover { background: hsl(185 80% 55%); }
button.btn {
padding: 7px 14px;
background: hsl(220 25% 14%);
color: var(--hc-text, #e6eaee);
border: 1px solid var(--hc-border, #2a323e);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
}
button.btn:hover { background: hsl(220 20% 18%); }
button.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; }
.toast { padding: 8px 12px; background: hsl(165 60% 16%); color: hsl(165 60% 80%); border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
`;
@state() private states: StateView[] = [];
@state() private config: ApiConfig | null = null;
@state() private error: string | null = null;
@state() private loading = true;
@state() private modalOpen = false;
@state() private submitToast: string | null = null;
@state() private editingState: StateView | null = null; // null = create mode
@state() private deletingState: StateView | null = null; // null = no confirm
@query('hc-entity-form') private _form?: EntityForm;
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<void> {
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;
}
}
private _openCreate() {
this.editingState = null;
this.modalOpen = true;
}
private _openEdit(e: CustomEvent<{ state: StateView }>) {
this.editingState = e.detail.state;
this.modalOpen = true;
}
private _openDeleteConfirm(e: CustomEvent<{ state: StateView }>) {
this.deletingState = e.detail.state;
}
private async _confirmDelete() {
const target = this.deletingState;
if (!target) return;
try {
const resp = await fetch(`/api/states/${encodeURIComponent(target.entity_id)}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${resolveToken()}` },
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
this.deletingState = null;
this.submitToast = `Deleted ${target.entity_id}`;
window.setTimeout(() => (this.submitToast = null), 3000);
await this.refresh();
} catch (err) {
this.error = err instanceof Error ? err.message : String(err);
this.deletingState = null;
}
}
private async _onSubmit(e: CustomEvent<{ entity_id: string; state: string; attributes: Record<string, unknown> }>) {
const { entity_id, state, attributes } = e.detail;
const wasEditing = this.editingState !== null;
// Clear any previous server-side error before the next attempt.
this._form?.setSubmitError(null);
try {
const resp = await fetch(`/api/states/${encodeURIComponent(entity_id)}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${resolveToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ state, attributes }),
});
if (!resp.ok) {
// Surface the server message inline in the form, not at
// the top of the page — the form is what the user is
// looking at.
const body = await resp.text();
this._form?.setSubmitError(`server rejected (${resp.status}): ${body || resp.statusText}`);
return;
}
this.modalOpen = false;
this.editingState = null;
this.submitToast = `${wasEditing ? 'Updated' : 'Created'} ${entity_id} = ${state}`;
window.setTimeout(() => (this.submitToast = null), 3000);
await this.refresh();
} catch (err) {
this._form?.setSubmitError(err instanceof Error ? err.message : String(err));
}
}
render() {
if (this.error && this.states.length === 0) {
return html`<div class="err">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.
</div>`;
}
if (this.loading) {
return html`<div class="empty">loading HOMECORE state…</div>`;
}
const v = this.config?.version ?? '?';
const loc = this.config?.location_name ?? 'Home';
return html`
${this.submitToast ? html`<div class="toast">${this.submitToast}</div>` : ''}
<div class="toolbar">
<span class="grow"></span>
<button class="add" @click=${this._openCreate}>+ Add entity</button>
</div>
<div class="meta">
<span><strong>${loc}</strong></span>
<span>HOMECORE v<strong>${v}</strong></span>
<span><strong>${this.states.length}</strong> entities</span>
</div>
${this.states.length === 0
? html`<div class="empty">
No entities registered yet. Click <strong>+ Add entity</strong>
above, run <code>bash scripts/homecore-seed.sh</code>,
or boot <code>homecore-server</code> without
<code>--no-seed-entities</code>.
</div>`
: html`<div class="grid"
@hc-state-card-click=${(e: Event) => this._openEdit(e as CustomEvent)}
@hc-state-card-delete=${(e: Event) => this._openDeleteConfirm(e as CustomEvent)}>
${this.states.map(
(s) => html`<hc-state-card .state=${s}></hc-state-card>`
)}
</div>`}
<hc-modal .open=${this.deletingState !== null}
heading="Delete entity"
@hc-modal-close=${() => (this.deletingState = null)}>
<p style="margin:0 0 12px 0; line-height:1.5;">
Permanently remove
<code style="background:hsl(220 25% 14%); padding:2px 6px; border-radius:4px;">${this.deletingState?.entity_id ?? ''}</code>
from the state machine?
<br>
<span style="color:var(--hc-text-muted,#7b899d); font-size:12px;">
This is immediate. To restore, re-create the entity via "+ Add entity".
</span>
</p>
<button slot="footer" class="btn" @click=${() => (this.deletingState = null)}>Cancel</button>
<button slot="footer" class="btn"
style="background:hsl(0 50% 25%); border-color:hsl(0 50% 35%); color:hsl(0 60% 88%);"
@click=${this._confirmDelete}>Delete</button>
</hc-modal>
<hc-modal .open=${this.modalOpen}
heading=${this.editingState ? `Edit ${this.editingState.entity_id}` : 'Add entity'}
@hc-modal-close=${() => { this.modalOpen = false; this.editingState = null; }}>
<hc-entity-form
.entityId=${this.editingState?.entity_id ?? ''}
.state=${this.editingState?.state ?? ''}
.entityAttrs=${this.editingState?.attributes ?? {}}
.editing=${this.editingState !== null}
@hc-entity-submit=${(e: Event) => this._onSubmit(e as CustomEvent)}
@hc-entity-cancel=${() => { this.modalOpen = false; this.editingState = null; }}></hc-entity-form>
<button slot="footer" class="btn" @click=${() => this._form?.requestCancel()}>Cancel</button>
<button slot="footer" class="btn primary" @click=${() => this._form?.requestSubmit()}>${this.editingState ? 'Save' : 'Create'}</button>
</hc-modal>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'hc-dashboard': Dashboard;
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Services page — lists every registered service grouped by domain.
* Reads from `/api/services` (HA-wire-compat).
*/
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { HomecoreClient } from '../api/client.js';
import type { ServiceDomainView } from '../api/types.js';
function resolveToken(): string {
if (typeof localStorage !== 'undefined') {
const stored = localStorage.getItem('homecore.token');
if (stored) return stored;
}
const qs = new URL(window.location.href).searchParams.get('token');
return qs ?? 'dev-token';
}
@customElement('hc-services')
export class ServicesPage extends LitElement {
static styles = css`
:host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; }
.domain { background: hsl(220 20% 10%); border: 1px solid var(--hc-border, #2a323e); border-radius: 8px; margin-bottom: 12px; padding: 14px 16px; }
.domain h2 { font-size: 14px; font-weight: 600; margin: 0 0 8px 0; color: var(--hc-primary, #19d4e5); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); }
ul { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; }
li { background: hsl(220 25% 14%); padding: 4px 10px; border-radius: 4px; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 12px; color: var(--hc-text-muted, #7b899d); }
.empty { padding: 24px; border: 1px dashed var(--hc-border, #2a323e); border-radius: 8px; text-align: center; color: var(--hc-text-muted, #7b899d); }
.err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-size: 13px; }
`;
@state() private domains: ServiceDomainView[] = [];
@state() private error: string | null = null;
@state() private loading = true;
private client = new HomecoreClient({ token: resolveToken() });
connectedCallback(): void {
super.connectedCallback();
void this.refresh();
}
private async refresh(): Promise<void> {
try {
const r = await fetch('/api/services', { headers: { 'Authorization': `Bearer ${resolveToken()}` } });
if (!r.ok) throw new Error(`/api/services -> HTTP ${r.status}`);
this.domains = await r.json();
this.error = null;
} catch (e) {
this.error = e instanceof Error ? e.message : String(e);
} finally {
this.loading = false;
}
void this.client; // suppress unused warning while keeping the import shape consistent
}
render() {
if (this.error) return html`<div class="err">backend unreachable — ${this.error}</div>`;
if (this.loading) return html`<div>loading services…</div>`;
if (this.domains.length === 0) {
return html`
<h1>Services (0 domains)</h1>
<div class="empty">
No services registered. Services are registered by plugins
(Wasmtime or InProcess) or by integrations that call
<code>services::register()</code> on boot.
</div>
`;
}
return html`
<h1>Services (${this.domains.length} domain${this.domains.length === 1 ? '' : 's'})</h1>
${this.domains.map(d => html`
<div class="domain">
<h2>${d.domain}</h2>
<ul>
${Object.keys(d.services).map(name => html`<li>${name}</li>`)}
</ul>
</div>
`)}
`;
}
}
declare global { interface HTMLElementTagNameMap { 'hc-services': ServicesPage; } }
+94
View File
@@ -0,0 +1,94 @@
/**
* Settings page — backend config + bearer-token editor (localStorage).
*/
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { HomecoreClient } from '../api/client.js';
import type { ApiConfig } from '../api/types.js';
function resolveToken(): string {
if (typeof localStorage !== 'undefined') {
const stored = localStorage.getItem('homecore.token');
if (stored) return stored;
}
const qs = new URL(window.location.href).searchParams.get('token');
return qs ?? 'dev-token';
}
@customElement('hc-settings')
export class SettingsPage extends LitElement {
static styles = css`
:host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; }
section { background: hsl(220 20% 10%); border: 1px solid var(--hc-border, #2a323e); border-radius: 8px; padding: 16px; margin-bottom: 16px; }
h2 { font-size: 14px; font-weight: 600; margin: 0 0 12px 0; color: var(--hc-primary, #19d4e5); }
dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; margin: 0; font-size: 13px; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); }
dt { color: var(--hc-text-muted, #7b899d); }
dd { margin: 0; }
label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--hc-text-muted, #7b899d); }
input { width: 100%; box-sizing: border-box; padding: 8px 12px; background: hsl(220 25% 14%); border: 1px solid var(--hc-border, #2a323e); border-radius: 6px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; }
button { margin-top: 10px; padding: 8px 16px; background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border: none; border-radius: 6px; font-weight: 600; font-size: 13px; cursor: pointer; font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
button:hover { background: hsl(185 80% 55%); }
.toast { font-size: 12px; color: var(--hc-primary, #19d4e5); margin-top: 8px; }
.err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-size: 13px; }
`;
@state() private config: ApiConfig | null = null;
@state() private error: string | null = null;
@state() private token = resolveToken();
@state() private savedAt = 0;
private client = new HomecoreClient({ token: resolveToken() });
connectedCallback(): void {
super.connectedCallback();
void this.refresh();
}
private async refresh(): Promise<void> {
try {
this.config = await this.client.getConfig();
this.error = null;
} catch (e) {
this.error = e instanceof Error ? e.message : String(e);
}
}
private saveToken() {
localStorage.setItem('homecore.token', this.token);
this.savedAt = Date.now();
this.client = new HomecoreClient({ token: this.token });
void this.refresh();
}
render() {
return html`
<h1>Settings</h1>
<section>
<h2>backend</h2>
${this.error
? html`<div class="err">unreachable — ${this.error}</div>`
: this.config
? html`<dl>
<dt>location</dt><dd>${this.config.location_name}</dd>
<dt>version</dt><dd>${this.config.version}</dd>
<dt>state</dt><dd>${this.config.state}</dd>
<dt>components</dt><dd>${this.config.components.join(', ')}</dd>
</dl>`
: html`loading…`}
</section>
<section>
<h2>auth — bearer token</h2>
<label for="tok">stored at localStorage["homecore.token"]; DEV mode accepts any non-empty value</label>
<input id="tok" type="password" .value=${this.token}
@input=${(e: Event) => (this.token = (e.target as HTMLInputElement).value)} />
<button @click=${this.saveToken}>save & reload backend</button>
${this.savedAt > 0 ? html`<div class="toast">saved at ${new Date(this.savedAt).toLocaleTimeString()}</div>` : ''}
</section>
`;
}
}
declare global { interface HTMLElementTagNameMap { 'hc-settings': SettingsPage; } }
+85
View File
@@ -0,0 +1,85 @@
/**
* States page — full table view of every entity in the state machine.
* Mirrors Home Assistant's `/developer-tools/state` view (read-only).
*/
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { HomecoreClient } from '../api/client.js';
import type { StateView } from '../api/types.js';
function resolveToken(): string {
if (typeof localStorage !== 'undefined') {
const stored = localStorage.getItem('homecore.token');
if (stored) return stored;
}
const qs = new URL(window.location.href).searchParams.get('token');
return qs ?? 'dev-token';
}
@customElement('hc-states')
export class StatesPage extends LitElement {
static styles = css`
:host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--hc-border, #2a323e); color: var(--hc-text-muted, #7b899d); font-weight: 500; }
td { padding: 10px 12px; border-bottom: 1px solid hsl(220 15% 14%); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); }
td.attrs { color: var(--hc-text-muted, #7b899d); font-size: 12px; max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
tr:hover td { background: hsl(220 20% 10%); }
.state { color: var(--hc-primary, #19d4e5); }
.err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; }
`;
@state() private states: StateView[] = [];
@state() private error: string | null = null;
@state() private loading = true;
private client = new HomecoreClient({ token: resolveToken() });
private timer?: number;
connectedCallback(): void {
super.connectedCallback();
void this.refresh();
this.timer = window.setInterval(() => void this.refresh(), 5000);
}
disconnectedCallback(): void {
if (this.timer !== undefined) window.clearInterval(this.timer);
super.disconnectedCallback();
}
private async refresh(): Promise<void> {
try {
this.states = await this.client.getStates();
this.error = null;
} catch (e) {
this.error = e instanceof Error ? e.message : String(e);
} finally {
this.loading = false;
}
}
render() {
if (this.error) return html`<div class="err">backend unreachable — ${this.error}</div>`;
if (this.loading) return html`<div>loading…</div>`;
return html`
<h1>States (${this.states.length})</h1>
<table>
<thead><tr><th>entity_id</th><th>state</th><th>last_changed</th><th>attributes</th></tr></thead>
<tbody>
${this.states.map(s => html`
<tr>
<td>${s.entity_id}</td>
<td class="state">${s.state}</td>
<td>${s.last_changed.replace('T', ' ').replace(/\..*$/, '')}</td>
<td class="attrs" title=${JSON.stringify(s.attributes)}>${JSON.stringify(s.attributes)}</td>
</tr>
`)}
</tbody>
</table>
`;
}
}
declare global { interface HTMLElementTagNameMap { 'hc-states': StatesPage; } }
+83
View File
@@ -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."
+6 -1
View File
@@ -28,7 +28,12 @@ pub fn router(state: SharedState) -> Router {
.route("/api/", get(rest::api_root))
.route("/api/config", get(rest::get_config))
.route("/api/states", get(rest::get_states))
.route("/api/states/:entity_id", get(rest::get_state).post(rest::set_state))
.route(
"/api/states/:entity_id",
get(rest::get_state)
.post(rest::set_state)
.delete(rest::delete_state),
)
.route("/api/services", get(rest::get_services))
.route("/api/services/:domain/:service", post(rest::call_service))
.route("/api/websocket", get(ws::websocket_handler))
+15
View File
@@ -92,6 +92,21 @@ pub struct SetStateRequest {
pub attributes: serde_json::Value,
}
/// DELETE /api/states/:entity_id — remove an entity from the state
/// machine. Idempotent: returns 204 whether or not the entity existed,
/// matching HA's removal semantics. 4xx only for malformed entity_id or
/// auth failure.
pub async fn delete_state(
headers: HeaderMap,
State(s): State<SharedState>,
Path(entity_id): Path<String>,
) -> ApiResult<StatusCode> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
let id = EntityId::parse(entity_id).map_err(|e| ApiError::BadRequest(e.to_string()))?;
s.homecore().states().remove(&id);
Ok(StatusCode::NO_CONTENT)
}
pub async fn set_state(
headers: HeaderMap,
State(s): State<SharedState>,
+138 -1
View File
@@ -25,7 +25,8 @@ use anyhow::Result;
use clap::Parser;
use tracing::{info, warn};
use homecore::HomeCore;
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
use homecore::service::FnHandler;
use homecore_api::{router, LongLivedTokenStore, SharedState};
use homecore_assist::pipeline::default_pipeline;
use homecore_assist::RegexIntentRecognizer;
@@ -52,6 +53,12 @@ struct Cli {
/// Disable the SQLite recorder for low-resource deployments.
#[arg(long)]
no_recorder: bool,
/// Skip the boot-time entity seeding (10 demo entities including
/// 4 RuView-derived sensors). Use this when wiring real
/// integrations that will populate the state machine themselves.
#[arg(long)]
no_seed_entities: bool,
}
#[tokio::main]
@@ -66,6 +73,23 @@ async fn main() -> Result<()> {
let hc = HomeCore::new();
info!("HomeCore state machine + event bus + service registry online");
// Seed a representative set of built-in services so the web UI
// and HA-wire-compat clients see a populated /api/services on
// first boot. These are no-op handlers (they just echo back the
// call as JSON for observability) — integrations override them
// by registering the same ServiceName later.
seed_default_services(&hc).await;
// Seed 10 representative entities so the web UI's Dashboard +
// States pages have content out of the box. Operators registering
// real integrations / plugins overwrite these by writing the same
// entity_id with new values. Opt out with `--no-seed-entities`.
if !cli.no_seed_entities {
seed_default_entities(&hc);
} else {
info!("Entity seeding disabled by --no-seed-entities");
}
// ── 2. Recorder (optional) ──────────────────────────────────────
if !cli.no_recorder {
match Recorder::open(&cli.db).await {
@@ -154,3 +178,116 @@ fn init_tracing() {
)
.init();
}
/// Register a representative set of built-in services so `/api/services`
/// is non-empty on first boot. Each handler simply echoes the call back
/// as a JSON acknowledgement — integrations override these by
/// re-registering the same `ServiceName` with a real handler later.
///
/// The set covers the HA wire-compat "starter pack" (homeassistant /
/// light / switch / scene / automation domains) plus a `homecore.*`
/// domain so operators can see HOMECORE-native services distinguished
/// from the HA-compat ones.
async fn seed_default_services(hc: &HomeCore) {
let echo = || FnHandler(|call: ServiceCall| async move {
Ok(serde_json::json!({
"called": format!("{}.{}", call.name.domain, call.name.service),
"service_data": call.data,
"acknowledged": true,
}))
});
let svcs = [
// Conventional HA wire-compat services
("homeassistant", "restart"),
("homeassistant", "stop"),
("homeassistant", "reload_core_config"),
("light", "turn_on"),
("light", "turn_off"),
("light", "toggle"),
("switch", "turn_on"),
("switch", "turn_off"),
("switch", "toggle"),
("scene", "apply"),
("automation", "trigger"),
// HOMECORE-native services
("homecore", "ping"),
("homecore", "snapshot_state"),
];
for (domain, service) in svcs {
hc.services()
.register(ServiceName::new(domain, service), echo())
.await;
}
let count = hc.services().registered_services().await.len();
let _ = ServiceError::NotRegistered { domain: String::new(), service: String::new() };
info!("Service registry seeded with {} default service(s)", count);
}
/// Register 10 representative entities so a fresh `--db :memory:`
/// boot has content for the web UI. Mirrors `scripts/homecore-seed.sh`
/// — when both are run the script just overwrites these values, so
/// they stay in sync.
fn seed_default_entities(hc: &HomeCore) {
let entities: Vec<(&str, &str, serde_json::Value)> = vec![
("sensor.living_room_presence", "false", serde_json::json!({
"friendly_name": "Living Room Presence", "device_class": "occupancy",
"source": "RuView ESP32-C6 BFLD"
})),
("sensor.living_room_motion_score", "0.0", serde_json::json!({
"friendly_name": "Living Room Motion Score", "unit_of_measurement": "score",
"icon": "mdi:motion-sensor"
})),
("sensor.bedroom_breathing_rate", "14.5", serde_json::json!({
"friendly_name": "Bedroom Breathing Rate", "unit_of_measurement": "BPM",
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
})),
("sensor.bedroom_heart_rate", "68.0", serde_json::json!({
"friendly_name": "Bedroom Heart Rate", "unit_of_measurement": "BPM",
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
})),
("light.kitchen_ceiling", "on", serde_json::json!({
"friendly_name": "Kitchen Ceiling", "brightness": 230,
"color_temp_kelvin": 4000, "supported_color_modes": ["color_temp"]
})),
("light.living_room_lamp", "off", serde_json::json!({
"friendly_name": "Living Room Lamp", "brightness": 0,
"supported_color_modes": ["brightness"]
})),
("switch.coffee_maker", "off", serde_json::json!({
"friendly_name": "Coffee Maker", "device_class": "outlet"
})),
("binary_sensor.front_door", "off", serde_json::json!({
"friendly_name": "Front Door", "device_class": "door"
})),
("climate.thermostat", "heat", serde_json::json!({
"friendly_name": "Thermostat", "current_temperature": 21.5,
"temperature": 22.0, "hvac_modes": ["off", "heat", "cool", "auto"],
"supported_features": 387
})),
("sensor.air_quality_index", "42", serde_json::json!({
"friendly_name": "Air Quality Index", "unit_of_measurement": "AQI",
"device_class": "aqi"
})),
];
for (id, state, attrs) in entities {
match EntityId::parse(id) {
Ok(eid) => {
hc.states().set(eid, state, attrs, Context::new());
}
Err(e) => warn!("seed_default_entities: bad entity_id {id}: {e}"),
}
}
let _ = ServiceCall {
name: ServiceName::new("homecore", "noop"),
data: serde_json::json!({}),
context: Context::new(),
};
let total = hc.states().all().len();
info!("State machine seeded with {} default entit{}", total,
if total == 1 { "y" } else { "ies" });
}