refactor: move frontend/ to examples/frontend/

The Lit + Vite HOMECORE web UI is an example consumer of the
sensing stack, not a top-level deliverable — relocate it under
examples/ alongside the other sensor and dashboard demos.

Add an entry to examples/README.md so it's discoverable.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-05-27 12:20:49 -04:00
parent 224689a5bc
commit 04f205a05e
26 changed files with 14 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
/**
* HOMECORE API client.
*
* REST: fetch-based, bearer token auth. Base URL defaults to window.location.origin
* so the Vite dev-server proxy handles the `/api` → `:8123` rewrite.
* WS: native WebSocket, mirrors HA's ws handshake protocol (auth_required → auth → auth_ok).
*/
import type {
ApiConfig,
ServiceDomainView,
StateView,
WsAuthOk,
WsAuthRequired,
WsServerMessage,
} from './types.js';
export interface ClientOptions {
baseUrl?: string;
token: string;
}
export class HomecoreClient {
private readonly base: string;
private readonly token: string;
constructor(options: ClientOptions) {
this.base = options.baseUrl ?? '';
this.token = options.token;
}
// ── REST helpers ────────────────────────────────────────────────────────────
private headers(): HeadersInit {
return {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
};
}
private async get<T>(path: string): Promise<T> {
const resp = await fetch(`${this.base}${path}`, {
method: 'GET',
headers: this.headers(),
});
if (!resp.ok) {
throw new Error(`GET ${path}${resp.status} ${resp.statusText}`);
}
return resp.json() as Promise<T>;
}
private async post<T>(path: string, body: unknown): Promise<T> {
const resp = await fetch(`${this.base}${path}`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify(body),
});
if (!resp.ok) {
throw new Error(`POST ${path}${resp.status} ${resp.statusText}`);
}
return resp.json() as Promise<T>;
}
// ── REST endpoints (mirrors rest.rs) ─────────────────────────────────────
getConfig(): Promise<ApiConfig> {
return this.get<ApiConfig>('/api/config');
}
getStates(): Promise<StateView[]> {
return this.get<StateView[]>('/api/states');
}
getState(entityId: string): Promise<StateView> {
return this.get<StateView>(`/api/states/${encodeURIComponent(entityId)}`);
}
setState(entityId: string, state: string, attributes?: Record<string, unknown>): Promise<StateView> {
return this.post<StateView>(`/api/states/${encodeURIComponent(entityId)}`, {
state,
attributes: attributes ?? {},
});
}
getServices(): Promise<ServiceDomainView[]> {
return this.get<ServiceDomainView[]>('/api/services');
}
callService(domain: string, service: string, data?: unknown): Promise<unknown> {
return this.post<unknown>(`/api/services/${domain}/${service}`, data ?? {});
}
// ── WebSocket ────────────────────────────────────────────────────────────
/**
* Open an authenticated WebSocket connection.
* Resolves once `auth_ok` is received; rejects on auth failure or network error.
* Returns the live socket; caller is responsible for `.close()`.
*/
openWebSocket(wsBase?: string): Promise<WebSocket> {
const resolved = wsBase ?? this.base.replace(/^http/, 'ws');
const origin = resolved || window.location.origin.replace(/^http/, 'ws');
const url = `${origin}/api/websocket`;
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.onmessage = (evt: MessageEvent<string>) => {
const msg = JSON.parse(evt.data) as WsServerMessage;
if ((msg as WsAuthRequired).type === 'auth_required') {
ws.send(JSON.stringify({ type: 'auth', access_token: this.token }));
return;
}
if ((msg as WsAuthOk).type === 'auth_ok') {
ws.onmessage = null;
resolve(ws);
return;
}
if (msg.type === 'auth_invalid') {
ws.close();
reject(new Error(`WS auth_invalid`));
}
};
ws.onerror = () => reject(new Error('WebSocket connection error'));
ws.onclose = () => reject(new Error('WebSocket closed before auth_ok'));
});
}
}
+98
View File
@@ -0,0 +1,98 @@
/**
* TypeScript types mirroring the JSON shapes from homecore-api/src/rest.rs and ws.rs.
* Keep in sync with Rust `StateView`, `ApiConfig`, `ServiceDomainView`.
*/
/** Context for a state change — mirrors Rust `ContextView`. */
export interface ContextView {
id: string;
user_id: string | null;
parent_id: string | null;
}
/** Snapshot of a single entity state — mirrors Rust `StateView`. */
export interface StateView {
entity_id: string;
state: string;
/** Arbitrary JSON attributes attached to the entity. */
attributes: Record<string, unknown>;
/** RFC 3339 timestamp of last state value change. */
last_changed: string;
/** RFC 3339 timestamp of last update (attributes may have changed). */
last_updated: string;
context: ContextView;
}
/** HOMECORE configuration — mirrors Rust `ApiConfig`. */
export interface ApiConfig {
location_name: string;
version: string;
state: 'RUNNING' | 'STARTING' | 'STOPPING';
components: string[];
}
/** Services grouped by domain — mirrors Rust `ServiceDomainView`. */
export interface ServiceDomainView {
domain: string;
/** Keyed by service name; value is the service schema (may be empty `{}`). */
services: Record<string, unknown>;
}
// ── WebSocket protocol types ──────────────────────────────────────────────────
/** Sent by server immediately upon WS upgrade. */
export interface WsAuthRequired {
type: 'auth_required';
ha_version: string;
}
/** Sent by client to authenticate. */
export interface WsAuth {
type: 'auth';
access_token: string;
}
/** Sent by server on successful auth. */
export interface WsAuthOk {
type: 'auth_ok';
ha_version: string;
}
/** Sent by server on failed auth. */
export interface WsAuthInvalid {
type: 'auth_invalid';
message: string;
}
/** Generic result message from server. */
export interface WsResult<T = unknown> {
id: number;
type: 'result';
success: boolean;
result?: T;
error?: { code: string; message: string };
}
/** State-changed event pushed by server via `subscribe_events`. */
export interface WsStateChangedEvent {
id: number;
type: 'event';
event: {
event_type: 'state_changed';
data: {
entity_id: string;
old_state: StateView | null;
new_state: StateView | null;
};
origin: 'LOCAL' | 'REMOTE';
time_fired: string;
};
}
/** Union of all inbound WS server messages. */
export type WsServerMessage =
| WsAuthRequired
| WsAuthOk
| WsAuthInvalid
| WsResult
| WsStateChangedEvent;