@glasshome/sync-layer 0.1.16 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connection/bridged.d.ts +16 -0
- package/dist/connection/manager.d.ts +4 -2
- package/dist/core/store.d.ts +12 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +220 -9
- package/dist/worker/bridge-client.d.ts +46 -0
- package/dist/worker/enforcement.d.ts +53 -0
- package/dist/worker/protocol.d.ts +120 -0
- package/dist/worker/token-store.d.ts +3 -0
- package/dist/worker/worker-main.d.ts +10 -0
- package/dist/worker/worker-main.js +3 -0
- package/dist/worker-main-VfHPLX2h.js +478 -0
- package/package.json +8 -2
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { HaBridge } from "../worker/bridge-client";
|
|
2
|
+
/**
|
|
3
|
+
* Wire an HA bridge (worker-backed connection) into the store. The bridge's
|
|
4
|
+
* facade rides the same load/subscribe pipeline as a direct socket; only the
|
|
5
|
+
* lifecycle signals differ — they arrive as worker messages instead of
|
|
6
|
+
* Connection events.
|
|
7
|
+
*
|
|
8
|
+
* Call after bridge.connect() resolved. Returns when initial data is loaded.
|
|
9
|
+
*/
|
|
10
|
+
export declare function attachBridgeToStore(bridge: HaBridge, hassUrl: string): Promise<void>;
|
|
11
|
+
/** Map worker connection-state messages onto the store. */
|
|
12
|
+
export declare function applyBridgeConnState(state: "connected" | "disconnected" | "reconnecting"): void;
|
|
13
|
+
/** Re-sync after the worker reports a completed reconnect. */
|
|
14
|
+
export declare function reloadAfterBridgeReconnect(bridge: HaBridge): Promise<void>;
|
|
15
|
+
/** Detach on shutdown; the caller terminates the worker itself. */
|
|
16
|
+
export declare function detachBridgeFromStore(): void;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* @packageDocumentation
|
|
8
8
|
*/
|
|
9
9
|
import { type Auth, type Connection, type ConnectionOptions as HAConnectionOptions } from "home-assistant-js-websocket";
|
|
10
|
+
import { type HaLink } from "../core/store";
|
|
10
11
|
import { type OAuthOptions } from "./auth";
|
|
11
12
|
/**
|
|
12
13
|
* Options for initializing a connection
|
|
@@ -29,9 +30,9 @@ export declare function initConnection(options: ConnectionOptions): Promise<Conn
|
|
|
29
30
|
*/
|
|
30
31
|
export declare function disconnect(): void;
|
|
31
32
|
/**
|
|
32
|
-
* Get current connection
|
|
33
|
+
* Get current connection link
|
|
33
34
|
*/
|
|
34
|
-
export declare function getConnection():
|
|
35
|
+
export declare function getConnection(): HaLink | null;
|
|
35
36
|
/**
|
|
36
37
|
* Check if currently connected
|
|
37
38
|
*/
|
|
@@ -43,6 +44,7 @@ export declare function isConnected(): boolean;
|
|
|
43
44
|
* `isConnected()` which reflects the manager's internal lifecycle state.
|
|
44
45
|
*/
|
|
45
46
|
export declare function getConnectionState(): boolean;
|
|
47
|
+
export declare function loadInitialData(conn: Pick<HaLink, "sendMessagePromise">): Promise<void>;
|
|
46
48
|
/**
|
|
47
49
|
* Refresh all data
|
|
48
50
|
*/
|
package/dist/core/store.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
/** Central reactive state store for sync-layer using SolidJS createStore. */
|
|
2
|
-
import type { Connection } from "home-assistant-js-websocket";
|
|
3
2
|
import type { SetStoreFunction, Store } from "solid-js/store";
|
|
4
3
|
import type { CameraStreamData } from "../camera/types";
|
|
5
4
|
import type { EntityHistoryData } from "../history/types";
|
|
@@ -14,9 +13,19 @@ import type { AreaId, AreaRegistryEntry, ConnectionState, DeviceId, DeviceRegist
|
|
|
14
13
|
* NOTE: No EntityIndices field -- indices are derived reactively in Plan 03.
|
|
15
14
|
* NOTE: No _activeSubscriptions -- managed externally.
|
|
16
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* The structural surface every conn consumer actually uses. Satisfied by a
|
|
18
|
+
* real home-assistant-js-websocket Connection, the bridge facade (traffic
|
|
19
|
+
* tunneled through the HA worker), and mock connections.
|
|
20
|
+
*/
|
|
21
|
+
export interface HaLink {
|
|
22
|
+
sendMessagePromise<T>(message: unknown): Promise<T>;
|
|
23
|
+
subscribeMessage<T>(callback: (message: T) => void, message: unknown): Promise<() => Promise<void>>;
|
|
24
|
+
subscribeEvents(callback: (event: unknown) => void, eventType?: string): Promise<() => void | Promise<void>>;
|
|
25
|
+
}
|
|
17
26
|
export interface GlassHomeState {
|
|
18
|
-
/**
|
|
19
|
-
conn:
|
|
27
|
+
/** Live HA link (real socket, worker bridge facade, or mock) */
|
|
28
|
+
conn: HaLink | null;
|
|
20
29
|
/** Current connection state */
|
|
21
30
|
connectionState: ConnectionState;
|
|
22
31
|
/** Connection error (if any) */
|
package/dist/index.d.ts
CHANGED
|
@@ -43,4 +43,8 @@ export { fetchForecast, fetchForecastData, fetchForecasts } from "./weather/fetc
|
|
|
43
43
|
export { getForecast, getForecasts, refreshForecast, refreshForecasts } from "./weather/query";
|
|
44
44
|
export type { EntityForecastQueryOptions, ForecastQueryOptions, ForecastResult, ForecastsResult, ForecastType, WeatherForecast, WeatherForecastData, WeatherForecasts, WeatherForecastsData, } from "./weather/types";
|
|
45
45
|
export { applyDemoServiceCall, createDemoFixtures, isDemoMode, loadDemoData, startDemoEnergyTicker, stopDemoEnergyTicker, unloadDemoData, } from "./demo/demo-provider";
|
|
46
|
-
export declare const VERSION = "0.
|
|
46
|
+
export declare const VERSION = "0.2.0";
|
|
47
|
+
export { applyBridgeConnState, attachBridgeToStore, detachBridgeFromStore, reloadAfterBridgeReconnect, } from "./connection/bridged";
|
|
48
|
+
export { BridgeNeedsAuthError, createHaBridge, type BridgeConnectOptions, type BridgeEvents, type HaBridge, } from "./worker/bridge-client";
|
|
49
|
+
export type { AuthMode, ConnState, OAuthTokenData } from "./worker/protocol";
|
|
50
|
+
export { runHaBridgeWorker, type WorkerScope } from "./worker/worker-main";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ENERGY_ENTITY_IDS, createDemoFixtures, demoEnergyPreferences, energyEntityValue, formatEnergyState, isEnergyEntity, isSunUp, resetStore, setState, simulateEnergy, state, sunEvents, synthesizeEnergyHistory, synthesizeEnergyStatistics } from "./demo-data-CavpxBw3.js";
|
|
2
|
-
import {
|
|
2
|
+
import { runHaBridgeWorker } from "./worker-main-VfHPLX2h.js";
|
|
3
|
+
import { createConnection, createLongLivedTokenAuth, getAuth } from "home-assistant-js-websocket";
|
|
3
4
|
import { produce, reconcile } from "solid-js/store";
|
|
4
5
|
|
|
5
6
|
//#region src/connection/adapter.ts
|
|
@@ -663,7 +664,13 @@ async function callService(domain, service, serviceData = {}, target = {}) {
|
|
|
663
664
|
try {
|
|
664
665
|
const mockConnection = connection;
|
|
665
666
|
if (mockConnection && typeof mockConnection.callService === "function") await mockConnection.callService(domain, service, serviceData, target);
|
|
666
|
-
else await
|
|
667
|
+
else await connection.sendMessagePromise({
|
|
668
|
+
type: "call_service",
|
|
669
|
+
domain,
|
|
670
|
+
service,
|
|
671
|
+
service_data: serviceData,
|
|
672
|
+
target
|
|
673
|
+
});
|
|
667
674
|
} catch (error) {
|
|
668
675
|
throw new Error(`Service call failed: ${error.message || String(error)}`);
|
|
669
676
|
}
|
|
@@ -1244,6 +1251,7 @@ function applyStateDiff(s, entityId, diff) {
|
|
|
1244
1251
|
const HEARTBEAT_INTERVAL_MS = 3e4;
|
|
1245
1252
|
const HEARTBEAT_TIMEOUT_MS = 15e3;
|
|
1246
1253
|
const SUSPEND_DELAY_MS = 5 * 60 * 1e3;
|
|
1254
|
+
let activeConn = null;
|
|
1247
1255
|
let heartbeatInterval = null;
|
|
1248
1256
|
let suspendTimeout = null;
|
|
1249
1257
|
let visiblePromiseResolve = null;
|
|
@@ -1338,6 +1346,7 @@ async function initConnection(options) {
|
|
|
1338
1346
|
...options.createSocket ? { createSocket: options.createSocket } : {}
|
|
1339
1347
|
});
|
|
1340
1348
|
const wrappedConn = wrapHAConnection(conn$1);
|
|
1349
|
+
activeConn = conn$1;
|
|
1341
1350
|
setState(produce((s) => {
|
|
1342
1351
|
s.conn = conn$1;
|
|
1343
1352
|
s.hassUrl = options.url.replace(/\/$/, "");
|
|
@@ -1364,17 +1373,17 @@ async function initConnection(options) {
|
|
|
1364
1373
|
* Disconnect from Home Assistant
|
|
1365
1374
|
*/
|
|
1366
1375
|
function disconnect() {
|
|
1367
|
-
const conn$1 = state.conn;
|
|
1368
1376
|
stopHeartbeat();
|
|
1369
1377
|
teardownLifecycleListeners();
|
|
1370
|
-
|
|
1378
|
+
activeConn?.close();
|
|
1379
|
+
activeConn = null;
|
|
1371
1380
|
setState(produce((s) => {
|
|
1372
1381
|
s.conn = null;
|
|
1373
1382
|
s.connectionState = "disconnected";
|
|
1374
1383
|
}));
|
|
1375
1384
|
}
|
|
1376
1385
|
/**
|
|
1377
|
-
* Get current connection
|
|
1386
|
+
* Get current connection link
|
|
1378
1387
|
*/
|
|
1379
1388
|
function getConnection() {
|
|
1380
1389
|
return state.conn;
|
|
@@ -1392,8 +1401,7 @@ function isConnected() {
|
|
|
1392
1401
|
* `isConnected()` which reflects the manager's internal lifecycle state.
|
|
1393
1402
|
*/
|
|
1394
1403
|
function getConnectionState() {
|
|
1395
|
-
|
|
1396
|
-
return conn$1 ? conn$1.connected : false;
|
|
1404
|
+
return activeConn ? activeConn.connected : state.connectionState === "connected";
|
|
1397
1405
|
}
|
|
1398
1406
|
function setupEventHandlers(conn$1, options) {
|
|
1399
1407
|
conn$1.addEventListener("disconnected", () => {
|
|
@@ -2702,9 +2710,212 @@ async function refreshForecasts(entityId, types) {
|
|
|
2702
2710
|
return getForecasts(entityId, types);
|
|
2703
2711
|
}
|
|
2704
2712
|
|
|
2713
|
+
//#endregion
|
|
2714
|
+
//#region src/connection/bridged.ts
|
|
2715
|
+
/**
|
|
2716
|
+
* Wire an HA bridge (worker-backed connection) into the store. The bridge's
|
|
2717
|
+
* facade rides the same load/subscribe pipeline as a direct socket; only the
|
|
2718
|
+
* lifecycle signals differ — they arrive as worker messages instead of
|
|
2719
|
+
* Connection events.
|
|
2720
|
+
*
|
|
2721
|
+
* Call after bridge.connect() resolved. Returns when initial data is loaded.
|
|
2722
|
+
*/
|
|
2723
|
+
async function attachBridgeToStore(bridge, hassUrl) {
|
|
2724
|
+
setState(produce((s) => {
|
|
2725
|
+
s.conn = bridge.conn;
|
|
2726
|
+
s.hassUrl = hassUrl.replace(/\/$/, "");
|
|
2727
|
+
s.connectionState = "connected";
|
|
2728
|
+
s.connectionError = null;
|
|
2729
|
+
}));
|
|
2730
|
+
await loadInitialData(bridge.conn);
|
|
2731
|
+
await subscribeToUpdates(bridge.conn);
|
|
2732
|
+
}
|
|
2733
|
+
/** Map worker connection-state messages onto the store. */
|
|
2734
|
+
function applyBridgeConnState(state$1) {
|
|
2735
|
+
setState(produce((s) => {
|
|
2736
|
+
s.connectionState = state$1;
|
|
2737
|
+
if (state$1 === "connected") s.connectionError = null;
|
|
2738
|
+
}));
|
|
2739
|
+
}
|
|
2740
|
+
/** Re-sync after the worker reports a completed reconnect. */
|
|
2741
|
+
async function reloadAfterBridgeReconnect(bridge) {
|
|
2742
|
+
await loadInitialData(bridge.conn);
|
|
2743
|
+
await forceResubscribe();
|
|
2744
|
+
}
|
|
2745
|
+
/** Detach on shutdown; the caller terminates the worker itself. */
|
|
2746
|
+
function detachBridgeFromStore() {
|
|
2747
|
+
setState(produce((s) => {
|
|
2748
|
+
s.conn = null;
|
|
2749
|
+
s.connectionState = "disconnected";
|
|
2750
|
+
}));
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
//#endregion
|
|
2754
|
+
//#region src/worker/bridge-client.ts
|
|
2755
|
+
var BridgeNeedsAuthError = class extends Error {
|
|
2756
|
+
constructor() {
|
|
2757
|
+
super("No stored Home Assistant tokens; interactive sign-in required");
|
|
2758
|
+
}
|
|
2759
|
+
};
|
|
2760
|
+
function createHaBridge(worker, events = {}) {
|
|
2761
|
+
let nextId = 1;
|
|
2762
|
+
let connState = "disconnected";
|
|
2763
|
+
const pendingSends = /* @__PURE__ */ new Map();
|
|
2764
|
+
const subCallbacks = /* @__PURE__ */ new Map();
|
|
2765
|
+
const rawListeners = /* @__PURE__ */ new Set();
|
|
2766
|
+
let pendingConnect = null;
|
|
2767
|
+
const post = (msg, transfer) => transfer ? worker.postMessage(msg, transfer) : worker.postMessage(msg);
|
|
2768
|
+
const failAllPending = (reason) => {
|
|
2769
|
+
const err = new Error(reason);
|
|
2770
|
+
for (const { reject } of pendingSends.values()) reject(err);
|
|
2771
|
+
pendingSends.clear();
|
|
2772
|
+
pendingConnect?.reject(err);
|
|
2773
|
+
pendingConnect = null;
|
|
2774
|
+
};
|
|
2775
|
+
worker.onerror = () => failAllPending("HA bridge worker crashed");
|
|
2776
|
+
worker.onmessage = (ev) => {
|
|
2777
|
+
const msg = ev.data;
|
|
2778
|
+
switch (msg.k) {
|
|
2779
|
+
case "connect_result":
|
|
2780
|
+
if (msg.ok) pendingConnect?.resolve();
|
|
2781
|
+
else pendingConnect?.reject(msg.needsAuth ? new BridgeNeedsAuthError() : new Error(msg.error ?? "Connect failed"));
|
|
2782
|
+
pendingConnect = null;
|
|
2783
|
+
break;
|
|
2784
|
+
case "conn":
|
|
2785
|
+
connState = msg.state;
|
|
2786
|
+
events.onConnState?.(msg.state);
|
|
2787
|
+
break;
|
|
2788
|
+
case "ready_after_reconnect":
|
|
2789
|
+
events.onReadyAfterReconnect?.();
|
|
2790
|
+
break;
|
|
2791
|
+
case "result": {
|
|
2792
|
+
const pending = pendingSends.get(msg.id);
|
|
2793
|
+
pendingSends.delete(msg.id);
|
|
2794
|
+
if (!pending) break;
|
|
2795
|
+
if (msg.ok) pending.resolve(msg.result);
|
|
2796
|
+
else pending.reject(new Error(msg.error ?? "Request failed"));
|
|
2797
|
+
break;
|
|
2798
|
+
}
|
|
2799
|
+
case "event": {
|
|
2800
|
+
subCallbacks.get(msg.id)?.(msg.payload);
|
|
2801
|
+
for (const listener of rawListeners) listener(msg.payload);
|
|
2802
|
+
break;
|
|
2803
|
+
}
|
|
2804
|
+
case "sub_end":
|
|
2805
|
+
subCallbacks.delete(msg.id);
|
|
2806
|
+
break;
|
|
2807
|
+
case "denial":
|
|
2808
|
+
events.onDenial?.(msg);
|
|
2809
|
+
break;
|
|
2810
|
+
}
|
|
2811
|
+
};
|
|
2812
|
+
function sendMessagePromise(message) {
|
|
2813
|
+
const id = nextId++;
|
|
2814
|
+
return new Promise((resolve, reject) => {
|
|
2815
|
+
pendingSends.set(id, {
|
|
2816
|
+
resolve,
|
|
2817
|
+
reject
|
|
2818
|
+
});
|
|
2819
|
+
post({
|
|
2820
|
+
k: "send",
|
|
2821
|
+
id,
|
|
2822
|
+
message
|
|
2823
|
+
});
|
|
2824
|
+
});
|
|
2825
|
+
}
|
|
2826
|
+
function subscribe(kind, callback, eventType, message) {
|
|
2827
|
+
const id = nextId++;
|
|
2828
|
+
subCallbacks.set(id, callback);
|
|
2829
|
+
post({
|
|
2830
|
+
k: "sub",
|
|
2831
|
+
id,
|
|
2832
|
+
kind,
|
|
2833
|
+
eventType,
|
|
2834
|
+
message
|
|
2835
|
+
});
|
|
2836
|
+
return async () => {
|
|
2837
|
+
subCallbacks.delete(id);
|
|
2838
|
+
post({
|
|
2839
|
+
k: "unsub",
|
|
2840
|
+
id
|
|
2841
|
+
});
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
const conn$1 = {
|
|
2845
|
+
connect: () => Promise.resolve(),
|
|
2846
|
+
disconnect: () => post({ k: "disconnect" }),
|
|
2847
|
+
get connected$() {
|
|
2848
|
+
return connState === "connected";
|
|
2849
|
+
},
|
|
2850
|
+
authenticate: () => Promise.resolve(),
|
|
2851
|
+
get authState() {
|
|
2852
|
+
return connState === "connected" ? "authenticated" : "pending";
|
|
2853
|
+
},
|
|
2854
|
+
subscribeEvents: (callback, eventType) => Promise.resolve(subscribe("events", callback, eventType)),
|
|
2855
|
+
subscribeMessage: (callback, message) => Promise.resolve(subscribe("message", callback, void 0, message)),
|
|
2856
|
+
sendMessagePromise,
|
|
2857
|
+
addEventListener: (handler) => {
|
|
2858
|
+
rawListeners.add(handler);
|
|
2859
|
+
return () => rawListeners.delete(handler);
|
|
2860
|
+
}
|
|
2861
|
+
};
|
|
2862
|
+
return {
|
|
2863
|
+
connect(opts) {
|
|
2864
|
+
return new Promise((resolve, reject) => {
|
|
2865
|
+
pendingConnect = {
|
|
2866
|
+
resolve,
|
|
2867
|
+
reject
|
|
2868
|
+
};
|
|
2869
|
+
post({
|
|
2870
|
+
k: "connect",
|
|
2871
|
+
url: opts.url,
|
|
2872
|
+
mode: opts.mode,
|
|
2873
|
+
proxyWsPath: opts.proxyWsPath
|
|
2874
|
+
});
|
|
2875
|
+
});
|
|
2876
|
+
},
|
|
2877
|
+
disconnect: () => post({ k: "disconnect" }),
|
|
2878
|
+
reconnect: () => post({ k: "reconnect" }),
|
|
2879
|
+
clearTokens: () => post({ k: "clear_tokens" }),
|
|
2880
|
+
conn: conn$1,
|
|
2881
|
+
registerWidget(widgetId, caps) {
|
|
2882
|
+
const channel = new MessageChannel();
|
|
2883
|
+
post({
|
|
2884
|
+
k: "register_widget",
|
|
2885
|
+
widgetId,
|
|
2886
|
+
caps
|
|
2887
|
+
}, [channel.port2]);
|
|
2888
|
+
return channel.port1;
|
|
2889
|
+
},
|
|
2890
|
+
unregisterWidget: (widgetId) => post({
|
|
2891
|
+
k: "unregister_widget",
|
|
2892
|
+
widgetId
|
|
2893
|
+
}),
|
|
2894
|
+
forwardLifecycle() {
|
|
2895
|
+
const onVisibility = () => post({
|
|
2896
|
+
k: "visibility",
|
|
2897
|
+
hidden: document.visibilityState === "hidden"
|
|
2898
|
+
});
|
|
2899
|
+
const onPageshow = (event) => {
|
|
2900
|
+
if (event.persisted) post({ k: "pageshow_persisted" });
|
|
2901
|
+
};
|
|
2902
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
2903
|
+
window.addEventListener("pageshow", onPageshow);
|
|
2904
|
+
return () => {
|
|
2905
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
2906
|
+
window.removeEventListener("pageshow", onPageshow);
|
|
2907
|
+
};
|
|
2908
|
+
},
|
|
2909
|
+
terminate() {
|
|
2910
|
+
failAllPending("HA bridge terminated");
|
|
2911
|
+
worker.terminate();
|
|
2912
|
+
}
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2705
2916
|
//#endregion
|
|
2706
2917
|
//#region src/index.ts
|
|
2707
|
-
const VERSION = "0.
|
|
2918
|
+
const VERSION = "0.2.0";
|
|
2708
2919
|
|
|
2709
2920
|
//#endregion
|
|
2710
|
-
export { DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, MAX_HISTORY_POINTS, NEED_ATTRIBUTE_DOMAINS, VERSION, addDebugIncomingMessageListener, addDebugOutgoingMessageListener, appendHistoryPoint, applyDemoServiceCall, area, areas, authenticateWithOAuth, authenticateWithToken, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, bulkAppendHistoryPoints, callService, createDemoFixtures, disconnect, entities, entitiesByAreaAndDomain, entitiesWhere, entity, entityIdHistoryNeedsAttributes, entityViewEquals, extractDomain, fetchEnergyPreferences, fetchEntityHistory, fetchForecast, fetchForecastData, fetchForecasts, fetchHistory, fetchStatisticsDuringPeriod, fetchStream, fetchStreamData, getAllEntityViews, getAreaView, getAreaViews, getConnection, getConnectionState, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, historyStateToTimeline, initConnection, isAuthValid, isConnected, isDemoMode, isHistoryTracked, loadDemoData, normalizeStatisticTime, refreshAuth, refreshForecast, refreshForecasts, refreshStream, registerEntity, resetStore, searchEntities, sendCommand, sendWebRtcCandidate, setState, startDemoEnergyTicker, startWebRtcSession, state, stopDemoEnergyTicker, toggle, trackEntityHistory, turnOff, turnOn, unloadDemoData, untrackEntityHistory, updateEntity };
|
|
2921
|
+
export { BridgeNeedsAuthError, DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, MAX_HISTORY_POINTS, NEED_ATTRIBUTE_DOMAINS, VERSION, addDebugIncomingMessageListener, addDebugOutgoingMessageListener, appendHistoryPoint, applyBridgeConnState, applyDemoServiceCall, area, areas, attachBridgeToStore, authenticateWithOAuth, authenticateWithToken, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, bulkAppendHistoryPoints, callService, createDemoFixtures, createHaBridge, detachBridgeFromStore, disconnect, entities, entitiesByAreaAndDomain, entitiesWhere, entity, entityIdHistoryNeedsAttributes, entityViewEquals, extractDomain, fetchEnergyPreferences, fetchEntityHistory, fetchForecast, fetchForecastData, fetchForecasts, fetchHistory, fetchStatisticsDuringPeriod, fetchStream, fetchStreamData, getAllEntityViews, getAreaView, getAreaViews, getConnection, getConnectionState, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, historyStateToTimeline, initConnection, isAuthValid, isConnected, isDemoMode, isHistoryTracked, loadDemoData, normalizeStatisticTime, refreshAuth, refreshForecast, refreshForecasts, refreshStream, registerEntity, reloadAfterBridgeReconnect, resetStore, runHaBridgeWorker, searchEntities, sendCommand, sendWebRtcCandidate, setState, startDemoEnergyTicker, startWebRtcSession, state, stopDemoEnergyTicker, toggle, trackEntityHistory, turnOff, turnOn, unloadDemoData, untrackEntityHistory, updateEntity };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { CapabilityGrant } from "@glasshome/widget-contract";
|
|
2
|
+
import type { SyncLayerConnection } from "../connection/types";
|
|
3
|
+
import type { AuthMode, ConnState } from "./protocol";
|
|
4
|
+
/**
|
|
5
|
+
* Main-thread side of the HA bridge. Wraps the Worker and exposes:
|
|
6
|
+
* - a SyncLayerConnection facade so the existing store/subscription/command
|
|
7
|
+
* code runs unchanged, with all HA traffic tunneled through the worker
|
|
8
|
+
* - registerWidget(), which mints the per-widget MessagePort the SDK's
|
|
9
|
+
* service hooks call through (capability-checked in the worker)
|
|
10
|
+
*
|
|
11
|
+
* The Worker reference and its privileged channel live in this module's
|
|
12
|
+
* closures only.
|
|
13
|
+
*/
|
|
14
|
+
export interface BridgeConnectOptions {
|
|
15
|
+
url: string;
|
|
16
|
+
mode: AuthMode;
|
|
17
|
+
proxyWsPath?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface BridgeEvents {
|
|
20
|
+
onConnState?: (state: ConnState) => void;
|
|
21
|
+
onReadyAfterReconnect?: () => void;
|
|
22
|
+
onDenial?: (denial: {
|
|
23
|
+
widgetId: string;
|
|
24
|
+
domain: string;
|
|
25
|
+
service: string;
|
|
26
|
+
entityIds: string[];
|
|
27
|
+
message: string;
|
|
28
|
+
}) => void;
|
|
29
|
+
}
|
|
30
|
+
export interface HaBridge {
|
|
31
|
+
/** Resolves on socket-up; rejects with BridgeNeedsAuthError when the worker has no stored OAuth tokens. */
|
|
32
|
+
connect(opts: BridgeConnectOptions): Promise<void>;
|
|
33
|
+
disconnect(): void;
|
|
34
|
+
reconnect(): void;
|
|
35
|
+
clearTokens(): void;
|
|
36
|
+
conn: SyncLayerConnection;
|
|
37
|
+
registerWidget(widgetId: string, caps: CapabilityGrant[]): MessagePort;
|
|
38
|
+
unregisterWidget(widgetId: string): void;
|
|
39
|
+
/** Forward visibility/pageshow so the worker can drive suspend/reconnect. Returns teardown. */
|
|
40
|
+
forwardLifecycle(): () => void;
|
|
41
|
+
terminate(): void;
|
|
42
|
+
}
|
|
43
|
+
export declare class BridgeNeedsAuthError extends Error {
|
|
44
|
+
constructor();
|
|
45
|
+
}
|
|
46
|
+
export declare function createHaBridge(worker: Worker, events?: BridgeEvents): HaBridge;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type CapabilityGrant } from "@glasshome/widget-contract";
|
|
2
|
+
/**
|
|
3
|
+
* Capability enforcement for widget service calls. Pure and registry-backed:
|
|
4
|
+
* the worker feeds it its own mirror of the HA registries, so expansion never
|
|
5
|
+
* trusts anything computed in the widget's realm.
|
|
6
|
+
*/
|
|
7
|
+
export interface EntityFacts {
|
|
8
|
+
deviceId: string | null;
|
|
9
|
+
areaId: string | null;
|
|
10
|
+
labels: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface DeviceFacts {
|
|
13
|
+
areaId: string | null;
|
|
14
|
+
labels: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface ServiceCallShape {
|
|
17
|
+
domain: string;
|
|
18
|
+
service: string;
|
|
19
|
+
data?: Record<string, unknown>;
|
|
20
|
+
target?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
export declare class RegistryMirror {
|
|
23
|
+
readonly entities: Map<string, EntityFacts>;
|
|
24
|
+
readonly devices: Map<string, DeviceFacts>;
|
|
25
|
+
replace(entityRegistry: {
|
|
26
|
+
entity_id: string;
|
|
27
|
+
device_id: string | null;
|
|
28
|
+
area_id: string | null;
|
|
29
|
+
labels?: string[];
|
|
30
|
+
}[], deviceRegistry: {
|
|
31
|
+
id: string;
|
|
32
|
+
area_id: string | null;
|
|
33
|
+
labels?: string[];
|
|
34
|
+
}[]): void;
|
|
35
|
+
entityAreaId(entityId: string): string | null;
|
|
36
|
+
entityLabels(entityId: string): string[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Expand a service call to the concrete entity ids it can touch. Reads BOTH
|
|
40
|
+
* `target` and the legacy id fields inside `data` — HA honors entity_id (and
|
|
41
|
+
* friends) in service data, so ignoring it would let a call bypass entity
|
|
42
|
+
* narrowing.
|
|
43
|
+
*/
|
|
44
|
+
export declare function expandTargets(call: ServiceCallShape, registry: RegistryMirror): string[];
|
|
45
|
+
export type EnforcementVerdict = {
|
|
46
|
+
allowed: true;
|
|
47
|
+
entityIds: string[];
|
|
48
|
+
} | {
|
|
49
|
+
allowed: false;
|
|
50
|
+
entityIds: string[];
|
|
51
|
+
message: string;
|
|
52
|
+
};
|
|
53
|
+
export declare function enforceServiceCall(caps: readonly CapabilityGrant[], call: ServiceCallShape, registry: RegistryMirror): EnforcementVerdict;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { CapabilityGrant } from "@glasshome/widget-contract";
|
|
2
|
+
/**
|
|
3
|
+
* Message protocol between the main thread and the HA bridge worker.
|
|
4
|
+
*
|
|
5
|
+
* Two channels with different privilege:
|
|
6
|
+
* - The worker's own message port (privileged): full tunnel to the HA socket.
|
|
7
|
+
* Held in closure by the bridge client; never handed to widget code.
|
|
8
|
+
* - Per-widget MessagePorts: exactly one verb (call_service), validated
|
|
9
|
+
* against the widget's granted capabilities inside the worker.
|
|
10
|
+
*/
|
|
11
|
+
export type AuthMode = {
|
|
12
|
+
kind: "token";
|
|
13
|
+
token: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: "brokered";
|
|
16
|
+
mintUrl: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: "oauth";
|
|
19
|
+
data?: OAuthTokenData;
|
|
20
|
+
};
|
|
21
|
+
/** Serializable subset of home-assistant-js-websocket AuthData. */
|
|
22
|
+
export interface OAuthTokenData {
|
|
23
|
+
hassUrl: string;
|
|
24
|
+
clientId: string | null;
|
|
25
|
+
access_token: string;
|
|
26
|
+
refresh_token: string;
|
|
27
|
+
expires: number;
|
|
28
|
+
expires_in: number;
|
|
29
|
+
}
|
|
30
|
+
export type MainToWorker = {
|
|
31
|
+
k: "connect";
|
|
32
|
+
url: string;
|
|
33
|
+
mode: AuthMode;
|
|
34
|
+
/** Same-origin WS path to use instead of the HA host (tunnel proxy). */
|
|
35
|
+
proxyWsPath?: string;
|
|
36
|
+
} | {
|
|
37
|
+
k: "send";
|
|
38
|
+
id: number;
|
|
39
|
+
message: unknown;
|
|
40
|
+
} | {
|
|
41
|
+
k: "sub";
|
|
42
|
+
id: number;
|
|
43
|
+
kind: "events" | "message";
|
|
44
|
+
eventType?: string;
|
|
45
|
+
message?: unknown;
|
|
46
|
+
} | {
|
|
47
|
+
k: "unsub";
|
|
48
|
+
id: number;
|
|
49
|
+
} | {
|
|
50
|
+
k: "visibility";
|
|
51
|
+
hidden: boolean;
|
|
52
|
+
} | {
|
|
53
|
+
k: "pageshow_persisted";
|
|
54
|
+
} | {
|
|
55
|
+
k: "reconnect";
|
|
56
|
+
} | {
|
|
57
|
+
k: "disconnect";
|
|
58
|
+
} | {
|
|
59
|
+
k: "clear_tokens";
|
|
60
|
+
} | {
|
|
61
|
+
k: "register_widget";
|
|
62
|
+
widgetId: string;
|
|
63
|
+
caps: CapabilityGrant[];
|
|
64
|
+
} | {
|
|
65
|
+
k: "unregister_widget";
|
|
66
|
+
widgetId: string;
|
|
67
|
+
};
|
|
68
|
+
export type ConnState = "connected" | "disconnected" | "reconnecting";
|
|
69
|
+
export type WorkerToMain = {
|
|
70
|
+
k: "connect_result";
|
|
71
|
+
ok: boolean;
|
|
72
|
+
error?: string;
|
|
73
|
+
/** OAuth mode with no stored tokens: main thread must run interactive auth. */
|
|
74
|
+
needsAuth?: boolean;
|
|
75
|
+
} | {
|
|
76
|
+
k: "conn";
|
|
77
|
+
state: ConnState;
|
|
78
|
+
}
|
|
79
|
+
/** Fired after a reconnect completes; main thread reloads data + resubscribes. */
|
|
80
|
+
| {
|
|
81
|
+
k: "ready_after_reconnect";
|
|
82
|
+
} | {
|
|
83
|
+
k: "result";
|
|
84
|
+
id: number;
|
|
85
|
+
ok: boolean;
|
|
86
|
+
result?: unknown;
|
|
87
|
+
error?: string;
|
|
88
|
+
} | {
|
|
89
|
+
k: "event";
|
|
90
|
+
id: number;
|
|
91
|
+
payload: unknown;
|
|
92
|
+
} | {
|
|
93
|
+
k: "sub_end";
|
|
94
|
+
id: number;
|
|
95
|
+
error?: string;
|
|
96
|
+
} | {
|
|
97
|
+
k: "denial";
|
|
98
|
+
widgetId: string;
|
|
99
|
+
domain: string;
|
|
100
|
+
service: string;
|
|
101
|
+
entityIds: string[];
|
|
102
|
+
message: string;
|
|
103
|
+
};
|
|
104
|
+
/** Widget port: request. The only verb widget code can reach. */
|
|
105
|
+
export interface WidgetServiceCall {
|
|
106
|
+
id: number;
|
|
107
|
+
domain: string;
|
|
108
|
+
service: string;
|
|
109
|
+
data?: Record<string, unknown>;
|
|
110
|
+
target?: Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
export type WidgetServiceResult = {
|
|
113
|
+
id: number;
|
|
114
|
+
ok: true;
|
|
115
|
+
} | {
|
|
116
|
+
id: number;
|
|
117
|
+
ok: false;
|
|
118
|
+
code: "CAPABILITY_DENIED" | "UNAVAILABLE" | "ERROR";
|
|
119
|
+
message: string;
|
|
120
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Structural worker-global surface; the package compiles against DOM libs. */
|
|
2
|
+
export interface WorkerScope {
|
|
3
|
+
postMessage(message: unknown): void;
|
|
4
|
+
onmessage: ((ev: MessageEvent) => void) | null;
|
|
5
|
+
location: {
|
|
6
|
+
protocol: string;
|
|
7
|
+
host: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export declare function runHaBridgeWorker(scope: WorkerScope): void;
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { Auth, createConnection, createSocket, genExpires } from "home-assistant-js-websocket";
|
|
2
|
+
import { matchesCapability } from "@glasshome/widget-contract";
|
|
3
|
+
|
|
4
|
+
//#region src/worker/enforcement.ts
|
|
5
|
+
var RegistryMirror = class {
|
|
6
|
+
entities = /* @__PURE__ */ new Map();
|
|
7
|
+
devices = /* @__PURE__ */ new Map();
|
|
8
|
+
replace(entityRegistry, deviceRegistry) {
|
|
9
|
+
this.entities.clear();
|
|
10
|
+
this.devices.clear();
|
|
11
|
+
for (const e of entityRegistry) this.entities.set(e.entity_id, {
|
|
12
|
+
deviceId: e.device_id,
|
|
13
|
+
areaId: e.area_id,
|
|
14
|
+
labels: e.labels ?? []
|
|
15
|
+
});
|
|
16
|
+
for (const d of deviceRegistry) this.devices.set(d.id, {
|
|
17
|
+
areaId: d.area_id,
|
|
18
|
+
labels: d.labels ?? []
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
entityAreaId(entityId) {
|
|
22
|
+
const e = this.entities.get(entityId);
|
|
23
|
+
if (!e) return null;
|
|
24
|
+
if (e.areaId) return e.areaId;
|
|
25
|
+
return e.deviceId ? this.devices.get(e.deviceId)?.areaId ?? null : null;
|
|
26
|
+
}
|
|
27
|
+
entityLabels(entityId) {
|
|
28
|
+
const e = this.entities.get(entityId);
|
|
29
|
+
if (!e) return [];
|
|
30
|
+
const fromDevice = e.deviceId ? this.devices.get(e.deviceId)?.labels ?? [] : [];
|
|
31
|
+
return [...e.labels, ...fromDevice];
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const asArray = (v) => typeof v === "string" ? [v] : Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
35
|
+
/**
|
|
36
|
+
* Expand a service call to the concrete entity ids it can touch. Reads BOTH
|
|
37
|
+
* `target` and the legacy id fields inside `data` — HA honors entity_id (and
|
|
38
|
+
* friends) in service data, so ignoring it would let a call bypass entity
|
|
39
|
+
* narrowing.
|
|
40
|
+
*/
|
|
41
|
+
function expandTargets(call, registry) {
|
|
42
|
+
const sources = [call.target ?? {}, call.data ?? {}];
|
|
43
|
+
const ids = /* @__PURE__ */ new Set();
|
|
44
|
+
for (const src of sources) {
|
|
45
|
+
for (const id of asArray(src.entity_id)) ids.add(id);
|
|
46
|
+
for (const deviceId of asArray(src.device_id)) for (const [entityId, facts] of registry.entities) if (facts.deviceId === deviceId) ids.add(entityId);
|
|
47
|
+
for (const areaId of asArray(src.area_id)) for (const [entityId] of registry.entities) if (registry.entityAreaId(entityId) === areaId) ids.add(entityId);
|
|
48
|
+
for (const labelId of asArray(src.label_id)) for (const [entityId] of registry.entities) if (registry.entityLabels(entityId).includes(labelId)) ids.add(entityId);
|
|
49
|
+
}
|
|
50
|
+
return [...ids];
|
|
51
|
+
}
|
|
52
|
+
function enforceServiceCall(caps, call, registry) {
|
|
53
|
+
const entityIds = expandTargets(call, registry);
|
|
54
|
+
if (matchesCapability(caps, {
|
|
55
|
+
domain: call.domain,
|
|
56
|
+
service: call.service,
|
|
57
|
+
entityIds
|
|
58
|
+
})) return {
|
|
59
|
+
allowed: true,
|
|
60
|
+
entityIds
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
allowed: false,
|
|
64
|
+
entityIds,
|
|
65
|
+
message: `Call to ${call.domain}.${call.service} is not covered by the widget's granted capabilities`
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/worker/token-store.ts
|
|
71
|
+
/**
|
|
72
|
+
* OAuth token persistence inside the worker. IndexedDB is the only storage
|
|
73
|
+
* a worker has; keeping tokens here (and deleting the old localStorage copy
|
|
74
|
+
* on the main thread) is what makes "widgets never see the token" hold.
|
|
75
|
+
*/
|
|
76
|
+
const DB_NAME = "glasshome-ha-bridge";
|
|
77
|
+
const STORE = "auth";
|
|
78
|
+
const KEY = "oauth";
|
|
79
|
+
function openDb() {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const req = indexedDB.open(DB_NAME, 1);
|
|
82
|
+
req.onupgradeneeded = () => {
|
|
83
|
+
req.result.createObjectStore(STORE);
|
|
84
|
+
};
|
|
85
|
+
req.onsuccess = () => resolve(req.result);
|
|
86
|
+
req.onerror = () => reject(req.error);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function settle(req) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
req.onsuccess = () => resolve(req.result);
|
|
92
|
+
req.onerror = () => reject(req.error);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async function loadTokens() {
|
|
96
|
+
try {
|
|
97
|
+
const db = await openDb();
|
|
98
|
+
const store = db.transaction(STORE, "readonly").objectStore(STORE);
|
|
99
|
+
const data = await settle(store.get(KEY));
|
|
100
|
+
db.close();
|
|
101
|
+
return data ?? null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function saveTokens(data) {
|
|
107
|
+
try {
|
|
108
|
+
const db = await openDb();
|
|
109
|
+
const store = db.transaction(STORE, "readwrite").objectStore(STORE);
|
|
110
|
+
await (data ? settle(store.put(data, KEY)) : settle(store.delete(KEY)));
|
|
111
|
+
db.close();
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/worker/worker-main.ts
|
|
117
|
+
/**
|
|
118
|
+
* HA bridge worker. Owns the socket and the auth tokens; neither ever
|
|
119
|
+
* reaches the main thread. The privileged channel (the worker's own port)
|
|
120
|
+
* tunnels arbitrary HA traffic for the host. Widget MessagePorts get exactly
|
|
121
|
+
* one verb — call_service — validated against the widget's granted
|
|
122
|
+
* capabilities with the worker's own registry mirror, so enforcement does
|
|
123
|
+
* not trust anything computed in the widget's realm.
|
|
124
|
+
*/
|
|
125
|
+
const HEARTBEAT_INTERVAL_MS = 3e4;
|
|
126
|
+
const HEARTBEAT_TIMEOUT_MS = 15e3;
|
|
127
|
+
const SUSPEND_DELAY_MS = 5 * 60 * 1e3;
|
|
128
|
+
function isHaWireMessage(value) {
|
|
129
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
130
|
+
}
|
|
131
|
+
var BrokeredAuth = class extends Auth {
|
|
132
|
+
constructor(mintUrl, data) {
|
|
133
|
+
super(data);
|
|
134
|
+
this.mintUrl = mintUrl;
|
|
135
|
+
}
|
|
136
|
+
async refreshAccessToken() {
|
|
137
|
+
const res = await fetch(this.mintUrl, { credentials: "same-origin" });
|
|
138
|
+
if (!res.ok) throw new Error(`Token broker responded ${res.status}`);
|
|
139
|
+
const broker = await res.json();
|
|
140
|
+
this.data = {
|
|
141
|
+
...this.data,
|
|
142
|
+
access_token: broker.accessToken,
|
|
143
|
+
expires_in: broker.expiresInSec,
|
|
144
|
+
expires: genExpires(broker.expiresInSec),
|
|
145
|
+
refresh_token: "",
|
|
146
|
+
hassUrl: broker.haUrl,
|
|
147
|
+
clientId: broker.haClientId
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
function runHaBridgeWorker(scope) {
|
|
152
|
+
let conn = null;
|
|
153
|
+
const widgets = /* @__PURE__ */ new Map();
|
|
154
|
+
const subs = /* @__PURE__ */ new Map();
|
|
155
|
+
const registry = new RegistryMirror();
|
|
156
|
+
let suspendTimeout = null;
|
|
157
|
+
let resumeVisible = null;
|
|
158
|
+
let heartbeat = null;
|
|
159
|
+
const post = (msg) => scope.postMessage(msg);
|
|
160
|
+
async function loadRegistryMirror(c) {
|
|
161
|
+
const [entityRegistry, deviceRegistry] = await Promise.all([c.sendMessagePromise({ type: "config/entity_registry/list" }), c.sendMessagePromise({ type: "config/device_registry/list" })]);
|
|
162
|
+
registry.replace(entityRegistry, deviceRegistry);
|
|
163
|
+
}
|
|
164
|
+
function watchRegistryMirror(c) {
|
|
165
|
+
const refresh = () => {
|
|
166
|
+
loadRegistryMirror(c).catch(() => {});
|
|
167
|
+
};
|
|
168
|
+
c.subscribeEvents(refresh, "entity_registry_updated");
|
|
169
|
+
c.subscribeEvents(refresh, "device_registry_updated");
|
|
170
|
+
c.subscribeEvents(refresh, "area_registry_updated");
|
|
171
|
+
}
|
|
172
|
+
function attachWidgetPort(widgetId, caps, port) {
|
|
173
|
+
widgets.get(widgetId)?.port.close();
|
|
174
|
+
widgets.set(widgetId, {
|
|
175
|
+
port,
|
|
176
|
+
caps
|
|
177
|
+
});
|
|
178
|
+
port.onmessage = async (ev) => {
|
|
179
|
+
const call = ev.data;
|
|
180
|
+
const reply = (msg) => port.postMessage(msg);
|
|
181
|
+
const verdict = enforceServiceCall(caps, call, registry);
|
|
182
|
+
if (!verdict.allowed) {
|
|
183
|
+
post({
|
|
184
|
+
k: "denial",
|
|
185
|
+
widgetId,
|
|
186
|
+
domain: call.domain,
|
|
187
|
+
service: call.service,
|
|
188
|
+
entityIds: verdict.entityIds,
|
|
189
|
+
message: verdict.message
|
|
190
|
+
});
|
|
191
|
+
reply({
|
|
192
|
+
id: call.id,
|
|
193
|
+
ok: false,
|
|
194
|
+
code: "CAPABILITY_DENIED",
|
|
195
|
+
message: verdict.message
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!conn) {
|
|
200
|
+
reply({
|
|
201
|
+
id: call.id,
|
|
202
|
+
ok: false,
|
|
203
|
+
code: "UNAVAILABLE",
|
|
204
|
+
message: "Not connected"
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
await conn.sendMessagePromise({
|
|
210
|
+
type: "call_service",
|
|
211
|
+
domain: call.domain,
|
|
212
|
+
service: call.service,
|
|
213
|
+
service_data: call.data ?? {},
|
|
214
|
+
...call.target ? { target: call.target } : {}
|
|
215
|
+
});
|
|
216
|
+
reply({
|
|
217
|
+
id: call.id,
|
|
218
|
+
ok: true
|
|
219
|
+
});
|
|
220
|
+
} catch (err) {
|
|
221
|
+
reply({
|
|
222
|
+
id: call.id,
|
|
223
|
+
ok: false,
|
|
224
|
+
code: "ERROR",
|
|
225
|
+
message: err instanceof Error ? err.message : String(err)
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
port.start?.();
|
|
230
|
+
}
|
|
231
|
+
function startHeartbeat(c) {
|
|
232
|
+
stopHeartbeat();
|
|
233
|
+
heartbeat = setInterval(() => {
|
|
234
|
+
const timer = new Promise((_, reject) => setTimeout(() => reject(new Error("ping timeout")), HEARTBEAT_TIMEOUT_MS));
|
|
235
|
+
Promise.race([c.ping(), timer]).catch(() => c.reconnect());
|
|
236
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
237
|
+
}
|
|
238
|
+
function stopHeartbeat() {
|
|
239
|
+
if (heartbeat !== null) {
|
|
240
|
+
clearInterval(heartbeat);
|
|
241
|
+
heartbeat = null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function onVisibility(hidden) {
|
|
245
|
+
if (!conn) return;
|
|
246
|
+
if (hidden) {
|
|
247
|
+
conn.suspendReconnectUntil(new Promise((resolve) => {
|
|
248
|
+
resumeVisible = resolve;
|
|
249
|
+
}));
|
|
250
|
+
suspendTimeout ??= setTimeout(() => {
|
|
251
|
+
suspendTimeout = null;
|
|
252
|
+
conn?.suspend();
|
|
253
|
+
}, SUSPEND_DELAY_MS);
|
|
254
|
+
} else {
|
|
255
|
+
if (suspendTimeout !== null) {
|
|
256
|
+
clearTimeout(suspendTimeout);
|
|
257
|
+
suspendTimeout = null;
|
|
258
|
+
}
|
|
259
|
+
resumeVisible?.();
|
|
260
|
+
resumeVisible = null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function buildAuth(url, mode) {
|
|
264
|
+
switch (mode.kind) {
|
|
265
|
+
case "token": return new Auth({
|
|
266
|
+
hassUrl: url,
|
|
267
|
+
clientId: null,
|
|
268
|
+
access_token: mode.token,
|
|
269
|
+
refresh_token: "",
|
|
270
|
+
expires: Date.now() + 365 * 24 * 3600 * 1e3,
|
|
271
|
+
expires_in: 365 * 24 * 3600
|
|
272
|
+
});
|
|
273
|
+
case "brokered": return new BrokeredAuth(mode.mintUrl, {
|
|
274
|
+
hassUrl: url,
|
|
275
|
+
clientId: null,
|
|
276
|
+
access_token: "",
|
|
277
|
+
refresh_token: "",
|
|
278
|
+
expires: 0,
|
|
279
|
+
expires_in: 0
|
|
280
|
+
});
|
|
281
|
+
case "oauth": {
|
|
282
|
+
const data = mode.data ?? await loadTokens();
|
|
283
|
+
if (!data) return "needs-auth";
|
|
284
|
+
if (mode.data) await saveTokens(mode.data);
|
|
285
|
+
return new Auth(data, (updated) => {
|
|
286
|
+
saveTokens(updated);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async function connect(url, mode, proxyWsPath) {
|
|
292
|
+
const auth = await buildAuth(url, mode);
|
|
293
|
+
if (auth === "needs-auth") {
|
|
294
|
+
post({
|
|
295
|
+
k: "connect_result",
|
|
296
|
+
ok: false,
|
|
297
|
+
needsAuth: true
|
|
298
|
+
});
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (mode.kind === "brokered") try {
|
|
302
|
+
await auth.refreshAccessToken();
|
|
303
|
+
} catch (err) {
|
|
304
|
+
post({
|
|
305
|
+
k: "connect_result",
|
|
306
|
+
ok: false,
|
|
307
|
+
error: err instanceof Error ? err.message : String(err)
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const socketFactory = proxyWsPath ? (options) => {
|
|
312
|
+
const proto = scope.location.protocol === "https:" ? "wss:" : "ws:";
|
|
313
|
+
const proxied = new Proxy(options.auth, { get: (target, prop) => prop === "wsUrl" ? `${proto}//${scope.location.host}${proxyWsPath}` : Reflect.get(target, prop) });
|
|
314
|
+
return createSocket({
|
|
315
|
+
...options,
|
|
316
|
+
auth: proxied
|
|
317
|
+
});
|
|
318
|
+
} : void 0;
|
|
319
|
+
try {
|
|
320
|
+
conn = await createConnection({
|
|
321
|
+
auth,
|
|
322
|
+
...socketFactory ? { createSocket: socketFactory } : {}
|
|
323
|
+
});
|
|
324
|
+
} catch (err) {
|
|
325
|
+
post({
|
|
326
|
+
k: "connect_result",
|
|
327
|
+
ok: false,
|
|
328
|
+
error: err instanceof Error ? err.message : String(err)
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
conn.addEventListener("disconnected", () => post({
|
|
333
|
+
k: "conn",
|
|
334
|
+
state: "disconnected"
|
|
335
|
+
}));
|
|
336
|
+
conn.addEventListener("reconnect-error", () => post({
|
|
337
|
+
k: "conn",
|
|
338
|
+
state: "reconnecting"
|
|
339
|
+
}));
|
|
340
|
+
let everReady = false;
|
|
341
|
+
conn.addEventListener("ready", () => {
|
|
342
|
+
post({
|
|
343
|
+
k: "conn",
|
|
344
|
+
state: "connected"
|
|
345
|
+
});
|
|
346
|
+
loadRegistryMirror(conn).catch(() => {});
|
|
347
|
+
if (everReady) post({ k: "ready_after_reconnect" });
|
|
348
|
+
everReady = true;
|
|
349
|
+
});
|
|
350
|
+
await loadRegistryMirror(conn);
|
|
351
|
+
watchRegistryMirror(conn);
|
|
352
|
+
startHeartbeat(conn);
|
|
353
|
+
everReady = true;
|
|
354
|
+
post({
|
|
355
|
+
k: "connect_result",
|
|
356
|
+
ok: true
|
|
357
|
+
});
|
|
358
|
+
post({
|
|
359
|
+
k: "conn",
|
|
360
|
+
state: "connected"
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
scope.onmessage = async (ev) => {
|
|
364
|
+
const msg = ev.data;
|
|
365
|
+
switch (msg.k) {
|
|
366
|
+
case "connect":
|
|
367
|
+
await connect(msg.url, msg.mode, msg.proxyWsPath);
|
|
368
|
+
break;
|
|
369
|
+
case "send": {
|
|
370
|
+
if (!conn || !isHaWireMessage(msg.message)) {
|
|
371
|
+
post({
|
|
372
|
+
k: "result",
|
|
373
|
+
id: msg.id,
|
|
374
|
+
ok: false,
|
|
375
|
+
error: conn ? "Malformed message" : "Not connected"
|
|
376
|
+
});
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
const result = await conn.sendMessagePromise(msg.message);
|
|
381
|
+
post({
|
|
382
|
+
k: "result",
|
|
383
|
+
id: msg.id,
|
|
384
|
+
ok: true,
|
|
385
|
+
result
|
|
386
|
+
});
|
|
387
|
+
} catch (err) {
|
|
388
|
+
post({
|
|
389
|
+
k: "result",
|
|
390
|
+
id: msg.id,
|
|
391
|
+
ok: false,
|
|
392
|
+
error: err instanceof Error ? err.message : String(err)
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "sub": {
|
|
398
|
+
if (!conn) {
|
|
399
|
+
post({
|
|
400
|
+
k: "sub_end",
|
|
401
|
+
id: msg.id,
|
|
402
|
+
error: "Not connected"
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const forward = (payload) => post({
|
|
408
|
+
k: "event",
|
|
409
|
+
id: msg.id,
|
|
410
|
+
payload
|
|
411
|
+
});
|
|
412
|
+
let unsub;
|
|
413
|
+
if (msg.kind === "events") unsub = await conn.subscribeEvents(forward, msg.eventType);
|
|
414
|
+
else {
|
|
415
|
+
if (!isHaWireMessage(msg.message)) {
|
|
416
|
+
post({
|
|
417
|
+
k: "sub_end",
|
|
418
|
+
id: msg.id,
|
|
419
|
+
error: "Malformed message"
|
|
420
|
+
});
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
unsub = await conn.subscribeMessage(forward, msg.message);
|
|
424
|
+
}
|
|
425
|
+
subs.set(msg.id, unsub);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
post({
|
|
428
|
+
k: "sub_end",
|
|
429
|
+
id: msg.id,
|
|
430
|
+
error: err instanceof Error ? err.message : String(err)
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case "unsub": {
|
|
436
|
+
const unsub = subs.get(msg.id);
|
|
437
|
+
subs.delete(msg.id);
|
|
438
|
+
try {
|
|
439
|
+
await unsub?.();
|
|
440
|
+
} catch {}
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
case "visibility":
|
|
444
|
+
onVisibility(msg.hidden);
|
|
445
|
+
break;
|
|
446
|
+
case "pageshow_persisted":
|
|
447
|
+
conn?.reconnect();
|
|
448
|
+
break;
|
|
449
|
+
case "reconnect":
|
|
450
|
+
conn?.reconnect();
|
|
451
|
+
break;
|
|
452
|
+
case "disconnect":
|
|
453
|
+
stopHeartbeat();
|
|
454
|
+
conn?.close();
|
|
455
|
+
conn = null;
|
|
456
|
+
post({
|
|
457
|
+
k: "conn",
|
|
458
|
+
state: "disconnected"
|
|
459
|
+
});
|
|
460
|
+
break;
|
|
461
|
+
case "clear_tokens":
|
|
462
|
+
await saveTokens(null);
|
|
463
|
+
break;
|
|
464
|
+
case "register_widget": {
|
|
465
|
+
const port = ev.ports[0];
|
|
466
|
+
if (port) attachWidgetPort(msg.widgetId, msg.caps, port);
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
case "unregister_widget":
|
|
470
|
+
widgets.get(msg.widgetId)?.port.close();
|
|
471
|
+
widgets.delete(msg.widgetId);
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
//#endregion
|
|
478
|
+
export { runHaBridgeWorker };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glasshome/sync-layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Type-safe, reactive state synchronization layer for Home Assistant",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
"types": "./dist/testing/index.d.ts",
|
|
27
27
|
"import": "./dist/testing/index.js"
|
|
28
28
|
},
|
|
29
|
+
"./worker": {
|
|
30
|
+
"@glasshome/source": "./src/worker/worker-main.ts",
|
|
31
|
+
"types": "./dist/worker/worker-main.d.ts",
|
|
32
|
+
"import": "./dist/worker/worker-main.js"
|
|
33
|
+
},
|
|
29
34
|
"./package.json": "./package.json"
|
|
30
35
|
},
|
|
31
36
|
"peerDependencies": {
|
|
@@ -50,7 +55,8 @@
|
|
|
50
55
|
"lint": "tsc --noEmit"
|
|
51
56
|
},
|
|
52
57
|
"dependencies": {
|
|
53
|
-
"home-assistant-js-websocket": "^9.6.0"
|
|
58
|
+
"home-assistant-js-websocket": "^9.6.0",
|
|
59
|
+
"@glasshome/widget-contract": "0.1.0"
|
|
54
60
|
},
|
|
55
61
|
"devDependencies": {
|
|
56
62
|
"@glasshome/ha-types": "0.1.2",
|