@sukramj/fabric-ui 0.1.2 → 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.
@@ -1,6 +1,6 @@
1
1
  // The event stream helper: GET /node/stream as typed callbacks. A host with
2
2
  // a hub of its own feeds it from here (see ui/README.md).
3
- import type { ExposureEvent, FabricEvent, WindowEvent } from "./client";
3
+ import type { ControllerEvent, ExposureEvent, FabricEvent, WindowEvent } from "./client";
4
4
 
5
5
  /** The event types of the stream, as the contract names them. */
6
6
  export const STREAM_EVENTS = [
@@ -52,3 +52,42 @@ export function subscribeStream(
52
52
  }
53
53
  return () => es.close();
54
54
  }
55
+
56
+ /** The event types of the controller stream (GET /controller/stream). */
57
+ export const CONTROLLER_STREAM_EVENTS = [
58
+ "node_added",
59
+ "node_updated",
60
+ "node_removed",
61
+ "connection",
62
+ "subscription",
63
+ "commissioning_progress",
64
+ "update",
65
+ ] as const;
66
+
67
+ export type ControllerStreamEventType = (typeof CONTROLLER_STREAM_EVENTS)[number];
68
+
69
+ export type ControllerStreamEvent = { type: ControllerStreamEventType; data: ControllerEvent };
70
+
71
+ /**
72
+ * Subscribes to the controller stream at `url`, as subscribeStream does
73
+ * for the node's. Returns the close function.
74
+ */
75
+ export function subscribeControllerStream(
76
+ url: string,
77
+ onEvent: (ev: ControllerStreamEvent) => void,
78
+ open: EventSourceFactory = (u) => new EventSource(u, { withCredentials: true }),
79
+ ): () => void {
80
+ const es = open(url);
81
+ for (const type of CONTROLLER_STREAM_EVENTS) {
82
+ es.addEventListener(type, (msg: MessageEvent) => {
83
+ let data: unknown;
84
+ try {
85
+ data = JSON.parse(String(msg.data));
86
+ } catch {
87
+ return;
88
+ }
89
+ onEvent({ type, data: data as ControllerEvent });
90
+ });
91
+ }
92
+ return () => es.close();
93
+ }
@@ -1,11 +1,12 @@
1
1
  // The fabric-ui context: what every page reads — the client, the host's
2
- // translator, toast and confirm, the node store and the links between the
3
- // pages. A host provides it once above the pages (provideFabricUI); the
2
+ // translator, toast and confirm, the node and controller stores and the
3
+ // links between the pages. A host provides it once above the pages (provideFabricUI); the
4
4
  // standalone shell provides its own.
5
5
  import { getContext, hasContext, setContext } from "svelte";
6
6
  import type { FabricClient } from "./api/client";
7
7
  import { defaultT, type Translate } from "./i18n";
8
8
  import { createNodeStore, type NodeStore, type NodeStoreDeps, type Toast } from "./store.svelte";
9
+ import { createControllerStore, type ControllerStore, type ControllerStoreDeps } from "./controller-store.svelte";
9
10
 
10
11
  export type { Toast } from "./store.svelte";
11
12
 
@@ -26,6 +27,11 @@ export interface Routes {
26
27
  selection: string;
27
28
  endpoints: string;
28
29
  diagnostics: string;
30
+ controllerNodes: string;
31
+ controllerCommission: string;
32
+ controllerSettings: string;
33
+ /** The prefix of one node's page; the page appends the node's 16-digit hex id. */
34
+ controllerNode: string;
29
35
  }
30
36
 
31
37
  export const defaultRoutes: Routes = {
@@ -35,6 +41,10 @@ export const defaultRoutes: Routes = {
35
41
  selection: "#/node/selection",
36
42
  endpoints: "#/node/endpoints",
37
43
  diagnostics: "#/node/diagnostics",
44
+ controllerNodes: "#/controller/nodes",
45
+ controllerCommission: "#/controller/commission",
46
+ controllerSettings: "#/controller/settings",
47
+ controllerNode: "#/controller/node/",
38
48
  };
39
49
 
40
50
  export interface FabricUI {
@@ -43,6 +53,7 @@ export interface FabricUI {
43
53
  toast: Toast;
44
54
  confirm: Confirm;
45
55
  node: NodeStore;
56
+ controller: ControllerStore;
46
57
  routes: Routes;
47
58
  }
48
59
 
@@ -53,6 +64,7 @@ export interface FabricUIOptions {
53
64
  confirm?: Confirm;
54
65
  routes?: Partial<Routes>;
55
66
  subscribe?: NodeStoreDeps["subscribe"];
67
+ subscribeController?: ControllerStoreDeps["subscribe"];
56
68
  }
57
69
 
58
70
  const KEY = Symbol.for("@sukramj/fabric-ui");
@@ -73,6 +85,7 @@ export function createFabricUI(opts: FabricUIOptions): FabricUI {
73
85
  toast,
74
86
  confirm: opts.confirm ?? (async (o) => window.confirm(o.body ? `${o.title}\n\n${o.body}` : o.title)),
75
87
  node: createNodeStore(opts.client, { t, toast, subscribe: opts.subscribe }),
88
+ controller: createControllerStore(opts.client, { t, toast, subscribe: opts.subscribeController }),
76
89
  routes: { ...defaultRoutes, ...opts.routes },
77
90
  };
78
91
  }
@@ -0,0 +1,175 @@
1
+ // The controller family's state: the controller's status, the node list,
2
+ // the credentials and the update queue, and the progress of a
3
+ // commissioning, kept current by the controller stream. One instance per
4
+ // fabric-ui context, as the node store.
5
+ import {
6
+ ApiError,
7
+ type ControllerNodeSummary,
8
+ type ControllerStatus,
9
+ type Credential,
10
+ type FabricClient,
11
+ type UpdateQueue,
12
+ } from "./api/client";
13
+ import { subscribeControllerStream, type ControllerStreamEvent } from "./api/stream";
14
+ import type { Toast } from "./store.svelte";
15
+ import type { Translate } from "./i18n";
16
+
17
+ export type CommissionPhase = "idle" | "started" | "succeeded" | "failed";
18
+
19
+ export interface CommissionProgress {
20
+ phase: CommissionPhase;
21
+ /** The failure's kind (no_device, timeout, …) of a failed commissioning. */
22
+ detail: string | null;
23
+ /** The node a succeeded commissioning added. */
24
+ nodeIdHex: string | null;
25
+ }
26
+
27
+ export interface ControllerStoreDeps {
28
+ t: Translate;
29
+ toast: Toast;
30
+ /** Subscribes to the controller stream; the default opens an EventSource on the client's URL. */
31
+ subscribe?: (onEvent: (ev: ControllerStreamEvent) => void) => () => void;
32
+ }
33
+
34
+ const IDLE: CommissionProgress = { phase: "idle", detail: null, nodeIdHex: null };
35
+
36
+ /** How long the store waits before reloading after an event, so a burst reloads once. */
37
+ const RELOAD_DELAY = 250;
38
+
39
+ export function createControllerStore(client: FabricClient, deps: ControllerStoreDeps) {
40
+ let status = $state<ControllerStatus | null>(null);
41
+ // null until probed; false when the host serves no controller (404).
42
+ let present = $state<boolean | null>(null);
43
+ let statusError = $state<string | null>(null);
44
+ let nodes = $state<ControllerNodeSummary[]>([]);
45
+ let nodesLoading = $state(false);
46
+ let nodesError = $state<string | null>(null);
47
+ let credentials = $state<Credential[]>([]);
48
+ let credentialsError = $state<string | null>(null);
49
+ let updates = $state<UpdateQueue | null>(null);
50
+ let progress = $state<CommissionProgress>({ ...IDLE });
51
+ // Bumped on every event about a node, so an open node page reloads.
52
+ let revision = $state(0);
53
+ let changed = $state<string | null>(null);
54
+
55
+ let unsub: (() => void) | null = null;
56
+ let reload: ReturnType<typeof setTimeout> | null = null;
57
+
58
+ const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
59
+
60
+ /** Reads the status; a 404 marks the family absent, not an error. */
61
+ async function loadStatus() {
62
+ statusError = null;
63
+ try {
64
+ status = await client.controllerStatus();
65
+ present = true;
66
+ } catch (err) {
67
+ if (err instanceof ApiError && err.status === 404) {
68
+ present = false;
69
+ status = null;
70
+ return;
71
+ }
72
+ statusError = message(err);
73
+ }
74
+ }
75
+
76
+ async function loadNodes() {
77
+ nodesLoading = true;
78
+ nodesError = null;
79
+ try {
80
+ nodes = (await client.controllerNodes()).nodes;
81
+ } catch (err) {
82
+ nodesError = message(err);
83
+ } finally {
84
+ nodesLoading = false;
85
+ }
86
+ }
87
+
88
+ async function loadCredentials() {
89
+ credentialsError = null;
90
+ try {
91
+ credentials = (await client.credentials()).credentials;
92
+ } catch (err) {
93
+ credentialsError = message(err);
94
+ }
95
+ }
96
+
97
+ async function loadUpdates() {
98
+ try {
99
+ updates = await client.updates();
100
+ } catch (err) {
101
+ deps.toast.error(message(err));
102
+ }
103
+ }
104
+
105
+ function scheduleReload() {
106
+ if (reload) return;
107
+ reload = setTimeout(() => {
108
+ reload = null;
109
+ void loadNodes();
110
+ void loadStatus();
111
+ }, RELOAD_DELAY);
112
+ }
113
+
114
+ function applyEvent(ev: ControllerStreamEvent) {
115
+ switch (ev.type) {
116
+ case "commissioning_progress": {
117
+ const phase = (ev.data.phase ?? "started") as CommissionPhase;
118
+ progress = { phase, detail: ev.data.detail ?? null, nodeIdHex: ev.data.node_id_hex ?? null };
119
+ if (phase === "succeeded") scheduleReload();
120
+ break;
121
+ }
122
+ case "update":
123
+ void loadUpdates();
124
+ break;
125
+ default:
126
+ changed = ev.data.node_id_hex ?? null;
127
+ revision++;
128
+ scheduleReload();
129
+ }
130
+ }
131
+
132
+ function resetProgress() {
133
+ progress = { ...IDLE };
134
+ }
135
+
136
+ function connect() {
137
+ if (unsub) return;
138
+ const sub = deps.subscribe ?? ((fn) => subscribeControllerStream(client.controllerStreamUrl(), fn));
139
+ unsub = sub(applyEvent);
140
+ }
141
+
142
+ function close() {
143
+ if (reload) clearTimeout(reload);
144
+ reload = null;
145
+ unsub?.();
146
+ unsub = null;
147
+ }
148
+
149
+ return {
150
+ get status() { return status; },
151
+ get present() { return present; },
152
+ get statusError() { return statusError; },
153
+ get nodes() { return nodes; },
154
+ get nodesLoading() { return nodesLoading; },
155
+ get nodesError() { return nodesError; },
156
+ get credentials() { return credentials; },
157
+ get credentialsError() { return credentialsError; },
158
+ get updates() { return updates; },
159
+ get progress() { return progress; },
160
+ /** Counts the events about nodes; a node page reloads when it moves. */
161
+ get revision() { return revision; },
162
+ /** The node the last event named. */
163
+ get changed() { return changed; },
164
+ loadStatus,
165
+ loadNodes,
166
+ loadCredentials,
167
+ loadUpdates,
168
+ applyEvent,
169
+ resetProgress,
170
+ connect,
171
+ close,
172
+ };
173
+ }
174
+
175
+ export type ControllerStore = ReturnType<typeof createControllerStore>;
@@ -12,6 +12,213 @@
12
12
  "common.save": "Speichern",
13
13
  "common.saving": "Wird gespeichert…",
14
14
  "common.search": "Suchen…",
15
+ "controller.acl.add": "Eintrag hinzufügen",
16
+ "controller.acl.add_entry": "Hinzufügen",
17
+ "controller.acl.any_subject": "jedes Subjekt",
18
+ "controller.acl.discard": "Verwerfen",
19
+ "controller.acl.empty": "Kein Eintrag der Fabric dieses Controllers",
20
+ "controller.acl.invalid_subject": "Ein Subjekt ist keine hexadezimale ID.",
21
+ "controller.acl.invalid_target": "Der Cluster ist keine hexadezimale ID.",
22
+ "controller.acl.lockout": "Diesen Controller aussperren?",
23
+ "controller.acl.lockout_body": "Die Liste hat keinen Administer-Eintrag für diesen Controller; nach dem Schreiben kann er den Knoten nicht mehr ändern.",
24
+ "controller.acl.mode.case": "CASE",
25
+ "controller.acl.mode.group": "Gruppe",
26
+ "controller.acl.mode.pase": "PASE",
27
+ "controller.acl.mode_label": "Authentifizierung",
28
+ "controller.acl.privilege.administer": "Administrieren",
29
+ "controller.acl.privilege.manage": "Verwalten",
30
+ "controller.acl.privilege.operate": "Bedienen",
31
+ "controller.acl.privilege.proxy_view": "Proxy-Ansicht",
32
+ "controller.acl.privilege.view": "Ansehen",
33
+ "controller.acl.privilege_label": "Berechtigung",
34
+ "controller.acl.saved": "Die ACL ist geschrieben.",
35
+ "controller.acl.subjects": "Subjekte (hex, durch Komma getrennt)",
36
+ "controller.acl.target_cluster": "Cluster (hex)",
37
+ "controller.acl.target_endpoint": "Endpoint",
38
+ "controller.acl.whole_node": "der ganze Knoten",
39
+ "controller.attributes.attribute": "Attribut-ID",
40
+ "controller.attributes.cluster": "Cluster",
41
+ "controller.attributes.endpoint": "Endpoint",
42
+ "controller.attributes.hint": "Ein Attribut lesen: Endpoint und Cluster wählen, die Attribut-ID hexadezimal angeben.",
43
+ "controller.attributes.invalid_json": "Der Wert ist kein JSON.",
44
+ "controller.attributes.invalid_path": "Cluster- oder Attribut-ID ist nicht hexadezimal.",
45
+ "controller.attributes.read": "Lesen",
46
+ "controller.attributes.status": "Der Knoten antwortete mit Status {status}.",
47
+ "controller.attributes.value": "Wert als JSON",
48
+ "controller.attributes.write": "Schreiben",
49
+ "controller.attributes.write_status": "Der Knoten lehnte das Schreiben ab: Status {status}.",
50
+ "controller.attributes.written": "Geschrieben.",
51
+ "controller.bindings.add": "Hinzufügen",
52
+ "controller.bindings.cluster": "Cluster (hex)",
53
+ "controller.bindings.empty": "Kein Binding",
54
+ "controller.bindings.endpoint": "Endpoint",
55
+ "controller.bindings.group": "Gruppe {group}",
56
+ "controller.bindings.invalid": "Ein Ziel ist eine Node-ID mit Endpoint oder g: mit einer Gruppen-ID.",
57
+ "controller.bindings.none": "Der Knoten hat keinen Binding-Cluster",
58
+ "controller.bindings.saved": "Die Bindings sind geschrieben.",
59
+ "controller.bindings.target": "Node-ID (hex) oder g:<Gruppe>",
60
+ "controller.commission.by_code": "Kopplungscode",
61
+ "controller.commission.by_code_hint": "QR-Inhalt (MT:…) oder den auf dem Gerät gedruckten manuellen Kopplungscode eingeben.",
62
+ "controller.commission.code": "Kopplungscode",
63
+ "controller.commission.code_placeholder": "MT:… oder 11 Ziffern",
64
+ "controller.commission.col_addresses": "Adressen",
65
+ "controller.commission.col_device": "Gerät",
66
+ "controller.commission.col_discriminator": "Diskriminator",
67
+ "controller.commission.col_vendor": "Hersteller",
68
+ "controller.commission.discovery": "Geräte im Netz",
69
+ "controller.commission.done": "{name} ist gekoppelt.",
70
+ "controller.commission.failed": "Kopplung fehlgeschlagen",
71
+ "controller.commission.network": "Netz für das Gerät",
72
+ "controller.commission.network_none": "Keine Netz-Zugangsdaten",
73
+ "controller.commission.network_only": "Nur über das Netz (kein Bluetooth)",
74
+ "controller.commission.none_found": "Kein koppelbares Gerät hat geantwortet",
75
+ "controller.commission.pair": "Koppeln",
76
+ "controller.commission.passcode": "PIN",
77
+ "controller.commission.passcode_invalid": "Die PIN liegt zwischen 1 und 99999998.",
78
+ "controller.commission.running": "Kopplung läuft…",
79
+ "controller.commission.search": "Suchen",
80
+ "controller.commission.searching": "Suche läuft…",
81
+ "controller.commission.start": "Koppeln",
82
+ "controller.commission.succeeded": "Gekoppelt",
83
+ "controller.connection.connected": "Verbunden",
84
+ "controller.connection.disconnected": "Getrennt",
85
+ "controller.connection.reconnecting": "Verbindet neu",
86
+ "controller.connection.waiting_for_discovery": "Wartet auf Erkennung",
87
+ "controller.credential.kind_thread": "Thread",
88
+ "controller.credential.kind_wifi": "WLAN",
89
+ "controller.endpoints.empty": "Der Controller kennt noch keinen Endpoint dieses Knotens",
90
+ "controller.endpoints.endpoint": "Endpoint {number}",
91
+ "controller.fabrics.col_fabric_id": "Fabric-ID",
92
+ "controller.fabrics.own": "dieser Controller",
93
+ "controller.fabrics.remove_confirm": "Die Fabric dieses Administrators vom Knoten entfernen?",
94
+ "controller.fabrics.removed": "Die Fabric ist entfernt.",
95
+ "controller.icd.awake": "Wach",
96
+ "controller.icd.forget": "Ohne den Knoten vergessen",
97
+ "controller.icd.last_check_in": "Letzter Check-in",
98
+ "controller.icd.lit_supported": "Unterstützt LIT",
99
+ "controller.icd.mode.lit": "LIT",
100
+ "controller.icd.mode.sit": "SIT",
101
+ "controller.icd.multi_admin": "Trotz anderer Administratoren registrieren?",
102
+ "controller.icd.next_check_in": "Nächster Check-in",
103
+ "controller.icd.register": "Registrieren",
104
+ "controller.icd.registered": "Registriert",
105
+ "controller.icd.registered_done": "Dieser Controller ist registriert.",
106
+ "controller.icd.unavailable": "Check-in überfällig",
107
+ "controller.icd.unregister": "Abmelden",
108
+ "controller.icd.unregistered": "Nicht registriert",
109
+ "controller.icd.unregistered_done": "Dieser Controller ist abgemeldet.",
110
+ "controller.icd.unsupported": "Der Knoten ist kein zeitweise verbundenes Gerät",
111
+ "controller.node.back": "Alle Knoten",
112
+ "controller.node.tab.acl": "ACL",
113
+ "controller.node.tab.attributes": "Attribute",
114
+ "controller.node.tab.bindings": "Bindings",
115
+ "controller.node.tab.endpoints": "Endpoints",
116
+ "controller.node.tab.fabrics": "Fabrics",
117
+ "controller.node.tab.icd": "ICD",
118
+ "controller.node.tab.overview": "Übersicht",
119
+ "controller.node.tab.updates": "Updates",
120
+ "controller.node.tab.window": "Fenster",
121
+ "controller.node.tabs": "Bereiche des Knotens",
122
+ "controller.nodes.col_addresses": "Adressen",
123
+ "controller.nodes.col_connection": "Verbindung",
124
+ "controller.nodes.col_name": "Knoten",
125
+ "controller.nodes.col_node_id": "Node-ID",
126
+ "controller.nodes.col_vendor": "Hersteller",
127
+ "controller.nodes.commission": "Gerät koppeln",
128
+ "controller.nodes.disable": "Deaktivieren",
129
+ "controller.nodes.disabled": "Deaktiviert",
130
+ "controller.nodes.disabled_done": "Der Knoten ist deaktiviert.",
131
+ "controller.nodes.empty": "Noch kein Knoten gekoppelt",
132
+ "controller.nodes.enable": "Aktivieren",
133
+ "controller.nodes.enabled": "Der Knoten ist aktiviert.",
134
+ "controller.nodes.filter_all": "Alle",
135
+ "controller.nodes.filter_connection": "Nach Verbindung filtern",
136
+ "controller.nodes.filter_icd": "Nach ICD filtern",
137
+ "controller.nodes.filter_vendor": "Nach Hersteller filtern",
138
+ "controller.nodes.icd": "ICD",
139
+ "controller.nodes.icd_no": "Kein ICD",
140
+ "controller.nodes.icd_yes": "Nur ICD",
141
+ "controller.nodes.interview": "Neu einlesen",
142
+ "controller.nodes.interviewed": "Der Knoten wurde neu eingelesen.",
143
+ "controller.nodes.remove_confirm": "Diesen Knoten entfernen?",
144
+ "controller.nodes.remove_confirm_body": "{name} verlässt die Fabric dieses Controllers; andere Controller behalten ihn.",
145
+ "controller.nodes.removed": "Der Knoten ist entfernt.",
146
+ "controller.nodes.subscribed": "Abonniert",
147
+ "controller.nodes.window": "Fenster",
148
+ "controller.overview.addresses": "Adressen",
149
+ "controller.overview.commissioned_at": "Gekoppelt am",
150
+ "controller.overview.connection": "Verbindung",
151
+ "controller.overview.fabric_index": "Unser Fabric-Index auf dem Knoten",
152
+ "controller.overview.flag.battery_powered": "Batteriebetrieben",
153
+ "controller.overview.flag.intermittently_connected": "Zeitweise verbunden",
154
+ "controller.overview.flag.mains_powered": "Netzbetrieben",
155
+ "controller.overview.flag.supports_ethernet": "Ethernet",
156
+ "controller.overview.flag.supports_thread": "Thread",
157
+ "controller.overview.flag.supports_wifi": "WLAN",
158
+ "controller.overview.flag.thread_sleepy_end_device": "Schlafendes Endgerät",
159
+ "controller.overview.idle_mode": "Ruhemodus: {seconds} s",
160
+ "controller.overview.node": "Knoten",
161
+ "controller.overview.node_id": "Node-ID",
162
+ "controller.overview.product": "Produkt",
163
+ "controller.overview.properties": "Eigenschaften",
164
+ "controller.overview.saved": "Gespeichert.",
165
+ "controller.overview.serial": "Seriennummer",
166
+ "controller.overview.software": "Software",
167
+ "controller.overview.spec_version": "Spezifikationsversion {version}",
168
+ "controller.overview.subscribe": "Abonnieren",
169
+ "controller.overview.unsubscribe": "Abonnement beenden",
170
+ "controller.overview.vendor": "Hersteller",
171
+ "controller.settings.cap.ble": "Bluetooth LE",
172
+ "controller.settings.cap.credentials": "Zugangsdaten-Speicher",
173
+ "controller.settings.cap.ota": "OTA-Provider",
174
+ "controller.settings.capabilities": "Fähigkeiten",
175
+ "controller.settings.col_id": "ID",
176
+ "controller.settings.col_kind": "Art",
177
+ "controller.settings.col_name": "Name",
178
+ "controller.settings.compressed_fabric_id": "Komprimierte Fabric-ID",
179
+ "controller.settings.credential_add": "Speichern",
180
+ "controller.settings.credential_remove": "Diese Zugangsdaten entfernen?",
181
+ "controller.settings.credential_saved": "Die Zugangsdaten sind gespeichert.",
182
+ "controller.settings.credentials": "Netz-Zugangsdaten",
183
+ "controller.settings.credentials_empty": "Keine Zugangsdaten gespeichert",
184
+ "controller.settings.dataset": "Operational Dataset (hex)",
185
+ "controller.settings.fabric_id": "Fabric-ID",
186
+ "controller.settings.info": "Controller",
187
+ "controller.settings.label": "Fabric-Bezeichnung",
188
+ "controller.settings.label_hint": "Bis zu 32 Zeichen; die Knoten erhalten sie im Hintergrund.",
189
+ "controller.settings.label_saved": "Die Bezeichnung ist gesetzt.",
190
+ "controller.settings.no_store": "Der Host speichert keine Netz-Zugangsdaten.",
191
+ "controller.settings.node_id": "Node-ID",
192
+ "controller.settings.nodes": "Knoten",
193
+ "controller.settings.passphrase": "Passwort",
194
+ "controller.settings.ssid": "SSID",
195
+ "controller.settings.vendor": "Hersteller",
196
+ "controller.settings.write_only": "Passwort oder Dataset werden nur geschrieben und nie wieder angezeigt.",
197
+ "controller.subtitle": "Die Knoten, die dieser Controller in Betrieb genommen hat",
198
+ "controller.tab.commission": "Koppeln",
199
+ "controller.tab.nodes": "Knoten",
200
+ "controller.tab.settings": "Einstellungen",
201
+ "controller.title": "Controller",
202
+ "controller.updates.check": "Nach Updates suchen",
203
+ "controller.updates.consent": "Auf {version} aktualisieren",
204
+ "controller.updates.consented": "Das Update ist eingereiht.",
205
+ "controller.updates.none_available": "Kein Update verfügbar.",
206
+ "controller.updates.queue_empty": "Für diesen Knoten ist kein Update eingereiht",
207
+ "controller.updates.status.in_progress": "Läuft",
208
+ "controller.updates.status.queued": "Eingereiht",
209
+ "controller.updates.status.stalled": "Hängt",
210
+ "controller.updates.unavailable": "Der Controller betreibt keinen OTA-Provider",
211
+ "controller.updates.version": "Version {version}",
212
+ "controller.window.close": "Fenster schließen",
213
+ "controller.window.closed": "Das Fenster ist geschlossen.",
214
+ "controller.window.discriminator": "Diskriminator",
215
+ "controller.window.hint": "Ein Fenster öffnen, damit ein weiterer Administrator (ein anderes Ökosystem) diesen Knoten ebenfalls koppeln kann.",
216
+ "controller.window.manual_code": "Manueller Code",
217
+ "controller.window.minutes": "{minutes} Minuten",
218
+ "controller.window.open": "Fenster öffnen",
219
+ "controller.window.qr_code": "QR-Inhalt",
220
+ "controller.window.seconds": "{seconds} s",
221
+ "controller.window.timeout": "Offen für",
15
222
  "node.commissioning.closed": "Kopplungsfenster geschlossen",
16
223
  "node.diag.age_hours": "{n} h",
17
224
  "node.diag.age_minutes": "{n} min",
@@ -156,5 +363,9 @@
156
363
  "node.warnings.summary": "{count} Konfigurationswarnungen",
157
364
  "node.warnings.title": "Konfigurationswarnungen",
158
365
  "select.placeholder": "Auswählen…",
159
- "shell.language": "Sprache"
366
+ "shell.families": "Seitenfamilien",
367
+ "shell.family.controller": "Controller",
368
+ "shell.family.node": "Node",
369
+ "shell.language": "Sprache",
370
+ "shell.no_family": "Dieser Host stellt weder einen Node noch einen Controller bereit."
160
371
  }