@matter-server/dashboard 0.7.0-alpha.0-20260512-b404bea → 0.7.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.
Files changed (171) hide show
  1. package/dist/esm/client/client-context.d.ts +3 -0
  2. package/dist/esm/client/client-context.d.ts.map +1 -1
  3. package/dist/esm/client/client-context.js +3 -1
  4. package/dist/esm/client/client-context.js.map +1 -1
  5. package/dist/esm/client/models/descriptions.js +45 -45
  6. package/dist/esm/components/avsum-ptz-strip.d.ts +41 -0
  7. package/dist/esm/components/avsum-ptz-strip.d.ts.map +1 -0
  8. package/dist/esm/components/avsum-ptz-strip.js +379 -0
  9. package/dist/esm/components/avsum-ptz-strip.js.map +6 -0
  10. package/dist/esm/components/dialogs/binding/node-binding-dialog.js +1 -1
  11. package/dist/esm/components/dialogs/binding/show-node-binding-dialog.d.ts +2 -2
  12. package/dist/esm/components/dialogs/binding/show-node-binding-dialog.d.ts.map +1 -1
  13. package/dist/esm/components/dialogs/binding/show-node-binding-dialog.js +1 -2
  14. package/dist/esm/components/dialogs/binding/show-node-binding-dialog.js.map +1 -1
  15. package/dist/esm/components/dialogs/commission-node-dialog/commission-node-dialog.d.ts +1 -0
  16. package/dist/esm/components/dialogs/commission-node-dialog/commission-node-dialog.d.ts.map +1 -1
  17. package/dist/esm/components/dialogs/commission-node-dialog/commission-node-dialog.js +12 -3
  18. package/dist/esm/components/dialogs/commission-node-dialog/commission-node-dialog.js.map +1 -1
  19. package/dist/esm/components/dialogs/commission-node-dialog/show-commission-node-dialog.d.ts +1 -2
  20. package/dist/esm/components/dialogs/commission-node-dialog/show-commission-node-dialog.d.ts.map +1 -1
  21. package/dist/esm/components/dialogs/commission-node-dialog/show-commission-node-dialog.js +1 -2
  22. package/dist/esm/components/dialogs/commission-node-dialog/show-commission-node-dialog.js.map +1 -1
  23. package/dist/esm/components/dialogs/settings/log-level-dialog.d.ts +0 -2
  24. package/dist/esm/components/dialogs/settings/log-level-dialog.d.ts.map +1 -1
  25. package/dist/esm/components/dialogs/settings/log-level-dialog.js +2 -8
  26. package/dist/esm/components/dialogs/settings/log-level-dialog.js.map +1 -1
  27. package/dist/esm/components/dialogs/settings/log-level-section.d.ts +2 -1
  28. package/dist/esm/components/dialogs/settings/log-level-section.d.ts.map +1 -1
  29. package/dist/esm/components/dialogs/settings/log-level-section.js +10 -2
  30. package/dist/esm/components/dialogs/settings/log-level-section.js.map +1 -1
  31. package/dist/esm/components/dialogs/settings/settings-dialog.d.ts +1 -0
  32. package/dist/esm/components/dialogs/settings/settings-dialog.d.ts.map +1 -1
  33. package/dist/esm/components/dialogs/settings/settings-dialog.js +8 -2
  34. package/dist/esm/components/dialogs/settings/settings-dialog.js.map +1 -1
  35. package/dist/esm/components/dialogs/settings/show-log-level-dialog.d.ts +1 -2
  36. package/dist/esm/components/dialogs/settings/show-log-level-dialog.d.ts.map +1 -1
  37. package/dist/esm/components/dialogs/settings/show-log-level-dialog.js +1 -2
  38. package/dist/esm/components/dialogs/settings/show-log-level-dialog.js.map +1 -1
  39. package/dist/esm/components/dialogs/settings/show-settings-dialog.d.ts +1 -2
  40. package/dist/esm/components/dialogs/settings/show-settings-dialog.d.ts.map +1 -1
  41. package/dist/esm/components/dialogs/settings/show-settings-dialog.js +1 -2
  42. package/dist/esm/components/dialogs/settings/show-settings-dialog.js.map +1 -1
  43. package/dist/esm/components/webrtc-stream-view.d.ts +97 -0
  44. package/dist/esm/components/webrtc-stream-view.d.ts.map +1 -0
  45. package/dist/esm/components/webrtc-stream-view.js +878 -0
  46. package/dist/esm/components/webrtc-stream-view.js.map +6 -0
  47. package/dist/esm/pages/camera-overlay.d.ts +63 -0
  48. package/dist/esm/pages/camera-overlay.d.ts.map +1 -0
  49. package/dist/esm/pages/camera-overlay.js +546 -0
  50. package/dist/esm/pages/camera-overlay.js.map +6 -0
  51. package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts +1 -0
  52. package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts.map +1 -1
  53. package/dist/esm/pages/cluster-commands/base-cluster-commands.js +11 -1
  54. package/dist/esm/pages/cluster-commands/base-cluster-commands.js.map +1 -1
  55. package/dist/esm/pages/cluster-commands/clusters/avsum-commands.d.ts +38 -0
  56. package/dist/esm/pages/cluster-commands/clusters/avsum-commands.d.ts.map +1 -0
  57. package/dist/esm/pages/cluster-commands/clusters/avsum-commands.js +534 -0
  58. package/dist/esm/pages/cluster-commands/clusters/avsum-commands.js.map +6 -0
  59. package/dist/esm/pages/cluster-commands/clusters/chime-commands.d.ts +32 -0
  60. package/dist/esm/pages/cluster-commands/clusters/chime-commands.d.ts.map +1 -0
  61. package/dist/esm/pages/cluster-commands/clusters/chime-commands.js +261 -0
  62. package/dist/esm/pages/cluster-commands/clusters/chime-commands.js.map +6 -0
  63. package/dist/esm/pages/cluster-commands/index.d.ts +2 -0
  64. package/dist/esm/pages/cluster-commands/index.d.ts.map +1 -1
  65. package/dist/esm/pages/cluster-commands/index.js +2 -0
  66. package/dist/esm/pages/cluster-commands/index.js.map +1 -1
  67. package/dist/esm/pages/components/header.d.ts +1 -0
  68. package/dist/esm/pages/components/header.d.ts.map +1 -1
  69. package/dist/esm/pages/components/header.js +10 -3
  70. package/dist/esm/pages/components/header.js.map +1 -1
  71. package/dist/esm/pages/components/node-details.d.ts +3 -0
  72. package/dist/esm/pages/components/node-details.d.ts.map +1 -1
  73. package/dist/esm/pages/components/node-details.js +34 -4
  74. package/dist/esm/pages/components/node-details.js.map +1 -1
  75. package/dist/esm/pages/components/server-details.d.ts +1 -0
  76. package/dist/esm/pages/components/server-details.d.ts.map +1 -1
  77. package/dist/esm/pages/components/server-details.js +11 -2
  78. package/dist/esm/pages/components/server-details.js.map +1 -1
  79. package/dist/esm/pages/matter-cluster-view.d.ts +1 -0
  80. package/dist/esm/pages/matter-cluster-view.d.ts.map +1 -1
  81. package/dist/esm/pages/matter-cluster-view.js +15 -8
  82. package/dist/esm/pages/matter-cluster-view.js.map +1 -1
  83. package/dist/esm/pages/matter-dashboard-app.d.ts +3 -1
  84. package/dist/esm/pages/matter-dashboard-app.d.ts.map +1 -1
  85. package/dist/esm/pages/matter-dashboard-app.js +12 -16
  86. package/dist/esm/pages/matter-dashboard-app.js.map +1 -1
  87. package/dist/esm/pages/matter-endpoint-view.d.ts +2 -1
  88. package/dist/esm/pages/matter-endpoint-view.d.ts.map +1 -1
  89. package/dist/esm/pages/matter-endpoint-view.js +17 -3
  90. package/dist/esm/pages/matter-endpoint-view.js.map +1 -1
  91. package/dist/esm/pages/matter-network-view.d.ts.map +1 -1
  92. package/dist/esm/pages/matter-network-view.js +5 -1
  93. package/dist/esm/pages/matter-network-view.js.map +1 -1
  94. package/dist/esm/pages/matter-node-view.d.ts +1 -0
  95. package/dist/esm/pages/matter-node-view.d.ts.map +1 -1
  96. package/dist/esm/pages/matter-node-view.js +15 -7
  97. package/dist/esm/pages/matter-node-view.js.map +1 -1
  98. package/dist/esm/pages/matter-server-view.d.ts +1 -0
  99. package/dist/esm/pages/matter-server-view.d.ts.map +1 -1
  100. package/dist/esm/pages/matter-server-view.js +13 -2
  101. package/dist/esm/pages/matter-server-view.js.map +1 -1
  102. package/dist/esm/pages/network/network-details.d.ts.map +1 -1
  103. package/dist/esm/pages/network/network-details.js +0 -3
  104. package/dist/esm/pages/network/network-details.js.map +1 -1
  105. package/dist/esm/pages/network/update-connections-dialog.d.ts +1 -0
  106. package/dist/esm/pages/network/update-connections-dialog.d.ts.map +1 -1
  107. package/dist/esm/pages/network/update-connections-dialog.js +7 -1
  108. package/dist/esm/pages/network/update-connections-dialog.js.map +1 -1
  109. package/dist/esm/util/attribute-shapes.d.ts +11 -0
  110. package/dist/esm/util/attribute-shapes.d.ts.map +1 -0
  111. package/dist/esm/util/attribute-shapes.js +37 -0
  112. package/dist/esm/util/attribute-shapes.js.map +6 -0
  113. package/dist/esm/util/avsum.d.ts +69 -0
  114. package/dist/esm/util/avsum.d.ts.map +1 -0
  115. package/dist/esm/util/avsum.js +150 -0
  116. package/dist/esm/util/avsum.js.map +6 -0
  117. package/dist/esm/util/chime.d.ts +20 -0
  118. package/dist/esm/util/chime.d.ts.map +1 -0
  119. package/dist/esm/util/chime.js +60 -0
  120. package/dist/esm/util/chime.js.map +6 -0
  121. package/dist/web/js/{attribute-write-dialog-DzMWN_T3.js → attribute-write-dialog-DLRP2hlx.js} +1 -1
  122. package/dist/web/js/{command-invoke-dialog-BhAOXzjX.js → command-invoke-dialog-BcCfT1sn.js} +1 -1
  123. package/dist/web/js/{commission-node-dialog-DF87YIqR.js → commission-node-dialog-BfldkUxD.js} +16 -8
  124. package/dist/web/js/{commission-node-existing-DLcWvJTL.js → commission-node-existing-omW-An0J.js} +2 -2
  125. package/dist/web/js/{commission-node-thread-m2fqED-2.js → commission-node-thread-BZpJIxlI.js} +2 -2
  126. package/dist/web/js/{commission-node-wifi-PCsot-CX.js → commission-node-wifi-HA2pYTW2.js} +2 -2
  127. package/dist/web/js/{dialog-box-DiqYXM8c.js → dialog-box-BjDID9j4.js} +1 -1
  128. package/dist/web/js/{fire_event-BnpND_gK.js → fire_event-CChLgSiV.js} +1 -1
  129. package/dist/web/js/main.js +39 -1
  130. package/dist/web/js/{matter-dashboard-app-BZOhBELR.js → matter-dashboard-app-XUWBcruM.js} +8265 -2035
  131. package/dist/web/js/{node-binding-dialog-B3lVN-FU.js → node-binding-dialog-_yHtf7WP.js} +2 -2
  132. package/dist/web/js/settings-dialog-BFPfOSOI.js +607 -0
  133. package/package.json +4 -4
  134. package/src/client/client-context.ts +1 -0
  135. package/src/client/models/descriptions.ts +45 -45
  136. package/src/components/avsum-ptz-strip.ts +388 -0
  137. package/src/components/dialogs/binding/node-binding-dialog.ts +1 -1
  138. package/src/components/dialogs/binding/show-node-binding-dialog.ts +2 -3
  139. package/src/components/dialogs/commission-node-dialog/commission-node-dialog.ts +10 -4
  140. package/src/components/dialogs/commission-node-dialog/show-commission-node-dialog.ts +1 -4
  141. package/src/components/dialogs/settings/log-level-dialog.ts +2 -9
  142. package/src/components/dialogs/settings/log-level-section.ts +11 -4
  143. package/src/components/dialogs/settings/settings-dialog.ts +8 -3
  144. package/src/components/dialogs/settings/show-log-level-dialog.ts +1 -4
  145. package/src/components/dialogs/settings/show-settings-dialog.ts +1 -4
  146. package/src/components/webrtc-stream-view.ts +999 -0
  147. package/src/pages/camera-overlay.ts +562 -0
  148. package/src/pages/cluster-commands/base-cluster-commands.ts +7 -1
  149. package/src/pages/cluster-commands/clusters/avsum-commands.ts +562 -0
  150. package/src/pages/cluster-commands/clusters/chime-commands.ts +272 -0
  151. package/src/pages/cluster-commands/index.ts +2 -0
  152. package/src/pages/components/header.ts +7 -3
  153. package/src/pages/components/node-details.ts +33 -3
  154. package/src/pages/components/server-details.ts +8 -2
  155. package/src/pages/matter-cluster-view.ts +13 -9
  156. package/src/pages/matter-dashboard-app.ts +11 -16
  157. package/src/pages/matter-endpoint-view.ts +13 -5
  158. package/src/pages/matter-network-view.ts +3 -1
  159. package/src/pages/matter-node-view.ts +9 -7
  160. package/src/pages/matter-server-view.ts +7 -2
  161. package/src/pages/network/network-details.ts +0 -3
  162. package/src/pages/network/update-connections-dialog.ts +6 -1
  163. package/src/util/attribute-shapes.ts +34 -0
  164. package/src/util/avsum.ts +212 -0
  165. package/src/util/chime.ts +82 -0
  166. package/dist/esm/util/clone_class.d.ts +0 -7
  167. package/dist/esm/util/clone_class.d.ts.map +0 -1
  168. package/dist/esm/util/clone_class.js +0 -10
  169. package/dist/esm/util/clone_class.js.map +0 -6
  170. package/dist/web/js/settings-dialog-D75IAPKe.js +0 -4039
  171. package/src/util/clone_class.ts +0 -7
@@ -0,0 +1,562 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { consume } from "@lit/context";
8
+ import "@material/web/button/filled-button.js";
9
+ import "@material/web/button/text-button.js";
10
+ import "@material/web/iconbutton/icon-button.js";
11
+ import "@material/web/select/outlined-select.js";
12
+ import "@material/web/select/select-option.js";
13
+ import type { MatterClient } from "@matter-server/ws-client";
14
+ import { mdiCamera, mdiClose, mdiVolumeHigh, mdiVolumeOff } from "@mdi/js";
15
+ import { LitElement, css, html, nothing } from "lit";
16
+ import { customElement, property, state } from "lit/decorators.js";
17
+ import { createRef, ref } from "lit/directives/ref.js";
18
+ import { clientContext } from "../client/client-context.js";
19
+ import {
20
+ AVSM_FEAT_OSD,
21
+ AVSM_FEAT_WMARK,
22
+ AVSM_FEATURE_MAP_ATTR_ID,
23
+ CAMERA_AV_STREAM_MANAGEMENT_CLUSTER_ID,
24
+ } from "../components/webrtc-stream-view.js";
25
+ import "../components/avsum-ptz-strip.js";
26
+ import "../components/ha-svg-icon.js";
27
+ import "../components/webrtc-stream-view.js";
28
+ import type { WebRtcStreamView } from "../components/webrtc-stream-view.js";
29
+ import { asObject, pickNumber } from "../util/attribute-shapes.js";
30
+ import { hasAvsumOnEndpoint } from "../util/avsum.js";
31
+
32
+ type StreamState = "idle" | "connecting" | "streaming" | "error";
33
+
34
+ interface Resolution {
35
+ width: number;
36
+ height: number;
37
+ }
38
+
39
+ const HA_DEFAULT_RESOLUTIONS: Resolution[] = [
40
+ { width: 1920, height: 1080 },
41
+ { width: 1280, height: 720 },
42
+ { width: 640, height: 480 },
43
+ ];
44
+
45
+ @customElement("camera-overlay")
46
+ export class CameraOverlay extends LitElement {
47
+ @consume({ context: clientContext, subscribe: true })
48
+ @property({ attribute: false })
49
+ client?: MatterClient;
50
+
51
+ @property({ attribute: false }) nodeId!: number | bigint;
52
+ @property({ type: Number }) endpointId!: number;
53
+
54
+ @state() private _state: StreamState = "idle";
55
+ @state() private _errorMessage: string | null = null;
56
+ @state() private _snapshotDataUri: string | null = null;
57
+ @state() private _snapshotResolution: Resolution | null = null;
58
+ @state() private _snapshotBusy = false;
59
+ @state() private _snapshotError: string | null = null;
60
+ @state() private _resolutions: Resolution[] = [];
61
+ @state() private _selectedResolution: Resolution | null = null;
62
+ @state() private _resolutionsLoading = true;
63
+ @state() private _closing = false;
64
+ @state() private _activeVideoStreamId: number | null = null;
65
+ @state() private _muted = true;
66
+ @state() private _watermarkEnabled = false;
67
+ @state() private _osdEnabled = false;
68
+ @state() private _snapshotResolutions: Resolution[] = [];
69
+ @state() private _selectedSnapshotResolution: Resolution | null = null;
70
+
71
+ private get _snapshotSupported(): boolean {
72
+ const node = this.client?.nodes[String(this.nodeId)];
73
+ // CaptureSnapshot (cmd 12) must appear in AcceptedCommandList (attr 0xfff9 = 65529).
74
+ const accepted = node?.attributes[`${this.endpointId}/1361/65529`];
75
+ if (!Array.isArray(accepted) || !accepted.includes(12)) return false;
76
+ // FeatureMap (attr 0xfffc = 65532) bit 2 (SNP) advertises snapshot support — some cameras
77
+ // set the bit but leave SnapshotCapabilities empty, so the feature bit is the authoritative
78
+ // signal and SnapshotCapabilities a hint we fall back from.
79
+ const featureMap = node?.attributes[`${this.endpointId}/1361/65532`];
80
+ if (typeof featureMap === "number" && (featureMap & 0x04) !== 0) return true;
81
+ const caps = node?.attributes[`${this.endpointId}/1361/10`];
82
+ return Array.isArray(caps) && caps.length > 0;
83
+ }
84
+
85
+ private _streamViewRef = createRef<WebRtcStreamView>();
86
+
87
+ override firstUpdated(): void {
88
+ this._initResolutions();
89
+ }
90
+
91
+ private _initResolutions(): void {
92
+ this._resolutions = this._loadResolutions();
93
+ this._selectedResolution = this._resolutions[0] ?? null;
94
+ this._snapshotResolutions = this._loadSnapshotResolutions();
95
+ this._selectedSnapshotResolution = this._snapshotResolutions[0] ?? null;
96
+ this._resolutionsLoading = false;
97
+ }
98
+
99
+ private _loadSnapshotResolutions(): Resolution[] {
100
+ const raw = this._readCachedAvsmAttribute(10);
101
+ if (!Array.isArray(raw)) return [];
102
+ const seen = new Map<string, Resolution>();
103
+ for (const item of raw) {
104
+ const obj = asObject(item);
105
+ if (!obj) continue;
106
+ const res = asObject(obj["resolution"] ?? obj["0"]);
107
+ if (!res) continue;
108
+ const w = pickNumber(res, "width", "0");
109
+ const h = pickNumber(res, "height", "1");
110
+ if (w !== null && h !== null) {
111
+ const key = `${w}x${h}`;
112
+ if (!seen.has(key)) seen.set(key, { width: w, height: h });
113
+ }
114
+ }
115
+ return [...seen.values()].sort((a, b) => b.width * b.height - a.width * a.height);
116
+ }
117
+
118
+ private _onSnapshotResolutionChange(ev: Event): void {
119
+ const value = (ev.target as HTMLSelectElement).value;
120
+ const [w, h] = value.split("x").map(Number);
121
+ if (!Number.isFinite(w) || !Number.isFinite(h)) return;
122
+ this._selectedSnapshotResolution = { width: w, height: h };
123
+ }
124
+
125
+ private _readCachedAvsmAttribute(attributeId: number): unknown {
126
+ const node = this.client?.nodes[String(this.nodeId)];
127
+ if (!node) return undefined;
128
+ return node.attributes[`${this.endpointId}/${CAMERA_AV_STREAM_MANAGEMENT_CLUSTER_ID}/${attributeId}`];
129
+ }
130
+
131
+ private _loadResolutions(): Resolution[] {
132
+ // 1. RateDistortionTradeOffPoints (attr id 5) — richest capability source
133
+ const rdtRaw = this._readCachedAvsmAttribute(5);
134
+ if (Array.isArray(rdtRaw) && rdtRaw.length > 0) {
135
+ const seen = new Map<string, Resolution>();
136
+ for (const item of rdtRaw) {
137
+ const resObj = asObject(item)?.["resolution"];
138
+ const res = asObject(resObj);
139
+ if (!res) continue;
140
+ const w = pickNumber(res, "width");
141
+ const h = pickNumber(res, "height");
142
+ if (w !== null && h !== null) {
143
+ const key = `${w}x${h}`;
144
+ if (!seen.has(key)) seen.set(key, { width: w, height: h });
145
+ }
146
+ }
147
+ const results = [...seen.values()].sort((a, b) => b.width * b.height - a.width * a.height);
148
+ if (results.length > 0) return results;
149
+ }
150
+
151
+ // 2. MinViewportResolution (attr id 4) — single VideoResolution struct {width, height}
152
+ const minVpRaw = this._readCachedAvsmAttribute(4);
153
+ const minVp = asObject(minVpRaw);
154
+ if (minVp) {
155
+ const w = pickNumber(minVp, "width");
156
+ const h = pickNumber(minVp, "height");
157
+ if (w !== null && h !== null && w > 0 && h > 0) return [{ width: w, height: h }];
158
+ }
159
+
160
+ return HA_DEFAULT_RESOLUTIONS;
161
+ }
162
+
163
+ private _getSensorSize(): { width: number; height: number } | null {
164
+ const node = this.client?.nodes[String(this.nodeId)];
165
+ if (!node) return null;
166
+ const raw = node.attributes[`${this.endpointId}/1361/7`];
167
+ const obj = asObject(raw);
168
+ if (!obj) return null;
169
+ const w = pickNumber(obj, "sensorWidth", "0");
170
+ const h = pickNumber(obj, "sensorHeight", "1");
171
+ if (w === null || h === null) return null;
172
+ return { width: w, height: h };
173
+ }
174
+
175
+ private _avsmFeatures(): { wmark: boolean; osd: boolean } {
176
+ const node = this.client?.nodes[String(this.nodeId)];
177
+ const raw =
178
+ node?.attributes[
179
+ `${this.endpointId}/${CAMERA_AV_STREAM_MANAGEMENT_CLUSTER_ID}/${AVSM_FEATURE_MAP_ATTR_ID}`
180
+ ];
181
+ const bits = typeof raw === "number" ? raw : 0;
182
+ return {
183
+ wmark: (bits & AVSM_FEAT_WMARK) !== 0,
184
+ osd: (bits & AVSM_FEAT_OSD) !== 0,
185
+ };
186
+ }
187
+
188
+ private _avsumPresent(): boolean {
189
+ const node = this.client?.nodes[String(this.nodeId)];
190
+ if (!node) return false;
191
+ return hasAvsumOnEndpoint(node, this.endpointId);
192
+ }
193
+
194
+ private async _close(): Promise<void> {
195
+ const view = this._streamViewRef.value;
196
+ if (view) {
197
+ this._closing = true;
198
+ try {
199
+ await view.stop();
200
+ await view.deallocateSnapshot();
201
+ } finally {
202
+ this._closing = false;
203
+ }
204
+ }
205
+ this.remove();
206
+ }
207
+
208
+ private _onStreamState(ev: CustomEvent<{ state: StreamState; errorMessage: string | null }>): void {
209
+ this._state = ev.detail.state;
210
+ this._errorMessage = ev.detail.errorMessage;
211
+ // Only expose the underlying VideoStreamID to the AVSUM strip while actively
212
+ // streaming. Other states would surface a stale id (during error/idle the
213
+ // stream is being torn down) or a not-yet-allocated null (during connecting).
214
+ this._activeVideoStreamId =
215
+ ev.detail.state === "streaming" ? (this._streamViewRef.value?.videoStreamId ?? null) : null;
216
+ }
217
+
218
+ private _start(): void {
219
+ const view = this._streamViewRef.value;
220
+ if (view) {
221
+ void view.start();
222
+ }
223
+ }
224
+
225
+ private _stop(): void {
226
+ const view = this._streamViewRef.value;
227
+ if (view) {
228
+ void view.stop();
229
+ }
230
+ }
231
+
232
+ private _toggleMute(): void {
233
+ const view = this._streamViewRef.value;
234
+ if (!view) return;
235
+ const next = !this._muted;
236
+ view.setMuted(next);
237
+ this._muted = next;
238
+ }
239
+
240
+ private async _onSnapshot(): Promise<void> {
241
+ const view = this._streamViewRef.value;
242
+ if (!view) return;
243
+ this._snapshotBusy = true;
244
+ this._snapshotError = null;
245
+ try {
246
+ const { dataUri, resolution } = await view.takeSnapshot();
247
+ this._snapshotDataUri = dataUri;
248
+ this._snapshotResolution = resolution;
249
+ } catch (e) {
250
+ this._snapshotError = e instanceof Error ? e.message : String(e);
251
+ } finally {
252
+ this._snapshotBusy = false;
253
+ }
254
+ }
255
+
256
+ private _downloadSnapshot(): void {
257
+ if (!this._snapshotDataUri) return;
258
+ const a = document.createElement("a");
259
+ a.href = this._snapshotDataUri;
260
+ a.download = `snapshot-node${this.nodeId}-ep${this.endpointId}-${Date.now()}.jpg`;
261
+ a.click();
262
+ }
263
+
264
+ private _onResolutionChange(ev: Event): void {
265
+ const value = (ev.target as HTMLSelectElement).value;
266
+ const [w, h] = value.split("x").map(Number);
267
+ if (!Number.isFinite(w) || !Number.isFinite(h)) return;
268
+ this._selectedResolution = { width: w, height: h };
269
+ }
270
+
271
+ override render() {
272
+ const canStart = this._state === "idle" || this._state === "error";
273
+
274
+ return html`
275
+ <div class="backdrop" @click=${this._closing ? undefined : this._close}></div>
276
+ <div class="frame" @click=${(e: Event) => e.stopPropagation()}>
277
+ <header>
278
+ <md-icon-button @click=${this._close} ?disabled=${this._closing} aria-label="Close">
279
+ <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
280
+ </md-icon-button>
281
+ <span>Node ${this.nodeId} • Endpoint ${this.endpointId}</span>
282
+ </header>
283
+ ${this._avsumPresent()
284
+ ? html`<avsum-ptz-strip
285
+ .nodeId=${this.nodeId}
286
+ .endpointId=${this.endpointId}
287
+ .activeVideoStreamId=${this._activeVideoStreamId}
288
+ .sensorSize=${this._getSensorSize()}
289
+ ></avsum-ptz-strip>`
290
+ : nothing}
291
+ <main>
292
+ ${this.client
293
+ ? html`<webrtc-stream-view
294
+ ${ref(this._streamViewRef)}
295
+ .nodeId=${this.nodeId}
296
+ .endpointId=${this.endpointId}
297
+ .resolution=${this._selectedResolution}
298
+ .watermarkEnabled=${this._watermarkEnabled}
299
+ .osdEnabled=${this._osdEnabled}
300
+ .snapshotResolution=${this._selectedSnapshotResolution}
301
+ @streamstate=${this._onStreamState}
302
+ ></webrtc-stream-view>`
303
+ : html`<div class="status error">No Matter client available.</div>`}
304
+ ${this._snapshotDataUri
305
+ ? html`
306
+ <div class="snapshot-preview">
307
+ <img
308
+ src=${this._snapshotDataUri}
309
+ @click=${this._downloadSnapshot}
310
+ title="Click to download${this._snapshotResolution
311
+ ? ` (${this._snapshotResolution.width}×${this._snapshotResolution.height})`
312
+ : ""}"
313
+ alt="Snapshot"
314
+ />
315
+ <md-icon-button
316
+ @click=${() => {
317
+ this._snapshotDataUri = null;
318
+ }}
319
+ aria-label="Close snapshot"
320
+ >
321
+ <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
322
+ </md-icon-button>
323
+ </div>
324
+ `
325
+ : nothing}
326
+ ${this._snapshotError ? html`<div class="snapshot-error">${this._snapshotError}</div>` : nothing}
327
+ </main>
328
+ <footer>
329
+ ${this._closing ? html`<span class="footer-status">Closing…</span>` : nothing}
330
+ ${!this._closing && this._state === "connecting"
331
+ ? html`<span class="footer-status">Waiting for camera response…</span>`
332
+ : nothing}
333
+ ${!this._closing && this._state === "error" && this._errorMessage
334
+ ? html`<span class="footer-status error">${this._errorMessage}</span>`
335
+ : nothing}
336
+ ${canStart && this._resolutions.length > 0
337
+ ? html`
338
+ <md-outlined-select
339
+ label="Resolution"
340
+ .value=${this._selectedResolution
341
+ ? `${this._selectedResolution.width}x${this._selectedResolution.height}`
342
+ : ""}
343
+ @change=${this._onResolutionChange}
344
+ >
345
+ ${this._resolutions.map(
346
+ r => html`
347
+ <md-select-option value=${`${r.width}x${r.height}`}>
348
+ <div slot="headline">${r.width}×${r.height}</div>
349
+ </md-select-option>
350
+ `,
351
+ )}
352
+ </md-outlined-select>
353
+ `
354
+ : nothing}
355
+ ${canStart && this._snapshotSupported && this._snapshotResolutions.length > 0
356
+ ? html`
357
+ <md-outlined-select
358
+ label="Snapshot"
359
+ .value=${this._selectedSnapshotResolution
360
+ ? `${this._selectedSnapshotResolution.width}x${this._selectedSnapshotResolution.height}`
361
+ : ""}
362
+ @change=${this._onSnapshotResolutionChange}
363
+ >
364
+ ${this._snapshotResolutions.map(
365
+ r => html`
366
+ <md-select-option value=${`${r.width}x${r.height}`}>
367
+ <div slot="headline">${r.width}×${r.height}</div>
368
+ </md-select-option>
369
+ `,
370
+ )}
371
+ </md-outlined-select>
372
+ `
373
+ : nothing}
374
+ ${canStart && this._avsmFeatures().wmark
375
+ ? html`<label class="overlay-toggle">
376
+ <input
377
+ type="checkbox"
378
+ ?checked=${this._watermarkEnabled}
379
+ @change=${(e: Event) =>
380
+ (this._watermarkEnabled = (e.target as HTMLInputElement).checked)}
381
+ />
382
+ Watermark
383
+ </label>`
384
+ : nothing}
385
+ ${canStart && this._avsmFeatures().osd
386
+ ? html`<label class="overlay-toggle">
387
+ <input
388
+ type="checkbox"
389
+ ?checked=${this._osdEnabled}
390
+ @change=${(e: Event) => (this._osdEnabled = (e.target as HTMLInputElement).checked)}
391
+ />
392
+ OSD
393
+ </label>`
394
+ : nothing}
395
+ ${canStart
396
+ ? html`<md-filled-button
397
+ @click=${this._start}
398
+ ?disabled=${!this.client || this._resolutionsLoading}
399
+ >
400
+ ${this._state === "error" ? "Retry" : "Start"}
401
+ </md-filled-button>`
402
+ : nothing}
403
+ ${this._state === "streaming"
404
+ ? html`<md-filled-button @click=${this._stop}>End</md-filled-button>
405
+ <md-text-button @click=${this._toggleMute} aria-label=${this._muted ? "Unmute" : "Mute"}>
406
+ <ha-svg-icon
407
+ slot="icon"
408
+ .path=${this._muted ? mdiVolumeOff : mdiVolumeHigh}
409
+ ></ha-svg-icon>
410
+ ${this._muted ? "Unmute" : "Mute"}
411
+ </md-text-button>`
412
+ : nothing}
413
+ ${this._snapshotSupported
414
+ ? html`<md-text-button
415
+ @click=${this._onSnapshot}
416
+ ?disabled=${this._snapshotBusy || !this.client}
417
+ aria-label="Take snapshot"
418
+ >
419
+ <ha-svg-icon .path=${mdiCamera} slot="icon"></ha-svg-icon>
420
+ ${this._snapshotBusy ? "Capturing…" : "Snapshot"}
421
+ </md-text-button>`
422
+ : nothing}
423
+ <md-text-button @click=${this._close} ?disabled=${this._closing}>Close</md-text-button>
424
+ </footer>
425
+ </div>
426
+ `;
427
+ }
428
+
429
+ static override styles = css`
430
+ :host {
431
+ position: fixed;
432
+ inset: 0;
433
+ display: grid;
434
+ place-items: center;
435
+ z-index: 9999;
436
+ }
437
+ .backdrop {
438
+ position: fixed;
439
+ inset: 0;
440
+ background: rgba(0, 0, 0, 0.8);
441
+ }
442
+ .frame {
443
+ position: relative;
444
+ width: min(80vw, 1200px);
445
+ height: min(80vh, 800px);
446
+ background: var(--md-sys-color-surface);
447
+ color: var(--md-sys-color-on-surface);
448
+ display: grid;
449
+ grid-template-rows: auto auto 1fr auto;
450
+ grid-template-areas: "header" "strip" "main" "footer";
451
+ border-radius: 8px;
452
+ overflow: hidden;
453
+ }
454
+ header {
455
+ grid-area: header;
456
+ }
457
+ avsum-ptz-strip {
458
+ grid-area: strip;
459
+ }
460
+ main {
461
+ grid-area: main;
462
+ }
463
+ footer {
464
+ grid-area: footer;
465
+ }
466
+ header {
467
+ display: flex;
468
+ align-items: center;
469
+ padding: 8px 16px;
470
+ gap: 12px;
471
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
472
+ }
473
+ main {
474
+ display: flex;
475
+ flex-direction: column;
476
+ background: black;
477
+ position: relative;
478
+ overflow: hidden;
479
+ min-height: 0;
480
+ }
481
+ webrtc-stream-view {
482
+ flex: 1 1 0;
483
+ min-height: 0;
484
+ width: 100%;
485
+ }
486
+ .status.error {
487
+ color: var(--danger-color);
488
+ text-align: center;
489
+ padding: 16px;
490
+ }
491
+ .overlay-toggle {
492
+ display: inline-flex;
493
+ align-items: center;
494
+ gap: 6px;
495
+ font-size: 0.85rem;
496
+ color: var(--md-sys-color-on-surface-variant);
497
+ user-select: none;
498
+ cursor: pointer;
499
+ }
500
+ .overlay-toggle input[type="checkbox"] {
501
+ accent-color: var(--md-sys-color-primary);
502
+ margin: 0;
503
+ }
504
+ .snapshot-preview {
505
+ position: absolute;
506
+ top: 8px;
507
+ right: 8px;
508
+ max-width: 200px;
509
+ background: var(--md-sys-color-surface-container);
510
+ border: 1px solid var(--md-sys-color-outline-variant);
511
+ border-radius: 4px;
512
+ padding: 4px;
513
+ display: grid;
514
+ grid-template-columns: 1fr auto;
515
+ align-items: start;
516
+ gap: 4px;
517
+ z-index: 10;
518
+ }
519
+ .snapshot-preview img {
520
+ max-width: 100%;
521
+ cursor: pointer;
522
+ display: block;
523
+ border-radius: 2px;
524
+ }
525
+ .snapshot-error {
526
+ position: absolute;
527
+ bottom: 8px;
528
+ left: 50%;
529
+ transform: translateX(-50%);
530
+ color: var(--danger-color);
531
+ background: var(--md-sys-color-surface-container);
532
+ border-radius: 4px;
533
+ padding: 6px 12px;
534
+ font-size: 0.875rem;
535
+ z-index: 10;
536
+ white-space: nowrap;
537
+ }
538
+ footer {
539
+ padding: 8px 16px;
540
+ display: flex;
541
+ align-items: center;
542
+ justify-content: flex-end;
543
+ gap: 12px;
544
+ border-top: 1px solid var(--md-sys-color-outline-variant);
545
+ }
546
+ .footer-status {
547
+ margin-right: auto;
548
+ color: var(--md-sys-color-on-surface-variant);
549
+ font-style: italic;
550
+ }
551
+ .footer-status.error {
552
+ color: var(--danger-color);
553
+ font-style: normal;
554
+ }
555
+ `;
556
+ }
557
+
558
+ declare global {
559
+ interface HTMLElementTagNameMap {
560
+ "camera-overlay": CameraOverlay;
561
+ }
562
+ }
@@ -4,9 +4,11 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
 
7
+ import { consume } from "@lit/context";
7
8
  import { MatterClient, MatterNode } from "@matter-server/ws-client";
8
9
  import { LitElement, css, type CSSResultGroup } from "lit";
9
10
  import { property } from "lit/decorators.js";
11
+ import { clientContext, tickContext } from "../../client/client-context.js";
10
12
  import { reducedMotionStyles } from "../../util/shared-styles.js";
11
13
 
12
14
  /**
@@ -14,9 +16,12 @@ import { reducedMotionStyles } from "../../util/shared-styles.js";
14
16
  * Provides shared properties, styling, and helper methods for sending commands.
15
17
  */
16
18
  export abstract class BaseClusterCommands extends LitElement {
17
- @property({ attribute: false })
19
+ @consume({ context: clientContext })
18
20
  public client!: MatterClient;
19
21
 
22
+ @consume({ context: tickContext, subscribe: true })
23
+ protected _tick = 0;
24
+
20
25
  @property({ attribute: false })
21
26
  public node!: MatterNode;
22
27
 
@@ -45,6 +50,7 @@ export abstract class BaseClusterCommands extends LitElement {
45
50
  css`
46
51
  :host {
47
52
  display: block;
53
+ color: var(--md-sys-color-on-surface);
48
54
  }
49
55
 
50
56
  details.command-panel {