mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99c78f512c | |||
| 3f5a7411db | |||
| c0bb6f4fc7 |
@@ -22,6 +22,63 @@ 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 = '';
|
||||
@@ -31,6 +88,10 @@ export class EntityForm extends LitElement {
|
||||
|
||||
@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); }
|
||||
@@ -45,6 +106,14 @@ export class EntityForm extends LitElement {
|
||||
}
|
||||
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; }
|
||||
@@ -70,6 +139,47 @@ export class EntityForm extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(); }
|
||||
|
||||
@@ -118,21 +228,27 @@ export class EntityForm extends LitElement {
|
||||
<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.entityId = (e.target as HTMLInputElement).value)}
|
||||
@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}
|
||||
@input=${(e: Event) => (this.state = (e.target as HTMLInputElement).value)}
|
||||
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}
|
||||
@input=${(e: Event) => (this._attrs = (e.target as HTMLTextAreaElement).value)}
|
||||
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>
|
||||
|
||||
@@ -38,8 +38,27 @@ export class StateCard extends LitElement {
|
||||
border-color: hsl(185 80% 50% / 0.4);
|
||||
}
|
||||
|
||||
.card { cursor: pointer; }
|
||||
.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;
|
||||
@@ -121,6 +140,11 @@ export class StateCard extends LitElement {
|
||||
@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>`
|
||||
@@ -141,6 +165,15 @@ export class StateCard extends LitElement {
|
||||
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 {
|
||||
|
||||
@@ -103,6 +103,7 @@ export class Dashboard extends LitElement {
|
||||
@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;
|
||||
|
||||
@@ -146,9 +147,34 @@ export class Dashboard extends LitElement {
|
||||
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',
|
||||
@@ -158,14 +184,21 @@ export class Dashboard extends LitElement {
|
||||
},
|
||||
body: JSON.stringify({ state, attributes }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
|
||||
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.error = err instanceof Error ? err.message : String(err);
|
||||
this._form?.setSubmitError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,12 +233,31 @@ export class Dashboard extends LitElement {
|
||||
<code>--no-seed-entities</code>.
|
||||
</div>`
|
||||
: html`<div class="grid"
|
||||
@hc-state-card-click=${(e: Event) => this._openEdit(e as CustomEvent)}>
|
||||
@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; }}>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Services page — lists every registered service grouped by domain.
|
||||
* Reads from `/api/services` (HA-wire-compat).
|
||||
* Services page — lists every registered service grouped by domain,
|
||||
* and lets the operator call any of them with a JSON service_data
|
||||
* payload (POST /api/services/<domain>/<service>).
|
||||
*/
|
||||
|
||||
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';
|
||||
import '../components/Modal.js';
|
||||
|
||||
function resolveToken(): string {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
@@ -26,16 +27,93 @@ export class ServicesPage extends LitElement {
|
||||
.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); }
|
||||
li {
|
||||
background: hsl(220 25% 14%);
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 12px;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
li .name { padding: 4px 10px; }
|
||||
li button.call {
|
||||
background: hsl(220 25% 18%);
|
||||
color: var(--hc-primary, #19d4e5);
|
||||
border: none;
|
||||
border-left: 1px solid var(--hc-border, #2a323e);
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
font-weight: 600;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
li button.call:hover { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); }
|
||||
.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; }
|
||||
.toast { padding: 8px 12px; background: hsl(165 60% 16%); color: hsl(165 60% 80%); border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
|
||||
|
||||
/* Service-call modal contents */
|
||||
.form label { display: block; margin: 6px 0 4px; font-size: 12px; color: var(--hc-text-muted, #7b899d); }
|
||||
.form code.target { color: var(--hc-primary, #19d4e5); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; }
|
||||
.form 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;
|
||||
min-height: 90px;
|
||||
resize: vertical;
|
||||
}
|
||||
.form textarea.invalid { border-color: hsl(0 60% 50%); }
|
||||
.form .hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; }
|
||||
.form .field-status { font-size: 11px; margin-top: 4px; }
|
||||
.form .field-status.ok { color: hsl(150 60% 55%); }
|
||||
.form .field-status.err { color: hsl(0 70% 70%); }
|
||||
.form pre {
|
||||
background: hsl(220 25% 8%);
|
||||
border: 1px solid var(--hc-border, #2a323e);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.form .resp-ok { border-color: hsl(150 50% 35%); }
|
||||
.form .resp-err { border-color: hsl(0 50% 45%); color: #f0c0c0; }
|
||||
.form .err { padding: 10px; margin-top: 10px; border: 1px solid #b35a5a; border-radius: 6px; background: hsl(0 35% 12%); color: #f0c0c0; font-size: 12px; }
|
||||
|
||||
button.btn {
|
||||
padding: 8px 16px;
|
||||
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.btn.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; }
|
||||
button.btn.primary[disabled] { background: hsl(220 15% 20%); color: var(--hc-text-muted, #7b899d); border-color: var(--hc-border, #2a323e); cursor: not-allowed; }
|
||||
`;
|
||||
|
||||
@state() private domains: ServiceDomainView[] = [];
|
||||
@state() private error: string | null = null;
|
||||
@state() private loading = true;
|
||||
|
||||
private client = new HomecoreClient({ token: resolveToken() });
|
||||
@state() private calling: { domain: string; service: string } | null = null;
|
||||
@state() private callBody = '{}';
|
||||
@state() private callResp: { ok: boolean; text: string } | null = null;
|
||||
@state() private callErr: string | null = null;
|
||||
@state() private callPending = false;
|
||||
@state() private callToast: string | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
@@ -53,7 +131,72 @@ export class ServicesPage extends LitElement {
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
void this.client; // suppress unused warning while keeping the import shape consistent
|
||||
}
|
||||
|
||||
private _openCall(domain: string, service: string) {
|
||||
this.calling = { domain, service };
|
||||
this.callBody = '{}';
|
||||
this.callResp = null;
|
||||
this.callErr = null;
|
||||
}
|
||||
|
||||
private _closeCall() {
|
||||
this.calling = null;
|
||||
this.callBody = '{}';
|
||||
this.callResp = null;
|
||||
this.callErr = null;
|
||||
this.callPending = false;
|
||||
}
|
||||
|
||||
private _validateBody(): { ok: boolean; data?: unknown; msg?: string } {
|
||||
const raw = this.callBody.trim();
|
||||
if (!raw) return { ok: true, data: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
|
||||
return { ok: false, msg: 'service_data must be a JSON object (not array, not scalar)' };
|
||||
}
|
||||
return { ok: true, data: parsed };
|
||||
} catch (e) {
|
||||
return { ok: false, msg: `JSON parse: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
private async _doCall() {
|
||||
if (!this.calling) return;
|
||||
const v = this._validateBody();
|
||||
if (!v.ok) {
|
||||
this.callErr = v.msg ?? 'invalid';
|
||||
this.callResp = null;
|
||||
return;
|
||||
}
|
||||
this.callPending = true;
|
||||
this.callErr = null;
|
||||
const { domain, service } = this.calling;
|
||||
try {
|
||||
const r = await fetch(`/api/services/${encodeURIComponent(domain)}/${encodeURIComponent(service)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${resolveToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(v.data ?? {}),
|
||||
});
|
||||
const text = await r.text();
|
||||
if (r.ok) {
|
||||
let pretty = text;
|
||||
try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch { /* leave raw */ }
|
||||
this.callResp = { ok: true, text: pretty };
|
||||
this.callToast = `Called ${domain}.${service} → 200`;
|
||||
window.setTimeout(() => (this.callToast = null), 3000);
|
||||
} else {
|
||||
this.callResp = { ok: false, text: `HTTP ${r.status}\n${text}` };
|
||||
}
|
||||
} catch (e) {
|
||||
this.callErr = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
this.callPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -69,16 +212,59 @@ export class ServicesPage extends LitElement {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const validity = this._validateBody();
|
||||
return html`
|
||||
${this.callToast ? html`<div class="toast">${this.callToast}</div>` : ''}
|
||||
<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>`)}
|
||||
${Object.keys(d.services).map(name => html`
|
||||
<li>
|
||||
<span class="name">${name}</span>
|
||||
<button class="call"
|
||||
@click=${() => this._openCall(d.domain, name)}
|
||||
title="Call ${d.domain}.${name}">▶ Call</button>
|
||||
</li>
|
||||
`)}
|
||||
</ul>
|
||||
</div>
|
||||
`)}
|
||||
|
||||
<hc-modal .open=${this.calling !== null}
|
||||
heading=${this.calling ? `Call ${this.calling.domain}.${this.calling.service}` : ''}
|
||||
@hc-modal-close=${this._closeCall}>
|
||||
<div class="form">
|
||||
<label>target</label>
|
||||
<div><code class="target">POST /api/services/${this.calling?.domain ?? ''}/${this.calling?.service ?? ''}</code></div>
|
||||
|
||||
<label for="body">service_data (JSON object)</label>
|
||||
<textarea id="body"
|
||||
class=${validity.ok ? '' : 'invalid'}
|
||||
.value=${this.callBody}
|
||||
@input=${(e: Event) => (this.callBody = (e.target as HTMLTextAreaElement).value)}
|
||||
placeholder='{ "entity_id": "light.kitchen_ceiling", "brightness": 200 }'></textarea>
|
||||
<div class="hint">leave blank for <code>{}</code> — these handlers are no-op echoes, they round-trip whatever you send</div>
|
||||
${validity.ok
|
||||
? (this.callBody.trim()
|
||||
? html`<div class="field-status ok">✓ service_data OK</div>`
|
||||
: html`<div class="hint">empty → will send <code>{}</code></div>`)
|
||||
: html`<div class="field-status err">✗ ${validity.msg}</div>`}
|
||||
|
||||
${this.callErr ? html`<div class="err">${this.callErr}</div>` : ''}
|
||||
${this.callResp
|
||||
? html`<label>response</label>
|
||||
<pre class=${this.callResp.ok ? 'resp-ok' : 'resp-err'}>${this.callResp.text}</pre>`
|
||||
: ''}
|
||||
</div>
|
||||
<button slot="footer" class="btn" @click=${this._closeCall}>Close</button>
|
||||
<button slot="footer" class="btn primary"
|
||||
?disabled=${!validity.ok || this.callPending}
|
||||
@click=${this._doCall}>
|
||||
${this.callPending ? 'Calling…' : 'Call'}
|
||||
</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>,
|
||||
|
||||
Reference in New Issue
Block a user