mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f5a7411db | |||
| c0bb6f4fc7 | |||
| 89190b6c2d | |||
| e7215a16e5 | |||
| 0979faccd4 | |||
| 75f984e515 |
@@ -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; } }
|
||||
@@ -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; } }
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,10 +9,13 @@
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
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') {
|
||||
@@ -66,12 +69,43 @@ export class Dashboard extends LitElement {
|
||||
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;
|
||||
@@ -103,8 +137,73 @@ export class Dashboard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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.
|
||||
@@ -116,6 +215,11 @@ export class Dashboard extends LitElement {
|
||||
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>
|
||||
@@ -123,15 +227,50 @@ export class Dashboard extends LitElement {
|
||||
</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.
|
||||
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">
|
||||
: 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user