iobroker.assistant 0.1.6 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +61 -4
  2. package/admin/custom/.vite/manifest.json +122 -77
  3. package/admin/custom/assets/Components-7BDDNrGP.js +1 -0
  4. package/admin/custom/assets/_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-DcAGnE2z.js +1 -0
  5. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-CzBMxGFr.js +1 -0
  6. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-1XsLCK2M.js +1 -0
  7. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-CMv8bcyA.js +1217 -0
  8. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare__shared-3mzaJWDF.js +2 -0
  9. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-DS8oaQQt.js +122 -0
  10. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__-B_v57Qfr.js +1 -0
  11. package/admin/custom/assets/dist-DiU1y0R9.js +1 -0
  12. package/admin/custom/assets/helpers-Dot7vgQQ.js +1 -0
  13. package/admin/custom/assets/hostInit-MshDicd1.js +1 -0
  14. package/admin/custom/assets/iconLibraryData-B5fiyl1C.js +1 -0
  15. package/admin/custom/assets/index-P2Xu9kJm.js +1 -0
  16. package/admin/custom/assets/lib-DjwHKtRi.js +3 -0
  17. package/admin/custom/assets/{mf-entry-bootstrap-0-87f15505.js → mf-entry-bootstrap-0-7c1ddf47.js} +14 -2
  18. package/admin/custom/assets/pendingShares-CaJDCKLB.js +2 -0
  19. package/admin/custom/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  20. package/admin/custom/assets/utils-towTPKqG.js +4 -0
  21. package/admin/custom/assets/vite-preload-helper-BbbETgeG.js +1 -0
  22. package/admin/custom/customComponents.js +2 -2
  23. package/admin/custom/i18n/de.json +64 -57
  24. package/admin/custom/i18n/en.json +8 -1
  25. package/admin/custom/i18n/es.json +64 -57
  26. package/admin/custom/i18n/fr.json +64 -57
  27. package/admin/custom/i18n/it.json +64 -57
  28. package/admin/custom/i18n/nl.json +64 -57
  29. package/admin/custom/i18n/pl.json +64 -57
  30. package/admin/custom/i18n/pt.json +64 -57
  31. package/admin/custom/i18n/ru.json +64 -57
  32. package/admin/custom/i18n/uk.json +64 -57
  33. package/admin/custom/i18n/zh-cn.json +64 -57
  34. package/admin/custom/index.html +2 -2
  35. package/admin/custom/mf-manifest.json +1 -1
  36. package/admin/custom/mf-stats.json +1 -1
  37. package/admin/i18n/de.json +59 -3
  38. package/admin/i18n/en.json +59 -3
  39. package/admin/i18n/es.json +59 -3
  40. package/admin/i18n/fr.json +59 -3
  41. package/admin/i18n/it.json +59 -3
  42. package/admin/i18n/nl.json +59 -3
  43. package/admin/i18n/pl.json +59 -3
  44. package/admin/i18n/pt.json +59 -3
  45. package/admin/i18n/ru.json +59 -3
  46. package/admin/i18n/uk.json +59 -3
  47. package/admin/i18n/zh-cn.json +59 -3
  48. package/admin/jsonConfig.json +589 -75
  49. package/build/lib/ask.js +130 -0
  50. package/build/lib/ask.js.map +1 -0
  51. package/build/lib/llm.js +135 -13
  52. package/build/lib/llm.js.map +1 -1
  53. package/build/lib/nlu.js +74 -1
  54. package/build/lib/nlu.js.map +1 -1
  55. package/build/lib/notifications.js +112 -0
  56. package/build/lib/notifications.js.map +1 -0
  57. package/build/lib/presence.js +188 -0
  58. package/build/lib/presence.js.map +1 -0
  59. package/build/lib/routines.js +115 -0
  60. package/build/lib/routines.js.map +1 -0
  61. package/build/lib/targets.js +96 -0
  62. package/build/lib/targets.js.map +1 -0
  63. package/build/lib/triggers.js +632 -0
  64. package/build/lib/triggers.js.map +1 -0
  65. package/build/lib/voice/aws.js +9 -1
  66. package/build/lib/voice/aws.js.map +1 -1
  67. package/build/lib/voice/azure.js +9 -2
  68. package/build/lib/voice/azure.js.map +1 -1
  69. package/build/lib/voice/engines.js +82 -0
  70. package/build/lib/voice/engines.js.map +1 -1
  71. package/build/lib/voice/esphome.js +732 -0
  72. package/build/lib/voice/esphome.js.map +1 -0
  73. package/build/lib/voice/esphomeEntities.js +303 -0
  74. package/build/lib/voice/esphomeEntities.js.map +1 -0
  75. package/build/lib/voice/esphomeProto.js +181 -0
  76. package/build/lib/voice/esphomeProto.js.map +1 -0
  77. package/build/lib/voice/fallback.js +85 -0
  78. package/build/lib/voice/fallback.js.map +1 -0
  79. package/build/lib/voice/mediaServer.js +151 -0
  80. package/build/lib/voice/mediaServer.js.map +1 -0
  81. package/build/lib/voice/protocol.js.map +1 -1
  82. package/build/lib/voice/tone.js +32 -0
  83. package/build/lib/voice/tone.js.map +1 -0
  84. package/build/lib/voice/tts.js.map +1 -1
  85. package/build/lib/voice/ttsCache.js +274 -0
  86. package/build/lib/voice/ttsCache.js.map +1 -0
  87. package/build/lib/voice/vad.js +173 -0
  88. package/build/lib/voice/vad.js.map +1 -0
  89. package/build/lib/voice/voiceServer.js +13 -0
  90. package/build/lib/voice/voiceServer.js.map +1 -1
  91. package/build/main.js +1721 -67
  92. package/build/main.js.map +1 -1
  93. package/io-package.json +241 -3
  94. package/package.json +13 -12
  95. package/admin/custom/assets/Components-xWdtqvTH.js +0 -1
  96. package/admin/custom/assets/_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-dybYZgsu.js +0 -1
  97. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-BnSUsPHa.js +0 -1
  98. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-DlSVT49A.js +0 -1
  99. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-Cfsgderv.js +0 -1243
  100. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_iobroker_mf_1_json_mf_2_config__loadShare__.js-ByJf4zv_.js +0 -1494
  101. package/admin/custom/assets/hostInit-CZawMH9B.js +0 -1
  102. package/admin/custom/assets/index-JB7g1jgx.js +0 -1
  103. package/admin/custom/assets/lib-bB-LHRhZ.js +0 -3
  104. package/admin/custom/assets/rolldown-runtime-C0FnF6B9.js +0 -1
  105. package/admin/custom/assets/virtualExposes-brKKZvi0.js +0 -1
  106. package/admin/custom/assets/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__ConfigCustomAssistant__customComponents_js-CRhwj12f.js +0 -1
  107. package/admin/custom/assets/virtual_mf-exposes___mfe_internal__ConfigCustomAssistant__customComponents_js-C5XHUc1J.js +0 -2
  108. package/admin/custom/assets/vite-preload-helper-B7qeedMF.js +0 -1
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ /**
3
+ * Who is at home — the context that makes the difference between a useful answer and a silly one
4
+ * ("switch off everything" vs. announcing into an empty flat).
5
+ *
6
+ * Deliberately **not** tied to one adapter: presence lives in a different place in every installation
7
+ * (the `residents` adapter, a `ping.0.<phone>.alive`, a UniFi/Fritz!Box client state, a `0_userdata` flag),
8
+ * and ioBroker has no standard device type for it — `@iobroker/type-detector` knows `motion` and
9
+ * `location`, but no `presence`. So the user points at the states they already have and names the person,
10
+ * exactly like `weatherInstance` lets them pick their weather source.
11
+ *
12
+ * Presence values are installation-specific too, so {@link interpretHome} understands the common forms and
13
+ * every entry can override the value that means "home". The numeric default (`1`) follows the Python
14
+ * original (`C:\iot\Hannah`, `core/hannah/residents/Resident.py`, `HOME_PRESENCE_STATE = 1`), which had the
15
+ * same problem; there it was a global config option, here it is per entry, because one household can mix a
16
+ * boolean phone ping with a numeric residents state.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.PresenceTracker = void 0;
20
+ exports.interpretHome = interpretHome;
21
+ exports.parsePresenceRows = parsePresenceRows;
22
+ exports.buildPresencePrompt = buildPresencePrompt;
23
+ const KINDS = ['person', 'guest', 'pet'];
24
+ /** Words that mean "at home" in a string state (de/en/ru), beyond the obvious `true`/`1`. */
25
+ const HOME_WORDS = new Set(['home', 'present', 'yes', 'true', '1', 'da', 'zuhause', 'anwesend', 'дома']);
26
+ /** Words that mean "away". Anything else leaves the presence unknown rather than guessing. */
27
+ const AWAY_WORDS = new Set(['away', 'absent', 'no', 'false', '0', 'weg', 'abwesend', 'нет', 'не дома']);
28
+ /**
29
+ * Is this value "at home"? Returns null when we cannot tell — an unknown value must not be read as "away",
30
+ * because that would quietly announce someone's absence we never established.
31
+ */
32
+ function interpretHome(value, homeValue) {
33
+ if (value === undefined || value === null || value === '') {
34
+ return null;
35
+ }
36
+ // Only a scalar can mean presence; a state holding an object tells us nothing (and stringifying it
37
+ // would compare against "[object Object]").
38
+ if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
39
+ return null;
40
+ }
41
+ const wanted = (homeValue || '').trim();
42
+ if (wanted) {
43
+ // An explicit mapping wins and is compared loosely: a state may hold 1 where the config says "1".
44
+ return String(value).trim().toLowerCase() === wanted.toLowerCase();
45
+ }
46
+ if (typeof value === 'boolean') {
47
+ return value;
48
+ }
49
+ if (typeof value === 'number') {
50
+ return value === 1; // see the module header
51
+ }
52
+ const v = value.trim().toLowerCase();
53
+ if (HOME_WORDS.has(v)) {
54
+ return true;
55
+ }
56
+ if (AWAY_WORDS.has(v)) {
57
+ return false;
58
+ }
59
+ return null;
60
+ }
61
+ /** Turn settings-table rows into entries: trimmed, with a valid kind, skipping rows without a state id. */
62
+ function parsePresenceRows(rows) {
63
+ const out = [];
64
+ const seen = new Set();
65
+ for (const row of rows || []) {
66
+ const id = String(row?.id ?? '').trim();
67
+ if (!id || seen.has(id)) {
68
+ continue; // an empty row is just empty; the same state twice would double-count the person
69
+ }
70
+ seen.add(id);
71
+ const kind = String(row.kind ?? '')
72
+ .trim()
73
+ .toLowerCase();
74
+ out.push({
75
+ id,
76
+ name: String(row.name ?? '').trim() || id.split('.').pop() || id,
77
+ kind: KINDS.includes(kind) ? kind : 'person',
78
+ homeValue: String(row.homeValue ?? '').trim(),
79
+ });
80
+ }
81
+ return out;
82
+ }
83
+ /** Tracks who is at home, from the configured states. */
84
+ class PresenceTracker {
85
+ opts;
86
+ people = new Map();
87
+ constructor(opts) {
88
+ this.opts = opts;
89
+ for (const entry of opts.entries) {
90
+ this.people.set(entry.id, { ...entry, home: null });
91
+ }
92
+ }
93
+ /** The state ids to subscribe to. */
94
+ stateIds() {
95
+ return [...this.people.keys()];
96
+ }
97
+ /**
98
+ * Feed a state value in. Returns what happened, so the caller can announce it — `null` for "nothing
99
+ * changed", which also covers the very first value of a state: there is no previous state to compare
100
+ * against, and greeting someone on every adapter restart would be worse than staying quiet.
101
+ */
102
+ update(id, value) {
103
+ const who = this.people.get(id);
104
+ if (!who) {
105
+ return null;
106
+ }
107
+ const before = who.home;
108
+ const now = interpretHome(value, who.homeValue);
109
+ if (now === before) {
110
+ return null;
111
+ }
112
+ who.home = now;
113
+ this.opts.onChange?.(this.list());
114
+ if (before === null || now === null) {
115
+ this.opts.log?.debug(`presence: ${who.name} is now ${now === null ? 'unknown' : now ? 'home' : 'away'}`);
116
+ return null;
117
+ }
118
+ const event = now ? 'arrived' : 'left';
119
+ this.opts.log?.info(`presence: ${who.name} ${now ? 'came home' : 'left'}`);
120
+ try {
121
+ (now ? this.opts.onArrival : this.opts.onDeparture)?.({ ...who });
122
+ }
123
+ catch (e) {
124
+ this.opts.log?.warn(`presence callback failed: ${e.message}`);
125
+ }
126
+ return event;
127
+ }
128
+ /** Everyone configured, in configuration order. */
129
+ list() {
130
+ return [...this.people.values()].map(p => ({ ...p }));
131
+ }
132
+ /** Those known to be at home (pets included — filter by `kind` if that matters). */
133
+ home() {
134
+ return this.list().filter(p => p.home === true);
135
+ }
136
+ /** Those known to be away (an unknown presence is neither home nor away). */
137
+ away() {
138
+ return this.list().filter(p => p.home === false);
139
+ }
140
+ /**
141
+ * Is a human at home? Pets do not count — the point of this flag is whether an announcement would
142
+ * reach anybody, and the cat will not pass it on. With nothing configured it is `false`, so a caller
143
+ * must treat "no presence configured" as "do not gate" rather than "nobody home".
144
+ */
145
+ anyoneHome() {
146
+ return this.list().some(p => p.home === true && p.kind !== 'pet');
147
+ }
148
+ /** Whether any presence source is configured at all. */
149
+ get configured() {
150
+ return this.people.size > 0;
151
+ }
152
+ }
153
+ exports.PresenceTracker = PresenceTracker;
154
+ /**
155
+ * One line for the LLM: who is home and who is not, localized (de/en/ru). Empty when nothing is known
156
+ * yet — an empty list would otherwise read like "nobody is home", which is a different statement.
157
+ *
158
+ * Goes into the user turn, never into the (prompt-cached) system prompt: it changes with every arrival.
159
+ */
160
+ function buildPresencePrompt(list, lang) {
161
+ const l = lang === 'ru' ? 'ru' : lang === 'de' ? 'de' : 'en';
162
+ const label = (p) => {
163
+ if (p.kind === 'guest') {
164
+ return `${p.name} (${l === 'ru' ? 'гость' : l === 'de' ? 'Gast' : 'guest'})`;
165
+ }
166
+ if (p.kind === 'pet') {
167
+ return `${p.name} (${l === 'ru' ? 'питомец' : l === 'de' ? 'Haustier' : 'pet'})`;
168
+ }
169
+ return p.name;
170
+ };
171
+ const home = list.filter(p => p.home === true).map(label);
172
+ const away = list.filter(p => p.home === false).map(label);
173
+ if (!home.length && !away.length) {
174
+ return '';
175
+ }
176
+ const parts = [];
177
+ if (home.length) {
178
+ parts.push(`${l === 'ru' ? 'Дома' : l === 'de' ? 'Zuhause' : 'At home'}: ${home.join(', ')}`);
179
+ }
180
+ else {
181
+ parts.push(l === 'ru' ? 'Дома никого нет' : l === 'de' ? 'Niemand ist zuhause' : 'Nobody is at home');
182
+ }
183
+ if (away.length) {
184
+ parts.push(`${l === 'ru' ? 'нет дома' : l === 'de' ? 'nicht zuhause' : 'away'}: ${away.join(', ')}`);
185
+ }
186
+ return `${parts.join('; ')}.`;
187
+ }
188
+ //# sourceMappingURL=presence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presence.js","sourceRoot":"","sources":["../../src/lib/presence.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Who is at home — the context that makes the difference between a useful answer and a silly one\n * (\"switch off everything\" vs. announcing into an empty flat).\n *\n * Deliberately **not** tied to one adapter: presence lives in a different place in every installation\n * (the `residents` adapter, a `ping.0.<phone>.alive`, a UniFi/Fritz!Box client state, a `0_userdata` flag),\n * and ioBroker has no standard device type for it — `@iobroker/type-detector` knows `motion` and\n * `location`, but no `presence`. So the user points at the states they already have and names the person,\n * exactly like `weatherInstance` lets them pick their weather source.\n *\n * Presence values are installation-specific too, so {@link interpretHome} understands the common forms and\n * every entry can override the value that means \"home\". The numeric default (`1`) follows the Python\n * original (`C:\\iot\\Hannah`, `core/hannah/residents/Resident.py`, `HOME_PRESENCE_STATE = 1`), which had the\n * same problem; there it was a global config option, here it is per entry, because one household can mix a\n * boolean phone ping with a numeric residents state.\n */\n\n/** What kind of presence an entry tracks. Pets never count towards \"is anyone home\". */\nexport type PresenceKind = 'person' | 'guest' | 'pet';\n\n/** One configured presence source. */\nexport interface PresenceEntry {\n /** State id that tells whether this person is at home. */\n id: string;\n /** Display name, used in the spoken context and the states. */\n name: string;\n kind: PresenceKind;\n /** Value that means \"home\" ('' = recognise the usual forms, see {@link interpretHome}). */\n homeValue?: string;\n}\n\n/** A configured source plus what we currently know about it. */\nexport interface PresenceInfo extends PresenceEntry {\n /** true = home, false = away, null = not read yet / value not understood. */\n home: boolean | null;\n}\n\n/** One row of the settings table (everything arrives as text). */\nexport interface PresenceRow {\n id?: string;\n name?: string;\n kind?: string;\n homeValue?: string;\n}\n\nconst KINDS: PresenceKind[] = ['person', 'guest', 'pet'];\n\n/** Words that mean \"at home\" in a string state (de/en/ru), beyond the obvious `true`/`1`. */\nconst HOME_WORDS = new Set(['home', 'present', 'yes', 'true', '1', 'da', 'zuhause', 'anwesend', 'дома']);\n/** Words that mean \"away\". Anything else leaves the presence unknown rather than guessing. */\nconst AWAY_WORDS = new Set(['away', 'absent', 'no', 'false', '0', 'weg', 'abwesend', 'нет', 'не дома']);\n\n/**\n * Is this value \"at home\"? Returns null when we cannot tell — an unknown value must not be read as \"away\",\n * because that would quietly announce someone's absence we never established.\n */\nexport function interpretHome(value: unknown, homeValue?: string): boolean | null {\n if (value === undefined || value === null || value === '') {\n return null;\n }\n // Only a scalar can mean presence; a state holding an object tells us nothing (and stringifying it\n // would compare against \"[object Object]\").\n if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {\n return null;\n }\n const wanted = (homeValue || '').trim();\n if (wanted) {\n // An explicit mapping wins and is compared loosely: a state may hold 1 where the config says \"1\".\n return String(value).trim().toLowerCase() === wanted.toLowerCase();\n }\n if (typeof value === 'boolean') {\n return value;\n }\n if (typeof value === 'number') {\n return value === 1; // see the module header\n }\n const v = value.trim().toLowerCase();\n if (HOME_WORDS.has(v)) {\n return true;\n }\n if (AWAY_WORDS.has(v)) {\n return false;\n }\n return null;\n}\n\n/** Turn settings-table rows into entries: trimmed, with a valid kind, skipping rows without a state id. */\nexport function parsePresenceRows(rows: PresenceRow[] | undefined): PresenceEntry[] {\n const out: PresenceEntry[] = [];\n const seen = new Set<string>();\n for (const row of rows || []) {\n const id = String(row?.id ?? '').trim();\n if (!id || seen.has(id)) {\n continue; // an empty row is just empty; the same state twice would double-count the person\n }\n seen.add(id);\n const kind = String(row.kind ?? '')\n .trim()\n .toLowerCase();\n out.push({\n id,\n name: String(row.name ?? '').trim() || id.split('.').pop() || id,\n kind: (KINDS as string[]).includes(kind) ? (kind as PresenceKind) : 'person',\n homeValue: String(row.homeValue ?? '').trim(),\n });\n }\n return out;\n}\n\nexport interface PresenceTrackerOptions {\n entries: PresenceEntry[];\n /** Someone came home (fired on the transition, never on the first value we read). */\n onArrival?: (who: PresenceInfo) => void;\n /** Someone left. */\n onDeparture?: (who: PresenceInfo) => void;\n /** Any change, for the state mirror. */\n onChange?: (list: PresenceInfo[]) => void;\n log?: Pick<ioBroker.Log, 'debug' | 'info' | 'warn'>;\n}\n\n/** Tracks who is at home, from the configured states. */\nexport class PresenceTracker {\n private readonly people = new Map<string, PresenceInfo>();\n\n constructor(private readonly opts: PresenceTrackerOptions) {\n for (const entry of opts.entries) {\n this.people.set(entry.id, { ...entry, home: null });\n }\n }\n\n /** The state ids to subscribe to. */\n stateIds(): string[] {\n return [...this.people.keys()];\n }\n\n /**\n * Feed a state value in. Returns what happened, so the caller can announce it — `null` for \"nothing\n * changed\", which also covers the very first value of a state: there is no previous state to compare\n * against, and greeting someone on every adapter restart would be worse than staying quiet.\n */\n update(id: string, value: unknown): 'arrived' | 'left' | null {\n const who = this.people.get(id);\n if (!who) {\n return null;\n }\n const before = who.home;\n const now = interpretHome(value, who.homeValue);\n if (now === before) {\n return null;\n }\n who.home = now;\n this.opts.onChange?.(this.list());\n if (before === null || now === null) {\n this.opts.log?.debug(`presence: ${who.name} is now ${now === null ? 'unknown' : now ? 'home' : 'away'}`);\n return null;\n }\n const event = now ? 'arrived' : 'left';\n this.opts.log?.info(`presence: ${who.name} ${now ? 'came home' : 'left'}`);\n try {\n (now ? this.opts.onArrival : this.opts.onDeparture)?.({ ...who });\n } catch (e) {\n this.opts.log?.warn(`presence callback failed: ${(e as Error).message}`);\n }\n return event;\n }\n\n /** Everyone configured, in configuration order. */\n list(): PresenceInfo[] {\n return [...this.people.values()].map(p => ({ ...p }));\n }\n\n /** Those known to be at home (pets included — filter by `kind` if that matters). */\n home(): PresenceInfo[] {\n return this.list().filter(p => p.home === true);\n }\n\n /** Those known to be away (an unknown presence is neither home nor away). */\n away(): PresenceInfo[] {\n return this.list().filter(p => p.home === false);\n }\n\n /**\n * Is a human at home? Pets do not count — the point of this flag is whether an announcement would\n * reach anybody, and the cat will not pass it on. With nothing configured it is `false`, so a caller\n * must treat \"no presence configured\" as \"do not gate\" rather than \"nobody home\".\n */\n anyoneHome(): boolean {\n return this.list().some(p => p.home === true && p.kind !== 'pet');\n }\n\n /** Whether any presence source is configured at all. */\n get configured(): boolean {\n return this.people.size > 0;\n }\n}\n\n/**\n * One line for the LLM: who is home and who is not, localized (de/en/ru). Empty when nothing is known\n * yet — an empty list would otherwise read like \"nobody is home\", which is a different statement.\n *\n * Goes into the user turn, never into the (prompt-cached) system prompt: it changes with every arrival.\n */\nexport function buildPresencePrompt(list: PresenceInfo[], lang: string): string {\n const l = lang === 'ru' ? 'ru' : lang === 'de' ? 'de' : 'en';\n const label = (p: PresenceInfo): string => {\n if (p.kind === 'guest') {\n return `${p.name} (${l === 'ru' ? 'гость' : l === 'de' ? 'Gast' : 'guest'})`;\n }\n if (p.kind === 'pet') {\n return `${p.name} (${l === 'ru' ? 'питомец' : l === 'de' ? 'Haustier' : 'pet'})`;\n }\n return p.name;\n };\n const home = list.filter(p => p.home === true).map(label);\n const away = list.filter(p => p.home === false).map(label);\n if (!home.length && !away.length) {\n return '';\n }\n const parts: string[] = [];\n if (home.length) {\n parts.push(`${l === 'ru' ? 'Дома' : l === 'de' ? 'Zuhause' : 'At home'}: ${home.join(', ')}`);\n } else {\n parts.push(l === 'ru' ? 'Дома никого нет' : l === 'de' ? 'Niemand ist zuhause' : 'Nobody is at home');\n }\n if (away.length) {\n parts.push(`${l === 'ru' ? 'нет дома' : l === 'de' ? 'nicht zuhause' : 'away'}: ${away.join(', ')}`);\n }\n return `${parts.join('; ')}.`;\n}\n"]}
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizePhrase = normalizePhrase;
4
+ exports.splitPhrases = splitPhrases;
5
+ exports.parseRoutineRows = parseRoutineRows;
6
+ exports.matchRoutine = matchRoutine;
7
+ /**
8
+ * Fold a phrase to what matching compares: lower case, umlauts spelled out, punctuation gone, single
9
+ * spaces. "Gute Nacht!" and "gute nacht" are the same thing, and so are "Büro" and "Buero" — people type
10
+ * one and say the other.
11
+ */
12
+ function normalizePhrase(text) {
13
+ if (typeof text !== 'string') {
14
+ return '';
15
+ }
16
+ return text
17
+ .toLowerCase()
18
+ .replace(/ä/g, 'ae')
19
+ .replace(/ö/g, 'oe')
20
+ .replace(/ü/g, 'ue')
21
+ .replace(/ß/g, 'ss')
22
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
23
+ .trim()
24
+ .replace(/\s+/g, ' ');
25
+ }
26
+ /** Split the phrase column: one per line, or comma/semicolon separated. */
27
+ function splitPhrases(phrases) {
28
+ if (Array.isArray(phrases)) {
29
+ return phrases.map(p => normalizePhrase(p)).filter(Boolean);
30
+ }
31
+ if (typeof phrases !== 'string') {
32
+ return [];
33
+ }
34
+ return phrases
35
+ .split(/[\r\n,;]+/)
36
+ .map(p => normalizePhrase(p))
37
+ .filter(Boolean);
38
+ }
39
+ /**
40
+ * Turn settings-table rows into routines. The action column is JSON, parsed here so a typo surfaces as
41
+ * one warning instead of a routine that quietly does nothing; a row without phrases or without anything
42
+ * to do is skipped.
43
+ */
44
+ function parseRoutineRows(rows, opts = {}) {
45
+ const out = [];
46
+ for (const row of rows || []) {
47
+ const name = String(row?.name ?? '').trim();
48
+ const phrases = splitPhrases(row?.phrases);
49
+ if (!phrases.length) {
50
+ continue;
51
+ }
52
+ let actions = [];
53
+ const raw = row?.actions;
54
+ if (Array.isArray(raw)) {
55
+ actions = raw;
56
+ }
57
+ else if (typeof raw === 'string' && raw.trim()) {
58
+ try {
59
+ const parsed = JSON.parse(raw);
60
+ actions = Array.isArray(parsed) ? parsed : [];
61
+ }
62
+ catch (e) {
63
+ opts.warn?.(`routine '${name || phrases[0]}': actions is not valid JSON (${e.message}) — skipped`);
64
+ continue;
65
+ }
66
+ }
67
+ actions = actions.filter(a => a && (a.say || a.setState));
68
+ const reply = String(row?.reply ?? '').trim();
69
+ if (!actions.length && !reply) {
70
+ opts.warn?.(`routine '${name || phrases[0]}': nothing to do (no actions, no reply) — skipped`);
71
+ continue;
72
+ }
73
+ out.push({ name: name || phrases[0], phrases, actions, reply });
74
+ }
75
+ return out;
76
+ }
77
+ /**
78
+ * Find the routine an utterance triggers, or null. The **longest** matching phrase wins, so a specific
79
+ * "gute nacht alle" beats a general "gute nacht" instead of depending on the table's order.
80
+ */
81
+ function matchRoutine(text, routines) {
82
+ const haystack = normalizePhrase(text);
83
+ if (!haystack) {
84
+ return null;
85
+ }
86
+ let best = null;
87
+ let bestLength = 0;
88
+ for (const routine of routines) {
89
+ for (const phrase of routine.phrases) {
90
+ if (phrase.length > bestLength && contains(haystack, phrase)) {
91
+ best = routine;
92
+ bestLength = phrase.length;
93
+ }
94
+ }
95
+ }
96
+ return best;
97
+ }
98
+ /** Whole-word containment: "licht" must not match "lichtschalter" and turn a question into a macro. */
99
+ function contains(haystack, phrase) {
100
+ let from = 0;
101
+ for (;;) {
102
+ const at = haystack.indexOf(phrase, from);
103
+ if (at < 0) {
104
+ return false;
105
+ }
106
+ const before = at === 0 || haystack[at - 1] === ' ';
107
+ const afterIndex = at + phrase.length;
108
+ const after = afterIndex === haystack.length || haystack[afterIndex] === ' ';
109
+ if (before && after) {
110
+ return true;
111
+ }
112
+ from = at + 1;
113
+ }
114
+ }
115
+ //# sourceMappingURL=routines.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routines.js","sourceRoot":"","sources":["../../src/lib/routines.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Routines — a phrase that runs a list of actions. \"Gute Nacht\" switches off five things and locks the\n * door; \"Filmabend\" dims the living room and closes the blinds.\n *\n * Deliberately **not** the LLM's job: a routine is a fixed macro the user wrote down, so matching it has\n * to be deterministic, instant and free. It also has to beat the rule-based NLU, which would otherwise\n * find the word \"Licht\" in \"Gute Nacht, Licht aus bitte\" and do only half of it.\n *\n * The actions are {@link TriggerAction}s, the same shape the proactive triggers use, so both share one\n * executor in the adapter — a routine is simply a trigger whose condition is a spoken phrase.\n *\n * Ported from the Python original (`C:\\iot\\Hannah`, `core/hannah/routines.py`), whose matching rule\n * (normalise, then look for the phrase anywhere in the utterance) is kept: people say \"mach mal Gute\n * Nacht\", not just \"Gute Nacht\".\n */\nimport type { TriggerAction } from './triggers';\n\n/** One routine. */\nexport interface Routine {\n name: string;\n /** Phrases that trigger it, already normalised (see {@link normalizePhrase}). */\n phrases: string[];\n actions: TriggerAction[];\n /** What to answer. Empty = answer nothing (the confirmation tone, if enabled, still plays). */\n reply: string;\n}\n\n/** One row of the settings table (everything arrives as text). */\nexport interface RoutineRow {\n name?: string;\n phrases?: string;\n actions?: TriggerAction[] | string;\n reply?: string;\n}\n\n/**\n * Fold a phrase to what matching compares: lower case, umlauts spelled out, punctuation gone, single\n * spaces. \"Gute Nacht!\" and \"gute nacht\" are the same thing, and so are \"Büro\" and \"Buero\" — people type\n * one and say the other.\n */\nexport function normalizePhrase(text: unknown): string {\n if (typeof text !== 'string') {\n return '';\n }\n return text\n .toLowerCase()\n .replace(/ä/g, 'ae')\n .replace(/ö/g, 'oe')\n .replace(/ü/g, 'ue')\n .replace(/ß/g, 'ss')\n .replace(/[^\\p{L}\\p{N}]+/gu, ' ')\n .trim()\n .replace(/\\s+/g, ' ');\n}\n\n/** Split the phrase column: one per line, or comma/semicolon separated. */\nexport function splitPhrases(phrases: unknown): string[] {\n if (Array.isArray(phrases)) {\n return phrases.map(p => normalizePhrase(p)).filter(Boolean);\n }\n if (typeof phrases !== 'string') {\n return [];\n }\n return phrases\n .split(/[\\r\\n,;]+/)\n .map(p => normalizePhrase(p))\n .filter(Boolean);\n}\n\n/**\n * Turn settings-table rows into routines. The action column is JSON, parsed here so a typo surfaces as\n * one warning instead of a routine that quietly does nothing; a row without phrases or without anything\n * to do is skipped.\n */\nexport function parseRoutineRows(\n rows: RoutineRow[] | undefined,\n opts: { warn?: (message: string) => void } = {},\n): Routine[] {\n const out: Routine[] = [];\n for (const row of rows || []) {\n const name = String(row?.name ?? '').trim();\n const phrases = splitPhrases(row?.phrases);\n if (!phrases.length) {\n continue;\n }\n let actions: TriggerAction[] = [];\n const raw = row?.actions;\n if (Array.isArray(raw)) {\n actions = raw;\n } else if (typeof raw === 'string' && raw.trim()) {\n try {\n const parsed: unknown = JSON.parse(raw);\n actions = Array.isArray(parsed) ? (parsed as TriggerAction[]) : [];\n } catch (e) {\n opts.warn?.(\n `routine '${name || phrases[0]}': actions is not valid JSON (${(e as Error).message}) — skipped`,\n );\n continue;\n }\n }\n actions = actions.filter(a => a && (a.say || a.setState));\n const reply = String(row?.reply ?? '').trim();\n if (!actions.length && !reply) {\n opts.warn?.(`routine '${name || phrases[0]}': nothing to do (no actions, no reply) — skipped`);\n continue;\n }\n out.push({ name: name || phrases[0], phrases, actions, reply });\n }\n return out;\n}\n\n/**\n * Find the routine an utterance triggers, or null. The **longest** matching phrase wins, so a specific\n * \"gute nacht alle\" beats a general \"gute nacht\" instead of depending on the table's order.\n */\nexport function matchRoutine(text: string, routines: Routine[]): Routine | null {\n const haystack = normalizePhrase(text);\n if (!haystack) {\n return null;\n }\n let best: Routine | null = null;\n let bestLength = 0;\n for (const routine of routines) {\n for (const phrase of routine.phrases) {\n if (phrase.length > bestLength && contains(haystack, phrase)) {\n best = routine;\n bestLength = phrase.length;\n }\n }\n }\n return best;\n}\n\n/** Whole-word containment: \"licht\" must not match \"lichtschalter\" and turn a question into a macro. */\nfunction contains(haystack: string, phrase: string): boolean {\n let from = 0;\n for (;;) {\n const at = haystack.indexOf(phrase, from);\n if (at < 0) {\n return false;\n }\n const before = at === 0 || haystack[at - 1] === ' ';\n const afterIndex = at + phrase.length;\n const after = afterIndex === haystack.length || haystack[afterIndex] === ' ';\n if (before && after) {\n return true;\n }\n from = at + 1;\n }\n}\n"]}
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ /**
3
+ * Named announcement targets — groups of rooms ("upstairs") and people ("Denis").
4
+ *
5
+ * Both are the same thing mechanically: a name standing for a set of satellites. A group is "every
6
+ * speaker up there", a person is "every speaker that is mine" — so one table serves both, and `kind` only
7
+ * exists to tell the LLM whether a name is a place or a human ("tell Denis dinner is ready" has to reach
8
+ * his speakers, not the kitchen).
9
+ *
10
+ * Ported from the Python original (`C:\iot\Hannah`, `core/main.py:717` `_resolve_targets`), including the
11
+ * resolution order that matters: a real satellite or room wins over a group of the same name, so a
12
+ * speaker can never be made unreachable by naming a group after it.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.splitMembers = splitMembers;
16
+ exports.parseTargetRows = parseTargetRows;
17
+ exports.findTarget = findTarget;
18
+ exports.isBroadcast = isBroadcast;
19
+ exports.describeTargets = describeTargets;
20
+ /** Split a member list as typed into the table: comma or semicolon separated, blanks dropped. */
21
+ function splitMembers(members) {
22
+ if (Array.isArray(members)) {
23
+ return members.map(m => String(m).trim()).filter(Boolean);
24
+ }
25
+ if (typeof members !== 'string') {
26
+ return []; // a structured value is not a member list, and stringifying it would read "[object Object]"
27
+ }
28
+ return members
29
+ .split(/[,;]/)
30
+ .map(m => m.trim())
31
+ .filter(Boolean);
32
+ }
33
+ /** Turn settings-table rows into named targets; rows without a name or without members are skipped. */
34
+ function parseTargetRows(rows) {
35
+ const out = [];
36
+ const seen = new Set();
37
+ for (const row of rows || []) {
38
+ const name = String(row?.name ?? '').trim();
39
+ const members = splitMembers(row?.members);
40
+ if (!name || !members.length) {
41
+ continue; // half-filled row: nothing to resolve, and an empty group would silently match
42
+ }
43
+ const key = name.toLowerCase();
44
+ if (seen.has(key)) {
45
+ continue; // first definition wins, so a duplicate cannot shadow it
46
+ }
47
+ seen.add(key);
48
+ out.push({
49
+ name,
50
+ members,
51
+ kind: String(row.kind ?? '')
52
+ .trim()
53
+ .toLowerCase() === 'person'
54
+ ? 'person'
55
+ : 'group',
56
+ });
57
+ }
58
+ return out;
59
+ }
60
+ /** Find a named target, ignoring case and surrounding space (people type "denis", not "Denis"). */
61
+ function findTarget(name, groups) {
62
+ const wanted = String(name ?? '')
63
+ .trim()
64
+ .toLowerCase();
65
+ if (!wanted) {
66
+ return null;
67
+ }
68
+ return groups.find(g => g.name.toLowerCase() === wanted) ?? null;
69
+ }
70
+ /** True for the name that means "every satellite" (`all`, or nothing at all). */
71
+ function isBroadcast(name) {
72
+ const v = String(name ?? '')
73
+ .trim()
74
+ .toLowerCase();
75
+ return v === '' || v === 'all';
76
+ }
77
+ /**
78
+ * One line naming the configured targets, for the `announce` tool description — without it the model has
79
+ * no way of knowing that "Denis" is a valid target. Empty when nothing is configured.
80
+ */
81
+ function describeTargets(groups) {
82
+ if (!groups.length) {
83
+ return '';
84
+ }
85
+ const people = groups.filter(g => g.kind === 'person').map(g => g.name);
86
+ const rooms = groups.filter(g => g.kind === 'group').map(g => g.name);
87
+ const parts = [];
88
+ if (people.length) {
89
+ parts.push(`people: ${people.join(', ')}`);
90
+ }
91
+ if (rooms.length) {
92
+ parts.push(`groups: ${rooms.join(', ')}`);
93
+ }
94
+ return parts.join('; ');
95
+ }
96
+ //# sourceMappingURL=targets.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"targets.js","sourceRoot":"","sources":["../../src/lib/targets.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Named announcement targets — groups of rooms (\"upstairs\") and people (\"Denis\").\n *\n * Both are the same thing mechanically: a name standing for a set of satellites. A group is \"every\n * speaker up there\", a person is \"every speaker that is mine\" — so one table serves both, and `kind` only\n * exists to tell the LLM whether a name is a place or a human (\"tell Denis dinner is ready\" has to reach\n * his speakers, not the kitchen).\n *\n * Ported from the Python original (`C:\\iot\\Hannah`, `core/main.py:717` `_resolve_targets`), including the\n * resolution order that matters: a real satellite or room wins over a group of the same name, so a\n * speaker can never be made unreachable by naming a group after it.\n */\n\n/** What a name stands for. Only used to describe the target to the LLM. */\nexport type TargetKind = 'group' | 'person';\n\n/** One named target. */\nexport interface TargetGroup {\n name: string;\n /** Satellite state ids, room names or device names. Resolved by the caller, which knows the satellites. */\n members: string[];\n kind: TargetKind;\n}\n\n/** One row of the settings table (everything arrives as text). */\nexport interface TargetRow {\n name?: string;\n members?: string;\n kind?: string;\n}\n\n/** Split a member list as typed into the table: comma or semicolon separated, blanks dropped. */\nexport function splitMembers(members: unknown): string[] {\n if (Array.isArray(members)) {\n return members.map(m => String(m).trim()).filter(Boolean);\n }\n if (typeof members !== 'string') {\n return []; // a structured value is not a member list, and stringifying it would read \"[object Object]\"\n }\n return members\n .split(/[,;]/)\n .map(m => m.trim())\n .filter(Boolean);\n}\n\n/** Turn settings-table rows into named targets; rows without a name or without members are skipped. */\nexport function parseTargetRows(rows: TargetRow[] | undefined): TargetGroup[] {\n const out: TargetGroup[] = [];\n const seen = new Set<string>();\n for (const row of rows || []) {\n const name = String(row?.name ?? '').trim();\n const members = splitMembers(row?.members);\n if (!name || !members.length) {\n continue; // half-filled row: nothing to resolve, and an empty group would silently match\n }\n const key = name.toLowerCase();\n if (seen.has(key)) {\n continue; // first definition wins, so a duplicate cannot shadow it\n }\n seen.add(key);\n out.push({\n name,\n members,\n kind:\n String(row.kind ?? '')\n .trim()\n .toLowerCase() === 'person'\n ? 'person'\n : 'group',\n });\n }\n return out;\n}\n\n/** Find a named target, ignoring case and surrounding space (people type \"denis\", not \"Denis\"). */\nexport function findTarget(name: string, groups: TargetGroup[]): TargetGroup | null {\n const wanted = String(name ?? '')\n .trim()\n .toLowerCase();\n if (!wanted) {\n return null;\n }\n return groups.find(g => g.name.toLowerCase() === wanted) ?? null;\n}\n\n/** True for the name that means \"every satellite\" (`all`, or nothing at all). */\nexport function isBroadcast(name: string | null | undefined): boolean {\n const v = String(name ?? '')\n .trim()\n .toLowerCase();\n return v === '' || v === 'all';\n}\n\n/**\n * One line naming the configured targets, for the `announce` tool description — without it the model has\n * no way of knowing that \"Denis\" is a valid target. Empty when nothing is configured.\n */\nexport function describeTargets(groups: TargetGroup[]): string {\n if (!groups.length) {\n return '';\n }\n const people = groups.filter(g => g.kind === 'person').map(g => g.name);\n const rooms = groups.filter(g => g.kind === 'group').map(g => g.name);\n const parts: string[] = [];\n if (people.length) {\n parts.push(`people: ${people.join(', ')}`);\n }\n if (rooms.length) {\n parts.push(`groups: ${rooms.join(', ')}`);\n }\n return parts.join('; ');\n}\n"]}