@camstack/addon-import-alexa 0.2.148 → 0.2.150
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/CHANGELOG.md +9 -0
- package/assets/alexa-echo.LICENSE.md +13 -0
- package/assets/alexa-echo.svg +1 -0
- package/dist/MaskShapeCanvas-B6q5RDLi.mjs +13807 -0
- package/dist/MotionZonesSettings-Dxo0EAzN.mjs +309 -0
- package/dist/PrivacyMaskSettings-DwWxpZrj.mjs +368 -0
- package/dist/SceneMonitorEditor-CC0dk5TR.mjs +846 -0
- package/dist/_stub.js +39566 -0
- package/dist/_virtual_mf-localSharedImportMap___mfe_internal__addon_import_alexa_page-CcQjQJmj.mjs +156 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.js-BmoaT3cr.mjs +25 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Msberbw_.mjs +26 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-Bct5qZia.mjs +25 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_trpc_mf_1_client__loadShare__.js-BHqOw8oh.mjs +25 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.js-B3qxfxBT.mjs +26 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react__loadShare__.js-DwXXYAlk.mjs +70 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-CUiVYNMY.mjs +26 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_2_dom__loadShare__.js-CIPmAyPi.mjs +25 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_2_dom_mf_1_client__loadShare__.js-tXm1x5EZ.mjs +25 -0
- package/dist/addon-import-alexa.css +3 -0
- package/dist/addon.js +2708 -149
- package/dist/addon.mjs +2724 -168
- package/dist/hostInit-CRUwUtSm.mjs +129 -0
- package/dist/index.js +1 -0
- package/dist/index.mjs +2 -2
- package/dist/player-overlays-DNpsEqAe.mjs +51 -0
- package/dist/remoteEntry.js +2 -0
- package/dist/remoteEntry.ssr.js +33 -0
- package/dist/responsive-B82v3AbM.mjs +125 -0
- package/dist/rolldown-runtime-HEgqtunE.mjs +20 -0
- package/dist/scene-monitor-copy-CPnjwzw6.mjs +227 -0
- package/dist/square-YTl_L1_3.mjs +45 -0
- package/dist/trash-2-DCHugLVt.mjs +25 -0
- package/dist/ui-text-B5YO4hoJ.mjs +34 -0
- package/dist/virtualExposes-lZT06jeg.mjs +2 -0
- package/dist/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_import_alexa_page__remoteEntry_js-DMdgQOgz.mjs +2857 -0
- package/dist/virtual_mf-exposes-ssr___mfe_internal__addon_import_alexa_page__remoteEntry_js-CViESMdd.mjs +10 -0
- package/dist/virtual_mf-exposes___mfe_internal__addon_import_alexa_page__remoteEntry_js-C3qJCjtV.mjs +27 -0
- package/i18n/en.json +8 -1
- package/i18n/it.json +8 -1
- package/package.json +26 -4
- package/python/NOTICE +4 -0
- package/python/alexa_gateway.py +226 -0
- package/python/alexa_protocol.py +115 -0
- package/python/alexa_sequences.py +74 -0
- package/python/alexa_worker.py +50 -0
- package/python/requirements.txt +3 -0
- package/python/test_alexa_gateway.py +141 -0
- package/python/test_alexa_protocol.py +177 -0
package/dist/addon.js
CHANGED
|
@@ -35,9 +35,17 @@ var __toCommonJS = (mod) => __hasOwnProp.call(mod, "module.exports") ? mod["modu
|
|
|
35
35
|
//#endregion
|
|
36
36
|
let node_crypto = require("node:crypto");
|
|
37
37
|
let node_os = require("node:os");
|
|
38
|
+
node_os = __toESM(node_os, 1);
|
|
38
39
|
let node_fs = require("node:fs");
|
|
40
|
+
node_fs = __toESM(node_fs, 1);
|
|
39
41
|
let node_path = require("node:path");
|
|
42
|
+
let node_path$1 = __toESM(node_path, 1);
|
|
40
43
|
node_path = __toESM(node_path);
|
|
44
|
+
let node_fs_promises = require("node:fs/promises");
|
|
45
|
+
node_fs_promises = __toESM(node_fs_promises, 1);
|
|
46
|
+
let node_child_process = require("node:child_process");
|
|
47
|
+
require("node:zlib");
|
|
48
|
+
let node_url = require("node:url");
|
|
41
49
|
//#region ../types/dist/event-category-CT-hz5Rb.mjs
|
|
42
50
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
43
51
|
EventCategory["SystemBoot"] = "system.boot";
|
|
@@ -5614,7 +5622,7 @@ var ZodIssueCode = {
|
|
|
5614
5622
|
var ZodFirstPartyTypeKind;
|
|
5615
5623
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5616
5624
|
//#endregion
|
|
5617
|
-
//#region ../types/dist/sleep
|
|
5625
|
+
//#region ../types/dist/sleep--jXS9hWo.mjs
|
|
5618
5626
|
/**
|
|
5619
5627
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5620
5628
|
* window to float samples (D455).
|
|
@@ -8852,6 +8860,28 @@ function event(data) {
|
|
|
8852
8860
|
var StaticDirOutputSchema$1 = object({ staticDir: string() });
|
|
8853
8861
|
var VersionOutputSchema$1 = object({ version: string() });
|
|
8854
8862
|
method(_void(), StaticDirOutputSchema$1, { auth: "admin" }), method(_void(), VersionOutputSchema$1, { auth: "admin" });
|
|
8863
|
+
/**
|
|
8864
|
+
* Identity — preserves literal types for downstream inference.
|
|
8865
|
+
*
|
|
8866
|
+
* The constraint is `Record<string, unknown>` (not `CustomActionsSpec`) so
|
|
8867
|
+
* TypeScript does not widen each entry's literal `kind`/`auth` fields to
|
|
8868
|
+
* the broader unions declared on `CustomActionSpec`'s default generics.
|
|
8869
|
+
* Shape validity is enforced separately by the `customAction(...)` helper
|
|
8870
|
+
* whose return type is already a `CustomActionSpec<...>`.
|
|
8871
|
+
*/
|
|
8872
|
+
function defineCustomActions(spec) {
|
|
8873
|
+
return spec;
|
|
8874
|
+
}
|
|
8875
|
+
function customAction(input, output, options) {
|
|
8876
|
+
return {
|
|
8877
|
+
input,
|
|
8878
|
+
output,
|
|
8879
|
+
kind: options?.kind ?? "query",
|
|
8880
|
+
auth: options?.auth ?? "protected",
|
|
8881
|
+
scope: options?.scope ?? { kind: "system" },
|
|
8882
|
+
...options?.caller ? { caller: "required" } : {}
|
|
8883
|
+
};
|
|
8884
|
+
}
|
|
8855
8885
|
var StaticDirOutputSchema = object({ staticDir: string() });
|
|
8856
8886
|
var VersionOutputSchema = object({ version: string() });
|
|
8857
8887
|
method(_void(), StaticDirOutputSchema, { auth: "admin" }), method(_void(), VersionOutputSchema, { auth: "admin" });
|
|
@@ -8995,6 +9025,7 @@ var CAP_NAMES = Object.freeze([
|
|
|
8995
9025
|
"filesystem-browse",
|
|
8996
9026
|
"flood",
|
|
8997
9027
|
"gas",
|
|
9028
|
+
"group-active",
|
|
8998
9029
|
"group-commands",
|
|
8999
9030
|
"group-status",
|
|
9000
9031
|
"heating-valve",
|
|
@@ -9017,6 +9048,7 @@ var CAP_NAMES = Object.freeze([
|
|
|
9017
9048
|
"login-method",
|
|
9018
9049
|
"media-player",
|
|
9019
9050
|
"mesh-network",
|
|
9051
|
+
"meter",
|
|
9020
9052
|
"metrics-provider",
|
|
9021
9053
|
"model-convert",
|
|
9022
9054
|
"model-distributor",
|
|
@@ -9815,6 +9847,10 @@ Object.freeze({
|
|
|
9815
9847
|
"path": "detected",
|
|
9816
9848
|
"valueType": "boolean"
|
|
9817
9849
|
}],
|
|
9850
|
+
"group-active": [{
|
|
9851
|
+
"path": "active",
|
|
9852
|
+
"valueType": "boolean"
|
|
9853
|
+
}],
|
|
9818
9854
|
"group-status": [
|
|
9819
9855
|
{
|
|
9820
9856
|
"path": "included",
|
|
@@ -10074,6 +10110,10 @@ Object.freeze({
|
|
|
10074
10110
|
]
|
|
10075
10111
|
}
|
|
10076
10112
|
],
|
|
10113
|
+
"meter": [{
|
|
10114
|
+
"path": "value",
|
|
10115
|
+
"valueType": "number"
|
|
10116
|
+
}],
|
|
10077
10117
|
"motion": [
|
|
10078
10118
|
{
|
|
10079
10119
|
"path": "detected",
|
|
@@ -10817,7 +10857,7 @@ object({
|
|
|
10817
10857
|
params: record(string(), union([string(), number()])).optional()
|
|
10818
10858
|
});
|
|
10819
10859
|
//#endregion
|
|
10820
|
-
//#region ../types/dist/err-msg-
|
|
10860
|
+
//#region ../types/dist/err-msg-DIorGYeC.mjs
|
|
10821
10861
|
var DeviceType = /* @__PURE__ */ function(DeviceType) {
|
|
10822
10862
|
DeviceType["Camera"] = "camera";
|
|
10823
10863
|
DeviceType["Hub"] = "hub";
|
|
@@ -10911,8 +10951,14 @@ var DeviceType = /* @__PURE__ */ function(DeviceType) {
|
|
|
10911
10951
|
DeviceType["PetFeeder"] = "pet-feeder";
|
|
10912
10952
|
/** Built-in dishwasher — program lifecycle, door / remote-start gates,
|
|
10913
10953
|
* salt and rinse-aid levels. Installed with the `dishwasher` cap.
|
|
10914
|
-
* Sources: native Bosch Home Connect, reusable by similar integrations.
|
|
10954
|
+
* Sources: native Bosch Home Connect, reusable by similar integrations.
|
|
10955
|
+
* A plug-built dishwasher carries running-state and power instead. */
|
|
10915
10956
|
DeviceType["Dishwasher"] = "dishwasher";
|
|
10957
|
+
/** Washing machine. A plug-built one is running while its power stays
|
|
10958
|
+
* above a threshold. No program cap — that surface is the dishwasher's. */
|
|
10959
|
+
DeviceType["Washer"] = "washer";
|
|
10960
|
+
/** Tumble dryer. Same plug-built running surface as {@link Washer}. */
|
|
10961
|
+
DeviceType["Dryer"] = "dryer";
|
|
10916
10962
|
return DeviceType;
|
|
10917
10963
|
}({});
|
|
10918
10964
|
var DeviceFeature = /* @__PURE__ */ function(DeviceFeature) {
|
|
@@ -11170,7 +11216,7 @@ function errMsg(err) {
|
|
|
11170
11216
|
return String(err);
|
|
11171
11217
|
}
|
|
11172
11218
|
//#endregion
|
|
11173
|
-
//#region ../types/dist/composition-
|
|
11219
|
+
//#region ../types/dist/composition-Bb8iwHEJ.mjs
|
|
11174
11220
|
/**
|
|
11175
11221
|
* The ffmpeg build every CamStack node downloads, per platform and arch.
|
|
11176
11222
|
*
|
|
@@ -11485,6 +11531,13 @@ var table = {
|
|
|
11485
11531
|
apply: () => {
|
|
11486
11532
|
throw new ExpressionEvalError("latchedAt: stateful evaluation context is required");
|
|
11487
11533
|
}
|
|
11534
|
+
},
|
|
11535
|
+
schmitt: {
|
|
11536
|
+
minArgs: 3,
|
|
11537
|
+
maxArgs: 5,
|
|
11538
|
+
apply: () => {
|
|
11539
|
+
throw new ExpressionEvalError("schmitt: stateful evaluation context is required");
|
|
11540
|
+
}
|
|
11488
11541
|
}
|
|
11489
11542
|
};
|
|
11490
11543
|
Object.freeze(Object.assign(Object.create(null), table));
|
|
@@ -11926,6 +11979,99 @@ function validateExpressionSource(src) {
|
|
|
11926
11979
|
return null;
|
|
11927
11980
|
}
|
|
11928
11981
|
/**
|
|
11982
|
+
* One power reading becomes a washer, dryer or dishwasher (D944 helper).
|
|
11983
|
+
*
|
|
11984
|
+
* Standby of the control board is under 5 W; a motor or a heater is far
|
|
11985
|
+
* above that, so 5 W is the line. A door lock or a short pump spike is not
|
|
11986
|
+
* a cycle, so running starts only after 30 s above the line.
|
|
11987
|
+
*
|
|
11988
|
+
* A wash, dry or dishwasher pause (soak, heat-wait, heater off between
|
|
11989
|
+
* bursts) lasts several minutes. Ending the cycle on the first dip would
|
|
11990
|
+
* report "finished" in the middle of the program, so the stop dwell is
|
|
11991
|
+
* 10 minutes on every type. The number is stored per type so one machine
|
|
11992
|
+
* can move later without moving the others.
|
|
11993
|
+
*/
|
|
11994
|
+
var APPLIANCE_TEMPLATE_ID = "appliance";
|
|
11995
|
+
var APPLIANCE_KINDS = [
|
|
11996
|
+
"washer",
|
|
11997
|
+
"dryer",
|
|
11998
|
+
"dishwasher"
|
|
11999
|
+
];
|
|
12000
|
+
DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher;
|
|
12001
|
+
/**
|
|
12002
|
+
* Statistics and integrals over a history read (D944).
|
|
12003
|
+
*
|
|
12004
|
+
* Pure: the same points always give the same answer, so a restart recomputes
|
|
12005
|
+
* rather than trusting a buffer. A gauge mean is time-weighted (value × ms),
|
|
12006
|
+
* never the mean of the samples. A hole with no held value is not integrated
|
|
12007
|
+
* as zero. A truncated read is not published.
|
|
12008
|
+
*/
|
|
12009
|
+
var STATISTIC_STATS = [
|
|
12010
|
+
"min",
|
|
12011
|
+
"max",
|
|
12012
|
+
"mean",
|
|
12013
|
+
"sum",
|
|
12014
|
+
"count",
|
|
12015
|
+
"change"
|
|
12016
|
+
];
|
|
12017
|
+
/**
|
|
12018
|
+
* The range a statistics or integral helper asks history for (D944).
|
|
12019
|
+
*
|
|
12020
|
+
* Calendar edges are the hub's timezone, never UTC midnight: a day is 23, 24
|
|
12021
|
+
* or 25 hours across a DST change. A tier is eligible only when its retention
|
|
12022
|
+
* is strictly longer than the longest that range can be. Forever qualifies.
|
|
12023
|
+
* Equal to a tier's retention is a refusal, not a short answer.
|
|
12024
|
+
*/
|
|
12025
|
+
var MINUTE_MS = 6e4;
|
|
12026
|
+
var HOUR_MS = 60 * MINUTE_MS;
|
|
12027
|
+
var DAY_MS = 24 * HOUR_MS;
|
|
12028
|
+
var ROLLING_SPANS = [
|
|
12029
|
+
"1h",
|
|
12030
|
+
"24h",
|
|
12031
|
+
"2d",
|
|
12032
|
+
"7d",
|
|
12033
|
+
"30d",
|
|
12034
|
+
"custom"
|
|
12035
|
+
];
|
|
12036
|
+
var CALENDAR_PERIODS = [
|
|
12037
|
+
"today",
|
|
12038
|
+
"yesterday",
|
|
12039
|
+
"this-week",
|
|
12040
|
+
"this-month"
|
|
12041
|
+
];
|
|
12042
|
+
var RESET_PERIODS = [
|
|
12043
|
+
"never",
|
|
12044
|
+
"daily",
|
|
12045
|
+
"weekly",
|
|
12046
|
+
"monthly"
|
|
12047
|
+
];
|
|
12048
|
+
5 * MINUTE_MS;
|
|
12049
|
+
24 * HOUR_MS, 2 * DAY_MS, 7 * DAY_MS, 30 * DAY_MS;
|
|
12050
|
+
7 * DAY_MS, 30 * DAY_MS, 365 * DAY_MS;
|
|
12051
|
+
var rollingWindowObject = object({
|
|
12052
|
+
kind: literal("rolling"),
|
|
12053
|
+
span: _enum(ROLLING_SPANS),
|
|
12054
|
+
customMinutes: number().int().min(5).optional()
|
|
12055
|
+
}).check((payload) => {
|
|
12056
|
+
if (payload.value.span === "custom" && payload.value.customMinutes === void 0) payload.issues.push({
|
|
12057
|
+
code: "custom",
|
|
12058
|
+
message: "a custom range needs a duration in minutes",
|
|
12059
|
+
path: ["customMinutes"],
|
|
12060
|
+
input: payload.value
|
|
12061
|
+
});
|
|
12062
|
+
if (payload.value.span !== "custom" && payload.value.customMinutes !== void 0) payload.issues.push({
|
|
12063
|
+
code: "custom",
|
|
12064
|
+
message: "only a custom range carries a duration",
|
|
12065
|
+
path: ["customMinutes"],
|
|
12066
|
+
input: payload.value
|
|
12067
|
+
});
|
|
12068
|
+
});
|
|
12069
|
+
var RollingWindowSchema = rollingWindowObject;
|
|
12070
|
+
var StatisticWindowSchema = discriminatedUnion("kind", [rollingWindowObject, object({
|
|
12071
|
+
kind: literal("calendar"),
|
|
12072
|
+
period: _enum(CALENDAR_PERIODS)
|
|
12073
|
+
})]);
|
|
12074
|
+
/**
|
|
11929
12075
|
* A composition source identity: `(addonId, stableId)`, never a numeric device
|
|
11930
12076
|
* id. Split out so helper schemas can name it without a cycle through
|
|
11931
12077
|
* `composition.ts` (D659).
|
|
@@ -11951,7 +12097,13 @@ var GROUP_TEMPLATE_IDS = [
|
|
|
11951
12097
|
"occupancy",
|
|
11952
12098
|
"binary",
|
|
11953
12099
|
"switch",
|
|
11954
|
-
"light"
|
|
12100
|
+
"light",
|
|
12101
|
+
"fan",
|
|
12102
|
+
"cover",
|
|
12103
|
+
"valve",
|
|
12104
|
+
"lock",
|
|
12105
|
+
"media-player",
|
|
12106
|
+
"climate"
|
|
11955
12107
|
];
|
|
11956
12108
|
var NUMERIC_AGGREGATES = [
|
|
11957
12109
|
"mean",
|
|
@@ -11981,14 +12133,73 @@ var HelperMemberSchema = object({
|
|
|
11981
12133
|
cap: string().min(1),
|
|
11982
12134
|
fieldPath: string().min(1)
|
|
11983
12135
|
});
|
|
11984
|
-
var HelperSpecSchema = discriminatedUnion("kind", [
|
|
11985
|
-
|
|
11986
|
-
|
|
11987
|
-
|
|
11988
|
-
|
|
11989
|
-
|
|
11990
|
-
|
|
11991
|
-
|
|
12136
|
+
var HelperSpecSchema = discriminatedUnion("kind", [
|
|
12137
|
+
object({
|
|
12138
|
+
kind: literal("group"),
|
|
12139
|
+
templateId: GroupTemplateIdSchema,
|
|
12140
|
+
aggregate: AggregateReducerSchema,
|
|
12141
|
+
members: array(HelperMemberSchema).min(1).max(64),
|
|
12142
|
+
/** Actuator groups only. Readings leave this unset. */
|
|
12143
|
+
active: ActiveCriterionSchema.optional()
|
|
12144
|
+
}),
|
|
12145
|
+
object({
|
|
12146
|
+
kind: literal("appliance"),
|
|
12147
|
+
templateId: literal(APPLIANCE_TEMPLATE_ID),
|
|
12148
|
+
appliance: _enum(APPLIANCE_KINDS),
|
|
12149
|
+
thresholdWatts: number().positive(),
|
|
12150
|
+
startDwellMs: number().int().nonnegative(),
|
|
12151
|
+
stopDwellMs: number().int().nonnegative(),
|
|
12152
|
+
member: HelperMemberSchema
|
|
12153
|
+
}),
|
|
12154
|
+
object({
|
|
12155
|
+
kind: literal("statistic"),
|
|
12156
|
+
stat: _enum(STATISTIC_STATS),
|
|
12157
|
+
window: StatisticWindowSchema,
|
|
12158
|
+
source: HelperMemberSchema
|
|
12159
|
+
}),
|
|
12160
|
+
object({
|
|
12161
|
+
kind: literal("integral"),
|
|
12162
|
+
reset: _enum(RESET_PERIODS),
|
|
12163
|
+
output: _enum(["energy", "meter"]),
|
|
12164
|
+
source: HelperMemberSchema
|
|
12165
|
+
}),
|
|
12166
|
+
object({
|
|
12167
|
+
kind: literal("threshold"),
|
|
12168
|
+
source: HelperMemberSchema,
|
|
12169
|
+
high: number(),
|
|
12170
|
+
low: number(),
|
|
12171
|
+
onForMs: number().int().nonnegative(),
|
|
12172
|
+
offForMs: number().int().nonnegative()
|
|
12173
|
+
}),
|
|
12174
|
+
object({
|
|
12175
|
+
kind: literal("arithmetic"),
|
|
12176
|
+
op: _enum([
|
|
12177
|
+
"sum",
|
|
12178
|
+
"difference",
|
|
12179
|
+
"ratio"
|
|
12180
|
+
]),
|
|
12181
|
+
inputs: array(HelperMemberSchema).min(2).max(64)
|
|
12182
|
+
}),
|
|
12183
|
+
object({
|
|
12184
|
+
kind: literal("expression"),
|
|
12185
|
+
expr: string().min(1),
|
|
12186
|
+
inputs: array(object({
|
|
12187
|
+
name: string().min(1),
|
|
12188
|
+
member: HelperMemberSchema
|
|
12189
|
+
})).min(1).max(64),
|
|
12190
|
+
output: _enum(["number", "boolean"])
|
|
12191
|
+
}),
|
|
12192
|
+
object({
|
|
12193
|
+
kind: literal("derivative"),
|
|
12194
|
+
window: RollingWindowSchema,
|
|
12195
|
+
per: _enum([
|
|
12196
|
+
"second",
|
|
12197
|
+
"minute",
|
|
12198
|
+
"hour"
|
|
12199
|
+
]),
|
|
12200
|
+
source: HelperMemberSchema
|
|
12201
|
+
})
|
|
12202
|
+
]);
|
|
11992
12203
|
/** A composed device's stableId is this prefix plus the block id. */
|
|
11993
12204
|
var COMPOSED_DEVICE_STABLE_ID_PREFIX = "composed-";
|
|
11994
12205
|
/** Longest snippet a `code` source may carry (slice 4). */
|
|
@@ -12049,6 +12260,12 @@ var CompositionFieldSourceSchema = discriminatedUnion("kind", [
|
|
|
12049
12260
|
"all"
|
|
12050
12261
|
]),
|
|
12051
12262
|
reads: array(CompositionFieldReadSchema).min(1).max(64),
|
|
12263
|
+
/**
|
|
12264
|
+
* Set when the reads are an enum state, not a yes/no: the listed states
|
|
12265
|
+
* count as active (a cover is `open`). A state outside the list is inactive;
|
|
12266
|
+
* a read that is not a state at all is left out. Only `any` / `all` use it.
|
|
12267
|
+
*/
|
|
12268
|
+
activeWhen: array(string().min(1)).min(1).optional(),
|
|
12052
12269
|
/** `value` is the reading; the others are the member counts. */
|
|
12053
12270
|
publish: _enum([
|
|
12054
12271
|
"value",
|
|
@@ -12062,6 +12279,26 @@ var CompositionFieldSourceSchema = discriminatedUnion("kind", [
|
|
|
12062
12279
|
"omit",
|
|
12063
12280
|
"offline"
|
|
12064
12281
|
])
|
|
12282
|
+
}),
|
|
12283
|
+
object({
|
|
12284
|
+
kind: literal("series"),
|
|
12285
|
+
read: CompositionFieldReadSchema,
|
|
12286
|
+
/** `value` is the statistic or the integral. The others are the gap counts. */
|
|
12287
|
+
publish: _enum([
|
|
12288
|
+
"value",
|
|
12289
|
+
"included",
|
|
12290
|
+
"excluded",
|
|
12291
|
+
"total"
|
|
12292
|
+
]),
|
|
12293
|
+
/**
|
|
12294
|
+
* What an unanswered `value` does. Counts ignore this. `offline` leaves the
|
|
12295
|
+
* device unseeded until history answers, so a restart cannot publish 0.
|
|
12296
|
+
*/
|
|
12297
|
+
empty: _enum([
|
|
12298
|
+
"null",
|
|
12299
|
+
"omit",
|
|
12300
|
+
"offline"
|
|
12301
|
+
])
|
|
12065
12302
|
})
|
|
12066
12303
|
]);
|
|
12067
12304
|
var CompositionCommandTargetSchema = discriminatedUnion("kind", [object({
|
|
@@ -15822,7 +16059,24 @@ var AddonPageDeclarationSchema = object({
|
|
|
15822
16059
|
/** Display label for a CUSTOM `section` id (ignored for well-known ids). */
|
|
15823
16060
|
sectionLabel: string().optional()
|
|
15824
16061
|
});
|
|
15825
|
-
|
|
16062
|
+
var addonPagesSourceCapability = {
|
|
16063
|
+
name: "addon-pages-source",
|
|
16064
|
+
scope: "system",
|
|
16065
|
+
mode: "collection",
|
|
16066
|
+
internal: true,
|
|
16067
|
+
methods: {
|
|
16068
|
+
/**
|
|
16069
|
+
* `internal: true` did not gate the mount (see the 2026-08-26 note on
|
|
16070
|
+
* `data-store-provider`) — this was reachable on the AppRouter by ANY
|
|
16071
|
+
* authenticated session at the default `auth: 'protected'`. The only
|
|
16072
|
+
* consumer, the `addon-pages-aggregator` builtin, reads it via
|
|
16073
|
+
* `ctx.capabilities.getCollection('addon-pages-source')` — LOCAL-PROCESS
|
|
16074
|
+
* only, never tRPC (per `ctx.capabilities` seeing only the local
|
|
16075
|
+
* process). The public, enriched listing admin-ui actually consumes is
|
|
16076
|
+
* the separate `addon-pages` cap, unaffected by this change.
|
|
16077
|
+
*/
|
|
16078
|
+
listPages: method(_void(), array(AddonPageDeclarationSchema).readonly(), { auth: "admin" }) }
|
|
16079
|
+
};
|
|
15826
16080
|
var AddonHttpRouteSchema = object({
|
|
15827
16081
|
method: _enum([
|
|
15828
16082
|
"GET",
|
|
@@ -18511,7 +18765,8 @@ var CompositionProblemSchema = object({
|
|
|
18511
18765
|
"native-capability-replace",
|
|
18512
18766
|
"mixed-quantity",
|
|
18513
18767
|
"helper-mismatch",
|
|
18514
|
-
"member-constraint"
|
|
18768
|
+
"member-constraint",
|
|
18769
|
+
"retention"
|
|
18515
18770
|
]),
|
|
18516
18771
|
path: string(),
|
|
18517
18772
|
message: string()
|
|
@@ -18745,13 +19000,17 @@ Object.freeze({
|
|
|
18745
19000
|
latchedAt: {
|
|
18746
19001
|
kind: "fixed",
|
|
18747
19002
|
result: ["number", "null"]
|
|
19003
|
+
},
|
|
19004
|
+
schmitt: {
|
|
19005
|
+
kind: "fixed",
|
|
19006
|
+
result: ["boolean", "null"]
|
|
18748
19007
|
}
|
|
18749
19008
|
});
|
|
18750
19009
|
/**
|
|
18751
19010
|
* One catalog row per base reading a group helper can build (D944 §3).
|
|
18752
19011
|
* Data, not a form default written into the block.
|
|
18753
19012
|
*/
|
|
18754
|
-
var NOW = {
|
|
19013
|
+
var NOW$2 = {
|
|
18755
19014
|
fieldPath: "lastFetchedAt",
|
|
18756
19015
|
expr: "now"
|
|
18757
19016
|
};
|
|
@@ -18765,6 +19024,8 @@ function numeric(input) {
|
|
|
18765
19024
|
family: "numeric",
|
|
18766
19025
|
deviceType: DeviceType.Sensor,
|
|
18767
19026
|
memberDeviceType: DeviceType.Sensor,
|
|
19027
|
+
memberDeviceTypes: [DeviceType.Sensor],
|
|
19028
|
+
activeWhen: void 0,
|
|
18768
19029
|
commandCaps: [],
|
|
18769
19030
|
baseUnit: baseUnitOf(input.quantity),
|
|
18770
19031
|
aggregates: NUMERIC_AGGREGATES,
|
|
@@ -18780,6 +19041,8 @@ function binary(input) {
|
|
|
18780
19041
|
family: "binary",
|
|
18781
19042
|
deviceType: DeviceType.Sensor,
|
|
18782
19043
|
memberDeviceType: DeviceType.Sensor,
|
|
19044
|
+
memberDeviceTypes: [DeviceType.Sensor],
|
|
19045
|
+
activeWhen: void 0,
|
|
18783
19046
|
commandCaps: [],
|
|
18784
19047
|
quantity: "none",
|
|
18785
19048
|
baseUnit: void 0,
|
|
@@ -18797,7 +19060,7 @@ var GROUP_TEMPLATES = [
|
|
|
18797
19060
|
quantity: "temperature",
|
|
18798
19061
|
defaultAggregate: "mean",
|
|
18799
19062
|
empty: "null",
|
|
18800
|
-
companions: [NOW]
|
|
19063
|
+
companions: [NOW$2]
|
|
18801
19064
|
}),
|
|
18802
19065
|
numeric({
|
|
18803
19066
|
id: "humidity",
|
|
@@ -18808,7 +19071,7 @@ var GROUP_TEMPLATES = [
|
|
|
18808
19071
|
quantity: "humidity",
|
|
18809
19072
|
defaultAggregate: "mean",
|
|
18810
19073
|
empty: "null",
|
|
18811
|
-
companions: [NOW]
|
|
19074
|
+
companions: [NOW$2]
|
|
18812
19075
|
}),
|
|
18813
19076
|
numeric({
|
|
18814
19077
|
id: "pressure",
|
|
@@ -18819,7 +19082,7 @@ var GROUP_TEMPLATES = [
|
|
|
18819
19082
|
quantity: "pressure",
|
|
18820
19083
|
defaultAggregate: "mean",
|
|
18821
19084
|
empty: "null",
|
|
18822
|
-
companions: [NOW]
|
|
19085
|
+
companions: [NOW$2]
|
|
18823
19086
|
}),
|
|
18824
19087
|
numeric({
|
|
18825
19088
|
id: "illuminance",
|
|
@@ -18830,7 +19093,7 @@ var GROUP_TEMPLATES = [
|
|
|
18830
19093
|
quantity: "illuminance",
|
|
18831
19094
|
defaultAggregate: "mean",
|
|
18832
19095
|
empty: "null",
|
|
18833
|
-
companions: [NOW]
|
|
19096
|
+
companions: [NOW$2]
|
|
18834
19097
|
}),
|
|
18835
19098
|
numeric({
|
|
18836
19099
|
id: "power",
|
|
@@ -18841,7 +19104,7 @@ var GROUP_TEMPLATES = [
|
|
|
18841
19104
|
quantity: "power",
|
|
18842
19105
|
defaultAggregate: "sum",
|
|
18843
19106
|
empty: "omit",
|
|
18844
|
-
companions: [NOW]
|
|
19107
|
+
companions: [NOW$2]
|
|
18845
19108
|
}),
|
|
18846
19109
|
numeric({
|
|
18847
19110
|
id: "energy",
|
|
@@ -18852,7 +19115,7 @@ var GROUP_TEMPLATES = [
|
|
|
18852
19115
|
quantity: "energy",
|
|
18853
19116
|
defaultAggregate: "sum",
|
|
18854
19117
|
empty: "omit",
|
|
18855
|
-
companions: [NOW]
|
|
19118
|
+
companions: [NOW$2]
|
|
18856
19119
|
}),
|
|
18857
19120
|
numeric({
|
|
18858
19121
|
id: "battery",
|
|
@@ -18887,7 +19150,7 @@ var GROUP_TEMPLATES = [
|
|
|
18887
19150
|
quantity: "device",
|
|
18888
19151
|
defaultAggregate: "mean",
|
|
18889
19152
|
empty: "null",
|
|
18890
|
-
companions: [NOW]
|
|
19153
|
+
companions: [NOW$2]
|
|
18891
19154
|
}),
|
|
18892
19155
|
binary({
|
|
18893
19156
|
id: "motion",
|
|
@@ -18981,6 +19244,11 @@ var GROUP_TEMPLATES = [
|
|
|
18981
19244
|
summary: "On when at least one switch is on, or when all of them are. Offline and unknown members are left out of that decision, never read as off, and still receive the command.",
|
|
18982
19245
|
deviceType: DeviceType.Switch,
|
|
18983
19246
|
outputCap: "switch",
|
|
19247
|
+
outputField: "on",
|
|
19248
|
+
member: {
|
|
19249
|
+
cap: "switch",
|
|
19250
|
+
fieldPath: "on"
|
|
19251
|
+
},
|
|
18984
19252
|
commandCaps: ["switch"]
|
|
18985
19253
|
}),
|
|
18986
19254
|
actuator({
|
|
@@ -18988,34 +19256,136 @@ var GROUP_TEMPLATES = [
|
|
|
18988
19256
|
summary: "On when at least one light is on, or when all of them are. Brightness and colour are offered only when every member has them. A command is not sent to a member that lacks it.",
|
|
18989
19257
|
deviceType: DeviceType.Light,
|
|
18990
19258
|
outputCap: "switch",
|
|
19259
|
+
outputField: "on",
|
|
19260
|
+
member: {
|
|
19261
|
+
cap: "switch",
|
|
19262
|
+
fieldPath: "on"
|
|
19263
|
+
},
|
|
18991
19264
|
commandCaps: [
|
|
18992
19265
|
"switch",
|
|
18993
19266
|
"brightness",
|
|
18994
19267
|
"color"
|
|
18995
19268
|
]
|
|
19269
|
+
}),
|
|
19270
|
+
actuator({
|
|
19271
|
+
id: "fan",
|
|
19272
|
+
summary: "On when at least one fan is on, or when all of them are. Speed, preset, direction and oscillation are offered only when every fan has them. A command is not sent to a fan that lacks it.",
|
|
19273
|
+
deviceType: DeviceType.Fan,
|
|
19274
|
+
outputCap: "switch",
|
|
19275
|
+
outputField: "on",
|
|
19276
|
+
member: {
|
|
19277
|
+
cap: "switch",
|
|
19278
|
+
fieldPath: "on"
|
|
19279
|
+
},
|
|
19280
|
+
commandCaps: ["switch", "fan-control"]
|
|
19281
|
+
}),
|
|
19282
|
+
actuator({
|
|
19283
|
+
id: "cover",
|
|
19284
|
+
summary: "Open when at least one cover is open or part-open, or only when all of them are. Position and tilt are offered only when every cover has them. A cover that reports nothing is left out, never read as closed.",
|
|
19285
|
+
deviceType: DeviceType.Cover,
|
|
19286
|
+
outputCap: "group-active",
|
|
19287
|
+
outputField: "active",
|
|
19288
|
+
member: {
|
|
19289
|
+
cap: "cover",
|
|
19290
|
+
fieldPath: "state"
|
|
19291
|
+
},
|
|
19292
|
+
activeWhen: [
|
|
19293
|
+
"open",
|
|
19294
|
+
"opening",
|
|
19295
|
+
"stopped"
|
|
19296
|
+
],
|
|
19297
|
+
commandCaps: ["cover"]
|
|
19298
|
+
}),
|
|
19299
|
+
actuator({
|
|
19300
|
+
id: "valve",
|
|
19301
|
+
summary: "Open when at least one valve is open or part-open, or only when all of them are. Position is offered only when every valve has it. A valve that reports nothing is left out, never read as closed.",
|
|
19302
|
+
deviceType: DeviceType.Valve,
|
|
19303
|
+
outputCap: "group-active",
|
|
19304
|
+
outputField: "active",
|
|
19305
|
+
member: {
|
|
19306
|
+
cap: "valve",
|
|
19307
|
+
fieldPath: "state"
|
|
19308
|
+
},
|
|
19309
|
+
activeWhen: [
|
|
19310
|
+
"open",
|
|
19311
|
+
"opening",
|
|
19312
|
+
"stopped"
|
|
19313
|
+
],
|
|
19314
|
+
commandCaps: ["valve"]
|
|
19315
|
+
}),
|
|
19316
|
+
actuator({
|
|
19317
|
+
id: "lock",
|
|
19318
|
+
summary: "Locked when at least one lock is locked, or only when all of them are. A jammed or moving lock is not locked. Locking is one tap; unlocking the group needs a confirmation every time, and nothing is sent without it.",
|
|
19319
|
+
deviceType: DeviceType.Lock,
|
|
19320
|
+
outputCap: "group-active",
|
|
19321
|
+
outputField: "active",
|
|
19322
|
+
member: {
|
|
19323
|
+
cap: "lock-control",
|
|
19324
|
+
fieldPath: "state"
|
|
19325
|
+
},
|
|
19326
|
+
activeWhen: ["locked"],
|
|
19327
|
+
commandCaps: ["lock-control"]
|
|
19328
|
+
}),
|
|
19329
|
+
actuator({
|
|
19330
|
+
id: "media-player",
|
|
19331
|
+
summary: "Playing when at least one player is playing, or only when all of them are. Volume is offered only when every player has it. A player that reports nothing is left out, never read as stopped.",
|
|
19332
|
+
deviceType: DeviceType.MediaPlayer,
|
|
19333
|
+
outputCap: "group-active",
|
|
19334
|
+
outputField: "active",
|
|
19335
|
+
member: {
|
|
19336
|
+
cap: "media-player",
|
|
19337
|
+
fieldPath: "state"
|
|
19338
|
+
},
|
|
19339
|
+
activeWhen: ["playing", "buffering"],
|
|
19340
|
+
commandCaps: ["media-player"]
|
|
19341
|
+
}),
|
|
19342
|
+
actuator({
|
|
19343
|
+
id: "climate",
|
|
19344
|
+
summary: "Running when at least one thermostat or air conditioner is not off, or only when all of them are. Target temperature and fan are offered only when every device has them. A device that reports nothing is left out, never read as off.",
|
|
19345
|
+
deviceType: DeviceType.Thermostat,
|
|
19346
|
+
memberDeviceTypes: [DeviceType.Thermostat, DeviceType.Climate],
|
|
19347
|
+
outputCap: "group-active",
|
|
19348
|
+
outputField: "active",
|
|
19349
|
+
member: {
|
|
19350
|
+
cap: "climate-control",
|
|
19351
|
+
fieldPath: "mode"
|
|
19352
|
+
},
|
|
19353
|
+
activeWhen: [
|
|
19354
|
+
"heat",
|
|
19355
|
+
"cool",
|
|
19356
|
+
"auto",
|
|
19357
|
+
"heat_cool",
|
|
19358
|
+
"fan_only",
|
|
19359
|
+
"dry"
|
|
19360
|
+
],
|
|
19361
|
+
commandCaps: ["climate-control"]
|
|
18996
19362
|
})
|
|
18997
19363
|
];
|
|
18998
19364
|
function actuator(input) {
|
|
18999
19365
|
return {
|
|
19000
|
-
|
|
19366
|
+
id: input.id,
|
|
19367
|
+
summary: input.summary,
|
|
19368
|
+
deviceType: input.deviceType,
|
|
19369
|
+
outputCap: input.outputCap,
|
|
19370
|
+
outputField: input.outputField,
|
|
19371
|
+
commandCaps: input.commandCaps,
|
|
19001
19372
|
family: "actuator",
|
|
19002
19373
|
role: void 0,
|
|
19003
19374
|
memberDeviceType: input.deviceType,
|
|
19004
|
-
|
|
19375
|
+
memberDeviceTypes: input.memberDeviceTypes ?? [input.deviceType],
|
|
19376
|
+
activeWhen: input.activeWhen,
|
|
19005
19377
|
quantity: "none",
|
|
19006
19378
|
baseUnit: void 0,
|
|
19007
19379
|
aggregates: BINARY_AGGREGATES,
|
|
19008
19380
|
defaultAggregate: "any",
|
|
19009
19381
|
empty: "offline",
|
|
19010
|
-
accept: [
|
|
19011
|
-
cap: "switch",
|
|
19012
|
-
fieldPath: "on"
|
|
19013
|
-
}],
|
|
19382
|
+
accept: [input.member],
|
|
19014
19383
|
companions: [CHANGED]
|
|
19015
19384
|
};
|
|
19016
19385
|
}
|
|
19017
19386
|
new Map(GROUP_TEMPLATES.map((template) => [template.id, template]));
|
|
19018
19387
|
DeviceFeature.LightColorRgb, DeviceFeature.LightColorHsv, DeviceFeature.LightColorMired;
|
|
19388
|
+
DeviceType.Camera;
|
|
19019
19389
|
`${COMPOSED_DEVICE_STABLE_ID_PREFIX}`;
|
|
19020
19390
|
/**
|
|
19021
19391
|
* What a composition may BUILD on a device type (G2, D676): the caps a new
|
|
@@ -44581,57 +44951,79 @@ var ProbeTargetConfigResultSchema = object({
|
|
|
44581
44951
|
stateInstance: string().optional(),
|
|
44582
44952
|
error: string().optional()
|
|
44583
44953
|
});
|
|
44584
|
-
|
|
44585
|
-
|
|
44586
|
-
|
|
44587
|
-
|
|
44588
|
-
|
|
44589
|
-
|
|
44590
|
-
|
|
44591
|
-
|
|
44592
|
-
|
|
44593
|
-
|
|
44594
|
-
|
|
44595
|
-
|
|
44596
|
-
|
|
44597
|
-
|
|
44598
|
-
|
|
44599
|
-
|
|
44600
|
-
|
|
44601
|
-
|
|
44602
|
-
|
|
44603
|
-
|
|
44604
|
-
|
|
44605
|
-
|
|
44606
|
-
|
|
44607
|
-
}),
|
|
44608
|
-
|
|
44609
|
-
|
|
44610
|
-
|
|
44611
|
-
|
|
44612
|
-
|
|
44613
|
-
}),
|
|
44614
|
-
|
|
44615
|
-
|
|
44616
|
-
|
|
44617
|
-
|
|
44618
|
-
|
|
44619
|
-
|
|
44620
|
-
|
|
44621
|
-
|
|
44622
|
-
|
|
44623
|
-
|
|
44624
|
-
|
|
44625
|
-
|
|
44626
|
-
|
|
44627
|
-
|
|
44628
|
-
|
|
44629
|
-
|
|
44630
|
-
|
|
44631
|
-
|
|
44632
|
-
|
|
44633
|
-
|
|
44634
|
-
|
|
44954
|
+
var notificationOutputCapability = {
|
|
44955
|
+
name: "notification-output",
|
|
44956
|
+
scope: "system",
|
|
44957
|
+
mode: "collection",
|
|
44958
|
+
methods: {
|
|
44959
|
+
listTargetKinds: method(object({}), array(TargetKindSchema)),
|
|
44960
|
+
listTargets: method(object({}), array(TargetSchema), { auth: "admin" }),
|
|
44961
|
+
/**
|
|
44962
|
+
* One target WITH secrets — admin edit / duplicate hydration only.
|
|
44963
|
+
* `listTargets` stays redacted; the editor loads this so password fields
|
|
44964
|
+
* are filled (masked in the input) and a save can round-trip the token
|
|
44965
|
+
* when a destination changes (Duplicate with a new chatId, etc.).
|
|
44966
|
+
*/
|
|
44967
|
+
getTarget: method(object({ targetId: string() }), TargetSchema, { auth: "admin" }),
|
|
44968
|
+
discoverTargets: method(object({
|
|
44969
|
+
kind: string(),
|
|
44970
|
+
config: record(string(), unknown()).optional(),
|
|
44971
|
+
/**
|
|
44972
|
+
* When editing / duplicating, the blank password field never round-trips
|
|
44973
|
+
* the stored token. Pass the source target id so the provider can restore
|
|
44974
|
+
* secrets server-side before dialling (same merge as upsert).
|
|
44975
|
+
*/
|
|
44976
|
+
targetId: string().optional()
|
|
44977
|
+
}), array(DiscoveredTargetSchema), { auth: "admin" }),
|
|
44978
|
+
probeTargetConfig: method(object({
|
|
44979
|
+
kind: string(),
|
|
44980
|
+
config: record(string(), unknown()),
|
|
44981
|
+
/** See `discoverTargets.targetId` — restore stored secrets for a blank password. */
|
|
44982
|
+
targetId: string().optional()
|
|
44983
|
+
}), ProbeTargetConfigResultSchema, {
|
|
44984
|
+
kind: "mutation",
|
|
44985
|
+
auth: "admin"
|
|
44986
|
+
}),
|
|
44987
|
+
send: method(object({
|
|
44988
|
+
targetId: string(),
|
|
44989
|
+
notification: NotificationSchema
|
|
44990
|
+
}), SendResultSchema, {
|
|
44991
|
+
kind: "mutation",
|
|
44992
|
+
auth: "admin"
|
|
44993
|
+
}),
|
|
44994
|
+
testTarget: method(object({
|
|
44995
|
+
targetId: string(),
|
|
44996
|
+
sample: NotificationSchema.optional()
|
|
44997
|
+
}), TestResultSchema, {
|
|
44998
|
+
kind: "mutation",
|
|
44999
|
+
auth: "admin"
|
|
45000
|
+
}),
|
|
45001
|
+
upsertTarget: method(object({
|
|
45002
|
+
target: TargetSchema,
|
|
45003
|
+
/**
|
|
45004
|
+
* Duplicate flow: create a NEW target id while copying secrets from an
|
|
45005
|
+
* existing row. The UI never holds the stored token; blank / redacted
|
|
45006
|
+
* password fields are restored from this source (D634 still applies if
|
|
45007
|
+
* destinations diverge without a re-typed secret).
|
|
45008
|
+
*/
|
|
45009
|
+
cloneSecretsFrom: string().optional()
|
|
45010
|
+
}), TargetSchema, {
|
|
45011
|
+
kind: "mutation",
|
|
45012
|
+
auth: "admin"
|
|
45013
|
+
}),
|
|
45014
|
+
deleteTarget: method(object({ targetId: string() }), _void(), {
|
|
45015
|
+
kind: "mutation",
|
|
45016
|
+
auth: "admin"
|
|
45017
|
+
}),
|
|
45018
|
+
setTargetEnabled: method(object({
|
|
45019
|
+
targetId: string(),
|
|
45020
|
+
enabled: boolean()
|
|
45021
|
+
}), _void(), {
|
|
45022
|
+
kind: "mutation",
|
|
45023
|
+
auth: "admin"
|
|
45024
|
+
})
|
|
45025
|
+
}
|
|
45026
|
+
};
|
|
44635
45027
|
new Set([
|
|
44636
45028
|
{
|
|
44637
45029
|
id: "person",
|
|
@@ -46926,7 +47318,20 @@ var NcRuleInputSchema = object({
|
|
|
46926
47318
|
* save by the hub's grouping guard). `.optional()`, never `.default()`: the addon cap
|
|
46927
47319
|
* path does not run Zod defaults.
|
|
46928
47320
|
*/
|
|
46929
|
-
groupingSec: number().int().min(5).max(60).optional()
|
|
47321
|
+
groupingSec: number().int().min(5).max(60).optional(),
|
|
47322
|
+
/**
|
|
47323
|
+
* ASK ME TO CONFIRM DETECTIONS (#243): a DETECTION notification of this rule
|
|
47324
|
+
* carries "Correct" / "Wrong" buttons (and, where the channel has room, a
|
|
47325
|
+
* "Wrong, it is a <class>" choice). A tap marks the track for retraining, the
|
|
47326
|
+
* same mark the Library writes, and records whose notification it was and
|
|
47327
|
+
* when. A channel with no buttons gets the same two choices as links.
|
|
47328
|
+
*
|
|
47329
|
+
* `.optional()`, never `.default()` (a Zod default does not run on the addon
|
|
47330
|
+
* cap path): absent and `false` both mean off, which is what every rule
|
|
47331
|
+
* authored before this field meant. An editor turns it off by writing
|
|
47332
|
+
* `false`, so it needs no `clear` key.
|
|
47333
|
+
*/
|
|
47334
|
+
askVerdict: boolean().optional()
|
|
46930
47335
|
});
|
|
46931
47336
|
/**
|
|
46932
47337
|
* Partial patch for `updateRule` — any subset of the input fields, plus the
|
|
@@ -60523,7 +60928,17 @@ var groupCommandsCapability = {
|
|
|
60523
60928
|
scope: "device",
|
|
60524
60929
|
deviceNative: true,
|
|
60525
60930
|
mode: "singleton",
|
|
60526
|
-
deviceTypes: [
|
|
60931
|
+
deviceTypes: [
|
|
60932
|
+
DeviceType.Switch,
|
|
60933
|
+
DeviceType.Light,
|
|
60934
|
+
DeviceType.Fan,
|
|
60935
|
+
DeviceType.Cover,
|
|
60936
|
+
DeviceType.Valve,
|
|
60937
|
+
DeviceType.Lock,
|
|
60938
|
+
DeviceType.MediaPlayer,
|
|
60939
|
+
DeviceType.Thermostat,
|
|
60940
|
+
DeviceType.Climate
|
|
60941
|
+
],
|
|
60527
60942
|
methods: {},
|
|
60528
60943
|
status: {
|
|
60529
60944
|
schema: GroupCommandsSchema,
|
|
@@ -60541,6 +60956,49 @@ var groupCommandsCapability = {
|
|
|
60541
60956
|
history: "none"
|
|
60542
60957
|
};
|
|
60543
60958
|
/**
|
|
60959
|
+
* Whether a helper actuator group counts as active (D944): at least one
|
|
60960
|
+
* included member is, or every included member is. What "active" means is the
|
|
60961
|
+
* type's: a cover is open, a lock is locked, a media player is playing, a
|
|
60962
|
+
* climate device is not off. A group of lights, switches and fans reports
|
|
60963
|
+
* `switch.on` instead; this cap is for the types whose own state is not a
|
|
60964
|
+
* yes/no. Unknown members are left out (`group-status` counts them) and an
|
|
60965
|
+
* empty included set takes the device offline, never `active: false`.
|
|
60966
|
+
*/
|
|
60967
|
+
var GroupActiveSchema = object({
|
|
60968
|
+
active: boolean().meta({ label: "Active" }),
|
|
60969
|
+
/** Ms epoch when the slice was last updated. */
|
|
60970
|
+
lastChangedAt: number()
|
|
60971
|
+
});
|
|
60972
|
+
var groupActiveCapability = {
|
|
60973
|
+
name: "group-active",
|
|
60974
|
+
scope: "device",
|
|
60975
|
+
deviceNative: true,
|
|
60976
|
+
mode: "singleton",
|
|
60977
|
+
deviceTypes: [
|
|
60978
|
+
DeviceType.Cover,
|
|
60979
|
+
DeviceType.Valve,
|
|
60980
|
+
DeviceType.Lock,
|
|
60981
|
+
DeviceType.MediaPlayer,
|
|
60982
|
+
DeviceType.Climate,
|
|
60983
|
+
DeviceType.Thermostat
|
|
60984
|
+
],
|
|
60985
|
+
methods: {},
|
|
60986
|
+
status: {
|
|
60987
|
+
schema: GroupActiveSchema,
|
|
60988
|
+
kind: "push"
|
|
60989
|
+
},
|
|
60990
|
+
runtimeState: GroupActiveSchema,
|
|
60991
|
+
/**
|
|
60992
|
+
* Runtime-state durability: **session** — recomputed from the members on the
|
|
60993
|
+
* next source event; a restored value would outlive the members it named.
|
|
60994
|
+
*
|
|
60995
|
+
* See `RuntimeStateDurability`. Enforced by
|
|
60996
|
+
* `scripts/check-runtime-state-durability.ts`.
|
|
60997
|
+
*/
|
|
60998
|
+
durability: "session",
|
|
60999
|
+
history: "none"
|
|
61000
|
+
};
|
|
61001
|
+
/**
|
|
60544
61002
|
* How many members of a helper group answered (D944). Counts are integers,
|
|
60545
61003
|
* not measurements: zero included is a real count, never a stand-in reading.
|
|
60546
61004
|
* The reading itself stays on the output capability. History is none — the
|
|
@@ -60571,7 +61029,14 @@ var groupStatusCapability = {
|
|
|
60571
61029
|
deviceTypes: [
|
|
60572
61030
|
DeviceType.Sensor,
|
|
60573
61031
|
DeviceType.Switch,
|
|
60574
|
-
DeviceType.Light
|
|
61032
|
+
DeviceType.Light,
|
|
61033
|
+
DeviceType.Fan,
|
|
61034
|
+
DeviceType.Cover,
|
|
61035
|
+
DeviceType.Valve,
|
|
61036
|
+
DeviceType.Lock,
|
|
61037
|
+
DeviceType.MediaPlayer,
|
|
61038
|
+
DeviceType.Thermostat,
|
|
61039
|
+
DeviceType.Climate
|
|
60575
61040
|
],
|
|
60576
61041
|
methods: {},
|
|
60577
61042
|
status: {
|
|
@@ -61844,7 +62309,14 @@ var lockControlCapability = {
|
|
|
61844
62309
|
}),
|
|
61845
62310
|
unlock: method(object({
|
|
61846
62311
|
deviceId: number().int().nonnegative(),
|
|
61847
|
-
code: string().min(1).optional()
|
|
62312
|
+
code: string().min(1).optional(),
|
|
62313
|
+
/**
|
|
62314
|
+
* Honoured by a helper lock GROUP only (D944): unlocking several doors
|
|
62315
|
+
* with one call is refused unless the caller says it was confirmed. A
|
|
62316
|
+
* single lock ignores it. The refusal lives in the callee, so no
|
|
62317
|
+
* automation or API caller reaches the doors by skipping a dialog.
|
|
62318
|
+
*/
|
|
62319
|
+
confirm: boolean().optional()
|
|
61848
62320
|
}), _void(), {
|
|
61849
62321
|
kind: "mutation",
|
|
61850
62322
|
auth: "protected"
|
|
@@ -61881,6 +62353,44 @@ var lockControlCapability = {
|
|
|
61881
62353
|
volatileStateFields: ["lastChangedAt"]
|
|
61882
62354
|
};
|
|
61883
62355
|
/**
|
|
62356
|
+
* A monotonic total between resets (D944). Energy uses `power-meter.kwhTotal`
|
|
62357
|
+
* so dashboards keep one series; every other instantaneous reading lands here.
|
|
62358
|
+
* `value` is the time integral in the source quantity's accumulated base
|
|
62359
|
+
* (base-unit seconds, or kWh on the power meter). `null` is a gap, never zero.
|
|
62360
|
+
*/
|
|
62361
|
+
var MeterStatusSchema = object({
|
|
62362
|
+
value: number().nullable().meta(readingMeta("device", { label: "Total" })),
|
|
62363
|
+
lastFetchedAt: number()
|
|
62364
|
+
});
|
|
62365
|
+
var meterCapability = {
|
|
62366
|
+
name: "meter",
|
|
62367
|
+
scope: "device",
|
|
62368
|
+
deviceNative: true,
|
|
62369
|
+
mode: "singleton",
|
|
62370
|
+
deviceTypes: [DeviceType.Sensor],
|
|
62371
|
+
methods: {},
|
|
62372
|
+
status: {
|
|
62373
|
+
schema: MeterStatusSchema,
|
|
62374
|
+
kind: "push"
|
|
62375
|
+
},
|
|
62376
|
+
runtimeState: MeterStatusSchema,
|
|
62377
|
+
/**
|
|
62378
|
+
* Runtime-state durability: **restored** — the total is a cache of the
|
|
62379
|
+
* history integral. A restart shows it only until the history pass
|
|
62380
|
+
* recomputes the open period; it is not an in-memory buffer.
|
|
62381
|
+
*
|
|
62382
|
+
* See `RuntimeStateDurability`. Enforced by
|
|
62383
|
+
* `scripts/check-runtime-state-durability.ts`.
|
|
62384
|
+
*/
|
|
62385
|
+
durability: "restored",
|
|
62386
|
+
history: { fields: { value: {
|
|
62387
|
+
kind: "counter",
|
|
62388
|
+
deadband: .001,
|
|
62389
|
+
minIntervalMs: 6e4
|
|
62390
|
+
} } },
|
|
62391
|
+
volatileStateFields: ["lastFetchedAt"]
|
|
62392
|
+
};
|
|
62393
|
+
/**
|
|
61884
62394
|
* Media-player cap. Models HA `media_player.*` (Sonos, Chromecast,
|
|
61885
62395
|
* Apple TV, Spotify, Roku, generic OTT receivers, …) on
|
|
61886
62396
|
* `DeviceType.MediaPlayer`. Disjoint from the camera streaming
|
|
@@ -64407,7 +64917,12 @@ var powerMeterCapability = {
|
|
|
64407
64917
|
scope: "device",
|
|
64408
64918
|
deviceNative: true,
|
|
64409
64919
|
mode: "singleton",
|
|
64410
|
-
deviceTypes: [
|
|
64920
|
+
deviceTypes: [
|
|
64921
|
+
DeviceType.Sensor,
|
|
64922
|
+
DeviceType.Washer,
|
|
64923
|
+
DeviceType.Dryer,
|
|
64924
|
+
DeviceType.Dishwasher
|
|
64925
|
+
],
|
|
64411
64926
|
methods: {},
|
|
64412
64927
|
status: {
|
|
64413
64928
|
schema: PowerMeterStatusSchema,
|
|
@@ -65145,6 +65660,8 @@ var runningCustomize = {
|
|
|
65145
65660
|
stateField: "running"
|
|
65146
65661
|
},
|
|
65147
65662
|
deviceTypes: [
|
|
65663
|
+
DeviceType.Washer,
|
|
65664
|
+
DeviceType.Dryer,
|
|
65148
65665
|
DeviceType.Dishwasher,
|
|
65149
65666
|
DeviceType.Vacuum,
|
|
65150
65667
|
DeviceType.LawnMower,
|
|
@@ -67634,7 +68151,8 @@ var switchCapability = {
|
|
|
67634
68151
|
deviceTypes: [
|
|
67635
68152
|
DeviceType.Switch,
|
|
67636
68153
|
DeviceType.Siren,
|
|
67637
|
-
DeviceType.Light
|
|
68154
|
+
DeviceType.Light,
|
|
68155
|
+
DeviceType.Fan
|
|
67638
68156
|
],
|
|
67639
68157
|
methods: { setState: method(object({
|
|
67640
68158
|
deviceId: number(),
|
|
@@ -69847,8 +70365,9 @@ method(object({}), array(TrainingBaseModelSchema).readonly(), { auth: "admin" })
|
|
|
69847
70365
|
kind: "mutation",
|
|
69848
70366
|
auth: "admin"
|
|
69849
70367
|
});
|
|
70368
|
+
DeviceFeature.FanSpeed, DeviceFeature.FanPreset, DeviceFeature.FanDirection, DeviceFeature.FanOscillating, DeviceFeature.CoverPositionable, DeviceFeature.CoverTilt, DeviceFeature.ValvePositionable, DeviceFeature.MediaPlayerVolume, DeviceFeature.ClimateFanMode;
|
|
69850
70369
|
SOURCE_CAPS.filter((cap) => cap !== "switch" && cap !== "motion");
|
|
69851
|
-
DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.PetFeeder, DeviceType.Container, DeviceType.Thermostat, DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher, DeviceType.Camera, DeviceType.Camera, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer, DeviceType.WaterHeater, DeviceType.Humidifier, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.PetFeeder;
|
|
70370
|
+
DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.PetFeeder, DeviceType.Container, DeviceType.Thermostat, DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer, DeviceType.Camera, DeviceType.Camera, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer, DeviceType.WaterHeater, DeviceType.Humidifier, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.PetFeeder;
|
|
69852
70371
|
String.raw`rose\(active(\d+), changed(\d+), (\d+(?:\.\d+)?)\)`;
|
|
69853
70372
|
DeviceRole.BinarySensor, DeviceRole.MotionSensor;
|
|
69854
70373
|
new Set(CAP_NAMES);
|
|
@@ -70177,6 +70696,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
|
|
|
70177
70696
|
featureProbe: featureProbeCapability,
|
|
70178
70697
|
flood: floodCapability,
|
|
70179
70698
|
gas: gasCapability,
|
|
70699
|
+
groupActive: groupActiveCapability,
|
|
70180
70700
|
groupCommands: groupCommandsCapability,
|
|
70181
70701
|
groupStatus: groupStatusCapability,
|
|
70182
70702
|
heatingValve: heatingValveCapability,
|
|
@@ -70188,6 +70708,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
|
|
|
70188
70708
|
lawnMowerControl: lawnMowerControlCapability,
|
|
70189
70709
|
lockControl: lockControlCapability,
|
|
70190
70710
|
mediaPlayer: mediaPlayerCapability,
|
|
70711
|
+
meter: meterCapability,
|
|
70191
70712
|
motion: motionCapability,
|
|
70192
70713
|
motionTrigger: motionTriggerCapability,
|
|
70193
70714
|
motionZones: motionZonesCapability,
|
|
@@ -71447,7 +71968,7 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
71447
71968
|
return toDeviceSummary(device, this.addonId);
|
|
71448
71969
|
}
|
|
71449
71970
|
};
|
|
71450
|
-
DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher;
|
|
71971
|
+
DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer;
|
|
71451
71972
|
new Set(Object.values(DeviceType));
|
|
71452
71973
|
Object.freeze({
|
|
71453
71974
|
"accessories.getStatus": {
|
|
@@ -74670,6 +75191,13 @@ Object.freeze({
|
|
|
74670
75191
|
access: "view",
|
|
74671
75192
|
role: "viewer"
|
|
74672
75193
|
},
|
|
75194
|
+
"groupActive.getStatus": {
|
|
75195
|
+
capName: "group-active",
|
|
75196
|
+
capScope: "device",
|
|
75197
|
+
addonId: null,
|
|
75198
|
+
access: "view",
|
|
75199
|
+
role: "viewer"
|
|
75200
|
+
},
|
|
74673
75201
|
"groupCommands.getStatus": {
|
|
74674
75202
|
capName: "group-commands",
|
|
74675
75203
|
capScope: "device",
|
|
@@ -75636,6 +76164,13 @@ Object.freeze({
|
|
|
75636
76164
|
access: "create",
|
|
75637
76165
|
role: "admin"
|
|
75638
76166
|
},
|
|
76167
|
+
"meter.getStatus": {
|
|
76168
|
+
capName: "meter",
|
|
76169
|
+
capScope: "device",
|
|
76170
|
+
addonId: null,
|
|
76171
|
+
access: "view",
|
|
76172
|
+
role: "viewer"
|
|
76173
|
+
},
|
|
75639
76174
|
"metricsProvider.collectSnapshot": {
|
|
75640
76175
|
capName: "metrics-provider",
|
|
75641
76176
|
capScope: "system",
|
|
@@ -83734,7 +84269,39 @@ var APPLIANCE_CAP_VOCABULARY = {
|
|
|
83734
84269
|
phrase: t("When {device} finishes", "Quando {device} finisce"),
|
|
83735
84270
|
icon: "square"
|
|
83736
84271
|
}
|
|
83737
|
-
}
|
|
84272
|
+
},
|
|
84273
|
+
overrides: { byType: {
|
|
84274
|
+
[DeviceType.Washer]: {
|
|
84275
|
+
state: { running: {
|
|
84276
|
+
whenTrue: t("When the washing machine starts", "Quando la lavatrice inizia"),
|
|
84277
|
+
whenFalse: t("When the washing machine has finished", "Quando la lavatrice ha finito")
|
|
84278
|
+
} },
|
|
84279
|
+
events: {
|
|
84280
|
+
onStarted: { phrase: t("When the washing machine starts", "Quando la lavatrice inizia") },
|
|
84281
|
+
onFinished: { phrase: t("When the washing machine has finished", "Quando la lavatrice ha finito") }
|
|
84282
|
+
}
|
|
84283
|
+
},
|
|
84284
|
+
[DeviceType.Dryer]: {
|
|
84285
|
+
state: { running: {
|
|
84286
|
+
whenTrue: t("When the dryer starts", "Quando l'asciugatrice inizia"),
|
|
84287
|
+
whenFalse: t("When the dryer has finished", "Quando l'asciugatrice ha finito")
|
|
84288
|
+
} },
|
|
84289
|
+
events: {
|
|
84290
|
+
onStarted: { phrase: t("When the dryer starts", "Quando l'asciugatrice inizia") },
|
|
84291
|
+
onFinished: { phrase: t("When the dryer has finished", "Quando l'asciugatrice ha finito") }
|
|
84292
|
+
}
|
|
84293
|
+
},
|
|
84294
|
+
[DeviceType.Dishwasher]: {
|
|
84295
|
+
state: { running: {
|
|
84296
|
+
whenTrue: t("When the dishwasher starts", "Quando la lavastoviglie inizia"),
|
|
84297
|
+
whenFalse: t("When the dishwasher has finished", "Quando la lavastoviglie ha finito")
|
|
84298
|
+
} },
|
|
84299
|
+
events: {
|
|
84300
|
+
onStarted: { phrase: t("When the dishwasher starts", "Quando la lavastoviglie inizia") },
|
|
84301
|
+
onFinished: { phrase: t("When the dishwasher has finished", "Quando la lavastoviglie ha finito") }
|
|
84302
|
+
}
|
|
84303
|
+
}
|
|
84304
|
+
} }
|
|
83738
84305
|
},
|
|
83739
84306
|
update: {
|
|
83740
84307
|
scope: "device",
|
|
@@ -84506,6 +85073,10 @@ var SENSOR_CAP_VOCABULARY = {
|
|
|
84506
85073
|
icon: "flame"
|
|
84507
85074
|
}]
|
|
84508
85075
|
},
|
|
85076
|
+
"group-active": {
|
|
85077
|
+
scope: "device",
|
|
85078
|
+
noun: t("Group state", "Stato del gruppo")
|
|
85079
|
+
},
|
|
84509
85080
|
"group-commands": {
|
|
84510
85081
|
scope: "device",
|
|
84511
85082
|
noun: t("Group command", "Comando di gruppo")
|
|
@@ -84562,6 +85133,17 @@ var SENSOR_CAP_VOCABULARY = {
|
|
|
84562
85133
|
}],
|
|
84563
85134
|
events: { onMotionChanged: { notOperatorFacing: "the `detected` state trigger phrases both edges, from the mirror that survives a dropped event (D49)" } }
|
|
84564
85135
|
},
|
|
85136
|
+
meter: {
|
|
85137
|
+
scope: "device",
|
|
85138
|
+
noun: t("Meter", "Contatore"),
|
|
85139
|
+
state: [{
|
|
85140
|
+
kind: "numeric",
|
|
85141
|
+
field: "value",
|
|
85142
|
+
quantity: t("the total", "il totale"),
|
|
85143
|
+
label: t("Total", "Totale"),
|
|
85144
|
+
icon: "gauge"
|
|
85145
|
+
}]
|
|
85146
|
+
},
|
|
84565
85147
|
"numeric-sensor": {
|
|
84566
85148
|
scope: "device",
|
|
84567
85149
|
noun: t("Sensor", "Sensore"),
|
|
@@ -85034,8 +85616,47 @@ function projectAction(cap, action) {
|
|
|
85034
85616
|
};
|
|
85035
85617
|
}
|
|
85036
85618
|
Object.entries(CAP_VOCABULARY_BY_NAME).flatMap(([cap, entry]) => entry.scope === "device" ? (entry.actions ?? []).map((a) => projectAction(cap, a)) : []);
|
|
85037
|
-
DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher;
|
|
85619
|
+
DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer;
|
|
85038
85620
|
DeviceFeature.BatteryOperated;
|
|
85621
|
+
DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher;
|
|
85622
|
+
var HA_AS = "a power sensor (device_class power). Home Assistant has no laundry domain. running-state stays unexported until a null reading can be published as unavailable.";
|
|
85623
|
+
var HOMEKIT_REASON = "HomeKit has no laundry-appliance service. The export tab would pair an accessory with nothing on it: running-state is withheld until a null reading can be unavailable, and power is not a HomeKit service this export honours.";
|
|
85624
|
+
var ALEXA_REASON = "Alexa has no laundry category in this export, and neither power nor running has an Alexa interface.";
|
|
85625
|
+
var GOOGLE_REASON = "Google WASHER, DRYER and DISHWASHER require RunCycle. A power threshold is not that trait, and this export does not publish RunCycle.";
|
|
85626
|
+
var MATTER_REASON = "No Matter export is built. The closest device types are Laundry Washer (0x0073) and Laundry Dryer (0x0074); a power threshold is not the Laundry Washer Mode cluster.";
|
|
85627
|
+
function withheld(reason) {
|
|
85628
|
+
return {
|
|
85629
|
+
[DeviceType.Washer]: {
|
|
85630
|
+
exported: false,
|
|
85631
|
+
reason
|
|
85632
|
+
},
|
|
85633
|
+
[DeviceType.Dryer]: {
|
|
85634
|
+
exported: false,
|
|
85635
|
+
reason
|
|
85636
|
+
},
|
|
85637
|
+
[DeviceType.Dishwasher]: {
|
|
85638
|
+
exported: false,
|
|
85639
|
+
reason
|
|
85640
|
+
}
|
|
85641
|
+
};
|
|
85642
|
+
}
|
|
85643
|
+
function offered(received) {
|
|
85644
|
+
return {
|
|
85645
|
+
[DeviceType.Washer]: {
|
|
85646
|
+
exported: true,
|
|
85647
|
+
as: received
|
|
85648
|
+
},
|
|
85649
|
+
[DeviceType.Dryer]: {
|
|
85650
|
+
exported: true,
|
|
85651
|
+
as: received
|
|
85652
|
+
},
|
|
85653
|
+
[DeviceType.Dishwasher]: {
|
|
85654
|
+
exported: true,
|
|
85655
|
+
as: received
|
|
85656
|
+
}
|
|
85657
|
+
};
|
|
85658
|
+
}
|
|
85659
|
+
offered(HA_AS), withheld(HOMEKIT_REASON), withheld(ALEXA_REASON), withheld(GOOGLE_REASON), withheld(MATTER_REASON);
|
|
85039
85660
|
Object.freeze({
|
|
85040
85661
|
"accessories.getStatus": [{
|
|
85041
85662
|
name: "deviceId",
|
|
@@ -86002,6 +86623,11 @@ Object.freeze({
|
|
|
86002
86623
|
form: "single",
|
|
86003
86624
|
optional: false
|
|
86004
86625
|
}],
|
|
86626
|
+
"groupActive.getStatus": [{
|
|
86627
|
+
name: "deviceId",
|
|
86628
|
+
form: "single",
|
|
86629
|
+
optional: false
|
|
86630
|
+
}],
|
|
86005
86631
|
"groupCommands.getStatus": [{
|
|
86006
86632
|
name: "deviceId",
|
|
86007
86633
|
form: "single",
|
|
@@ -86212,6 +86838,11 @@ Object.freeze({
|
|
|
86212
86838
|
form: "single",
|
|
86213
86839
|
optional: false
|
|
86214
86840
|
}],
|
|
86841
|
+
"meter.getStatus": [{
|
|
86842
|
+
name: "deviceId",
|
|
86843
|
+
form: "single",
|
|
86844
|
+
optional: false
|
|
86845
|
+
}],
|
|
86215
86846
|
"motion.getStatus": [{
|
|
86216
86847
|
name: "deviceId",
|
|
86217
86848
|
form: "single",
|
|
@@ -87819,7 +88450,6 @@ Object.freeze({
|
|
|
87819
88450
|
"network-access": "ingress",
|
|
87820
88451
|
"smtp-provider": "email"
|
|
87821
88452
|
});
|
|
87822
|
-
DeviceType.Camera;
|
|
87823
88453
|
/**
|
|
87824
88454
|
* A saved Customize assignment (D936). The schema is the type: callers use
|
|
87825
88455
|
* `z.infer`, they do not restate the shape.
|
|
@@ -87965,6 +88595,213 @@ var NC_AUDIO_DEFAULTS = {
|
|
|
87965
88595
|
samplingSeconds: 10
|
|
87966
88596
|
};
|
|
87967
88597
|
new Set(["devices", "classes"]);
|
|
88598
|
+
/** Strip a small, safe subset of Markdown down to plain text. */
|
|
88599
|
+
function markdownToText(md) {
|
|
88600
|
+
return md.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/(\*\*|__)(.*?)\1/g, "$2").replace(/(\*|_)(.*?)\1/g, "$2").replace(/`([^`]*)`/g, "$1").replace(/^\s{0,3}#{1,6}\s*/gm, "").replace(/^\s{0,3}>\s?/gm, "").replace(/^\s{0,3}[-*+]\s+/gm, "").replace(/^\s{0,3}\d+\.\s+/gm, "").trim();
|
|
88601
|
+
}
|
|
88602
|
+
var HTML_ENTITIES = [
|
|
88603
|
+
[/ /g, " "],
|
|
88604
|
+
[/&/g, "&"],
|
|
88605
|
+
[/</g, "<"],
|
|
88606
|
+
[/>/g, ">"],
|
|
88607
|
+
[/"/g, "\""],
|
|
88608
|
+
[/'/g, "'"]
|
|
88609
|
+
];
|
|
88610
|
+
/** Strip HTML tags, decode a few entities, map block/br elements to newlines. */
|
|
88611
|
+
function htmlToText(html) {
|
|
88612
|
+
let out = html.replace(/<\s*br\s*\/?\s*>/gi, "\n").replace(/<\s*\/\s*(p|div|li|h[1-6])\s*>/gi, "\n").replace(/<[^>]+>/g, "");
|
|
88613
|
+
for (const [re, rep] of HTML_ENTITIES) out = out.replace(re, rep);
|
|
88614
|
+
return out.replace(/\n{3,}/g, "\n\n").trim();
|
|
88615
|
+
}
|
|
88616
|
+
var HTML_ESCAPES = [
|
|
88617
|
+
[/&/g, "&"],
|
|
88618
|
+
[/</g, "<"],
|
|
88619
|
+
[/>/g, ">"],
|
|
88620
|
+
[/"/g, """],
|
|
88621
|
+
[/'/g, "'"]
|
|
88622
|
+
];
|
|
88623
|
+
function escapeHtml(text) {
|
|
88624
|
+
let out = text;
|
|
88625
|
+
for (const [re, rep] of HTML_ESCAPES) out = out.replace(re, rep);
|
|
88626
|
+
return out;
|
|
88627
|
+
}
|
|
88628
|
+
/** Plain text → HTML-lite (escape + newlines to <br>). */
|
|
88629
|
+
function textToHtml(text) {
|
|
88630
|
+
return escapeHtml(text).replace(/\n/g, "<br>");
|
|
88631
|
+
}
|
|
88632
|
+
/** Markdown → HTML-lite (bold/italic/code/links; used e.g. for Pushover). */
|
|
88633
|
+
function markdownToHtmlLite(md) {
|
|
88634
|
+
return escapeHtml(md).replace(/(\*\*|__)(.*?)\1/g, "<b>$2</b>").replace(/(\*|_)(.*?)\1/g, "<i>$2</i>").replace(/`([^`]*)`/g, "<code>$1</code>").replace(/\[([^\]]*)\]\(([^)]*)\)/g, "<a href=\"$2\">$1</a>").replace(/\n/g, "<br>");
|
|
88635
|
+
}
|
|
88636
|
+
/**
|
|
88637
|
+
* Preference order when the source format is NOT supported — first supported
|
|
88638
|
+
* entry wins. Encodes the Apprise-style "degrade DOWN, but preserve rendering
|
|
88639
|
+
* where a richer target exists" policy:
|
|
88640
|
+
* - markdown → html (html-lite) before text (strip)
|
|
88641
|
+
* - html → text before markdown
|
|
88642
|
+
* - text → markdown before html (text is valid markdown)
|
|
88643
|
+
*/
|
|
88644
|
+
var DEGRADE_ORDER = {
|
|
88645
|
+
text: [
|
|
88646
|
+
"text",
|
|
88647
|
+
"markdown",
|
|
88648
|
+
"html"
|
|
88649
|
+
],
|
|
88650
|
+
markdown: [
|
|
88651
|
+
"markdown",
|
|
88652
|
+
"html",
|
|
88653
|
+
"text"
|
|
88654
|
+
],
|
|
88655
|
+
html: [
|
|
88656
|
+
"html",
|
|
88657
|
+
"text",
|
|
88658
|
+
"markdown"
|
|
88659
|
+
]
|
|
88660
|
+
};
|
|
88661
|
+
/**
|
|
88662
|
+
* Pick the best target format the kind supports for a given source format.
|
|
88663
|
+
*
|
|
88664
|
+
* TOLERANT of a source outside the union. `NotificationSchema.format` carries
|
|
88665
|
+
* `.default('text')`, but that default only runs where the schema is PARSED —
|
|
88666
|
+
* the tRPC router. A cap call arriving from another addon (`ctx.api`) skips
|
|
88667
|
+
* that parse, so `format` reaches here `undefined`, and the bare
|
|
88668
|
+
* `DEGRADE_ORDER[source]` threw `is not iterable`. That broke the one promise
|
|
88669
|
+
* the degrade path makes ("never throws — degradation is reported, never
|
|
88670
|
+
* surfaced as an error") and dead-lettered every Notification-Center delivery
|
|
88671
|
+
* for a day (2026-07-30). An unknown source degrades like `text`.
|
|
88672
|
+
*/
|
|
88673
|
+
function resolveFormat(source, supported) {
|
|
88674
|
+
for (const candidate of DEGRADE_ORDER[source] ?? DEGRADE_ORDER.text) if (supported.includes(candidate)) return candidate;
|
|
88675
|
+
return supported[0] ?? "text";
|
|
88676
|
+
}
|
|
88677
|
+
/** Transcode a body between formats. */
|
|
88678
|
+
function transcodeBody(body, from, to) {
|
|
88679
|
+
if (from === to) return body;
|
|
88680
|
+
if (to === "text") {
|
|
88681
|
+
if (from === "markdown") return markdownToText(body);
|
|
88682
|
+
if (from === "html") return htmlToText(body);
|
|
88683
|
+
}
|
|
88684
|
+
if (to === "html") {
|
|
88685
|
+
if (from === "text") return textToHtml(body);
|
|
88686
|
+
if (from === "markdown") return markdownToHtmlLite(body);
|
|
88687
|
+
}
|
|
88688
|
+
if (to === "markdown") {
|
|
88689
|
+
if (from === "text") return body;
|
|
88690
|
+
if (from === "html") return htmlToText(body);
|
|
88691
|
+
}
|
|
88692
|
+
return body;
|
|
88693
|
+
}
|
|
88694
|
+
function toResolvedLevel(level) {
|
|
88695
|
+
return {
|
|
88696
|
+
id: level.id,
|
|
88697
|
+
ordinal: level.ordinal,
|
|
88698
|
+
...level.flags ? { flags: level.flags } : {},
|
|
88699
|
+
...level.requires ? { requires: level.requires } : {}
|
|
88700
|
+
};
|
|
88701
|
+
}
|
|
88702
|
+
function resolveLevel(levels, n) {
|
|
88703
|
+
if (levels.length === 0) return null;
|
|
88704
|
+
if (n.level !== void 0) {
|
|
88705
|
+
const explicit = levels.find((l) => l.id === n.level);
|
|
88706
|
+
if (explicit) return toResolvedLevel(explicit);
|
|
88707
|
+
}
|
|
88708
|
+
const withOrdinal = levels.filter((l) => l.ordinal !== null);
|
|
88709
|
+
if (withOrdinal.length === 0) return toResolvedLevel(levels[0]);
|
|
88710
|
+
const priority = n.priority;
|
|
88711
|
+
return toResolvedLevel([...withOrdinal].sort((a, b) => {
|
|
88712
|
+
const da = Math.abs((a.ordinal ?? 0) - priority);
|
|
88713
|
+
const db = Math.abs((b.ordinal ?? 0) - priority);
|
|
88714
|
+
if (da !== db) return da - db;
|
|
88715
|
+
return (b.ordinal ?? 0) - (a.ordinal ?? 0);
|
|
88716
|
+
})[0]);
|
|
88717
|
+
}
|
|
88718
|
+
function splitBody(body, maxLen) {
|
|
88719
|
+
if (!Number.isFinite(maxLen) || maxLen <= 0 || body.length <= maxLen) return [body];
|
|
88720
|
+
const parts = [];
|
|
88721
|
+
for (let i = 0; i < body.length; i += maxLen) parts.push(body.slice(i, i + maxLen));
|
|
88722
|
+
return parts;
|
|
88723
|
+
}
|
|
88724
|
+
/**
|
|
88725
|
+
* Prepare a canonical notification for one kind. Never throws — degradation is
|
|
88726
|
+
* always reported in `renderedAs`, never surfaced as an error.
|
|
88727
|
+
*/
|
|
88728
|
+
function prepareNotification(caps, n) {
|
|
88729
|
+
const dropped = [];
|
|
88730
|
+
const sourceFormat = n.format;
|
|
88731
|
+
const format = resolveFormat(sourceFormat, caps.format);
|
|
88732
|
+
if (format !== sourceFormat) dropped.push("format");
|
|
88733
|
+
const transcoded = transcodeBody(n.body, sourceFormat, format);
|
|
88734
|
+
const level = resolveLevel(caps.levels, n);
|
|
88735
|
+
const inAttachments = n.attachments ?? [];
|
|
88736
|
+
const attachments = [];
|
|
88737
|
+
for (const att of inAttachments) {
|
|
88738
|
+
if (!caps.attachments.mediaTypes.includes(att.mediaType)) {
|
|
88739
|
+
dropped.push(`attachment:${att.mediaType}`);
|
|
88740
|
+
continue;
|
|
88741
|
+
}
|
|
88742
|
+
if (caps.attachments.mode === "url" && att.url === void 0) {
|
|
88743
|
+
dropped.push("attachment:noUrl");
|
|
88744
|
+
continue;
|
|
88745
|
+
}
|
|
88746
|
+
if (att.bytes !== void 0 && caps.attachments.maxBytes !== void 0 && att.bytes.length > caps.attachments.maxBytes) {
|
|
88747
|
+
dropped.push("attachment:maxBytes");
|
|
88748
|
+
continue;
|
|
88749
|
+
}
|
|
88750
|
+
if (attachments.length >= caps.attachments.max) {
|
|
88751
|
+
dropped.push("attachment:max");
|
|
88752
|
+
continue;
|
|
88753
|
+
}
|
|
88754
|
+
const needsFetch = caps.attachments.mode === "bytes" && att.bytes === void 0 && att.url !== void 0;
|
|
88755
|
+
attachments.push({
|
|
88756
|
+
mediaType: att.mediaType,
|
|
88757
|
+
...att.url !== void 0 ? { url: att.url } : {},
|
|
88758
|
+
...att.bytes !== void 0 ? { bytes: att.bytes } : {},
|
|
88759
|
+
...att.mime !== void 0 ? { mime: att.mime } : {},
|
|
88760
|
+
...att.name !== void 0 ? { name: att.name } : {},
|
|
88761
|
+
needsFetch
|
|
88762
|
+
});
|
|
88763
|
+
}
|
|
88764
|
+
const inActions = n.actions ?? [];
|
|
88765
|
+
const kept = inActions.slice(0, Math.max(0, caps.actions));
|
|
88766
|
+
if (inActions.length > kept.length) dropped.push("actions");
|
|
88767
|
+
const iconsSupported = caps.actionIcons === true;
|
|
88768
|
+
if (!iconsSupported && kept.some((a) => a.icon !== void 0)) dropped.push("actionIcons");
|
|
88769
|
+
const actions = iconsSupported ? kept : kept.map(({ icon: _icon, ...rest }) => rest);
|
|
88770
|
+
let clickUrl = null;
|
|
88771
|
+
if (n.clickUrl !== void 0) if (caps.clickUrl) clickUrl = n.clickUrl;
|
|
88772
|
+
else dropped.push("clickUrl");
|
|
88773
|
+
let sound = null;
|
|
88774
|
+
if (n.sound !== void 0) if (caps.sound) sound = n.sound;
|
|
88775
|
+
else dropped.push("sound");
|
|
88776
|
+
let ttl = null;
|
|
88777
|
+
if (n.ttl !== void 0) if (caps.ttl) ttl = n.ttl;
|
|
88778
|
+
else dropped.push("ttl");
|
|
88779
|
+
const tag = n.tag ?? null;
|
|
88780
|
+
const bodyParts = splitBody(transcoded, caps.bodyMaxLen);
|
|
88781
|
+
const truncated = bodyParts.length > 1;
|
|
88782
|
+
const renderedAs = {
|
|
88783
|
+
level: level?.id ?? "",
|
|
88784
|
+
format,
|
|
88785
|
+
attachmentsSent: attachments.length,
|
|
88786
|
+
actionsSent: actions.length,
|
|
88787
|
+
truncated,
|
|
88788
|
+
dropped
|
|
88789
|
+
};
|
|
88790
|
+
return {
|
|
88791
|
+
body: bodyParts[0] ?? "",
|
|
88792
|
+
...n.title !== void 0 ? { title: n.title } : {},
|
|
88793
|
+
format,
|
|
88794
|
+
level,
|
|
88795
|
+
attachments,
|
|
88796
|
+
actions,
|
|
88797
|
+
clickUrl,
|
|
88798
|
+
sound,
|
|
88799
|
+
ttl,
|
|
88800
|
+
tag,
|
|
88801
|
+
bodyParts,
|
|
88802
|
+
renderedAs
|
|
88803
|
+
};
|
|
88804
|
+
}
|
|
87968
88805
|
new Set(NC_LEGACY_SYSTEM_EVENT_KINDS);
|
|
87969
88806
|
/**
|
|
87970
88807
|
* Every filter, for the callers that must cover all of them (guards, tests).
|
|
@@ -89186,12 +90023,62 @@ var alexaProxyLoginSchema = object({
|
|
|
89186
90023
|
proxyHost: string().default("").describe("Host/IP the browser uses to reach the login proxy (blank = auto-detect)"),
|
|
89187
90024
|
proxyPort: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(1).max(65535).default(3456)).describe("Port the local Amazon-login proxy listens on (must be reachable)")
|
|
89188
90025
|
});
|
|
90026
|
+
/**
|
|
90027
|
+
* Echo speech settings. Speech signs in as an import broker, so the targets
|
|
90028
|
+
* are what lives here. `email`, `password`, `region` and `session` are the OLD
|
|
90029
|
+
* standalone login: they are read once to migrate it onto the single broker
|
|
90030
|
+
* (see `speak/migration.ts`) and written back empty.
|
|
90031
|
+
*/
|
|
90032
|
+
var alexaSpeakSettingsSchema = object({
|
|
90033
|
+
email: string().default(""),
|
|
90034
|
+
password: string().default("").describe("Legacy Amazon password").meta({ secret: true }),
|
|
90035
|
+
region: _enum([
|
|
90036
|
+
"NA",
|
|
90037
|
+
"EU",
|
|
90038
|
+
"FE"
|
|
90039
|
+
]).default("EU"),
|
|
90040
|
+
session: string().default("").describe("Legacy Alexa session blob").meta({ secret: true }),
|
|
90041
|
+
targets: array(object({
|
|
90042
|
+
id: string(),
|
|
90043
|
+
name: string(),
|
|
90044
|
+
enabled: boolean(),
|
|
90045
|
+
/** The broker (Amazon account) the Echo belongs to; '' until one is chosen. */
|
|
90046
|
+
brokerId: string().default(""),
|
|
90047
|
+
echoId: string(),
|
|
90048
|
+
mode: _enum(["speak", "announce"]),
|
|
90049
|
+
volume: number(),
|
|
90050
|
+
respectDnd: boolean()
|
|
90051
|
+
})).default([])
|
|
90052
|
+
});
|
|
90053
|
+
function isRecord$3(value) {
|
|
90054
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
90055
|
+
}
|
|
90056
|
+
/** Resolved settings. A keychain marker (an object) is an empty secret, not a password. */
|
|
90057
|
+
function readSpeakSettings(config) {
|
|
90058
|
+
const raw = isRecord$3(config.speak) ? config.speak : {};
|
|
90059
|
+
const region = raw["region"];
|
|
90060
|
+
const targets = raw["targets"];
|
|
90061
|
+
return alexaSpeakSettingsSchema.parse({
|
|
90062
|
+
email: typeof raw["email"] === "string" ? raw["email"] : "",
|
|
90063
|
+
password: typeof raw["password"] === "string" ? raw["password"] : "",
|
|
90064
|
+
session: typeof raw["session"] === "string" ? raw["session"] : "",
|
|
90065
|
+
...region === "NA" || region === "EU" || region === "FE" ? { region } : {},
|
|
90066
|
+
...Array.isArray(targets) ? { targets } : {}
|
|
90067
|
+
});
|
|
90068
|
+
}
|
|
89189
90069
|
/** Top-level addon config — broker entries + the proxy-login settings. */
|
|
89190
90070
|
var alexaAddonConfigSchema = object({
|
|
89191
90071
|
brokers: array(AlexaBrokerEntrySchema).default([]),
|
|
89192
90072
|
proxyLogin: alexaProxyLoginSchema.default({
|
|
89193
90073
|
proxyHost: "",
|
|
89194
90074
|
proxyPort: 3456
|
|
90075
|
+
}),
|
|
90076
|
+
speak: alexaSpeakSettingsSchema.default({
|
|
90077
|
+
email: "",
|
|
90078
|
+
password: "",
|
|
90079
|
+
region: "EU",
|
|
90080
|
+
session: "",
|
|
90081
|
+
targets: []
|
|
89195
90082
|
})
|
|
89196
90083
|
});
|
|
89197
90084
|
/** Narrow an `unknown` to a shallow plain-object record (cast-free boundary). */
|
|
@@ -112965,17 +113852,17 @@ var require_view = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
112965
113852
|
* @private
|
|
112966
113853
|
*/
|
|
112967
113854
|
var debug = require_src$2()("express:view");
|
|
112968
|
-
var path$
|
|
113855
|
+
var path$8 = require("path");
|
|
112969
113856
|
var fs$4 = require("fs");
|
|
112970
113857
|
/**
|
|
112971
113858
|
* Module variables.
|
|
112972
113859
|
* @private
|
|
112973
113860
|
*/
|
|
112974
|
-
var dirname = path$
|
|
112975
|
-
var basename = path$
|
|
112976
|
-
var extname = path$
|
|
112977
|
-
var join = path$
|
|
112978
|
-
var resolve = path$
|
|
113861
|
+
var dirname = path$8.dirname;
|
|
113862
|
+
var basename = path$8.basename;
|
|
113863
|
+
var extname = path$8.extname;
|
|
113864
|
+
var join = path$8.join;
|
|
113865
|
+
var resolve = path$8.resolve;
|
|
112979
113866
|
/**
|
|
112980
113867
|
* Module exports.
|
|
112981
113868
|
* @public
|
|
@@ -113136,7 +114023,7 @@ var require_content_disposition = /* @__PURE__ */ __commonJSMin(((exports, modul
|
|
|
113136
114023
|
* Module dependencies.
|
|
113137
114024
|
* @private
|
|
113138
114025
|
*/
|
|
113139
|
-
var basename = require("path").basename;
|
|
114026
|
+
var basename$1 = require("path").basename;
|
|
113140
114027
|
var Buffer = require_safe_buffer().Buffer;
|
|
113141
114028
|
/**
|
|
113142
114029
|
* RegExp to match non attr-char, *after* encodeURIComponent (i.e. not including "%")
|
|
@@ -113259,9 +114146,9 @@ var require_content_disposition = /* @__PURE__ */ __commonJSMin(((exports, modul
|
|
|
113259
114146
|
if (fallback === void 0) fallback = true;
|
|
113260
114147
|
if (typeof fallback !== "string" && typeof fallback !== "boolean") throw new TypeError("fallback must be a string or boolean");
|
|
113261
114148
|
if (typeof fallback === "string" && NON_LATIN1_REGEXP.test(fallback)) throw new TypeError("fallback must be ISO-8859-1 string");
|
|
113262
|
-
var name = basename(filename);
|
|
114149
|
+
var name = basename$1(filename);
|
|
113263
114150
|
var isQuotedString = TEXT_REGEXP.test(name);
|
|
113264
|
-
var fallbackName = typeof fallback !== "string" ? fallback && getlatin1(name) : basename(fallback);
|
|
114151
|
+
var fallbackName = typeof fallback !== "string" ? fallback && getlatin1(name) : basename$1(fallback);
|
|
113265
114152
|
var hasFallback = typeof fallbackName === "string" && fallbackName !== name;
|
|
113266
114153
|
if (hasFallback || !isQuotedString || HEX_ESCAPE_REGEXP.test(name)) params["filename*"] = name;
|
|
113267
114154
|
if (isQuotedString || hasFallback) params.filename = hasFallback ? fallbackName : name;
|
|
@@ -115558,7 +116445,7 @@ var require_send = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
115558
116445
|
var ms = require_ms();
|
|
115559
116446
|
var onFinished = require_on_finished();
|
|
115560
116447
|
var parseRange = require_range_parser();
|
|
115561
|
-
var path$
|
|
116448
|
+
var path$7 = require("path");
|
|
115562
116449
|
var statuses = require_statuses();
|
|
115563
116450
|
var Stream = require("stream");
|
|
115564
116451
|
var util$4 = require("util");
|
|
@@ -115566,11 +116453,11 @@ var require_send = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
115566
116453
|
* Path function references.
|
|
115567
116454
|
* @private
|
|
115568
116455
|
*/
|
|
115569
|
-
var extname = path$
|
|
115570
|
-
var join = path$
|
|
115571
|
-
var normalize = path$
|
|
115572
|
-
var resolve = path$
|
|
115573
|
-
var sep = path$
|
|
116456
|
+
var extname = path$7.extname;
|
|
116457
|
+
var join = path$7.join;
|
|
116458
|
+
var normalize = path$7.normalize;
|
|
116459
|
+
var resolve = path$7.resolve;
|
|
116460
|
+
var sep = path$7.sep;
|
|
115574
116461
|
/**
|
|
115575
116462
|
* Regular expression for identifying a bytes Range header.
|
|
115576
116463
|
* @private
|
|
@@ -117512,7 +118399,7 @@ var require_application = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
117512
118399
|
var deprecate = require_depd()("express");
|
|
117513
118400
|
var flatten = require_array_flatten();
|
|
117514
118401
|
var merge = require_utils_merge();
|
|
117515
|
-
var resolve$
|
|
118402
|
+
var resolve$2 = require("path").resolve;
|
|
117516
118403
|
var setPrototypeOf = require_setprototypeof();
|
|
117517
118404
|
/**
|
|
117518
118405
|
* Module variables.
|
|
@@ -117575,7 +118462,7 @@ var require_application = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
117575
118462
|
this.mountpath = "/";
|
|
117576
118463
|
this.locals.settings = this.settings;
|
|
117577
118464
|
this.set("view", View);
|
|
117578
|
-
this.set("views", resolve$
|
|
118465
|
+
this.set("views", resolve$2("views"));
|
|
117579
118466
|
this.set("jsonp callback name", "callback");
|
|
117580
118467
|
if (env === "production") this.enable("view cache");
|
|
117581
118468
|
Object.defineProperty(this, "router", { get: function() {
|
|
@@ -119593,7 +120480,7 @@ var require_response = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
119593
120480
|
var http$3 = require("http");
|
|
119594
120481
|
var isAbsolute = require_utils$2().isAbsolute;
|
|
119595
120482
|
var onFinished = require_on_finished();
|
|
119596
|
-
var path$
|
|
120483
|
+
var path$6 = require("path");
|
|
119597
120484
|
var statuses = require_statuses();
|
|
119598
120485
|
var merge = require_utils_merge();
|
|
119599
120486
|
var sign = require_cookie_signature().sign;
|
|
@@ -119602,9 +120489,9 @@ var require_response = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
119602
120489
|
var setCharset = require_utils$2().setCharset;
|
|
119603
120490
|
var cookie = require_cookie();
|
|
119604
120491
|
var send = require_send();
|
|
119605
|
-
var extname = path$
|
|
120492
|
+
var extname = path$6.extname;
|
|
119606
120493
|
var mime = send.mime;
|
|
119607
|
-
var resolve = path$
|
|
120494
|
+
var resolve = path$6.resolve;
|
|
119608
120495
|
var vary = require_vary();
|
|
119609
120496
|
/**
|
|
119610
120497
|
* Response prototype.
|
|
@@ -120436,7 +121323,7 @@ var require_serve_static = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
120436
121323
|
var encodeUrl = require_encodeurl();
|
|
120437
121324
|
var escapeHtml = require_escape_html();
|
|
120438
121325
|
var parseUrl = require_parseurl();
|
|
120439
|
-
var resolve = require("path").resolve;
|
|
121326
|
+
var resolve$1 = require("path").resolve;
|
|
120440
121327
|
var send = require_send();
|
|
120441
121328
|
var url$6 = require("url");
|
|
120442
121329
|
/**
|
|
@@ -120460,7 +121347,7 @@ var require_serve_static = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
120460
121347
|
var setHeaders = opts.setHeaders;
|
|
120461
121348
|
if (setHeaders && typeof setHeaders !== "function") throw new TypeError("option setHeaders must be function");
|
|
120462
121349
|
opts.maxage = opts.maxage || opts.maxAge || 0;
|
|
120463
|
-
opts.root = resolve(root);
|
|
121350
|
+
opts.root = resolve$1(root);
|
|
120464
121351
|
var onDirectory = redirect ? createRedirectDirectoryListener() : createNotFoundDirectoryListener();
|
|
120465
121352
|
return function serveStatic(req, res, next) {
|
|
120466
121353
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
@@ -124129,7 +125016,7 @@ var require_braces = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
124129
125016
|
//#endregion
|
|
124130
125017
|
//#region ../../node_modules/micromatch/node_modules/picomatch/lib/constants.js
|
|
124131
125018
|
var require_constants = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
124132
|
-
var path$
|
|
125019
|
+
var path$5 = require("path");
|
|
124133
125020
|
var WIN_SLASH = "\\\\/";
|
|
124134
125021
|
var WIN_NO_SLASH = `[^${WIN_SLASH}]`;
|
|
124135
125022
|
var DEFAULT_MAX_EXTGLOB_RECURSION = 0;
|
|
@@ -124254,7 +125141,7 @@ var require_constants = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
124254
125141
|
CHAR_UNDERSCORE: 95,
|
|
124255
125142
|
CHAR_VERTICAL_LINE: 124,
|
|
124256
125143
|
CHAR_ZERO_WIDTH_NOBREAK_SPACE: 65279,
|
|
124257
|
-
SEP: path$
|
|
125144
|
+
SEP: path$5.sep,
|
|
124258
125145
|
/**
|
|
124259
125146
|
* Create EXTGLOB_CHARS
|
|
124260
125147
|
*/
|
|
@@ -124298,7 +125185,7 @@ var require_constants = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
124298
125185
|
//#endregion
|
|
124299
125186
|
//#region ../../node_modules/micromatch/node_modules/picomatch/lib/utils.js
|
|
124300
125187
|
var require_utils = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
124301
|
-
var path$
|
|
125188
|
+
var path$4 = require("path");
|
|
124302
125189
|
var win32 = process.platform === "win32";
|
|
124303
125190
|
var { REGEX_BACKSLASH, REGEX_REMOVE_BACKSLASH, REGEX_SPECIAL_CHARS, REGEX_SPECIAL_CHARS_GLOBAL } = require_constants();
|
|
124304
125191
|
exports.isObject = (val) => val !== null && typeof val === "object" && !Array.isArray(val);
|
|
@@ -124318,7 +125205,7 @@ var require_utils = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
124318
125205
|
};
|
|
124319
125206
|
exports.isWindows = (options) => {
|
|
124320
125207
|
if (options && typeof options.windows === "boolean") return options.windows;
|
|
124321
|
-
return win32 === true || path$
|
|
125208
|
+
return win32 === true || path$4.sep === "\\";
|
|
124322
125209
|
};
|
|
124323
125210
|
exports.escapeLast = (input, char, lastIdx) => {
|
|
124324
125211
|
const idx = input.lastIndexOf(char, lastIdx);
|
|
@@ -125688,7 +126575,7 @@ var require_parse = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
125688
126575
|
//#endregion
|
|
125689
126576
|
//#region ../../node_modules/micromatch/node_modules/picomatch/lib/picomatch.js
|
|
125690
126577
|
var require_picomatch$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
125691
|
-
var path$
|
|
126578
|
+
var path$3 = require("path");
|
|
125692
126579
|
var scan = require_scan();
|
|
125693
126580
|
var parse = require_parse();
|
|
125694
126581
|
var utils = require_utils();
|
|
@@ -125827,7 +126714,7 @@ var require_picomatch$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
125827
126714
|
* @api public
|
|
125828
126715
|
*/
|
|
125829
126716
|
picomatch.matchBase = (input, glob, options, posix = utils.isWindows(options)) => {
|
|
125830
|
-
return (glob instanceof RegExp ? glob : picomatch.makeRe(glob, options)).test(path$
|
|
126717
|
+
return (glob instanceof RegExp ? glob : picomatch.makeRe(glob, options)).test(path$3.basename(input));
|
|
125831
126718
|
};
|
|
125832
126719
|
/**
|
|
125833
126720
|
* Returns true if **any** of the given glob `patterns` match the specified `string`.
|
|
@@ -126913,7 +127800,7 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
126913
127800
|
var querystring$2 = require("querystring");
|
|
126914
127801
|
var cookieTools = require_cookie$1();
|
|
126915
127802
|
var fs$1 = require("fs");
|
|
126916
|
-
var path$
|
|
127803
|
+
var path$2 = require("path");
|
|
126917
127804
|
var crypto$1 = require("crypto");
|
|
126918
127805
|
var FORMERDATA_STORE_VERSION = 4;
|
|
126919
127806
|
function addCookies(Cookie, headers) {
|
|
@@ -126944,11 +127831,11 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
126944
127831
|
}
|
|
126945
127832
|
function initAmazonProxy(_options, callbackCookie, callbackListening) {
|
|
126946
127833
|
const initialCookies = {};
|
|
126947
|
-
const formerDataStorePath = _options.formerDataStorePath || path$
|
|
127834
|
+
const formerDataStorePath = _options.formerDataStorePath || path$2.join(__dirname, "formerDataStore.json");
|
|
126948
127835
|
let formerDataStoreValid = false;
|
|
126949
127836
|
if (!_options.formerRegistrationData) try {
|
|
126950
127837
|
if (fs$1.existsSync(formerDataStorePath)) {
|
|
126951
|
-
const formerDataStore = JSON.parse(fs$1.readFileSync(path$
|
|
127838
|
+
const formerDataStore = JSON.parse(fs$1.readFileSync(path$2.join(__dirname, "formerDataStore.json"), "utf8"));
|
|
126952
127839
|
if (typeof formerDataStore === "object" && formerDataStore.storeVersion === FORMERDATA_STORE_VERSION) {
|
|
126953
127840
|
_options.formerRegistrationData = _options.formerRegistrationData || {};
|
|
126954
127841
|
_options.formerRegistrationData.frc = _options.formerRegistrationData.frc || formerDataStore.frc;
|
|
@@ -131284,15 +132171,15 @@ function parseCookieMaterial(raw) {
|
|
|
131284
132171
|
}
|
|
131285
132172
|
}
|
|
131286
132173
|
/** True when `value` is a non-null, non-array plain object (cast-free guard). */
|
|
131287
|
-
function isRecord$
|
|
132174
|
+
function isRecord$2(value) {
|
|
131288
132175
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
131289
132176
|
}
|
|
131290
132177
|
/** Narrow a loaded JSON file to its `cookieData` (string | object), or null. */
|
|
131291
132178
|
function readCookieData(json) {
|
|
131292
|
-
if (!isRecord$
|
|
132179
|
+
if (!isRecord$2(json)) return null;
|
|
131293
132180
|
const value = json["cookieData"];
|
|
131294
132181
|
if (typeof value === "string") return value;
|
|
131295
|
-
if (isRecord$
|
|
132182
|
+
if (isRecord$2(value)) return value;
|
|
131296
132183
|
return null;
|
|
131297
132184
|
}
|
|
131298
132185
|
//#endregion
|
|
@@ -132267,7 +133154,7 @@ function mergeAlexaMotionStatus(cached, live, nowMs) {
|
|
|
132267
133154
|
};
|
|
132268
133155
|
}
|
|
132269
133156
|
/** True when `v` is a non-null, non-array object (cast-free guard). */
|
|
132270
|
-
function isRecord(v) {
|
|
133157
|
+
function isRecord$1(v) {
|
|
132271
133158
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
132272
133159
|
}
|
|
132273
133160
|
/**
|
|
@@ -132276,7 +133163,7 @@ function isRecord(v) {
|
|
|
132276
133163
|
*/
|
|
132277
133164
|
function decodeTemperatureCelsius(value) {
|
|
132278
133165
|
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
132279
|
-
if (!isRecord(value)) return null;
|
|
133166
|
+
if (!isRecord$1(value)) return null;
|
|
132280
133167
|
const raw = value["value"];
|
|
132281
133168
|
const n = typeof raw === "number" ? raw : Number(raw);
|
|
132282
133169
|
if (!Number.isFinite(n)) return null;
|
|
@@ -132287,7 +133174,7 @@ function decodeTemperatureCelsius(value) {
|
|
|
132287
133174
|
function decodeHumidityPercent(value) {
|
|
132288
133175
|
const direct = decodePercentage(value);
|
|
132289
133176
|
if (direct !== null) return direct;
|
|
132290
|
-
if (!isRecord(value)) return null;
|
|
133177
|
+
if (!isRecord$1(value)) return null;
|
|
132291
133178
|
return decodePercentage(value["value"]);
|
|
132292
133179
|
}
|
|
132293
133180
|
//#endregion
|
|
@@ -132623,6 +133510,11 @@ var alexaBrokersEntryKind = defineEntryKind("brokers", {
|
|
|
132623
133510
|
}),
|
|
132624
133511
|
writeTrigger: "an operator links or removes an Alexa account broker"
|
|
132625
133512
|
});
|
|
133513
|
+
var alexaSpeakSessionsEntryKind = defineEntryKind("speak-sessions", {
|
|
133514
|
+
title: "Alexa Echo speech sessions",
|
|
133515
|
+
schema: entryBody({ session: string().default("").meta({ secret: true }) }),
|
|
133516
|
+
writeTrigger: "an Echo worker signs in as an Alexa account broker"
|
|
133517
|
+
});
|
|
132626
133518
|
//#endregion
|
|
132627
133519
|
//#region src/alexa-broker-store.ts
|
|
132628
133520
|
function rowToEntry(id, data) {
|
|
@@ -132679,13 +133571,1627 @@ var AlexaBrokerStore = class AlexaBrokerStore {
|
|
|
132679
133571
|
}
|
|
132680
133572
|
};
|
|
132681
133573
|
//#endregion
|
|
133574
|
+
//#region src/speak/constants.ts
|
|
133575
|
+
/** Echo speech limits and the worker restart bound (same window as CrashSupervisor). */
|
|
133576
|
+
var ALEXA_SPEAK_ADDON_ID = "import-alexa";
|
|
133577
|
+
var ALEXA_SPEAK_KIND = "alexa-echo";
|
|
133578
|
+
var DEVICE_REFRESH_MS = 5 * 6e4;
|
|
133579
|
+
/** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
|
|
133580
|
+
var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
|
|
133581
|
+
//#endregion
|
|
133582
|
+
//#region src/speak/worker-protocol.ts
|
|
133583
|
+
/** Line-delimited JSON the long-lived Python worker speaks. One object per line. */
|
|
133584
|
+
var EchoDeviceSchema = object({
|
|
133585
|
+
id: string().min(1),
|
|
133586
|
+
name: string(),
|
|
133587
|
+
family: string(),
|
|
133588
|
+
online: boolean(),
|
|
133589
|
+
kind: _enum(["device", "group"]),
|
|
133590
|
+
dnd: boolean(),
|
|
133591
|
+
deviceType: string(),
|
|
133592
|
+
/** The room the account files the device under, when Amazon names one. */
|
|
133593
|
+
room: string().optional()
|
|
133594
|
+
});
|
|
133595
|
+
var LoginStatusSchema = _enum(["ok", "failed"]);
|
|
133596
|
+
var SessionStatusSchema = object({
|
|
133597
|
+
loggedIn: boolean(),
|
|
133598
|
+
ageSec: number().int().nonnegative(),
|
|
133599
|
+
lastSuccessAt: number().int().nullable()
|
|
133600
|
+
});
|
|
133601
|
+
var TargetDeliverySchema = object({
|
|
133602
|
+
targetId: string(),
|
|
133603
|
+
ok: boolean(),
|
|
133604
|
+
error: string().optional()
|
|
133605
|
+
});
|
|
133606
|
+
var WorkerLineSchema = union([object({
|
|
133607
|
+
id: string(),
|
|
133608
|
+
ok: literal(true),
|
|
133609
|
+
result: unknown().optional()
|
|
133610
|
+
}), object({
|
|
133611
|
+
id: string(),
|
|
133612
|
+
ok: literal(false),
|
|
133613
|
+
error: string()
|
|
133614
|
+
})]);
|
|
133615
|
+
function requestLine(id, request) {
|
|
133616
|
+
return `${JSON.stringify({
|
|
133617
|
+
id,
|
|
133618
|
+
...request
|
|
133619
|
+
})}\n`;
|
|
133620
|
+
}
|
|
133621
|
+
function parseLoginResult(result) {
|
|
133622
|
+
const parsed = object({
|
|
133623
|
+
status: LoginStatusSchema,
|
|
133624
|
+
error: string().optional()
|
|
133625
|
+
}).safeParse(result);
|
|
133626
|
+
if (!parsed.success) return {
|
|
133627
|
+
status: "failed",
|
|
133628
|
+
error: "worker returned an unexpected login result"
|
|
133629
|
+
};
|
|
133630
|
+
return parsed.data;
|
|
133631
|
+
}
|
|
133632
|
+
function parseDevices(result) {
|
|
133633
|
+
const parsed = object({ devices: array(EchoDeviceSchema) }).safeParse(result);
|
|
133634
|
+
if (!parsed.success) throw new Error("worker returned an unexpected device list");
|
|
133635
|
+
return parsed.data.devices;
|
|
133636
|
+
}
|
|
133637
|
+
function parseDeliveries(result) {
|
|
133638
|
+
const parsed = object({ outcomes: array(TargetDeliverySchema) }).safeParse(result);
|
|
133639
|
+
if (!parsed.success) throw new Error("worker returned an unexpected delivery list");
|
|
133640
|
+
return parsed.data.outcomes;
|
|
133641
|
+
}
|
|
133642
|
+
function parseSessionStatus(result) {
|
|
133643
|
+
const parsed = SessionStatusSchema.safeParse(result);
|
|
133644
|
+
if (!parsed.success) throw new Error("worker returned an unexpected session status");
|
|
133645
|
+
return parsed.data;
|
|
133646
|
+
}
|
|
133647
|
+
//#endregion
|
|
133648
|
+
//#region src/speak/actions.ts
|
|
133649
|
+
var BrokerEchoStateSchema = _enum([
|
|
133650
|
+
"starting",
|
|
133651
|
+
"ready",
|
|
133652
|
+
"broker-not-signed-in",
|
|
133653
|
+
"failed"
|
|
133654
|
+
]);
|
|
133655
|
+
var SpeakStatusSchema = object({
|
|
133656
|
+
brokers: array(object({
|
|
133657
|
+
id: string(),
|
|
133658
|
+
name: string(),
|
|
133659
|
+
state: BrokerEchoStateSchema,
|
|
133660
|
+
error: string().optional(),
|
|
133661
|
+
devices: array(EchoDeviceSchema),
|
|
133662
|
+
session: SessionStatusSchema
|
|
133663
|
+
})),
|
|
133664
|
+
/** Targets that name no Amazon account yet (they cannot speak until one is chosen). */
|
|
133665
|
+
unassignedTargets: number().int().nonnegative(),
|
|
133666
|
+
/** What the old standalone login left behind, named; '' when nothing is pending. */
|
|
133667
|
+
notice: _enum([
|
|
133668
|
+
"",
|
|
133669
|
+
"no-broker",
|
|
133670
|
+
"choose-broker"
|
|
133671
|
+
])
|
|
133672
|
+
});
|
|
133673
|
+
var alexaSpeakActions = defineCustomActions({
|
|
133674
|
+
speakStatus: customAction(object({}), SpeakStatusSchema, {
|
|
133675
|
+
kind: "mutation",
|
|
133676
|
+
auth: "admin"
|
|
133677
|
+
}),
|
|
133678
|
+
sendTest: customAction(object({
|
|
133679
|
+
brokerId: string().min(1),
|
|
133680
|
+
echoIds: array(string().min(1)).min(1),
|
|
133681
|
+
mode: _enum(["speak", "announce"]),
|
|
133682
|
+
volume: number().int().min(0).max(100),
|
|
133683
|
+
text: string().min(1).max(450)
|
|
133684
|
+
}), object({ outcomes: array(object({
|
|
133685
|
+
echoId: string(),
|
|
133686
|
+
ok: boolean(),
|
|
133687
|
+
error: string().optional()
|
|
133688
|
+
})) }), {
|
|
133689
|
+
kind: "mutation",
|
|
133690
|
+
auth: "admin"
|
|
133691
|
+
})
|
|
133692
|
+
});
|
|
133693
|
+
//#endregion
|
|
133694
|
+
//#region src/speak/broker-seed.ts
|
|
133695
|
+
/**
|
|
133696
|
+
* What the Echo worker needs from an import broker to act as the same Amazon
|
|
133697
|
+
* account: the refresh token of the broker's own device registration. The
|
|
133698
|
+
* broker keeps that registration fresh (it persists `cookieData` after every
|
|
133699
|
+
* refresh), so the freshest copy is the persisted session file, then the
|
|
133700
|
+
* stored cookie blob, then a bare refresh token. A raw cookie string carries
|
|
133701
|
+
* no refresh token and gives no seed.
|
|
133702
|
+
*/
|
|
133703
|
+
/** The registration name alexapy sends when the broker did not record one. */
|
|
133704
|
+
var DEFAULT_DEVICE_APP_NAME = "Alexa Media Player";
|
|
133705
|
+
function isRecord(value) {
|
|
133706
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
133707
|
+
}
|
|
133708
|
+
/** The registration blob alexa-remote2 hands back, from an object or its JSON text. */
|
|
133709
|
+
function asRegistration(value) {
|
|
133710
|
+
if (isRecord(value)) return value;
|
|
133711
|
+
if (typeof value !== "string") return null;
|
|
133712
|
+
const trimmed = value.trim();
|
|
133713
|
+
if (!trimmed.startsWith("{")) return null;
|
|
133714
|
+
try {
|
|
133715
|
+
const parsed = JSON.parse(trimmed);
|
|
133716
|
+
return isRecord(parsed) ? parsed : null;
|
|
133717
|
+
} catch {
|
|
133718
|
+
return null;
|
|
133719
|
+
}
|
|
133720
|
+
}
|
|
133721
|
+
function seedFromRegistration(value) {
|
|
133722
|
+
const registration = asRegistration(value);
|
|
133723
|
+
if (registration === null) return null;
|
|
133724
|
+
const token = registration["refreshToken"];
|
|
133725
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
133726
|
+
const name = registration["deviceAppName"];
|
|
133727
|
+
return {
|
|
133728
|
+
refreshToken: token,
|
|
133729
|
+
deviceAppName: typeof name === "string" && name.length > 0 ? name : DEFAULT_DEVICE_APP_NAME
|
|
133730
|
+
};
|
|
133731
|
+
}
|
|
133732
|
+
async function readPersistedRegistration(dataDir, brokerId) {
|
|
133733
|
+
if (dataDir === void 0) return null;
|
|
133734
|
+
try {
|
|
133735
|
+
const raw = await node_fs_promises.readFile(node_path.join(dataDir, `${brokerId}.${SESSION_FILE}`), "utf8");
|
|
133736
|
+
const json = JSON.parse(raw);
|
|
133737
|
+
return isRecord(json) ? json["cookieData"] : null;
|
|
133738
|
+
} catch {
|
|
133739
|
+
return null;
|
|
133740
|
+
}
|
|
133741
|
+
}
|
|
133742
|
+
async function readBrokerSeed(dataDir, entry) {
|
|
133743
|
+
const persisted = seedFromRegistration(await readPersistedRegistration(dataDir, entry.id));
|
|
133744
|
+
if (persisted !== null) return persisted;
|
|
133745
|
+
const stored = seedFromRegistration(entry.connection.cookie);
|
|
133746
|
+
if (stored !== null) return stored;
|
|
133747
|
+
const bare = entry.connection.refreshToken;
|
|
133748
|
+
if (bare.length > 0) return {
|
|
133749
|
+
refreshToken: bare,
|
|
133750
|
+
deviceAppName: DEFAULT_DEVICE_APP_NAME
|
|
133751
|
+
};
|
|
133752
|
+
return null;
|
|
133753
|
+
}
|
|
133754
|
+
/** Amazon domain to the region the worker uses for the spoken-sequence locale. */
|
|
133755
|
+
function regionOfDomain(domain) {
|
|
133756
|
+
const tail = domain.toLowerCase().replace(/^(www|alexa)\./, "");
|
|
133757
|
+
if (tail === "amazon.com") return "NA";
|
|
133758
|
+
if (tail === "amazon.co.jp" || tail === "amazon.com.au" || tail === "amazon.in") return "FE";
|
|
133759
|
+
return "EU";
|
|
133760
|
+
}
|
|
133761
|
+
process.platform, process.env["SystemRoot"];
|
|
133762
|
+
/**
|
|
133763
|
+
* Files only their owner may read: the one place a secret-bearing file is
|
|
133764
|
+
* restricted, on every OS.
|
|
133765
|
+
*
|
|
133766
|
+
* `mode: 0o600` is a POSIX idea. On Windows Node maps it to the read-only
|
|
133767
|
+
* attribute and nothing else, and `chmod` is a no-op for permissions, so a
|
|
133768
|
+
* "0600" `agent.json` (the mesh private key) would have
|
|
133769
|
+
* been readable by every local user. Windows has its own tool for this:
|
|
133770
|
+
* `icacls`. This module removes the inherited ACL entries and grants access to
|
|
133771
|
+
* the current user and SYSTEM only — the Windows reading of "0600".
|
|
133772
|
+
*
|
|
133773
|
+
* ZERO imports beyond Node built-ins, on purpose: the Electron shell bundles
|
|
133774
|
+
* this single file (see `packages/desktop/tsup.config.ts`) instead of the
|
|
133775
|
+
* whole `@camstack/types` barrel.
|
|
133776
|
+
*
|
|
133777
|
+
* Failure is NOT swallowed. A secret file that could not be restricted is a
|
|
133778
|
+
* secret file the caller must know about, so `OwnerOnlyError` propagates; the
|
|
133779
|
+
* write order below makes sure nothing sensitive is in the file yet when that
|
|
133780
|
+
* happens (the temp file is empty until it has been restricted).
|
|
133781
|
+
*
|
|
133782
|
+
* Out of scope on purpose: files that are only tidy, not secret (the job
|
|
133783
|
+
* journal, the addon manifest) keep a plain `mode`, since one `icacls` per
|
|
133784
|
+
* write would be a process spawn on a hot path.
|
|
133785
|
+
*/
|
|
133786
|
+
/** `*S-1-5-18` is SYSTEM in every locale — a name like "SYSTEM" is not. */
|
|
133787
|
+
var WINDOWS_SYSTEM_SID = "*S-1-5-18";
|
|
133788
|
+
var FILE_MODE = 384;
|
|
133789
|
+
var DIR_MODE = 448;
|
|
133790
|
+
var OwnerOnlyError = class extends Error {
|
|
133791
|
+
target;
|
|
133792
|
+
constructor(target, detail) {
|
|
133793
|
+
super(`could not restrict ${target} to its owner: ${detail}`);
|
|
133794
|
+
this.target = target;
|
|
133795
|
+
this.name = "OwnerOnlyError";
|
|
133796
|
+
}
|
|
133797
|
+
};
|
|
133798
|
+
function currentPrincipal() {
|
|
133799
|
+
const user = process.env["USERNAME"] ?? (0, node_os.userInfo)().username;
|
|
133800
|
+
const domain = process.env["USERDOMAIN"];
|
|
133801
|
+
return domain !== void 0 && domain.length > 0 ? `${domain}\\${user}` : user;
|
|
133802
|
+
}
|
|
133803
|
+
/** Resolved per call, not at import: a platform stubbed by a test must be seen. */
|
|
133804
|
+
function resolveDeps(overrides) {
|
|
133805
|
+
return {
|
|
133806
|
+
platform: process.platform,
|
|
133807
|
+
exec: (file, args) => {
|
|
133808
|
+
(0, node_child_process.execFileSync)(file, [...args], {
|
|
133809
|
+
stdio: "pipe",
|
|
133810
|
+
windowsHide: true
|
|
133811
|
+
});
|
|
133812
|
+
},
|
|
133813
|
+
principal: currentPrincipal,
|
|
133814
|
+
systemRoot: process.env["SystemRoot"],
|
|
133815
|
+
...overrides
|
|
133816
|
+
};
|
|
133817
|
+
}
|
|
133818
|
+
/** The icacls argv for one path: no inheritance, owner + SYSTEM only. */
|
|
133819
|
+
function icaclsArgs(target, principal, isDirectory) {
|
|
133820
|
+
const inherit = isDirectory ? "(OI)(CI)" : "";
|
|
133821
|
+
return [
|
|
133822
|
+
target,
|
|
133823
|
+
"/inheritance:r",
|
|
133824
|
+
"/grant:r",
|
|
133825
|
+
`${principal}:${inherit}F`,
|
|
133826
|
+
"/grant:r",
|
|
133827
|
+
`${WINDOWS_SYSTEM_SID}:${inherit}F`
|
|
133828
|
+
];
|
|
133829
|
+
}
|
|
133830
|
+
function restrictWindows(target, isDirectory, deps) {
|
|
133831
|
+
const icacls = node_path$1.win32.join(deps.systemRoot ?? "C:\\Windows", "System32", "icacls.exe");
|
|
133832
|
+
try {
|
|
133833
|
+
deps.exec(icacls, icaclsArgs(target, deps.principal(), isDirectory));
|
|
133834
|
+
} catch (err) {
|
|
133835
|
+
throw new OwnerOnlyError(target, err instanceof Error ? err.message : String(err));
|
|
133836
|
+
}
|
|
133837
|
+
}
|
|
133838
|
+
/** Restrict an existing file to its owner (chmod 0600 / owner+SYSTEM ACL). */
|
|
133839
|
+
function restrictFileToOwnerSync(target, overrides = {}) {
|
|
133840
|
+
const deps = resolveDeps(overrides);
|
|
133841
|
+
if (deps.platform === "win32") {
|
|
133842
|
+
restrictWindows(target, false, deps);
|
|
133843
|
+
return;
|
|
133844
|
+
}
|
|
133845
|
+
node_fs.chmodSync(target, FILE_MODE);
|
|
133846
|
+
}
|
|
133847
|
+
/** Restrict an existing directory to its owner (chmod 0700 / owner+SYSTEM, inherited). */
|
|
133848
|
+
function restrictDirToOwnerSync(target, overrides = {}) {
|
|
133849
|
+
const deps = resolveDeps(overrides);
|
|
133850
|
+
if (deps.platform === "win32") {
|
|
133851
|
+
restrictWindows(target, true, deps);
|
|
133852
|
+
return;
|
|
133853
|
+
}
|
|
133854
|
+
node_fs.chmodSync(target, DIR_MODE);
|
|
133855
|
+
}
|
|
133856
|
+
/**
|
|
133857
|
+
* Atomically write a secret-bearing file that only its owner can read.
|
|
133858
|
+
*
|
|
133859
|
+
* Order matters: the temp file is created EMPTY, restricted, and only then
|
|
133860
|
+
* given its content, so there is no moment at which the secret exists under a
|
|
133861
|
+
* permissive ACL. The rename carries the (already restricted) ACL with it.
|
|
133862
|
+
*/
|
|
133863
|
+
function writeOwnerOnlyFileSync(target, data, overrides = {}) {
|
|
133864
|
+
node_fs.mkdirSync(node_path$1.dirname(target), { recursive: true });
|
|
133865
|
+
const tmp = `${target}.tmp.${process.pid}`;
|
|
133866
|
+
node_fs.writeFileSync(tmp, "", { mode: FILE_MODE });
|
|
133867
|
+
try {
|
|
133868
|
+
restrictFileToOwnerSync(tmp, overrides);
|
|
133869
|
+
node_fs.writeFileSync(tmp, data);
|
|
133870
|
+
node_fs.renameSync(tmp, target);
|
|
133871
|
+
} catch (err) {
|
|
133872
|
+
node_fs.rmSync(tmp, { force: true });
|
|
133873
|
+
throw err;
|
|
133874
|
+
}
|
|
133875
|
+
}
|
|
133876
|
+
//#endregion
|
|
133877
|
+
//#region src/speak/secret-file.ts
|
|
133878
|
+
/**
|
|
133879
|
+
* Hand a refresh token or a saved Amazon session to the Python worker as an
|
|
133880
|
+
* owner-only file. The path travels on stdin. The bytes never travel on
|
|
133881
|
+
* argv or in the environment, and they are removed when the call settles.
|
|
133882
|
+
*/
|
|
133883
|
+
var REDACTED = "‹redacted›";
|
|
133884
|
+
function decodeSession(sessionB64) {
|
|
133885
|
+
if (sessionB64.length === 0) return Buffer.alloc(0);
|
|
133886
|
+
return Buffer.from(sessionB64, "base64");
|
|
133887
|
+
}
|
|
133888
|
+
async function readSessionB64(sessionPath) {
|
|
133889
|
+
const bytes = await node_fs_promises.readFile(sessionPath);
|
|
133890
|
+
if (bytes.length === 0) return "";
|
|
133891
|
+
return bytes.toString("base64");
|
|
133892
|
+
}
|
|
133893
|
+
/**
|
|
133894
|
+
* Create a 0700 directory, write the refresh token and the session 0600, run
|
|
133895
|
+
* `body`, then delete the directory whether `body` succeeded or threw.
|
|
133896
|
+
* Returns the session file as base64 so the caller can store it in the keychain.
|
|
133897
|
+
*/
|
|
133898
|
+
async function withSpeakSecretFiles(input, body) {
|
|
133899
|
+
const dir = await node_fs_promises.mkdtemp(node_path.join(node_os.tmpdir(), "camstack-alexa-"));
|
|
133900
|
+
const paths = {
|
|
133901
|
+
dir,
|
|
133902
|
+
refreshTokenPath: node_path.join(dir, "refresh-token"),
|
|
133903
|
+
sessionPath: node_path.join(dir, "session")
|
|
133904
|
+
};
|
|
133905
|
+
try {
|
|
133906
|
+
restrictDirToOwnerSync(dir);
|
|
133907
|
+
writeOwnerOnlyFileSync(paths.refreshTokenPath, input.refreshToken);
|
|
133908
|
+
writeOwnerOnlyFileSync(paths.sessionPath, decodeSession(input.sessionB64));
|
|
133909
|
+
await body(paths);
|
|
133910
|
+
return { sessionB64: await readSessionB64(paths.sessionPath) };
|
|
133911
|
+
} finally {
|
|
133912
|
+
await node_fs_promises.rm(dir, {
|
|
133913
|
+
recursive: true,
|
|
133914
|
+
force: true
|
|
133915
|
+
});
|
|
133916
|
+
}
|
|
133917
|
+
}
|
|
133918
|
+
/** Replace every occurrence of each non-empty secret. Used before a log line is emitted. */
|
|
133919
|
+
function scrubSecrets(text, secrets) {
|
|
133920
|
+
let out = text;
|
|
133921
|
+
for (const secret of secrets) {
|
|
133922
|
+
if (secret.length === 0) continue;
|
|
133923
|
+
out = out.split(secret).join(REDACTED);
|
|
133924
|
+
}
|
|
133925
|
+
return out;
|
|
133926
|
+
}
|
|
133927
|
+
//#endregion
|
|
133928
|
+
//#region src/speak/broker-echo.ts
|
|
133929
|
+
var BrokerEcho = class {
|
|
133930
|
+
deps;
|
|
133931
|
+
currentState = "starting";
|
|
133932
|
+
currentError = null;
|
|
133933
|
+
currentDevices = [];
|
|
133934
|
+
constructor(deps) {
|
|
133935
|
+
this.deps = deps;
|
|
133936
|
+
}
|
|
133937
|
+
get id() {
|
|
133938
|
+
return this.deps.brokerId;
|
|
133939
|
+
}
|
|
133940
|
+
get name() {
|
|
133941
|
+
return this.deps.brokerName;
|
|
133942
|
+
}
|
|
133943
|
+
get state() {
|
|
133944
|
+
return this.currentState;
|
|
133945
|
+
}
|
|
133946
|
+
get error() {
|
|
133947
|
+
return this.currentError;
|
|
133948
|
+
}
|
|
133949
|
+
get devices() {
|
|
133950
|
+
return this.currentDevices;
|
|
133951
|
+
}
|
|
133952
|
+
stop() {
|
|
133953
|
+
this.deps.worker.stop();
|
|
133954
|
+
}
|
|
133955
|
+
/** Sign the worker in as the broker's account. Never throws; the outcome is `state`. */
|
|
133956
|
+
async signIn() {
|
|
133957
|
+
const { brokerId, worker, sessions, logger } = this.deps;
|
|
133958
|
+
const tags = { tags: { brokerId } };
|
|
133959
|
+
let secrets = [];
|
|
133960
|
+
try {
|
|
133961
|
+
const seed = await this.deps.readSeed();
|
|
133962
|
+
const stored = await sessions.get(brokerId);
|
|
133963
|
+
if (seed === null && stored.length === 0) {
|
|
133964
|
+
this.currentState = "broker-not-signed-in";
|
|
133965
|
+
this.currentError = null;
|
|
133966
|
+
logger.warn("alexa speak: the broker has no sign-in to share yet", tags);
|
|
133967
|
+
return;
|
|
133968
|
+
}
|
|
133969
|
+
secrets = seed === null ? [] : [seed.refreshToken];
|
|
133970
|
+
worker.noteSecrets(secrets);
|
|
133971
|
+
let outcome = parseLoginResult({
|
|
133972
|
+
status: "failed",
|
|
133973
|
+
error: "worker gave no answer"
|
|
133974
|
+
});
|
|
133975
|
+
const held = await withSpeakSecretFiles({
|
|
133976
|
+
refreshToken: seed?.refreshToken ?? "",
|
|
133977
|
+
sessionB64: stored
|
|
133978
|
+
}, async (paths) => {
|
|
133979
|
+
outcome = parseLoginResult(await worker.request({
|
|
133980
|
+
op: "login_broker",
|
|
133981
|
+
label: brokerId,
|
|
133982
|
+
refreshTokenPath: seed === null ? "" : paths.refreshTokenPath,
|
|
133983
|
+
deviceAppName: seed?.deviceAppName ?? "",
|
|
133984
|
+
domain: this.deps.domain,
|
|
133985
|
+
sessionPath: paths.sessionPath,
|
|
133986
|
+
region: regionOfDomain(this.deps.domain)
|
|
133987
|
+
}));
|
|
133988
|
+
});
|
|
133989
|
+
if (held.sessionB64.length > 0 && held.sessionB64 !== stored) await sessions.put(brokerId, held.sessionB64);
|
|
133990
|
+
this.currentError = outcome.status === "ok" ? null : outcome.error ?? "sign-in failed";
|
|
133991
|
+
this.currentState = outcome.status === "ok" ? "ready" : "failed";
|
|
133992
|
+
if (outcome.status !== "ok") {
|
|
133993
|
+
logger.warn("alexa speak: sign-in as the broker failed", {
|
|
133994
|
+
...tags,
|
|
133995
|
+
meta: { error: this.currentError }
|
|
133996
|
+
});
|
|
133997
|
+
return;
|
|
133998
|
+
}
|
|
133999
|
+
await this.refreshDevices();
|
|
134000
|
+
} catch (err) {
|
|
134001
|
+
this.currentState = "failed";
|
|
134002
|
+
this.currentError = scrubSecrets(err instanceof Error ? err.message : String(err), secrets);
|
|
134003
|
+
logger.warn("alexa speak: sign-in as the broker failed", {
|
|
134004
|
+
...tags,
|
|
134005
|
+
meta: { error: this.currentError }
|
|
134006
|
+
});
|
|
134007
|
+
}
|
|
134008
|
+
}
|
|
134009
|
+
/** A session that is not ready tries to sign in again before it gives up. */
|
|
134010
|
+
async ensureReady() {
|
|
134011
|
+
if (this.currentState !== "ready") await this.signIn();
|
|
134012
|
+
return this.currentState === "ready";
|
|
134013
|
+
}
|
|
134014
|
+
async refreshDevices() {
|
|
134015
|
+
if (this.currentState !== "ready") return;
|
|
134016
|
+
try {
|
|
134017
|
+
this.currentDevices = [...parseDevices(await this.deps.worker.request({ op: "devices" }))];
|
|
134018
|
+
} catch (err) {
|
|
134019
|
+
this.deps.logger.warn("alexa speak: device refresh failed", {
|
|
134020
|
+
tags: { brokerId: this.deps.brokerId },
|
|
134021
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
134022
|
+
});
|
|
134023
|
+
}
|
|
134024
|
+
}
|
|
134025
|
+
async utter(op, targets, text, volume) {
|
|
134026
|
+
if (!await this.ensureReady()) throw new Error(this.notReadyReason());
|
|
134027
|
+
return parseDeliveries(await this.deps.worker.request({
|
|
134028
|
+
op,
|
|
134029
|
+
targets,
|
|
134030
|
+
text,
|
|
134031
|
+
volume
|
|
134032
|
+
}));
|
|
134033
|
+
}
|
|
134034
|
+
async session() {
|
|
134035
|
+
if (this.deps.worker.failed) return {
|
|
134036
|
+
loggedIn: false,
|
|
134037
|
+
ageSec: 0,
|
|
134038
|
+
lastSuccessAt: null
|
|
134039
|
+
};
|
|
134040
|
+
try {
|
|
134041
|
+
return parseSessionStatus(await this.deps.worker.request({ op: "session_status" }));
|
|
134042
|
+
} catch {
|
|
134043
|
+
return {
|
|
134044
|
+
loggedIn: this.currentState === "ready",
|
|
134045
|
+
ageSec: 0,
|
|
134046
|
+
lastSuccessAt: null
|
|
134047
|
+
};
|
|
134048
|
+
}
|
|
134049
|
+
}
|
|
134050
|
+
notReadyReason() {
|
|
134051
|
+
if (this.currentState === "broker-not-signed-in") return `account '${this.deps.brokerName}' is not signed in on its broker`;
|
|
134052
|
+
return this.currentError ?? "alexa session is not ready";
|
|
134053
|
+
}
|
|
134054
|
+
};
|
|
134055
|
+
//#endregion
|
|
134056
|
+
//#region src/speak/migration.ts
|
|
134057
|
+
function legacyLoginOf(settings) {
|
|
134058
|
+
return {
|
|
134059
|
+
email: settings.email,
|
|
134060
|
+
password: settings.password,
|
|
134061
|
+
session: settings.session
|
|
134062
|
+
};
|
|
134063
|
+
}
|
|
134064
|
+
function planLegacyMigration(legacy, brokerIds, targets) {
|
|
134065
|
+
const present = legacy.email.length > 0 || legacy.password.length > 0 || legacy.session.length > 0;
|
|
134066
|
+
const only = brokerIds.length === 1 ? brokerIds[0] ?? null : null;
|
|
134067
|
+
if (!present || only === null) return {
|
|
134068
|
+
present,
|
|
134069
|
+
attachTo: null,
|
|
134070
|
+
targets
|
|
134071
|
+
};
|
|
134072
|
+
return {
|
|
134073
|
+
present,
|
|
134074
|
+
attachTo: legacy.session.length > 0 ? only : null,
|
|
134075
|
+
targets: targets.map((row) => row.brokerId.length > 0 ? row : {
|
|
134076
|
+
...row,
|
|
134077
|
+
brokerId: only
|
|
134078
|
+
})
|
|
134079
|
+
};
|
|
134080
|
+
}
|
|
134081
|
+
/** What the page must tell the operator about targets that name no account. */
|
|
134082
|
+
function noticeFor(targets, brokerIds) {
|
|
134083
|
+
if (!targets.some((row) => row.brokerId.length === 0)) return "";
|
|
134084
|
+
if (brokerIds.length === 0) return "no-broker";
|
|
134085
|
+
return brokerIds.length > 1 ? "choose-broker" : "";
|
|
134086
|
+
}
|
|
134087
|
+
function unassignedCount(targets) {
|
|
134088
|
+
return targets.filter((row) => row.brokerId.length === 0).length;
|
|
134089
|
+
}
|
|
134090
|
+
//#endregion
|
|
134091
|
+
//#region src/speak/rate-limit.ts
|
|
134092
|
+
/**
|
|
134093
|
+
* Per-Echo admission. A burst is dropped here, not queued: a queue would be
|
|
134094
|
+
* minutes of speech long after the event.
|
|
134095
|
+
*/
|
|
134096
|
+
function emptySpeechLedger() {
|
|
134097
|
+
return { byEcho: {} };
|
|
134098
|
+
}
|
|
134099
|
+
/**
|
|
134100
|
+
* Identical text inside the collapse window is one utterance.
|
|
134101
|
+
* A different text inside the gap is refused until the gap elapses.
|
|
134102
|
+
* Admitted calls replace that Echo's memory; refusals leave it as it was.
|
|
134103
|
+
*/
|
|
134104
|
+
function admitSpeech(ledger, echoId, text, now) {
|
|
134105
|
+
const previous = ledger.byEcho[echoId];
|
|
134106
|
+
if (previous !== void 0 && previous.lastText === text && now - previous.lastSpokenAt < 6e4) return {
|
|
134107
|
+
decision: "collapsed",
|
|
134108
|
+
ledger
|
|
134109
|
+
};
|
|
134110
|
+
if (previous !== void 0 && now - previous.lastSpokenAt < 2e4) return {
|
|
134111
|
+
decision: "rate_limited",
|
|
134112
|
+
ledger
|
|
134113
|
+
};
|
|
134114
|
+
return {
|
|
134115
|
+
decision: "admit",
|
|
134116
|
+
ledger: { byEcho: {
|
|
134117
|
+
...ledger.byEcho,
|
|
134118
|
+
[echoId]: {
|
|
134119
|
+
lastSpokenAt: now,
|
|
134120
|
+
lastText: text
|
|
134121
|
+
}
|
|
134122
|
+
} }
|
|
134123
|
+
};
|
|
134124
|
+
}
|
|
134125
|
+
//#endregion
|
|
134126
|
+
//#region src/speak/speech-text.ts
|
|
134127
|
+
var ALEXA_SPEECH_CAPS = {
|
|
134128
|
+
attachments: {
|
|
134129
|
+
mediaTypes: [],
|
|
134130
|
+
mode: "url",
|
|
134131
|
+
max: 0
|
|
134132
|
+
},
|
|
134133
|
+
actions: 0,
|
|
134134
|
+
actionIcons: false,
|
|
134135
|
+
levels: [{
|
|
134136
|
+
id: "default",
|
|
134137
|
+
label: "Default",
|
|
134138
|
+
ordinal: 3
|
|
134139
|
+
}],
|
|
134140
|
+
format: ["text"],
|
|
134141
|
+
clickUrl: false,
|
|
134142
|
+
sound: false,
|
|
134143
|
+
ttl: false,
|
|
134144
|
+
bodyMaxLen: 450
|
|
134145
|
+
};
|
|
134146
|
+
function collapseWhitespace(value) {
|
|
134147
|
+
return value.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/[ \t]{2,}/g, " ").trim();
|
|
134148
|
+
}
|
|
134149
|
+
/** Markup that survived the degrade engine must not be read aloud. */
|
|
134150
|
+
function toPlainSpeech(value) {
|
|
134151
|
+
return collapseWhitespace(htmlToText(markdownToText(value)));
|
|
134152
|
+
}
|
|
134153
|
+
/**
|
|
134154
|
+
* Title then body, as one sentence, never longer than {@link SPEECH_MAX_CHARS}.
|
|
134155
|
+
* An empty result is an empty string — the caller refuses to send it.
|
|
134156
|
+
*/
|
|
134157
|
+
function speechFromNotification(notification) {
|
|
134158
|
+
const prepared = prepareNotification(ALEXA_SPEECH_CAPS, notification);
|
|
134159
|
+
const title = prepared.title !== void 0 ? toPlainSpeech(prepared.title) : "";
|
|
134160
|
+
const body = toPlainSpeech(prepared.body);
|
|
134161
|
+
const joined = title.length > 0 && body.length > 0 ? `${title}. ${body}` : title || body;
|
|
134162
|
+
const text = joined.length > 450 ? joined.slice(0, 450).trimEnd() : joined;
|
|
134163
|
+
const truncated = prepared.renderedAs.truncated || text.length < joined.length;
|
|
134164
|
+
return {
|
|
134165
|
+
text,
|
|
134166
|
+
renderedAs: {
|
|
134167
|
+
...prepared.renderedAs,
|
|
134168
|
+
truncated,
|
|
134169
|
+
format: "text"
|
|
134170
|
+
}
|
|
134171
|
+
};
|
|
134172
|
+
}
|
|
134173
|
+
//#endregion
|
|
134174
|
+
//#region src/speak/provider.ts
|
|
134175
|
+
function quietLogger$1() {
|
|
134176
|
+
const logger = {
|
|
134177
|
+
debug() {},
|
|
134178
|
+
info() {},
|
|
134179
|
+
warn() {},
|
|
134180
|
+
error() {},
|
|
134181
|
+
child() {
|
|
134182
|
+
return logger;
|
|
134183
|
+
},
|
|
134184
|
+
withTags() {
|
|
134185
|
+
return logger;
|
|
134186
|
+
}
|
|
134187
|
+
};
|
|
134188
|
+
return logger;
|
|
134189
|
+
}
|
|
134190
|
+
function readString(config, key) {
|
|
134191
|
+
const value = config[key];
|
|
134192
|
+
return typeof value === "string" ? value : "";
|
|
134193
|
+
}
|
|
134194
|
+
function readMode(config) {
|
|
134195
|
+
return config["mode"] === "announce" ? "announce" : "speak";
|
|
134196
|
+
}
|
|
134197
|
+
function readVolume(config) {
|
|
134198
|
+
const value = config["volume"];
|
|
134199
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 40;
|
|
134200
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
134201
|
+
}
|
|
134202
|
+
function respectDnd(config) {
|
|
134203
|
+
return config["respectDnd"] !== false;
|
|
134204
|
+
}
|
|
134205
|
+
/** "Kitchen · Living room · ECHO" — the name first, then what tells two Echoes apart. */
|
|
134206
|
+
function echoOptionLabel(device) {
|
|
134207
|
+
const kind = device.kind === "group" ? "Group" : device.family;
|
|
134208
|
+
const parts = [device.name.length > 0 ? device.name : device.id];
|
|
134209
|
+
if (device.room !== void 0 && device.room.length > 0) parts.push(device.room);
|
|
134210
|
+
if (kind.length > 0) parts.push(kind);
|
|
134211
|
+
return parts.join(" · ");
|
|
134212
|
+
}
|
|
134213
|
+
function echoField(devices, showWhen) {
|
|
134214
|
+
return {
|
|
134215
|
+
type: "select",
|
|
134216
|
+
key: "echoId",
|
|
134217
|
+
label: "Echo",
|
|
134218
|
+
labelKey: "import-alexa:echoTarget.s_echo.echoId.label",
|
|
134219
|
+
description: devices.length === 0 ? "No Echo found yet. Sign the Amazon account in on Integrations, Alexa." : void 0,
|
|
134220
|
+
descriptionKey: devices.length === 0 ? "import-alexa:echoTarget.s_echo.echoId.empty" : void 0,
|
|
134221
|
+
options: devices.map((device) => ({
|
|
134222
|
+
value: device.id,
|
|
134223
|
+
label: echoOptionLabel(device)
|
|
134224
|
+
})),
|
|
134225
|
+
...showWhen === void 0 ? {} : { showWhen }
|
|
134226
|
+
};
|
|
134227
|
+
}
|
|
134228
|
+
/**
|
|
134229
|
+
* The two pickers are not `required`: a required select shows its first option
|
|
134230
|
+
* while the form still holds no value, so a save would drop the choice. Left
|
|
134231
|
+
* optional they open on "Select", and `upsertTarget` refuses an empty pick.
|
|
134232
|
+
*
|
|
134233
|
+
* The Echo picker is built from the accounts' own device lists on every call,
|
|
134234
|
+
* so a new Echo shows up without a deploy. With one account there is no account
|
|
134235
|
+
* field; with several, each account's Echoes appear when that account is chosen.
|
|
134236
|
+
*/
|
|
134237
|
+
function configSchema(brokers) {
|
|
134238
|
+
const first = brokers[0];
|
|
134239
|
+
return { sections: [{
|
|
134240
|
+
id: "echo",
|
|
134241
|
+
title: "Echo",
|
|
134242
|
+
titleKey: "import-alexa:echoTarget.s_echo.title",
|
|
134243
|
+
fields: [
|
|
134244
|
+
...brokers.length > 1 ? [{
|
|
134245
|
+
type: "select",
|
|
134246
|
+
key: "brokerId",
|
|
134247
|
+
label: "Amazon account",
|
|
134248
|
+
labelKey: "import-alexa:echoTarget.s_echo.brokerId.label",
|
|
134249
|
+
options: brokers.map((broker) => ({
|
|
134250
|
+
value: broker.id,
|
|
134251
|
+
label: broker.name
|
|
134252
|
+
}))
|
|
134253
|
+
}, ...brokers.map((broker) => echoField(broker.devices, {
|
|
134254
|
+
field: "brokerId",
|
|
134255
|
+
equals: broker.id
|
|
134256
|
+
}))] : [echoField(first?.devices ?? [])],
|
|
134257
|
+
{
|
|
134258
|
+
type: "select",
|
|
134259
|
+
key: "mode",
|
|
134260
|
+
label: "Mode",
|
|
134261
|
+
labelKey: "import-alexa:echoTarget.s_echo.mode.label",
|
|
134262
|
+
default: "speak",
|
|
134263
|
+
options: [{
|
|
134264
|
+
value: "speak",
|
|
134265
|
+
label: "Speak (quiet hours apply)"
|
|
134266
|
+
}, {
|
|
134267
|
+
value: "announce",
|
|
134268
|
+
label: "Announce (chime, then speech)"
|
|
134269
|
+
}]
|
|
134270
|
+
},
|
|
134271
|
+
{
|
|
134272
|
+
type: "number",
|
|
134273
|
+
key: "volume",
|
|
134274
|
+
label: "Volume",
|
|
134275
|
+
labelKey: "import-alexa:echoTarget.s_echo.volume.label",
|
|
134276
|
+
min: 0,
|
|
134277
|
+
max: 100,
|
|
134278
|
+
default: 40
|
|
134279
|
+
},
|
|
134280
|
+
{
|
|
134281
|
+
type: "boolean",
|
|
134282
|
+
key: "respectDnd",
|
|
134283
|
+
label: "Respect Do Not Disturb",
|
|
134284
|
+
labelKey: "import-alexa:echoTarget.s_echo.respectDnd.label",
|
|
134285
|
+
default: true
|
|
134286
|
+
}
|
|
134287
|
+
]
|
|
134288
|
+
}] };
|
|
134289
|
+
}
|
|
134290
|
+
/** The Alexa target kind as the Add target form sees it, for the accounts as they are now. */
|
|
134291
|
+
function alexaTargetKind(brokers) {
|
|
134292
|
+
return {
|
|
134293
|
+
kind: ALEXA_SPEAK_KIND,
|
|
134294
|
+
label: "Alexa",
|
|
134295
|
+
icon: "megaphone",
|
|
134296
|
+
iconUrl: ALEXA_SPEAK_ICON_URL,
|
|
134297
|
+
iconMediaType: "image/svg+xml",
|
|
134298
|
+
addonId: ALEXA_SPEAK_ADDON_ID,
|
|
134299
|
+
supportsDiscovery: true,
|
|
134300
|
+
destinationKeys: ["brokerId", "echoId"],
|
|
134301
|
+
configSchema: configSchema(brokers),
|
|
134302
|
+
caps: {
|
|
134303
|
+
attachments: {
|
|
134304
|
+
mediaTypes: [],
|
|
134305
|
+
mode: "url",
|
|
134306
|
+
max: 0
|
|
134307
|
+
},
|
|
134308
|
+
actions: 0,
|
|
134309
|
+
actionIcons: false,
|
|
134310
|
+
levels: [{
|
|
134311
|
+
id: "default",
|
|
134312
|
+
label: "Default",
|
|
134313
|
+
ordinal: 3
|
|
134314
|
+
}],
|
|
134315
|
+
format: ["text"],
|
|
134316
|
+
clickUrl: false,
|
|
134317
|
+
sound: false,
|
|
134318
|
+
ttl: false,
|
|
134319
|
+
bodyMaxLen: 450
|
|
134320
|
+
}
|
|
134321
|
+
};
|
|
134322
|
+
}
|
|
134323
|
+
function discoveredFrom(broker, device) {
|
|
134324
|
+
return {
|
|
134325
|
+
kind: ALEXA_SPEAK_KIND,
|
|
134326
|
+
suggestedName: device.name.length > 0 ? device.name : device.id,
|
|
134327
|
+
config: {
|
|
134328
|
+
brokerId: broker.id,
|
|
134329
|
+
echoId: device.id,
|
|
134330
|
+
mode: "speak",
|
|
134331
|
+
volume: 40,
|
|
134332
|
+
respectDnd: true,
|
|
134333
|
+
family: device.family,
|
|
134334
|
+
deviceKind: device.kind
|
|
134335
|
+
}
|
|
134336
|
+
};
|
|
134337
|
+
}
|
|
134338
|
+
function createAlexaNotificationProvider(ports) {
|
|
134339
|
+
const logger = ports.logger ?? quietLogger$1();
|
|
134340
|
+
let targets = ports.loadTargets();
|
|
134341
|
+
let ledger = emptySpeechLedger();
|
|
134342
|
+
let deliveries = [];
|
|
134343
|
+
function record(row) {
|
|
134344
|
+
deliveries = [...deliveries, row];
|
|
134345
|
+
}
|
|
134346
|
+
/**
|
|
134347
|
+
* The account a target speaks through: the one it names; else the only one
|
|
134348
|
+
* there is; else the one whose Echoes include the target's Echo.
|
|
134349
|
+
*/
|
|
134350
|
+
function brokerFor(config) {
|
|
134351
|
+
const all = ports.brokers();
|
|
134352
|
+
const named = readString(config, "brokerId");
|
|
134353
|
+
if (named.length > 0) return all.find((broker) => broker.id === named);
|
|
134354
|
+
if (all.length === 1) return all[0];
|
|
134355
|
+
const echoId = readString(config, "echoId");
|
|
134356
|
+
const owners = all.filter((broker) => broker.devices.some((device) => device.id === echoId));
|
|
134357
|
+
return owners.length === 1 ? owners[0] : void 0;
|
|
134358
|
+
}
|
|
134359
|
+
function persist(next) {
|
|
134360
|
+
targets = next;
|
|
134361
|
+
ports.saveTargets(next);
|
|
134362
|
+
}
|
|
134363
|
+
async function deliver(target, notification) {
|
|
134364
|
+
const speech = speechFromNotification(notification);
|
|
134365
|
+
const echoId = readString(target.config, "echoId");
|
|
134366
|
+
const deviceId = notification.deviceId;
|
|
134367
|
+
const tags = deviceId !== void 0 ? { tags: { deviceId } } : {};
|
|
134368
|
+
if (echoId.length === 0) {
|
|
134369
|
+
record({
|
|
134370
|
+
targetId: target.id,
|
|
134371
|
+
outcome: "failed",
|
|
134372
|
+
at: ports.now(),
|
|
134373
|
+
error: "target has no echo id"
|
|
134374
|
+
});
|
|
134375
|
+
logger.warn("alexa speak dropped: target has no echo id", {
|
|
134376
|
+
...tags,
|
|
134377
|
+
meta: { targetId: target.id }
|
|
134378
|
+
});
|
|
134379
|
+
return {
|
|
134380
|
+
success: false,
|
|
134381
|
+
error: "target has no echo id",
|
|
134382
|
+
renderedAs: speech.renderedAs
|
|
134383
|
+
};
|
|
134384
|
+
}
|
|
134385
|
+
if (speech.text.length === 0) {
|
|
134386
|
+
record({
|
|
134387
|
+
targetId: target.id,
|
|
134388
|
+
outcome: "empty",
|
|
134389
|
+
at: ports.now()
|
|
134390
|
+
});
|
|
134391
|
+
logger.info("alexa speak dropped: empty text", {
|
|
134392
|
+
...tags,
|
|
134393
|
+
meta: {
|
|
134394
|
+
targetId: target.id,
|
|
134395
|
+
echoId
|
|
134396
|
+
}
|
|
134397
|
+
});
|
|
134398
|
+
return {
|
|
134399
|
+
success: true,
|
|
134400
|
+
renderedAs: speech.renderedAs
|
|
134401
|
+
};
|
|
134402
|
+
}
|
|
134403
|
+
const broker = brokerFor(target.config);
|
|
134404
|
+
if (broker === void 0) {
|
|
134405
|
+
const error = "target has no Amazon account";
|
|
134406
|
+
record({
|
|
134407
|
+
targetId: target.id,
|
|
134408
|
+
outcome: "failed",
|
|
134409
|
+
at: ports.now(),
|
|
134410
|
+
error
|
|
134411
|
+
});
|
|
134412
|
+
logger.warn("alexa speak dropped: target names no Amazon account", {
|
|
134413
|
+
...tags,
|
|
134414
|
+
meta: {
|
|
134415
|
+
targetId: target.id,
|
|
134416
|
+
echoId
|
|
134417
|
+
}
|
|
134418
|
+
});
|
|
134419
|
+
return {
|
|
134420
|
+
success: false,
|
|
134421
|
+
error,
|
|
134422
|
+
renderedAs: speech.renderedAs
|
|
134423
|
+
};
|
|
134424
|
+
}
|
|
134425
|
+
const device = broker.devices.find((row) => row.id === echoId);
|
|
134426
|
+
if (device !== void 0 && respectDnd(target.config) && device.dnd) {
|
|
134427
|
+
record({
|
|
134428
|
+
targetId: target.id,
|
|
134429
|
+
outcome: "dnd",
|
|
134430
|
+
at: ports.now()
|
|
134431
|
+
});
|
|
134432
|
+
logger.info("alexa speak skipped: do not disturb", {
|
|
134433
|
+
...tags,
|
|
134434
|
+
meta: {
|
|
134435
|
+
targetId: target.id,
|
|
134436
|
+
echoId
|
|
134437
|
+
}
|
|
134438
|
+
});
|
|
134439
|
+
return {
|
|
134440
|
+
success: true,
|
|
134441
|
+
renderedAs: speech.renderedAs
|
|
134442
|
+
};
|
|
134443
|
+
}
|
|
134444
|
+
if (device !== void 0 && !device.online) {
|
|
134445
|
+
record({
|
|
134446
|
+
targetId: target.id,
|
|
134447
|
+
outcome: "offline",
|
|
134448
|
+
at: ports.now()
|
|
134449
|
+
});
|
|
134450
|
+
logger.info("alexa speak not delivered: echo offline", {
|
|
134451
|
+
...tags,
|
|
134452
|
+
meta: {
|
|
134453
|
+
targetId: target.id,
|
|
134454
|
+
echoId
|
|
134455
|
+
}
|
|
134456
|
+
});
|
|
134457
|
+
return {
|
|
134458
|
+
success: false,
|
|
134459
|
+
error: "echo offline",
|
|
134460
|
+
renderedAs: speech.renderedAs
|
|
134461
|
+
};
|
|
134462
|
+
}
|
|
134463
|
+
const admitted = admitSpeech(ledger, echoId, speech.text, ports.now());
|
|
134464
|
+
ledger = admitted.ledger;
|
|
134465
|
+
if (admitted.decision !== "admit") {
|
|
134466
|
+
record({
|
|
134467
|
+
targetId: target.id,
|
|
134468
|
+
outcome: admitted.decision,
|
|
134469
|
+
at: ports.now()
|
|
134470
|
+
});
|
|
134471
|
+
logger.info("alexa speak skipped", {
|
|
134472
|
+
...tags,
|
|
134473
|
+
meta: {
|
|
134474
|
+
targetId: target.id,
|
|
134475
|
+
echoId,
|
|
134476
|
+
outcome: admitted.decision
|
|
134477
|
+
}
|
|
134478
|
+
});
|
|
134479
|
+
return {
|
|
134480
|
+
success: true,
|
|
134481
|
+
renderedAs: speech.renderedAs
|
|
134482
|
+
};
|
|
134483
|
+
}
|
|
134484
|
+
const mode = readMode(target.config);
|
|
134485
|
+
try {
|
|
134486
|
+
const outcomes = await ports.speak({
|
|
134487
|
+
brokerId: broker.id,
|
|
134488
|
+
mode,
|
|
134489
|
+
targets: [echoId],
|
|
134490
|
+
text: speech.text,
|
|
134491
|
+
volume: readVolume(target.config)
|
|
134492
|
+
});
|
|
134493
|
+
const one = outcomes.find((row) => row.targetId === echoId) ?? outcomes[0];
|
|
134494
|
+
if (one === void 0 || !one.ok) {
|
|
134495
|
+
const error = one?.error ?? "speak failed";
|
|
134496
|
+
record({
|
|
134497
|
+
targetId: target.id,
|
|
134498
|
+
outcome: "failed",
|
|
134499
|
+
at: ports.now(),
|
|
134500
|
+
error
|
|
134501
|
+
});
|
|
134502
|
+
logger.warn("alexa speak failed", {
|
|
134503
|
+
...tags,
|
|
134504
|
+
meta: {
|
|
134505
|
+
targetId: target.id,
|
|
134506
|
+
echoId,
|
|
134507
|
+
error
|
|
134508
|
+
}
|
|
134509
|
+
});
|
|
134510
|
+
return {
|
|
134511
|
+
success: false,
|
|
134512
|
+
error,
|
|
134513
|
+
renderedAs: speech.renderedAs
|
|
134514
|
+
};
|
|
134515
|
+
}
|
|
134516
|
+
const outcome = mode === "announce" ? "announced" : "spoken";
|
|
134517
|
+
record({
|
|
134518
|
+
targetId: target.id,
|
|
134519
|
+
outcome,
|
|
134520
|
+
at: ports.now()
|
|
134521
|
+
});
|
|
134522
|
+
logger.info("alexa speak delivered", {
|
|
134523
|
+
...tags,
|
|
134524
|
+
meta: {
|
|
134525
|
+
targetId: target.id,
|
|
134526
|
+
echoId,
|
|
134527
|
+
outcome
|
|
134528
|
+
}
|
|
134529
|
+
});
|
|
134530
|
+
return {
|
|
134531
|
+
success: true,
|
|
134532
|
+
renderedAs: speech.renderedAs
|
|
134533
|
+
};
|
|
134534
|
+
} catch (err) {
|
|
134535
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
134536
|
+
record({
|
|
134537
|
+
targetId: target.id,
|
|
134538
|
+
outcome: "failed",
|
|
134539
|
+
at: ports.now(),
|
|
134540
|
+
error
|
|
134541
|
+
});
|
|
134542
|
+
logger.warn("alexa speak failed", {
|
|
134543
|
+
...tags,
|
|
134544
|
+
meta: {
|
|
134545
|
+
targetId: target.id,
|
|
134546
|
+
echoId,
|
|
134547
|
+
error
|
|
134548
|
+
}
|
|
134549
|
+
});
|
|
134550
|
+
return {
|
|
134551
|
+
success: false,
|
|
134552
|
+
error,
|
|
134553
|
+
renderedAs: speech.renderedAs
|
|
134554
|
+
};
|
|
134555
|
+
}
|
|
134556
|
+
}
|
|
134557
|
+
return {
|
|
134558
|
+
get deliveries() {
|
|
134559
|
+
return deliveries;
|
|
134560
|
+
},
|
|
134561
|
+
async listTargetKinds() {
|
|
134562
|
+
return [alexaTargetKind(ports.brokers())];
|
|
134563
|
+
},
|
|
134564
|
+
async listTargets() {
|
|
134565
|
+
return [...targets];
|
|
134566
|
+
},
|
|
134567
|
+
async getTarget(input) {
|
|
134568
|
+
const found = targets.find((target) => target.id === input.targetId);
|
|
134569
|
+
if (found === void 0) throw new Error(`target not found: ${input.targetId}`);
|
|
134570
|
+
return found;
|
|
134571
|
+
},
|
|
134572
|
+
async discoverTargets() {
|
|
134573
|
+
const taken = new Set(targets.flatMap((row) => {
|
|
134574
|
+
const owner = brokerFor(row.config);
|
|
134575
|
+
return owner === void 0 ? [] : [`${owner.id}/${readString(row.config, "echoId")}`];
|
|
134576
|
+
}));
|
|
134577
|
+
return ports.brokers().flatMap((broker) => broker.devices.filter((device) => !taken.has(`${broker.id}/${device.id}`)).map((device) => discoveredFrom(broker, device)));
|
|
134578
|
+
},
|
|
134579
|
+
async probeTargetConfig(input) {
|
|
134580
|
+
const broker = brokerFor(input.config);
|
|
134581
|
+
return broker !== void 0 && broker.ready ? {
|
|
134582
|
+
ok: true,
|
|
134583
|
+
resolvedUrl: "alexa://session"
|
|
134584
|
+
} : {
|
|
134585
|
+
ok: false,
|
|
134586
|
+
resolvedUrl: "alexa://session",
|
|
134587
|
+
error: "the Amazon account is not signed in on its broker"
|
|
134588
|
+
};
|
|
134589
|
+
},
|
|
134590
|
+
async send(input) {
|
|
134591
|
+
const target = targets.find((row) => row.id === input.targetId);
|
|
134592
|
+
if (target === void 0) return {
|
|
134593
|
+
success: false,
|
|
134594
|
+
error: `target not found: ${input.targetId}`
|
|
134595
|
+
};
|
|
134596
|
+
if (!target.enabled) return {
|
|
134597
|
+
success: false,
|
|
134598
|
+
error: "target disabled"
|
|
134599
|
+
};
|
|
134600
|
+
return deliver(target, input.notification);
|
|
134601
|
+
},
|
|
134602
|
+
async testTarget(input) {
|
|
134603
|
+
const found = targets.find((row) => row.id === input.targetId);
|
|
134604
|
+
if (found === void 0) return {
|
|
134605
|
+
success: false,
|
|
134606
|
+
error: `target not found: ${input.targetId}`
|
|
134607
|
+
};
|
|
134608
|
+
if (!found.enabled) return {
|
|
134609
|
+
success: false,
|
|
134610
|
+
error: "target disabled"
|
|
134611
|
+
};
|
|
134612
|
+
return deliver(found, input.sample ?? {
|
|
134613
|
+
body: "This is a CamStack test notification.",
|
|
134614
|
+
title: "CamStack",
|
|
134615
|
+
format: "text",
|
|
134616
|
+
priority: 3
|
|
134617
|
+
});
|
|
134618
|
+
},
|
|
134619
|
+
async upsertTarget(input) {
|
|
134620
|
+
const owner = brokerFor(input.target.config);
|
|
134621
|
+
if (readString(input.target.config, "echoId").length === 0) throw new Error("Pick the Echo that should speak");
|
|
134622
|
+
if (owner === void 0) throw new Error("Pick the Amazon account this Echo belongs to");
|
|
134623
|
+
const pinned = readString(input.target.config, "brokerId").length === 0 ? {
|
|
134624
|
+
...input.target.config,
|
|
134625
|
+
brokerId: owner.id
|
|
134626
|
+
} : input.target.config;
|
|
134627
|
+
const target = {
|
|
134628
|
+
...input.target,
|
|
134629
|
+
config: pinned,
|
|
134630
|
+
addonId: ALEXA_SPEAK_ADDON_ID,
|
|
134631
|
+
kind: ALEXA_SPEAK_KIND
|
|
134632
|
+
};
|
|
134633
|
+
persist([...targets.filter((row) => row.id !== target.id), target]);
|
|
134634
|
+
return target;
|
|
134635
|
+
},
|
|
134636
|
+
async deleteTarget(input) {
|
|
134637
|
+
persist(targets.filter((row) => row.id !== input.targetId));
|
|
134638
|
+
},
|
|
134639
|
+
async setTargetEnabled(input) {
|
|
134640
|
+
persist(targets.map((row) => row.id === input.targetId ? {
|
|
134641
|
+
...row,
|
|
134642
|
+
enabled: input.enabled
|
|
134643
|
+
} : row));
|
|
134644
|
+
}
|
|
134645
|
+
};
|
|
134646
|
+
}
|
|
134647
|
+
//#endregion
|
|
134648
|
+
//#region src/speak/python-paths.ts
|
|
134649
|
+
/** Locate bundled `python/alexa_worker.py` from dist/ or from src/ under test. */
|
|
134650
|
+
function resolveAlexaWorkerScript(fromModuleUrl = require("url").pathToFileURL(__filename).href) {
|
|
134651
|
+
const here = (0, node_path.dirname)((0, node_url.fileURLToPath)(fromModuleUrl));
|
|
134652
|
+
const candidates = [
|
|
134653
|
+
(0, node_path.join)(here, "../python/alexa_worker.py"),
|
|
134654
|
+
(0, node_path.join)(here, "../../python/alexa_worker.py"),
|
|
134655
|
+
(0, node_path.join)(here, "../../../python/alexa_worker.py")
|
|
134656
|
+
];
|
|
134657
|
+
for (const candidate of candidates) if ((0, node_fs.existsSync)(candidate)) return candidate;
|
|
134658
|
+
throw new Error(`import-alexa: alexa_worker.py not found. Searched:\n${candidates.join("\n")}`);
|
|
134659
|
+
}
|
|
134660
|
+
//#endregion
|
|
134661
|
+
//#region src/speak/worker-client.ts
|
|
134662
|
+
/**
|
|
134663
|
+
* One Python process per broker, for the life of the addon. Requests are one JSON line
|
|
134664
|
+
* on stdin; responses are one JSON line on stdout, matched by id.
|
|
134665
|
+
* A crash restarts the process until the same bound CrashSupervisor uses,
|
|
134666
|
+
* then requests fail instead of looping.
|
|
134667
|
+
*/
|
|
134668
|
+
/** Pure: a new timestamp list, never a mutated one. */
|
|
134669
|
+
function recordWorkerCrash(times, now, windowMs, maxCrashes) {
|
|
134670
|
+
const cutoff = now - windowMs;
|
|
134671
|
+
const recent = [...times.filter((t) => t >= cutoff), now];
|
|
134672
|
+
if (recent.length >= maxCrashes) return {
|
|
134673
|
+
action: "failed",
|
|
134674
|
+
times: recent
|
|
134675
|
+
};
|
|
134676
|
+
return {
|
|
134677
|
+
action: "respawn",
|
|
134678
|
+
times: recent
|
|
134679
|
+
};
|
|
134680
|
+
}
|
|
134681
|
+
function quietLogger() {
|
|
134682
|
+
const logger = {
|
|
134683
|
+
debug() {},
|
|
134684
|
+
info() {},
|
|
134685
|
+
warn() {},
|
|
134686
|
+
error() {},
|
|
134687
|
+
child() {
|
|
134688
|
+
return logger;
|
|
134689
|
+
},
|
|
134690
|
+
withTags() {
|
|
134691
|
+
return logger;
|
|
134692
|
+
}
|
|
134693
|
+
};
|
|
134694
|
+
return logger;
|
|
134695
|
+
}
|
|
134696
|
+
function wrapChild(child) {
|
|
134697
|
+
const stdin = child.stdin;
|
|
134698
|
+
const stdout = child.stdout;
|
|
134699
|
+
const stderr = child.stderr;
|
|
134700
|
+
if (stdin === null || stdout === null || stderr === null) throw new Error("alexa worker spawn did not provide pipes");
|
|
134701
|
+
return {
|
|
134702
|
+
stdin: { write(chunk) {
|
|
134703
|
+
stdin.write(chunk);
|
|
134704
|
+
} },
|
|
134705
|
+
stdout: { on(event, listener) {
|
|
134706
|
+
stdout.on(event, listener);
|
|
134707
|
+
} },
|
|
134708
|
+
stderr: { on(event, listener) {
|
|
134709
|
+
stderr.on(event, listener);
|
|
134710
|
+
} },
|
|
134711
|
+
on(event, listener) {
|
|
134712
|
+
if (event === "close") child.on("close", listener);
|
|
134713
|
+
else child.on("error", listener);
|
|
134714
|
+
},
|
|
134715
|
+
kill(signal) {
|
|
134716
|
+
child.kill(signal);
|
|
134717
|
+
}
|
|
134718
|
+
};
|
|
134719
|
+
}
|
|
134720
|
+
function defaultSpawn(pythonPath, scriptPath, env) {
|
|
134721
|
+
return wrapChild((0, node_child_process.spawn)(pythonPath, [scriptPath], {
|
|
134722
|
+
stdio: [
|
|
134723
|
+
"pipe",
|
|
134724
|
+
"pipe",
|
|
134725
|
+
"pipe"
|
|
134726
|
+
],
|
|
134727
|
+
env
|
|
134728
|
+
}));
|
|
134729
|
+
}
|
|
134730
|
+
function startAlexaWorker(deps) {
|
|
134731
|
+
const logger = deps.logger ?? quietLogger();
|
|
134732
|
+
const now = deps.now ?? (() => Date.now());
|
|
134733
|
+
const schedule = deps.schedule ?? ((fn, delayMs) => {
|
|
134734
|
+
setTimeout(fn, delayMs);
|
|
134735
|
+
});
|
|
134736
|
+
const spawnChild = deps.spawnChild ?? defaultSpawn;
|
|
134737
|
+
const maxCrashes = deps.maxCrashes ?? 5;
|
|
134738
|
+
const windowMs = deps.windowMs ?? 6e4;
|
|
134739
|
+
const env = deps.env ?? process.env;
|
|
134740
|
+
let child = null;
|
|
134741
|
+
let buffer = "";
|
|
134742
|
+
let nextId = 1;
|
|
134743
|
+
let stopped = false;
|
|
134744
|
+
let failed = false;
|
|
134745
|
+
let crashTimes = [];
|
|
134746
|
+
let secrets = [];
|
|
134747
|
+
let pending = null;
|
|
134748
|
+
const queue = [];
|
|
134749
|
+
function scrub(text) {
|
|
134750
|
+
return scrubSecrets(text, secrets);
|
|
134751
|
+
}
|
|
134752
|
+
function failPending(err) {
|
|
134753
|
+
const current = pending;
|
|
134754
|
+
pending = null;
|
|
134755
|
+
current?.reject(err);
|
|
134756
|
+
}
|
|
134757
|
+
function attach(spawned) {
|
|
134758
|
+
child = spawned;
|
|
134759
|
+
buffer = "";
|
|
134760
|
+
let settled = false;
|
|
134761
|
+
const finish = (code) => {
|
|
134762
|
+
if (settled || child !== spawned) return;
|
|
134763
|
+
settled = true;
|
|
134764
|
+
onExit(code);
|
|
134765
|
+
};
|
|
134766
|
+
spawned.stdout.on("data", (chunk) => {
|
|
134767
|
+
buffer += chunk.toString("utf8");
|
|
134768
|
+
const lines = buffer.split("\n");
|
|
134769
|
+
buffer = lines.pop() ?? "";
|
|
134770
|
+
for (const line of lines) {
|
|
134771
|
+
if (line.trim().length === 0) continue;
|
|
134772
|
+
acceptLine(line);
|
|
134773
|
+
}
|
|
134774
|
+
});
|
|
134775
|
+
spawned.stderr.on("data", (chunk) => {
|
|
134776
|
+
const text = scrub(chunk.toString("utf8")).trim();
|
|
134777
|
+
if (text.length === 0) return;
|
|
134778
|
+
logger.warn("alexa worker stderr", { meta: { text: text.slice(0, 500) } });
|
|
134779
|
+
});
|
|
134780
|
+
spawned.on("error", (err) => {
|
|
134781
|
+
logger.warn("alexa worker spawn error", { meta: { error: scrub(err.message) } });
|
|
134782
|
+
finish(null);
|
|
134783
|
+
});
|
|
134784
|
+
spawned.on("close", (code) => {
|
|
134785
|
+
finish(code);
|
|
134786
|
+
});
|
|
134787
|
+
}
|
|
134788
|
+
function acceptLine(line) {
|
|
134789
|
+
let parsed;
|
|
134790
|
+
try {
|
|
134791
|
+
parsed = JSON.parse(line);
|
|
134792
|
+
} catch (err) {
|
|
134793
|
+
logger.warn("alexa worker non-JSON line", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
134794
|
+
return;
|
|
134795
|
+
}
|
|
134796
|
+
const checked = WorkerLineSchema.safeParse(parsed);
|
|
134797
|
+
if (!checked.success) {
|
|
134798
|
+
logger.warn("alexa worker line failed schema", { meta: { error: checked.error.message } });
|
|
134799
|
+
return;
|
|
134800
|
+
}
|
|
134801
|
+
const message = checked.data;
|
|
134802
|
+
if (pending === null || pending.id !== message.id) {
|
|
134803
|
+
logger.warn("alexa worker line for an unknown request", { meta: { id: message.id } });
|
|
134804
|
+
return;
|
|
134805
|
+
}
|
|
134806
|
+
const current = pending;
|
|
134807
|
+
pending = null;
|
|
134808
|
+
if (message.ok) current.resolve(message.result);
|
|
134809
|
+
else current.reject(new Error(scrub(message.error)));
|
|
134810
|
+
pump();
|
|
134811
|
+
}
|
|
134812
|
+
function onExit(code) {
|
|
134813
|
+
child = null;
|
|
134814
|
+
if (stopped) {
|
|
134815
|
+
failPending(/* @__PURE__ */ new Error("alexa worker stopped"));
|
|
134816
|
+
return;
|
|
134817
|
+
}
|
|
134818
|
+
failPending(/* @__PURE__ */ new Error(`alexa worker exited (${code ?? "null"})`));
|
|
134819
|
+
const decision = recordWorkerCrash(crashTimes, now(), windowMs, maxCrashes);
|
|
134820
|
+
crashTimes = decision.times;
|
|
134821
|
+
if (decision.action === "failed") {
|
|
134822
|
+
failed = true;
|
|
134823
|
+
logger.error("alexa worker restart bound reached", { meta: { crashes: crashTimes.length } });
|
|
134824
|
+
const waiting = queue.splice(0, queue.length);
|
|
134825
|
+
for (const item of waiting) item.pending.reject(/* @__PURE__ */ new Error("alexa worker restart bound reached"));
|
|
134826
|
+
return;
|
|
134827
|
+
}
|
|
134828
|
+
logger.warn("alexa worker exited; restarting", { meta: {
|
|
134829
|
+
code,
|
|
134830
|
+
crashes: crashTimes.length
|
|
134831
|
+
} });
|
|
134832
|
+
schedule(() => {
|
|
134833
|
+
if (stopped || failed) return;
|
|
134834
|
+
launch();
|
|
134835
|
+
pump();
|
|
134836
|
+
}, Math.min(3e4, 500 * 2 ** Math.min(6, crashTimes.length - 1)));
|
|
134837
|
+
}
|
|
134838
|
+
function launch() {
|
|
134839
|
+
if (stopped || failed) return;
|
|
134840
|
+
attach(spawnChild(deps.pythonPath, deps.scriptPath, env));
|
|
134841
|
+
}
|
|
134842
|
+
function pump() {
|
|
134843
|
+
if (pending !== null || child === null || failed || stopped) return;
|
|
134844
|
+
const next = queue.shift();
|
|
134845
|
+
if (next === void 0) return;
|
|
134846
|
+
pending = next.pending;
|
|
134847
|
+
child.stdin.write(requestLine(next.pending.id, next.request));
|
|
134848
|
+
}
|
|
134849
|
+
function request(body) {
|
|
134850
|
+
if (failed) return Promise.reject(/* @__PURE__ */ new Error("alexa worker restart bound reached"));
|
|
134851
|
+
if (stopped) return Promise.reject(/* @__PURE__ */ new Error("alexa worker stopped"));
|
|
134852
|
+
const id = String(nextId);
|
|
134853
|
+
nextId += 1;
|
|
134854
|
+
return new Promise((resolve, reject) => {
|
|
134855
|
+
queue.push({
|
|
134856
|
+
request: body,
|
|
134857
|
+
pending: {
|
|
134858
|
+
id,
|
|
134859
|
+
resolve,
|
|
134860
|
+
reject
|
|
134861
|
+
}
|
|
134862
|
+
});
|
|
134863
|
+
if (child === null) launch();
|
|
134864
|
+
pump();
|
|
134865
|
+
});
|
|
134866
|
+
}
|
|
134867
|
+
return {
|
|
134868
|
+
request,
|
|
134869
|
+
noteSecrets(next) {
|
|
134870
|
+
secrets = [...next];
|
|
134871
|
+
},
|
|
134872
|
+
stop() {
|
|
134873
|
+
stopped = true;
|
|
134874
|
+
const current = child;
|
|
134875
|
+
child = null;
|
|
134876
|
+
current?.kill("SIGTERM");
|
|
134877
|
+
failPending(/* @__PURE__ */ new Error("alexa worker stopped"));
|
|
134878
|
+
const waiting = queue.splice(0, queue.length);
|
|
134879
|
+
for (const item of waiting) item.pending.reject(/* @__PURE__ */ new Error("alexa worker stopped"));
|
|
134880
|
+
},
|
|
134881
|
+
get failed() {
|
|
134882
|
+
return failed;
|
|
134883
|
+
},
|
|
134884
|
+
get crashesInWindow() {
|
|
134885
|
+
return crashTimes.length;
|
|
134886
|
+
}
|
|
134887
|
+
};
|
|
134888
|
+
}
|
|
134889
|
+
//#endregion
|
|
134890
|
+
//#region src/speak/controller.ts
|
|
134891
|
+
function targetsFrom(settings) {
|
|
134892
|
+
return settings.targets.map((row) => ({
|
|
134893
|
+
id: row.id,
|
|
134894
|
+
name: row.name,
|
|
134895
|
+
kind: ALEXA_SPEAK_KIND,
|
|
134896
|
+
addonId: ALEXA_SPEAK_ADDON_ID,
|
|
134897
|
+
enabled: row.enabled,
|
|
134898
|
+
config: {
|
|
134899
|
+
...row.brokerId.length > 0 ? { brokerId: row.brokerId } : {},
|
|
134900
|
+
echoId: row.echoId,
|
|
134901
|
+
mode: row.mode,
|
|
134902
|
+
volume: row.volume,
|
|
134903
|
+
respectDnd: row.respectDnd
|
|
134904
|
+
}
|
|
134905
|
+
}));
|
|
134906
|
+
}
|
|
134907
|
+
function rowsFrom(targets) {
|
|
134908
|
+
return targets.map((target) => ({
|
|
134909
|
+
id: target.id,
|
|
134910
|
+
name: target.name,
|
|
134911
|
+
enabled: target.enabled,
|
|
134912
|
+
brokerId: typeof target.config["brokerId"] === "string" ? target.config["brokerId"] : "",
|
|
134913
|
+
echoId: typeof target.config["echoId"] === "string" ? target.config["echoId"] : "",
|
|
134914
|
+
mode: target.config["mode"] === "announce" ? "announce" : "speak",
|
|
134915
|
+
volume: typeof target.config["volume"] === "number" ? target.config["volume"] : 40,
|
|
134916
|
+
respectDnd: target.config["respectDnd"] !== false
|
|
134917
|
+
}));
|
|
134918
|
+
}
|
|
134919
|
+
var AlexaSpeakController = class {
|
|
134920
|
+
host;
|
|
134921
|
+
provider;
|
|
134922
|
+
settings;
|
|
134923
|
+
pythonPath = null;
|
|
134924
|
+
echoes = /* @__PURE__ */ new Map();
|
|
134925
|
+
timer = null;
|
|
134926
|
+
queue = Promise.resolve();
|
|
134927
|
+
now;
|
|
134928
|
+
constructor(host) {
|
|
134929
|
+
this.host = host;
|
|
134930
|
+
this.now = host.now ?? (() => Date.now());
|
|
134931
|
+
this.settings = readSpeakSettings(host.readConfig());
|
|
134932
|
+
this.provider = createAlexaNotificationProvider({
|
|
134933
|
+
brokers: () => this.brokerViews(),
|
|
134934
|
+
now: this.now,
|
|
134935
|
+
logger: host.logger,
|
|
134936
|
+
loadTargets: () => targetsFrom(this.settings),
|
|
134937
|
+
saveTargets: (targets) => {
|
|
134938
|
+
this.settings = {
|
|
134939
|
+
...this.settings,
|
|
134940
|
+
targets: [...rowsFrom(targets)]
|
|
134941
|
+
};
|
|
134942
|
+
this.persist();
|
|
134943
|
+
},
|
|
134944
|
+
speak: (command) => this.utter(command.brokerId, command.mode, command.targets, command.text, command.volume)
|
|
134945
|
+
});
|
|
134946
|
+
}
|
|
134947
|
+
async start() {
|
|
134948
|
+
this.pythonPath = await this.host.ensurePython();
|
|
134949
|
+
if (this.pythonPath === null) {
|
|
134950
|
+
this.host.logger.warn("alexa speak: embedded Python unavailable; Echo speech will not run");
|
|
134951
|
+
return;
|
|
134952
|
+
}
|
|
134953
|
+
await this.migrateLegacyLogin();
|
|
134954
|
+
await this.brokersChanged();
|
|
134955
|
+
this.timer = setInterval(() => {
|
|
134956
|
+
this.refreshAll();
|
|
134957
|
+
}, DEVICE_REFRESH_MS);
|
|
134958
|
+
}
|
|
134959
|
+
stop() {
|
|
134960
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
134961
|
+
this.timer = null;
|
|
134962
|
+
for (const echo of this.echoes.values()) echo.stop();
|
|
134963
|
+
this.echoes.clear();
|
|
134964
|
+
}
|
|
134965
|
+
/** The addon calls this whenever its list of brokers may have changed. */
|
|
134966
|
+
brokersChanged() {
|
|
134967
|
+
this.queue = this.queue.then(() => this.reconcile()).catch(() => void 0);
|
|
134968
|
+
return this.queue;
|
|
134969
|
+
}
|
|
134970
|
+
handlers() {
|
|
134971
|
+
return {
|
|
134972
|
+
speakStatus: async () => this.status(),
|
|
134973
|
+
sendTest: async (input) => this.sendTest(input)
|
|
134974
|
+
};
|
|
134975
|
+
}
|
|
134976
|
+
brokerViews() {
|
|
134977
|
+
return this.host.listBrokers().map((ref) => {
|
|
134978
|
+
const echo = this.echoes.get(ref.id);
|
|
134979
|
+
return {
|
|
134980
|
+
id: ref.id,
|
|
134981
|
+
name: ref.name,
|
|
134982
|
+
ready: echo?.state === "ready",
|
|
134983
|
+
devices: echo?.devices ?? []
|
|
134984
|
+
};
|
|
134985
|
+
});
|
|
134986
|
+
}
|
|
134987
|
+
async persist() {
|
|
134988
|
+
try {
|
|
134989
|
+
await this.host.writeSpeak(this.settings);
|
|
134990
|
+
} catch (err) {
|
|
134991
|
+
this.host.logger.warn("alexa speak: could not store settings", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
134992
|
+
}
|
|
134993
|
+
}
|
|
134994
|
+
/**
|
|
134995
|
+
* Drain the old standalone login once. With exactly one broker its jar and
|
|
134996
|
+
* its targets move onto that broker; otherwise the jar is dropped and the
|
|
134997
|
+
* targets wait for the operator to choose (the status names that state).
|
|
134998
|
+
*/
|
|
134999
|
+
async migrateLegacyLogin() {
|
|
135000
|
+
const brokers = this.host.listBrokers();
|
|
135001
|
+
const plan = planLegacyMigration(legacyLoginOf(this.settings), brokers.map((ref) => ref.id), this.settings.targets);
|
|
135002
|
+
if (!plan.present) return;
|
|
135003
|
+
if (plan.attachTo !== null) await this.host.sessions.put(plan.attachTo, this.settings.session);
|
|
135004
|
+
this.settings = {
|
|
135005
|
+
...this.settings,
|
|
135006
|
+
email: "",
|
|
135007
|
+
password: "",
|
|
135008
|
+
session: "",
|
|
135009
|
+
targets: [...plan.targets]
|
|
135010
|
+
};
|
|
135011
|
+
await this.persist();
|
|
135012
|
+
await this.host.clearLegacySecrets();
|
|
135013
|
+
this.host.logger.info("alexa speak: the old standalone login was retired", { meta: {
|
|
135014
|
+
attachedTo: plan.attachTo ?? "none",
|
|
135015
|
+
brokers: brokers.length,
|
|
135016
|
+
targets: plan.targets.length
|
|
135017
|
+
} });
|
|
135018
|
+
}
|
|
135019
|
+
makeWorker(brokerId, pythonPath) {
|
|
135020
|
+
if (this.host.startWorker !== void 0) return this.host.startWorker(brokerId, pythonPath);
|
|
135021
|
+
return startAlexaWorker({
|
|
135022
|
+
pythonPath,
|
|
135023
|
+
scriptPath: resolveAlexaWorkerScript(),
|
|
135024
|
+
logger: this.host.logger.withTags({ brokerId }),
|
|
135025
|
+
now: this.now
|
|
135026
|
+
});
|
|
135027
|
+
}
|
|
135028
|
+
async reconcile() {
|
|
135029
|
+
const pythonPath = this.pythonPath;
|
|
135030
|
+
if (pythonPath === null) return;
|
|
135031
|
+
const refs = this.host.listBrokers();
|
|
135032
|
+
const live = new Set(refs.map((ref) => ref.id));
|
|
135033
|
+
for (const [id, echo] of this.echoes) {
|
|
135034
|
+
if (live.has(id)) continue;
|
|
135035
|
+
echo.stop();
|
|
135036
|
+
this.echoes.delete(id);
|
|
135037
|
+
await this.host.sessions.delete(id).catch((err) => {
|
|
135038
|
+
this.host.logger.warn("alexa speak: could not drop the session of a removed account", {
|
|
135039
|
+
tags: { brokerId: id },
|
|
135040
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
135041
|
+
});
|
|
135042
|
+
});
|
|
135043
|
+
}
|
|
135044
|
+
const fresh = refs.filter((ref) => !this.echoes.has(ref.id));
|
|
135045
|
+
for (const ref of fresh) this.echoes.set(ref.id, new BrokerEcho({
|
|
135046
|
+
brokerId: ref.id,
|
|
135047
|
+
brokerName: ref.name,
|
|
135048
|
+
domain: ref.amazonDomain,
|
|
135049
|
+
worker: this.makeWorker(ref.id, pythonPath),
|
|
135050
|
+
readSeed: () => this.host.readSeed(ref.id),
|
|
135051
|
+
sessions: this.host.sessions,
|
|
135052
|
+
logger: this.host.logger
|
|
135053
|
+
}));
|
|
135054
|
+
await Promise.all(refs.map(async (ref) => {
|
|
135055
|
+
const echo = this.echoes.get(ref.id);
|
|
135056
|
+
if (echo !== void 0 && (fresh.includes(ref) || echo.state !== "ready")) await echo.signIn();
|
|
135057
|
+
}));
|
|
135058
|
+
}
|
|
135059
|
+
async refreshAll() {
|
|
135060
|
+
for (const echo of this.echoes.values()) if (echo.state === "ready") await echo.refreshDevices();
|
|
135061
|
+
else await echo.signIn();
|
|
135062
|
+
}
|
|
135063
|
+
async utter(brokerId, mode, targets, text, volume) {
|
|
135064
|
+
const echo = this.echoes.get(brokerId);
|
|
135065
|
+
if (echo === void 0) throw new Error("no Echo session for this Amazon account");
|
|
135066
|
+
return echo.utter(mode === "announce" ? "announce" : "speak", targets, text, volume);
|
|
135067
|
+
}
|
|
135068
|
+
async status() {
|
|
135069
|
+
const brokers = this.host.listBrokers();
|
|
135070
|
+
const rows = await Promise.all(brokers.map(async (ref) => {
|
|
135071
|
+
const echo = this.echoes.get(ref.id);
|
|
135072
|
+
return {
|
|
135073
|
+
id: ref.id,
|
|
135074
|
+
name: ref.name,
|
|
135075
|
+
state: echo?.state ?? "failed",
|
|
135076
|
+
...echo === void 0 ? { error: "embedded Python unavailable" } : echo.error === null ? {} : { error: echo.error },
|
|
135077
|
+
devices: [...echo?.devices ?? []],
|
|
135078
|
+
session: echo === void 0 ? {
|
|
135079
|
+
loggedIn: false,
|
|
135080
|
+
ageSec: 0,
|
|
135081
|
+
lastSuccessAt: null
|
|
135082
|
+
} : await echo.session()
|
|
135083
|
+
};
|
|
135084
|
+
}));
|
|
135085
|
+
const targets = this.settings.targets;
|
|
135086
|
+
const ids = brokers.map((ref) => ref.id);
|
|
135087
|
+
return {
|
|
135088
|
+
brokers: rows,
|
|
135089
|
+
unassignedTargets: unassignedCount(targets),
|
|
135090
|
+
notice: noticeFor(targets, ids)
|
|
135091
|
+
};
|
|
135092
|
+
}
|
|
135093
|
+
async sendTest(input) {
|
|
135094
|
+
const outcomes = await this.utter(input.brokerId, input.mode, input.echoIds, input.text, input.volume);
|
|
135095
|
+
const known = new Map((this.echoes.get(input.brokerId)?.devices ?? []).map((device) => [device.id, device]));
|
|
135096
|
+
const next = [...this.settings.targets];
|
|
135097
|
+
for (const echoId of input.echoIds) {
|
|
135098
|
+
if (next.some((row) => row.echoId === echoId && row.brokerId === input.brokerId)) continue;
|
|
135099
|
+
next.push({
|
|
135100
|
+
id: `echo-${input.brokerId}-${echoId}`,
|
|
135101
|
+
name: known.get(echoId)?.name ?? echoId,
|
|
135102
|
+
enabled: true,
|
|
135103
|
+
brokerId: input.brokerId,
|
|
135104
|
+
echoId,
|
|
135105
|
+
mode: input.mode,
|
|
135106
|
+
volume: input.volume,
|
|
135107
|
+
respectDnd: true
|
|
135108
|
+
});
|
|
135109
|
+
}
|
|
135110
|
+
this.settings = {
|
|
135111
|
+
...this.settings,
|
|
135112
|
+
targets: next
|
|
135113
|
+
};
|
|
135114
|
+
await this.persist();
|
|
135115
|
+
return { outcomes: outcomes.map((row) => ({
|
|
135116
|
+
echoId: row.targetId,
|
|
135117
|
+
ok: row.ok,
|
|
135118
|
+
...row.error !== void 0 ? { error: row.error } : {}
|
|
135119
|
+
})) };
|
|
135120
|
+
}
|
|
135121
|
+
};
|
|
135122
|
+
//#endregion
|
|
135123
|
+
//#region src/speak/icon-routes.ts
|
|
135124
|
+
var ALEXA_ICON_SVG = "<svg role=\"img\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#00CAFF\"><title>Amazon Alexa</title><path d=\"M12 0C5.37 0 0 5.37 0 12C0 18.09 4.53 23.11 10.4 23.9V21.5A1.59 1.59 0 0 0 9.32 19.97A8.41 8.41 0 0 1 3.6 11.8A8.37 8.37 0 0 1 12.09 3.6A8.4 8.4 0 0 1 20.4 12.31L20.39 12.38A8.68 8.68 0 0 1 20.36 12.76C20.36 12.83 20.35 12.9 20.34 12.96C20.34 13.04 20.33 13.12 20.32 13.19L20.3 13.29C19.27 20.07 10.45 23.87 10.4 23.9C10.92 23.97 11.46 24 12 24C18.63 24 24 18.63 24 12S18.63 0 12 0Z\"/></svg>";
|
|
135125
|
+
/** One day; the mark is immutable per addon version. */
|
|
135126
|
+
var CACHE_CONTROL = "public, max-age=86400, immutable";
|
|
135127
|
+
function speakIconRoutes() {
|
|
135128
|
+
return [{
|
|
135129
|
+
method: "GET",
|
|
135130
|
+
path: `/icons/${ALEXA_SPEAK_KIND}`,
|
|
135131
|
+
access: "public",
|
|
135132
|
+
description: "Alexa brand mark (image/svg+xml) for the Alexa notification target.",
|
|
135133
|
+
handler: async (_request, reply) => {
|
|
135134
|
+
reply.code(200).type("image/svg+xml").header("cache-control", CACHE_CONTROL).send(ALEXA_ICON_SVG);
|
|
135135
|
+
}
|
|
135136
|
+
}];
|
|
135137
|
+
}
|
|
135138
|
+
//#endregion
|
|
135139
|
+
//#region src/speak/session-store.ts
|
|
135140
|
+
/** The Echo worker's cookie jar per broker, in the records store (secret field, keychain-backed). */
|
|
135141
|
+
var RecordsSpeakSessionStore = class RecordsSpeakSessionStore {
|
|
135142
|
+
kind;
|
|
135143
|
+
constructor(kind) {
|
|
135144
|
+
this.kind = kind;
|
|
135145
|
+
}
|
|
135146
|
+
static of(records) {
|
|
135147
|
+
return new RecordsSpeakSessionStore(records.kind(alexaSpeakSessionsEntryKind));
|
|
135148
|
+
}
|
|
135149
|
+
async get(brokerId) {
|
|
135150
|
+
try {
|
|
135151
|
+
const resolved = await this.kind.getResolved(brokerId);
|
|
135152
|
+
return resolved === null ? "" : resolved.data.session;
|
|
135153
|
+
} catch (err) {
|
|
135154
|
+
if (err instanceof RecordSecretUnavailableError) return "";
|
|
135155
|
+
throw err;
|
|
135156
|
+
}
|
|
135157
|
+
}
|
|
135158
|
+
async put(brokerId, sessionB64) {
|
|
135159
|
+
await this.kind.put({
|
|
135160
|
+
id: brokerId,
|
|
135161
|
+
data: {
|
|
135162
|
+
name: brokerId,
|
|
135163
|
+
session: sessionB64
|
|
135164
|
+
}
|
|
135165
|
+
});
|
|
135166
|
+
}
|
|
135167
|
+
async delete(brokerId) {
|
|
135168
|
+
await this.kind.delete(brokerId);
|
|
135169
|
+
}
|
|
135170
|
+
};
|
|
135171
|
+
//#endregion
|
|
132682
135172
|
//#region src/addon.ts
|
|
135173
|
+
var SPEAK_PAGE = {
|
|
135174
|
+
id: "speak",
|
|
135175
|
+
label: "Speak on Echo",
|
|
135176
|
+
icon: "megaphone",
|
|
135177
|
+
path: "/addon/import-alexa",
|
|
135178
|
+
remoteName: "addon_import_alexa_page",
|
|
135179
|
+
bundle: "remoteEntry.js",
|
|
135180
|
+
section: "administration"
|
|
135181
|
+
};
|
|
132683
135182
|
/** Default config — a fresh install starts with no accounts. */
|
|
132684
135183
|
var DEFAULTS = {
|
|
132685
135184
|
brokers: [],
|
|
132686
135185
|
proxyLogin: {
|
|
132687
135186
|
proxyHost: "",
|
|
132688
135187
|
proxyPort: 3456
|
|
135188
|
+
},
|
|
135189
|
+
speak: {
|
|
135190
|
+
email: "",
|
|
135191
|
+
password: "",
|
|
135192
|
+
region: "EU",
|
|
135193
|
+
session: "",
|
|
135194
|
+
targets: []
|
|
132689
135195
|
}
|
|
132690
135196
|
};
|
|
132691
135197
|
/** Device types an imported Alexa appliance can materialise as. */
|
|
@@ -132727,6 +135233,8 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
132727
135233
|
proxyLogin = null;
|
|
132728
135234
|
brokerStore = null;
|
|
132729
135235
|
activeBrokers = [];
|
|
135236
|
+
speak = null;
|
|
135237
|
+
secretStatePaths = ["speak.password", "speak.session"];
|
|
132730
135238
|
constructor() {
|
|
132731
135239
|
super({ ...DEFAULTS });
|
|
132732
135240
|
}
|
|
@@ -132734,7 +135242,10 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
132734
135242
|
const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
|
|
132735
135243
|
const records = this.ctx.records;
|
|
132736
135244
|
if (records === void 0) throw new Error("import-alexa: ctx.records is required (hub-only addon)");
|
|
132737
|
-
await records.declare({
|
|
135245
|
+
await records.declare({
|
|
135246
|
+
brokers: alexaBrokersEntryKind,
|
|
135247
|
+
speakSessions: alexaSpeakSessionsEntryKind
|
|
135248
|
+
});
|
|
132738
135249
|
this.brokerStore = AlexaBrokerStore.of(records);
|
|
132739
135250
|
this.activeBrokers = await this.loadPersistedBrokers();
|
|
132740
135251
|
this.registry = new AlexaBrokerRegistry(this.ctx.logger, {
|
|
@@ -132775,26 +135286,71 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
132775
135286
|
this.ctx.logger.info("Alexa: import addon initialised", { meta: { brokerCount: this.activeBrokers.length } });
|
|
132776
135287
|
await this.reconcileIntegrationsToBrokers();
|
|
132777
135288
|
this.subscribeIntegrationLifecycle();
|
|
132778
|
-
const loginRoutes = buildAlexaLoginRoutes({
|
|
135289
|
+
const loginRoutes = [...buildAlexaLoginRoutes({
|
|
132779
135290
|
logger: this.ctx.logger,
|
|
132780
135291
|
proxyLogin: this.proxyLogin,
|
|
132781
135292
|
commitCookie: (input) => this.commitCapturedCookie(input)
|
|
132782
|
-
});
|
|
132783
|
-
|
|
132784
|
-
|
|
132785
|
-
|
|
132786
|
-
|
|
132787
|
-
|
|
135293
|
+
}), ...speakIconRoutes()];
|
|
135294
|
+
const speak = new AlexaSpeakController({
|
|
135295
|
+
logger: this.ctx.logger.child("speak"),
|
|
135296
|
+
readConfig: () => this.config,
|
|
135297
|
+
writeSpeak: async (next) => {
|
|
135298
|
+
await this.updateGlobalSettings({ speak: next });
|
|
132788
135299
|
},
|
|
132789
|
-
{
|
|
132790
|
-
|
|
132791
|
-
|
|
135300
|
+
listBrokers: () => this.activeBrokers.map((entry) => ({
|
|
135301
|
+
id: entry.id,
|
|
135302
|
+
name: entry.name,
|
|
135303
|
+
amazonDomain: entry.connection.amazonDomain
|
|
135304
|
+
})),
|
|
135305
|
+
readSeed: async (brokerId) => {
|
|
135306
|
+
const entry = this.activeBrokers.find((row) => row.id === brokerId);
|
|
135307
|
+
return entry === void 0 ? null : readBrokerSeed(this.ctx.dataDir, entry);
|
|
132792
135308
|
},
|
|
132793
|
-
|
|
132794
|
-
|
|
132795
|
-
|
|
132796
|
-
|
|
132797
|
-
|
|
135309
|
+
sessions: RecordsSpeakSessionStore.of(records),
|
|
135310
|
+
clearLegacySecrets: async () => {
|
|
135311
|
+
await this.updateGlobalSettingsAsOperator({ speak: {
|
|
135312
|
+
...this.config.speak,
|
|
135313
|
+
password: SECRET_CLEAR,
|
|
135314
|
+
session: SECRET_CLEAR
|
|
135315
|
+
} });
|
|
135316
|
+
},
|
|
135317
|
+
ensurePython: () => this.ctx.deps.ensurePython()
|
|
135318
|
+
});
|
|
135319
|
+
this.speak = speak;
|
|
135320
|
+
speak.start().catch((err) => {
|
|
135321
|
+
this.ctx.logger.warn("alexa speak: worker did not start", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
135322
|
+
});
|
|
135323
|
+
const pages = {
|
|
135324
|
+
id: this.addonId,
|
|
135325
|
+
listPages: () => [SPEAK_PAGE]
|
|
135326
|
+
};
|
|
135327
|
+
return {
|
|
135328
|
+
providers: [
|
|
135329
|
+
...regs,
|
|
135330
|
+
{
|
|
135331
|
+
capability: brokerCapability,
|
|
135332
|
+
provider: this.buildBrokerProvider()
|
|
135333
|
+
},
|
|
135334
|
+
{
|
|
135335
|
+
capability: deviceAdoptionCapability,
|
|
135336
|
+
provider: this.buildAdoptionProvider()
|
|
135337
|
+
},
|
|
135338
|
+
{
|
|
135339
|
+
capability: addonRoutesCapability,
|
|
135340
|
+
provider: buildAddonRouteProvider("import-alexa", loginRoutes)
|
|
135341
|
+
},
|
|
135342
|
+
{
|
|
135343
|
+
capability: notificationOutputCapability,
|
|
135344
|
+
provider: speak.provider
|
|
135345
|
+
},
|
|
135346
|
+
{
|
|
135347
|
+
capability: addonPagesSourceCapability,
|
|
135348
|
+
provider: pages
|
|
135349
|
+
}
|
|
135350
|
+
],
|
|
135351
|
+
customActions: alexaSpeakActions,
|
|
135352
|
+
actionHandlers: speak.handlers()
|
|
135353
|
+
};
|
|
132798
135354
|
}
|
|
132799
135355
|
async loadPersistedBrokers() {
|
|
132800
135356
|
const store = this.brokerStore;
|
|
@@ -132807,11 +135363,9 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
132807
135363
|
async persistBrokerEntries(entries) {
|
|
132808
135364
|
this.activeBrokers = [...entries];
|
|
132809
135365
|
const store = this.brokerStore;
|
|
132810
|
-
if (store !== null)
|
|
132811
|
-
|
|
132812
|
-
|
|
132813
|
-
}
|
|
132814
|
-
await this.updateGlobalSettings({ brokers: [...entries] });
|
|
135366
|
+
if (store !== null) await store.replaceAll(entries);
|
|
135367
|
+
else await this.updateGlobalSettings({ brokers: [...entries] });
|
|
135368
|
+
this.speak?.brokersChanged();
|
|
132815
135369
|
}
|
|
132816
135370
|
async onConfigChanged() {
|
|
132817
135371
|
const reg = this.registry;
|
|
@@ -132857,8 +135411,11 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
132857
135411
|
} });
|
|
132858
135412
|
}
|
|
132859
135413
|
}
|
|
135414
|
+
this.speak?.brokersChanged();
|
|
132860
135415
|
}
|
|
132861
135416
|
async onShutdown() {
|
|
135417
|
+
this.speak?.stop();
|
|
135418
|
+
this.speak = null;
|
|
132862
135419
|
alexaCommands.clear();
|
|
132863
135420
|
try {
|
|
132864
135421
|
await this.proxyLogin?.dispose();
|
|
@@ -133170,10 +135727,12 @@ exports.StubAlexaClient = StubAlexaClient;
|
|
|
133170
135727
|
exports.__toCommonJS = __toCommonJS;
|
|
133171
135728
|
exports.alexaAddonConfigSchema = alexaAddonConfigSchema;
|
|
133172
135729
|
exports.alexaConnectionSchema = alexaConnectionSchema;
|
|
135730
|
+
exports.alexaSpeakActions = alexaSpeakActions;
|
|
133173
135731
|
exports.buildAlexaApplianceCandidate = buildAlexaApplianceCandidate;
|
|
133174
135732
|
exports.buildConnectionFormSchema = buildConnectionFormSchema;
|
|
133175
135733
|
exports.connectionToCredentials = connectionToCredentials;
|
|
133176
135734
|
exports.createAlexaClient = createAlexaClient;
|
|
135735
|
+
exports.customActions = alexaSpeakActions;
|
|
133177
135736
|
exports.mapAlexaCapabilitiesToCamStack = mapAlexaCapabilitiesToCamStack;
|
|
133178
135737
|
exports.mappableAppliance = mappableAppliance;
|
|
133179
135738
|
exports.settingsToAlexaConnection = settingsToAlexaConnection;
|