Compare commits

...

3 Commits

Author SHA1 Message Date
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
7 changed files with 578 additions and 1 deletions
+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);
});
});
+143
View File
@@ -0,0 +1,143 @@
/**
* 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 } 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<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;
}
`;
@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<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;
}
}
render() {
if (this.error) {
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`
<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. Run
<code>bash scripts/homecore-seed.sh</code> to populate
~10 demo entities, or connect a plugin / integration.
</div>`
: html`<div class="grid">
${this.states.map(
(s) => html`<hc-state-card .state=${s}></hc-state-card>`
)}
</div>`}
`;
}
}
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."
+56 -1
View File
@@ -25,7 +25,8 @@ use anyhow::Result;
use clap::Parser;
use tracing::{info, warn};
use homecore::HomeCore;
use homecore::{HomeCore, ServiceCall, ServiceError, ServiceName};
use homecore::service::FnHandler;
use homecore_api::{router, LongLivedTokenStore, SharedState};
use homecore_assist::pipeline::default_pipeline;
use homecore_assist::RegexIntentRecognizer;
@@ -66,6 +67,13 @@ 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;
// ── 2. Recorder (optional) ──────────────────────────────────────
if !cli.no_recorder {
match Recorder::open(&cli.db).await {
@@ -154,3 +162,50 @@ 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);
}