iobroker.hassemu 1.32.0 → 1.32.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.
- package/README.md +10 -14
- package/build/lib/redirect-wrapper.js +172 -10
- package/build/lib/redirect-wrapper.js.map +2 -2
- package/build/lib/webserver.js +1 -1
- package/build/lib/webserver.js.map +2 -2
- package/io-package.json +38 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -156,9 +156,17 @@ Got scripts that still write to `visUrl`? Update them — write to `manualUrl` i
|
|
|
156
156
|
Placeholder for the next version (at the beginning of the line):
|
|
157
157
|
### **WORK IN PROGRESS**
|
|
158
158
|
-->
|
|
159
|
+
### 1.32.2 (2026-05-16)
|
|
160
|
+
|
|
161
|
+
- Internal cleanup. No user-facing changes.
|
|
162
|
+
|
|
163
|
+
### 1.32.1 (2026-05-16)
|
|
164
|
+
|
|
165
|
+
- If the adapter goes offline while the display is running, the display now switches to a clear offline page with a reload button instead of just stopping to update.
|
|
166
|
+
|
|
159
167
|
### 1.32.0 (2026-05-16)
|
|
160
168
|
|
|
161
|
-
-
|
|
169
|
+
- Two state descriptions in the object tree are now complete again. Internal cleanup, no further user-facing changes.
|
|
162
170
|
|
|
163
171
|
### 1.31.1 (2026-05-13)
|
|
164
172
|
|
|
@@ -169,19 +177,7 @@ Got scripts that still write to `visUrl`? Update them — write to `manualUrl` i
|
|
|
169
177
|
- Companion apps now stay signed in across adapter restarts (ioBroker update, network glitch, power cut). Existing paired apps will sign in once after the update, then stay signed in.
|
|
170
178
|
- Removed an internal brute-force-lockout layer that occasionally locked out legitimate companions after multiple quick restarts.
|
|
171
179
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
- Internal cleanup based on a source audit. No user-facing changes — except that adding or reconfiguring an Aura adapter now refreshes the URL dropdown automatically instead of requiring a manual refresh.
|
|
175
|
-
|
|
176
|
-
### 1.29.3 (2026-05-12)
|
|
177
|
-
|
|
178
|
-
- The "Connection to Home Assistant failed" popup on Shelly Wall Display 2.6.0+ also stays away when the landing page is shown (no URL configured yet). v1.29.2 only suppressed it when a target URL was set.
|
|
179
|
-
- Replaced the landing-page emblem with the real ioBroker brand mark (power-button "i" inside a ring).
|
|
180
|
-
|
|
181
|
-
Older entries are in [CHANGELOG_OLD.md](CHANGELOG_OLD.md).## Support
|
|
182
|
-
|
|
183
|
-
- [ioBroker Forum](https://forum.iobroker.net/)
|
|
184
|
-
- [GitHub Issues](https://github.com/krobipd/ioBroker.hassemu/issues)
|
|
180
|
+
Older entries are in [CHANGELOG_OLD.md](CHANGELOG_OLD.md).
|
|
185
181
|
|
|
186
182
|
### Support Development
|
|
187
183
|
|
|
@@ -23,40 +23,202 @@ __export(redirect_wrapper_exports, {
|
|
|
23
23
|
module.exports = __toCommonJS(redirect_wrapper_exports);
|
|
24
24
|
var import_coerce = require("./coerce");
|
|
25
25
|
var import_external_bridge = require("./external-bridge");
|
|
26
|
-
|
|
26
|
+
const DOWN_STRINGS = {
|
|
27
|
+
en: {
|
|
28
|
+
htmlLang: "en",
|
|
29
|
+
pageTitle: "hassemu offline \xB7 ioBroker",
|
|
30
|
+
heading: "hassemu offline",
|
|
31
|
+
subhead: "The page above is the last view this display received. As soon as hassemu is reachable again, the display updates by itself.",
|
|
32
|
+
deviceIdLabel: "Device ID",
|
|
33
|
+
ipLabel: "IP address",
|
|
34
|
+
reloadButton: "Reload now"
|
|
35
|
+
},
|
|
36
|
+
de: {
|
|
37
|
+
htmlLang: "de",
|
|
38
|
+
pageTitle: "hassemu offline \xB7 ioBroker",
|
|
39
|
+
heading: "hassemu offline",
|
|
40
|
+
subhead: "Die Seite oben ist die letzte Ansicht, die das Display erhalten hat. Sobald hassemu wieder erreichbar ist, aktualisiert sich die Anzeige automatisch.",
|
|
41
|
+
deviceIdLabel: "Ger\xE4te-ID",
|
|
42
|
+
ipLabel: "IP-Adresse",
|
|
43
|
+
reloadButton: "Jetzt neu laden"
|
|
44
|
+
},
|
|
45
|
+
ru: {
|
|
46
|
+
htmlLang: "ru",
|
|
47
|
+
pageTitle: "hassemu \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D \xB7 ioBroker",
|
|
48
|
+
heading: "hassemu \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D",
|
|
49
|
+
subhead: "\u0421\u0442\u0440\u0430\u043D\u0438\u0446\u0430 \u0432\u044B\u0448\u0435 \u2014 \u043F\u043E\u0441\u043B\u0435\u0434\u043D\u0438\u0439 \u0432\u0438\u0434, \u043A\u043E\u0442\u043E\u0440\u044B\u0439 \u043F\u043E\u043B\u0443\u0447\u0438\u043B \u0434\u0438\u0441\u043F\u043B\u0435\u0439. \u041A\u0430\u043A \u0442\u043E\u043B\u044C\u043A\u043E hassemu \u0441\u043D\u043E\u0432\u0430 \u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u044D\u043A\u0440\u0430\u043D \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u0441\u044F \u0430\u0432\u0442\u043E\u043C\u0430\u0442\u0438\u0447\u0435\u0441\u043A\u0438.",
|
|
50
|
+
deviceIdLabel: "ID \u0443\u0441\u0442\u0440\u043E\u0439\u0441\u0442\u0432\u0430",
|
|
51
|
+
ipLabel: "IP-\u0430\u0434\u0440\u0435\u0441",
|
|
52
|
+
reloadButton: "\u041F\u0435\u0440\u0435\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C"
|
|
53
|
+
},
|
|
54
|
+
pt: {
|
|
55
|
+
htmlLang: "pt",
|
|
56
|
+
pageTitle: "hassemu offline \xB7 ioBroker",
|
|
57
|
+
heading: "hassemu offline",
|
|
58
|
+
subhead: "A p\xE1gina acima \xE9 a \xFAltima visualiza\xE7\xE3o que este ecr\xE3 recebeu. Assim que o hassemu voltar a estar dispon\xEDvel, o ecr\xE3 atualiza-se automaticamente.",
|
|
59
|
+
deviceIdLabel: "ID do dispositivo",
|
|
60
|
+
ipLabel: "Endere\xE7o IP",
|
|
61
|
+
reloadButton: "Recarregar"
|
|
62
|
+
},
|
|
63
|
+
nl: {
|
|
64
|
+
htmlLang: "nl",
|
|
65
|
+
pageTitle: "hassemu offline \xB7 ioBroker",
|
|
66
|
+
heading: "hassemu offline",
|
|
67
|
+
subhead: "De pagina hierboven is de laatste weergave die dit display heeft ontvangen. Zodra hassemu weer bereikbaar is, wordt het scherm automatisch bijgewerkt.",
|
|
68
|
+
deviceIdLabel: "Apparaat-ID",
|
|
69
|
+
ipLabel: "IP-adres",
|
|
70
|
+
reloadButton: "Opnieuw laden"
|
|
71
|
+
},
|
|
72
|
+
fr: {
|
|
73
|
+
htmlLang: "fr",
|
|
74
|
+
pageTitle: "hassemu hors ligne \xB7 ioBroker",
|
|
75
|
+
heading: "hassemu hors ligne",
|
|
76
|
+
subhead: "La page ci-dessus est la derni\xE8re vue re\xE7ue par cet \xE9cran. D\xE8s que hassemu est de nouveau accessible, l'\xE9cran se met \xE0 jour automatiquement.",
|
|
77
|
+
deviceIdLabel: "Identifiant de l'appareil",
|
|
78
|
+
ipLabel: "Adresse IP",
|
|
79
|
+
reloadButton: "Recharger"
|
|
80
|
+
},
|
|
81
|
+
it: {
|
|
82
|
+
htmlLang: "it",
|
|
83
|
+
pageTitle: "hassemu offline \xB7 ioBroker",
|
|
84
|
+
heading: "hassemu offline",
|
|
85
|
+
subhead: "La pagina sopra \xE8 l\u2019ultima vista ricevuta dal display. Appena hassemu sar\xE0 nuovamente raggiungibile, la schermata si aggiorner\xE0 automaticamente.",
|
|
86
|
+
deviceIdLabel: "ID dispositivo",
|
|
87
|
+
ipLabel: "Indirizzo IP",
|
|
88
|
+
reloadButton: "Ricarica"
|
|
89
|
+
},
|
|
90
|
+
es: {
|
|
91
|
+
htmlLang: "es",
|
|
92
|
+
pageTitle: "hassemu sin conexi\xF3n \xB7 ioBroker",
|
|
93
|
+
heading: "hassemu sin conexi\xF3n",
|
|
94
|
+
subhead: "La p\xE1gina de arriba es la \xFAltima vista que recibi\xF3 esta pantalla. En cuanto hassemu vuelva a estar disponible, la pantalla se actualizar\xE1 autom\xE1ticamente.",
|
|
95
|
+
deviceIdLabel: "ID del dispositivo",
|
|
96
|
+
ipLabel: "Direcci\xF3n IP",
|
|
97
|
+
reloadButton: "Recargar"
|
|
98
|
+
},
|
|
99
|
+
pl: {
|
|
100
|
+
htmlLang: "pl",
|
|
101
|
+
pageTitle: "hassemu offline \xB7 ioBroker",
|
|
102
|
+
heading: "hassemu offline",
|
|
103
|
+
subhead: "Strona powy\u017Cej to ostatni widok otrzymany przez ten wy\u015Bwietlacz. Gdy hassemu zn\xF3w b\u0119dzie dost\u0119pny, ekran od\u015Bwie\u017Cy si\u0119 sam.",
|
|
104
|
+
deviceIdLabel: "ID urz\u0105dzenia",
|
|
105
|
+
ipLabel: "Adres IP",
|
|
106
|
+
reloadButton: "Za\u0142aduj ponownie"
|
|
107
|
+
},
|
|
108
|
+
uk: {
|
|
109
|
+
htmlLang: "uk",
|
|
110
|
+
pageTitle: "hassemu \u043E\u0444\u043B\u0430\u0439\u043D \xB7 ioBroker",
|
|
111
|
+
heading: "hassemu \u043E\u0444\u043B\u0430\u0439\u043D",
|
|
112
|
+
subhead: "\u0421\u0442\u043E\u0440\u0456\u043D\u043A\u0430 \u0432\u0438\u0449\u0435 \u2014 \u043E\u0441\u0442\u0430\u043D\u043D\u0456\u0439 \u0432\u0438\u0433\u043B\u044F\u0434, \u044F\u043A\u0438\u0439 \u043E\u0442\u0440\u0438\u043C\u0430\u0432 \u0446\u0435\u0439 \u0434\u0438\u0441\u043F\u043B\u0435\u0439. \u0429\u043E\u0439\u043D\u043E hassemu \u0437\u043D\u043E\u0432\u0443 \u0431\u0443\u0434\u0435 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u0438\u043C, \u0435\u043A\u0440\u0430\u043D \u043E\u043D\u043E\u0432\u0438\u0442\u044C\u0441\u044F \u0430\u0432\u0442\u043E\u043C\u0430\u0442\u0438\u0447\u043D\u043E.",
|
|
113
|
+
deviceIdLabel: "ID \u043F\u0440\u0438\u0441\u0442\u0440\u043E\u044E",
|
|
114
|
+
ipLabel: "IP-\u0430\u0434\u0440\u0435\u0441\u0430",
|
|
115
|
+
reloadButton: "\u041F\u0435\u0440\u0435\u0437\u0430\u0432\u0430\u043D\u0442\u0430\u0436\u0438\u0442\u0438"
|
|
116
|
+
},
|
|
117
|
+
"zh-cn": {
|
|
118
|
+
htmlLang: "zh-CN",
|
|
119
|
+
pageTitle: "hassemu \u79BB\u7EBF \xB7 ioBroker",
|
|
120
|
+
heading: "hassemu \u79BB\u7EBF",
|
|
121
|
+
subhead: "\u4E0A\u65B9\u9875\u9762\u662F\u6B64\u663E\u793A\u5668\u6536\u5230\u7684\u6700\u540E\u4E00\u6B21\u89C6\u56FE\u3002hassemu \u91CD\u65B0\u53EF\u7528\u540E\uFF0C\u5C4F\u5E55\u4F1A\u81EA\u52A8\u5237\u65B0\u3002",
|
|
122
|
+
deviceIdLabel: "\u8BBE\u5907 ID",
|
|
123
|
+
ipLabel: "IP \u5730\u5740",
|
|
124
|
+
reloadButton: "\u7ACB\u5373\u91CD\u65B0\u52A0\u8F7D"
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const DOWN_THRESHOLD = 3;
|
|
128
|
+
function renderRedirectWrapper(target, clientId, namespace, language = "en", ip = null) {
|
|
129
|
+
var _a, _b;
|
|
27
130
|
const escAttr = (0, import_coerce.escapeHtml)(target);
|
|
28
131
|
const escJs = JSON.stringify(target);
|
|
132
|
+
const s = (_a = DOWN_STRINGS[language]) != null ? _a : DOWN_STRINGS.en;
|
|
133
|
+
const id = (0, import_coerce.escapeHtml)(clientId);
|
|
134
|
+
const ns = (0, import_coerce.escapeHtml)(namespace);
|
|
135
|
+
const trimmedIp = (_b = ip == null ? void 0 : ip.trim()) != null ? _b : "";
|
|
136
|
+
const isLoopback = trimmedIp === "" || trimmedIp === "127.0.0.1" || trimmedIp === "::1" || trimmedIp === "0.0.0.0" || trimmedIp.startsWith("127.");
|
|
137
|
+
const ipRow = isLoopback ? "" : `<tr><th scope="row">${(0, import_coerce.escapeHtml)(s.ipLabel)}</th><td>${(0, import_coerce.escapeHtml)(trimmedIp)}</td></tr>`;
|
|
138
|
+
void ns;
|
|
29
139
|
return `<!DOCTYPE html>
|
|
30
|
-
<html lang="
|
|
140
|
+
<html lang="${(0, import_coerce.escapeHtml)(s.htmlLang)}">
|
|
31
141
|
<head>
|
|
32
142
|
<meta charset="utf-8">
|
|
33
143
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
34
144
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
|
35
|
-
<title
|
|
145
|
+
<title>${(0, import_coerce.escapeHtml)(s.pageTitle)}</title>
|
|
36
146
|
<style>
|
|
37
147
|
html,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden;}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
148
|
+
iframe{display:block;border:0;margin:0;padding:0;position:fixed;top:0;left:0;width:100vw;height:100vh;background:#000;z-index:1;}
|
|
149
|
+
#hassemu-down{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:#0f172a;color:#f1f5f9;font:16px/1.5 system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;align-items:center;justify-content:center;padding:1.5rem;box-sizing:border-box;z-index:10;}
|
|
150
|
+
#hassemu-down[hidden]{display:none;}
|
|
151
|
+
#hassemu-down.visible{display:flex;}
|
|
152
|
+
#hassemu-down .card{width:100%;max-width:44rem;background:#1e293b;border-radius:12px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.35);}
|
|
153
|
+
#hassemu-down .banner{background:#dc2626;color:#fff;padding:1.4rem 1.8rem;}
|
|
154
|
+
#hassemu-down .banner h1{margin:0;font-size:1.4rem;font-weight:600;}
|
|
155
|
+
#hassemu-down .banner p{margin:.4rem 0 0;font-size:.95rem;opacity:.95;}
|
|
156
|
+
#hassemu-down .content{padding:1.6rem 1.8rem 1.3rem;}
|
|
157
|
+
#hassemu-down table{width:100%;border-collapse:collapse;font-size:.95rem;margin-bottom:1.4rem;}
|
|
158
|
+
#hassemu-down th,#hassemu-down td{padding:.55rem .7rem;text-align:left;border-bottom:1px solid #334155;}
|
|
159
|
+
#hassemu-down tr:last-child th,#hassemu-down tr:last-child td{border-bottom:none;}
|
|
160
|
+
#hassemu-down th{font-weight:500;color:#94a3b8;width:9rem;white-space:nowrap;}
|
|
161
|
+
#hassemu-down code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#0f172a;padding:.15rem .45rem;border-radius:4px;font-size:.9em;}
|
|
162
|
+
#hassemu-down button{display:block;width:100%;padding:.9rem 1.2rem;background:#38bdf8;color:#0f172a;border:none;border-radius:6px;font-size:1rem;font-weight:600;cursor:pointer;}
|
|
163
|
+
#hassemu-down button:hover{background:#0ea5e9;}
|
|
164
|
+
@media (max-width:30rem){#hassemu-down{padding:0;}#hassemu-down .card{border-radius:0;}#hassemu-down th{width:auto;}}
|
|
43
165
|
</style>
|
|
44
166
|
</head>
|
|
45
167
|
<body>
|
|
46
|
-
<iframe src="${escAttr}" allow="autoplay; fullscreen; geolocation; microphone; camera"></iframe>
|
|
168
|
+
<iframe id="hassemu-iframe" src="${escAttr}" allow="autoplay; fullscreen; geolocation; microphone; camera"></iframe>
|
|
169
|
+
<div id="hassemu-down" role="status" aria-live="polite">
|
|
170
|
+
<div class="card">
|
|
171
|
+
<div class="banner">
|
|
172
|
+
<h1>${(0, import_coerce.escapeHtml)(s.heading)}</h1>
|
|
173
|
+
<p>${(0, import_coerce.escapeHtml)(s.subhead)}</p>
|
|
174
|
+
</div>
|
|
175
|
+
<div class="content">
|
|
176
|
+
<table>
|
|
177
|
+
<tbody>
|
|
178
|
+
<tr><th scope="row">${(0, import_coerce.escapeHtml)(s.deviceIdLabel)}</th><td><code>${id}</code></td></tr>
|
|
179
|
+
${ipRow}
|
|
180
|
+
</tbody>
|
|
181
|
+
</table>
|
|
182
|
+
<button type="button" onclick="location.reload()">${(0, import_coerce.escapeHtml)(s.reloadButton)}</button>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
47
186
|
${import_external_bridge.CONNECTION_STATUS_SCRIPT}
|
|
48
187
|
<script>
|
|
49
188
|
(function(){
|
|
50
189
|
var current=${escJs};
|
|
190
|
+
var fails=0;
|
|
191
|
+
var THRESHOLD=${DOWN_THRESHOLD};
|
|
192
|
+
var iframeEl=document.getElementById('hassemu-iframe');
|
|
193
|
+
var downEl=document.getElementById('hassemu-down');
|
|
194
|
+
function showDown(){
|
|
195
|
+
if(downEl && !downEl.classList.contains('visible')){
|
|
196
|
+
downEl.classList.add('visible');
|
|
197
|
+
if(iframeEl){iframeEl.style.display='none';}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function hideDown(){
|
|
201
|
+
if(downEl && downEl.classList.contains('visible')){
|
|
202
|
+
downEl.classList.remove('visible');
|
|
203
|
+
if(iframeEl){iframeEl.style.display='block';}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
51
206
|
setInterval(function(){
|
|
52
207
|
fetch('/api/redirect_check',{cache:'no-store',credentials:'same-origin'})
|
|
53
208
|
.then(function(r){return r.json();})
|
|
54
209
|
.then(function(j){
|
|
210
|
+
fails=0;
|
|
211
|
+
hideDown();
|
|
55
212
|
if(j&&typeof j.target==='string'&&j.target&&j.target!==current){
|
|
56
213
|
location.reload();
|
|
57
214
|
}
|
|
58
215
|
})
|
|
59
|
-
.catch(function(){
|
|
216
|
+
.catch(function(){
|
|
217
|
+
fails++;
|
|
218
|
+
if(fails>=THRESHOLD){
|
|
219
|
+
showDown();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
60
222
|
},30000);
|
|
61
223
|
})();
|
|
62
224
|
</script>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/redirect-wrapper.ts"],
|
|
4
|
-
"sourcesContent": ["import { escapeHtml } from './coerce';\nimport { CONNECTION_STATUS_SCRIPT } from './external-bridge';\n\n/**\n * HTML-Wrapper statt 302-Redirect (A3 / v1.7.0). Display l\u00E4dt das HTML einmal,\n * sieht den Target im iframe, polled `/api/redirect_check` alle 30s. Bei\n * Target-Wechsel (User edit) macht es `location.reload()` und bekommt das\n * neue iframe-Target.\n *\n * `target` muss bereits durch `coerceSafeUrl` validiert sein (Resolver garantiert\n * das). Der Wrapper escaped trotzdem \u2014 defense in depth.\n *\n * v1.32.0: aus `webserver.ts` ausgelagert f\u00FCr Symmetrie zu `landing-page.ts` /\n * `auth-page.ts`. `escapeHtml` ist shared helper aus `coerce.ts`.\n *\n * @param target Vom Resolver gelieferte Ziel-URL.\n */\nexport function renderRedirectWrapper(target: string): string {\n // Conservative HTML-attribute escape via shared helper. Sicherheits-relevant\n // weil target letztlich aus user-konfigurierten States stammt.\n const escAttr = escapeHtml(target);\n const escJs = JSON.stringify(target); // safe for inline JS string literal\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, user-scalable=no\">\n<title>ioBroker HASS Emulator</title>\n<style>\nhtml,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden;}\n/* display:block kills the inline-baseline gap below the iframe; position:fixed +\n 100vw/100vh nimmt das Display sicher voll aus, auch wenn ein WebView die\n 100%-Berechnung subpixel-falsch macht (Shelly Wall Display zeigte sonst\n einen schwarzen Streifen rechts/unten). */\niframe{display:block;border:0;margin:0;padding:0;position:fixed;top:0;left:0;width:100vw;height:100vh;background:#000;}\n</style>\n</head>\n<body>\n<iframe src=\"${escAttr}\" allow=\"autoplay; fullscreen; geolocation; microphone; camera\"></iframe>\n${CONNECTION_STATUS_SCRIPT}\n<script>\n(function(){\n var current=${escJs};\n setInterval(function(){\n fetch('/api/redirect_check',{cache:'no-store',credentials:'same-origin'})\n .then(function(r){return r.json();})\n .then(function(j){\n if(j&&typeof j.target==='string'&&j.target&&j.target!==current){\n location.reload();\n }\n })\n .catch(function(){/* silent \u2014 broker hiccup, retry next tick */});\n },30000);\n})();\n</script>\n</body>\n</html>`;\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,oBAA2B;AAC3B,6BAAyC;
|
|
4
|
+
"sourcesContent": ["import { escapeHtml } from './coerce';\nimport { CONNECTION_STATUS_SCRIPT } from './external-bridge';\n\n/** Supported languages \u2014 matches the 11 io-package.json translations. */\ntype DownLanguage = 'en' | 'de' | 'ru' | 'pt' | 'nl' | 'fr' | 'it' | 'es' | 'pl' | 'uk' | 'zh-cn';\n\ninterface DownStrings {\n htmlLang: string;\n pageTitle: string;\n heading: string;\n subhead: string;\n deviceIdLabel: string;\n ipLabel: string;\n reloadButton: string;\n}\n\nconst DOWN_STRINGS: Record<DownLanguage, DownStrings> = {\n en: {\n htmlLang: 'en',\n pageTitle: 'hassemu offline \u00B7 ioBroker',\n heading: 'hassemu offline',\n subhead:\n 'The page above is the last view this display received. As soon as hassemu is reachable again, the display updates by itself.',\n deviceIdLabel: 'Device ID',\n ipLabel: 'IP address',\n reloadButton: 'Reload now',\n },\n de: {\n htmlLang: 'de',\n pageTitle: 'hassemu offline \u00B7 ioBroker',\n heading: 'hassemu offline',\n subhead:\n 'Die Seite oben ist die letzte Ansicht, die das Display erhalten hat. Sobald hassemu wieder erreichbar ist, aktualisiert sich die Anzeige automatisch.',\n deviceIdLabel: 'Ger\u00E4te-ID',\n ipLabel: 'IP-Adresse',\n reloadButton: 'Jetzt neu laden',\n },\n ru: {\n htmlLang: 'ru',\n pageTitle: 'hassemu \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D \u00B7 ioBroker',\n heading: 'hassemu \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D',\n subhead:\n '\u0421\u0442\u0440\u0430\u043D\u0438\u0446\u0430 \u0432\u044B\u0448\u0435 \u2014 \u043F\u043E\u0441\u043B\u0435\u0434\u043D\u0438\u0439 \u0432\u0438\u0434, \u043A\u043E\u0442\u043E\u0440\u044B\u0439 \u043F\u043E\u043B\u0443\u0447\u0438\u043B \u0434\u0438\u0441\u043F\u043B\u0435\u0439. \u041A\u0430\u043A \u0442\u043E\u043B\u044C\u043A\u043E hassemu \u0441\u043D\u043E\u0432\u0430 \u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u044D\u043A\u0440\u0430\u043D \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u0441\u044F \u0430\u0432\u0442\u043E\u043C\u0430\u0442\u0438\u0447\u0435\u0441\u043A\u0438.',\n deviceIdLabel: 'ID \u0443\u0441\u0442\u0440\u043E\u0439\u0441\u0442\u0432\u0430',\n ipLabel: 'IP-\u0430\u0434\u0440\u0435\u0441',\n reloadButton: '\u041F\u0435\u0440\u0435\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C',\n },\n pt: {\n htmlLang: 'pt',\n pageTitle: 'hassemu offline \u00B7 ioBroker',\n heading: 'hassemu offline',\n subhead:\n 'A p\u00E1gina acima \u00E9 a \u00FAltima visualiza\u00E7\u00E3o que este ecr\u00E3 recebeu. Assim que o hassemu voltar a estar dispon\u00EDvel, o ecr\u00E3 atualiza-se automaticamente.',\n deviceIdLabel: 'ID do dispositivo',\n ipLabel: 'Endere\u00E7o IP',\n reloadButton: 'Recarregar',\n },\n nl: {\n htmlLang: 'nl',\n pageTitle: 'hassemu offline \u00B7 ioBroker',\n heading: 'hassemu offline',\n subhead:\n 'De pagina hierboven is de laatste weergave die dit display heeft ontvangen. Zodra hassemu weer bereikbaar is, wordt het scherm automatisch bijgewerkt.',\n deviceIdLabel: 'Apparaat-ID',\n ipLabel: 'IP-adres',\n reloadButton: 'Opnieuw laden',\n },\n fr: {\n htmlLang: 'fr',\n pageTitle: 'hassemu hors ligne \u00B7 ioBroker',\n heading: 'hassemu hors ligne',\n subhead:\n \"La page ci-dessus est la derni\u00E8re vue re\u00E7ue par cet \u00E9cran. D\u00E8s que hassemu est de nouveau accessible, l'\u00E9cran se met \u00E0 jour automatiquement.\",\n deviceIdLabel: \"Identifiant de l'appareil\",\n ipLabel: 'Adresse IP',\n reloadButton: 'Recharger',\n },\n it: {\n htmlLang: 'it',\n pageTitle: 'hassemu offline \u00B7 ioBroker',\n heading: 'hassemu offline',\n subhead:\n 'La pagina sopra \u00E8 l\u2019ultima vista ricevuta dal display. Appena hassemu sar\u00E0 nuovamente raggiungibile, la schermata si aggiorner\u00E0 automaticamente.',\n deviceIdLabel: 'ID dispositivo',\n ipLabel: 'Indirizzo IP',\n reloadButton: 'Ricarica',\n },\n es: {\n htmlLang: 'es',\n pageTitle: 'hassemu sin conexi\u00F3n \u00B7 ioBroker',\n heading: 'hassemu sin conexi\u00F3n',\n subhead:\n 'La p\u00E1gina de arriba es la \u00FAltima vista que recibi\u00F3 esta pantalla. En cuanto hassemu vuelva a estar disponible, la pantalla se actualizar\u00E1 autom\u00E1ticamente.',\n deviceIdLabel: 'ID del dispositivo',\n ipLabel: 'Direcci\u00F3n IP',\n reloadButton: 'Recargar',\n },\n pl: {\n htmlLang: 'pl',\n pageTitle: 'hassemu offline \u00B7 ioBroker',\n heading: 'hassemu offline',\n subhead:\n 'Strona powy\u017Cej to ostatni widok otrzymany przez ten wy\u015Bwietlacz. Gdy hassemu zn\u00F3w b\u0119dzie dost\u0119pny, ekran od\u015Bwie\u017Cy si\u0119 sam.',\n deviceIdLabel: 'ID urz\u0105dzenia',\n ipLabel: 'Adres IP',\n reloadButton: 'Za\u0142aduj ponownie',\n },\n uk: {\n htmlLang: 'uk',\n pageTitle: 'hassemu \u043E\u0444\u043B\u0430\u0439\u043D \u00B7 ioBroker',\n heading: 'hassemu \u043E\u0444\u043B\u0430\u0439\u043D',\n subhead:\n '\u0421\u0442\u043E\u0440\u0456\u043D\u043A\u0430 \u0432\u0438\u0449\u0435 \u2014 \u043E\u0441\u0442\u0430\u043D\u043D\u0456\u0439 \u0432\u0438\u0433\u043B\u044F\u0434, \u044F\u043A\u0438\u0439 \u043E\u0442\u0440\u0438\u043C\u0430\u0432 \u0446\u0435\u0439 \u0434\u0438\u0441\u043F\u043B\u0435\u0439. \u0429\u043E\u0439\u043D\u043E hassemu \u0437\u043D\u043E\u0432\u0443 \u0431\u0443\u0434\u0435 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u0438\u043C, \u0435\u043A\u0440\u0430\u043D \u043E\u043D\u043E\u0432\u0438\u0442\u044C\u0441\u044F \u0430\u0432\u0442\u043E\u043C\u0430\u0442\u0438\u0447\u043D\u043E.',\n deviceIdLabel: 'ID \u043F\u0440\u0438\u0441\u0442\u0440\u043E\u044E',\n ipLabel: 'IP-\u0430\u0434\u0440\u0435\u0441\u0430',\n reloadButton: '\u041F\u0435\u0440\u0435\u0437\u0430\u0432\u0430\u043D\u0442\u0430\u0436\u0438\u0442\u0438',\n },\n 'zh-cn': {\n htmlLang: 'zh-CN',\n pageTitle: 'hassemu \u79BB\u7EBF \u00B7 ioBroker',\n heading: 'hassemu \u79BB\u7EBF',\n subhead: '\u4E0A\u65B9\u9875\u9762\u662F\u6B64\u663E\u793A\u5668\u6536\u5230\u7684\u6700\u540E\u4E00\u6B21\u89C6\u56FE\u3002hassemu \u91CD\u65B0\u53EF\u7528\u540E\uFF0C\u5C4F\u5E55\u4F1A\u81EA\u52A8\u5237\u65B0\u3002',\n deviceIdLabel: '\u8BBE\u5907 ID',\n ipLabel: 'IP \u5730\u5740',\n reloadButton: '\u7ACB\u5373\u91CD\u65B0\u52A0\u8F7D',\n },\n};\n\n/**\n * v1.32.1: Threshold der konsekutiven Poll-Fails ab dem die Down-Seite eingeblendet wird.\n * 3 \u00D7 30 s = 1.5 min ohne Antwort von hassemu \u2014 toleriert kurze Hiccups, signalisiert echte Outage.\n */\nconst DOWN_THRESHOLD = 3;\n\n/**\n * HTML-Wrapper statt 302-Redirect (A3 / v1.7.0). Display l\u00E4dt das HTML einmal,\n * sieht den Target im iframe, polled `/api/redirect_check` alle 30s. Bei\n * Target-Wechsel (User edit) macht es `location.reload()`.\n *\n * v1.32.1: zus\u00E4tzliche Down-Seite (`#hassemu-down`-Div, hidden by default) wird\n * eingeblendet wenn der Polling-Endpoint `DOWN_THRESHOLD = 3` mal hintereinander\n * fehlschl\u00E4gt (~1.5 min). Inline-JS kommt vor dem Adapter-Down vom hassemu-\n * Wrapper im Browser an und lebt dort weiter \u2014 die Down-Seite kann also auch\n * gerendert werden wenn hassemu nicht mehr antwortet. Bei Recovery (erste\n * erfolgreiche Antwort) wird die Down-Seite wieder ausgeblendet, kein reload.\n * Plus expliziter \u201EReload now\"-Button als Touch-f\u00E4higer Fallback (Shelly Wall\n * Display + HA Companion WebView).\n *\n * Limitierung: ein Display der KALT bootet w\u00E4hrend hassemu down ist, kann\n * dieses HTML nicht von hassemu laden \u2014 Browser zeigt Connection-Error.\n * Service-Worker-Cache w\u00E4re die L\u00F6sung, ist hier bewusst nicht implementiert\n * (Wall-Display-WebView-Cache-Verhalten unklar, Cache-Invalidation eigenes\n * Problem). Praxis: 95% der Realit\u00E4t (Display lief vorher mal) deckt diese\n * Mechanik ab.\n *\n * @param target Vom Resolver gelieferte Ziel-URL.\n * @param clientId Short id of this display (f\u00FCr Anzeige auf der Down-Seite).\n * @param namespace Adapter-Namespace, z.B. `hassemu.0`.\n * @param language ioBroker-Systemsprache f\u00FCr die Down-Seite (EN-Fallback).\n * @param ip Optional IP-Adresse des Displays (f\u00FCr Anzeige auf der Down-Seite).\n */\nexport function renderRedirectWrapper(\n target: string,\n clientId: string,\n namespace: string,\n language: string = 'en',\n ip: string | null = null,\n): string {\n const escAttr = escapeHtml(target);\n const escJs = JSON.stringify(target);\n\n const s = DOWN_STRINGS[language as DownLanguage] ?? DOWN_STRINGS.en;\n const id = escapeHtml(clientId);\n const ns = escapeHtml(namespace);\n const trimmedIp = ip?.trim() ?? '';\n const isLoopback =\n trimmedIp === '' ||\n trimmedIp === '127.0.0.1' ||\n trimmedIp === '::1' ||\n trimmedIp === '0.0.0.0' ||\n trimmedIp.startsWith('127.');\n const ipRow = isLoopback\n ? ''\n : `<tr><th scope=\"row\">${escapeHtml(s.ipLabel)}</th><td>${escapeHtml(trimmedIp)}</td></tr>`;\n // ns is intentionally available for future state-tree hints in the down page \u2014\n // for now the down page only shows the device id, IP, and the reload button.\n void ns;\n\n return `<!DOCTYPE html>\n<html lang=\"${escapeHtml(s.htmlLang)}\">\n<head>\n<meta charset=\"utf-8\">\n<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, user-scalable=no\">\n<title>${escapeHtml(s.pageTitle)}</title>\n<style>\nhtml,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden;}\niframe{display:block;border:0;margin:0;padding:0;position:fixed;top:0;left:0;width:100vw;height:100vh;background:#000;z-index:1;}\n#hassemu-down{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:#0f172a;color:#f1f5f9;font:16px/1.5 system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;align-items:center;justify-content:center;padding:1.5rem;box-sizing:border-box;z-index:10;}\n#hassemu-down[hidden]{display:none;}\n#hassemu-down.visible{display:flex;}\n#hassemu-down .card{width:100%;max-width:44rem;background:#1e293b;border-radius:12px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.35);}\n#hassemu-down .banner{background:#dc2626;color:#fff;padding:1.4rem 1.8rem;}\n#hassemu-down .banner h1{margin:0;font-size:1.4rem;font-weight:600;}\n#hassemu-down .banner p{margin:.4rem 0 0;font-size:.95rem;opacity:.95;}\n#hassemu-down .content{padding:1.6rem 1.8rem 1.3rem;}\n#hassemu-down table{width:100%;border-collapse:collapse;font-size:.95rem;margin-bottom:1.4rem;}\n#hassemu-down th,#hassemu-down td{padding:.55rem .7rem;text-align:left;border-bottom:1px solid #334155;}\n#hassemu-down tr:last-child th,#hassemu-down tr:last-child td{border-bottom:none;}\n#hassemu-down th{font-weight:500;color:#94a3b8;width:9rem;white-space:nowrap;}\n#hassemu-down code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#0f172a;padding:.15rem .45rem;border-radius:4px;font-size:.9em;}\n#hassemu-down button{display:block;width:100%;padding:.9rem 1.2rem;background:#38bdf8;color:#0f172a;border:none;border-radius:6px;font-size:1rem;font-weight:600;cursor:pointer;}\n#hassemu-down button:hover{background:#0ea5e9;}\n@media (max-width:30rem){#hassemu-down{padding:0;}#hassemu-down .card{border-radius:0;}#hassemu-down th{width:auto;}}\n</style>\n</head>\n<body>\n<iframe id=\"hassemu-iframe\" src=\"${escAttr}\" allow=\"autoplay; fullscreen; geolocation; microphone; camera\"></iframe>\n<div id=\"hassemu-down\" role=\"status\" aria-live=\"polite\">\n <div class=\"card\">\n <div class=\"banner\">\n <h1>${escapeHtml(s.heading)}</h1>\n <p>${escapeHtml(s.subhead)}</p>\n </div>\n <div class=\"content\">\n <table>\n <tbody>\n <tr><th scope=\"row\">${escapeHtml(s.deviceIdLabel)}</th><td><code>${id}</code></td></tr>\n ${ipRow}\n </tbody>\n </table>\n <button type=\"button\" onclick=\"location.reload()\">${escapeHtml(s.reloadButton)}</button>\n </div>\n </div>\n</div>\n${CONNECTION_STATUS_SCRIPT}\n<script>\n(function(){\n var current=${escJs};\n var fails=0;\n var THRESHOLD=${DOWN_THRESHOLD};\n var iframeEl=document.getElementById('hassemu-iframe');\n var downEl=document.getElementById('hassemu-down');\n function showDown(){\n if(downEl && !downEl.classList.contains('visible')){\n downEl.classList.add('visible');\n if(iframeEl){iframeEl.style.display='none';}\n }\n }\n function hideDown(){\n if(downEl && downEl.classList.contains('visible')){\n downEl.classList.remove('visible');\n if(iframeEl){iframeEl.style.display='block';}\n }\n }\n setInterval(function(){\n fetch('/api/redirect_check',{cache:'no-store',credentials:'same-origin'})\n .then(function(r){return r.json();})\n .then(function(j){\n fails=0;\n hideDown();\n if(j&&typeof j.target==='string'&&j.target&&j.target!==current){\n location.reload();\n }\n })\n .catch(function(){\n fails++;\n if(fails>=THRESHOLD){\n showDown();\n }\n });\n },30000);\n})();\n</script>\n</body>\n</html>`;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/build/lib/webserver.js
CHANGED
|
@@ -767,7 +767,7 @@ class WebServer {
|
|
|
767
767
|
return reply.status(200).type("text/html; charset=utf-8").send((0, import_landing_page.renderLandingPage)(client.id, this.adapter.namespace, this.systemLanguage, client.ip));
|
|
768
768
|
}
|
|
769
769
|
this.adapter.log.debug(`GET / client=${client.id} \u2192 URL (chain=${chain})`);
|
|
770
|
-
return reply.status(200).type("text/html; charset=utf-8").send((0, import_redirect_wrapper.renderRedirectWrapper)(url));
|
|
770
|
+
return reply.status(200).type("text/html; charset=utf-8").send((0, import_redirect_wrapper.renderRedirectWrapper)(url, client.id, this.adapter.namespace, this.systemLanguage, client.ip));
|
|
771
771
|
});
|
|
772
772
|
this.app.get("/api/redirect_check", async (req, reply) => {
|
|
773
773
|
const client = await this.identify(req, reply);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/webserver.ts"],
|
|
4
|
-
"sourcesContent": ["import crypto from 'node:crypto';\nimport dns from 'node:dns/promises';\nimport fastifyCookie from '@fastify/cookie';\nimport fastifyFormbody from '@fastify/formbody';\nimport Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';\nimport {\n HA_VERSION,\n SESSION_TTL_MS,\n CLEANUP_INTERVAL_MS,\n LOGIN_SCHEMA,\n OAUTH_ACCESS_TOKEN_TTL_S,\n SESSIONS_CAP,\n WEBHOOK_REGISTRATIONS_CAP,\n REQUEST_ERROR_COOLDOWN_MS,\n REQUEST_ERROR_COOLDOWN_CAP,\n COOKIE_MAX_AGE_S,\n} from './constants';\nimport { coerceString, coerceUuid, evictOldest, isValidRedirectUri, safeStringEqual } from './coerce';\nimport { buildRedirectUrl, renderAuthorizeError, renderAuthorizeForm, renderAuthorizeRedirect } from './auth-page';\nimport type { ClientRegistry } from './client-registry';\nimport type { GlobalConfig } from './global-config';\nimport { renderLandingPage } from './landing-page';\nimport { getLocalIp, isWildcardBind } from './network';\nimport { renderRedirectWrapper } from './redirect-wrapper';\nimport type { AdapterConfig, AdapterInterface, ClientRecord, SessionData } from './types';\n\n// v1.22.0 (F5): `safeStringEqual` ist nach `coerce.ts` verschoben \u2014 generischer\n// crypto-Helper, kein webserver-spezifischer Belang.\n\n// v1.32.0: `renderRedirectWrapper` ist nach `lib/redirect-wrapper.ts` ausgelagert\n// f\u00FCr Symmetrie zu `landing-page.ts` / `auth-page.ts`. `evictOldest` ist shared\n// helper aus `coerce.ts`.\n\n/** Adapter surface the WebServer depends on \u2014 adds `namespace` for the setup page. */\nexport type WebServerAdapter = AdapterInterface & Pick<ioBroker.Adapter, 'namespace'>;\n\n/** Browser cookie name. Client identity lives here \u2014 auto-sent on every page navigation. */\nexport const CLIENT_COOKIE = 'hassemu_client';\n\n/**\n * Fastify web server emulating the HA REST API.\n *\n * Each incoming request is identified by cookie \u2192 {@link ClientRegistry} entry; new clients\n * get a channel created on first hit. Express was swapped for Fastify in 1.1.0 for first-party\n * cookie support, schema validation and a lighter runtime.\n */\nexport class WebServer {\n private readonly adapter: WebServerAdapter;\n private readonly config: AdapterConfig;\n private readonly registry: ClientRegistry;\n private readonly globalConfig: GlobalConfig;\n private readonly app: FastifyInstance;\n public readonly sessions: Map<string, SessionData> = new Map();\n /**\n * Mobile-App webhook registrations from `POST /api/mobile_app/registrations`\n * (v1.29.1). Key = webhookId (URL secret), Value = owning client cookie id.\n * Subsequent `POST /api/webhook/<id>` requests are validated against this\n * map. FIFO-capped at {@link WEBHOOK_REGISTRATIONS_CAP}.\n *\n * Reused for Shelly Wall Display FW 2.6.0+ onboarding \u2014 the on-device HA\n * Companion App requires this endpoint to complete device registration\n * after the OAuth2 sign-in. Without it the App refuses to proceed with a\n * \"Mobile-App-Integration nicht verf\u00FCgbar\" error.\n *\n * **Design \u2014 in-memory only, by intent.** The map is NOT persisted across\n * adapter restarts. Restart-recovery relies on the\n * `POST /api/webhook/<unknown-id>` branch returning HTTP 200 with an\n * empty body \u2014 the HA Companion App reads that as a stale webhook and\n * re-runs `update_registration`, which on hassemu issues a fresh\n * webhookId. (Source: home-assistant/android\n * IntegrationRepositoryImpl.kt:170 \u2014 `200 with empty body triggers\n * maybeReregisterDeviceOnFailedUpdate`.)\n *\n * If a future refactor changes the unknown-webhookId response from\n * `200 empty` to `404`, displays will silently break across adapter\n * restarts. Keep that response shape OR add real persistence here.\n */\n public readonly webhookRegistrations: Map<string, string> = new Map();\n /**\n * v1.32.0 F1: last redirect-target seen per client by `/api/redirect_check`.\n * Used to log only-on-change (instead of every 30s poll). Pruned in\n * {@link cleanupSessions} against `registry.listAll()` \u2014 stale entries from\n * removed clients are dropped within max 5 min.\n */\n private readonly lastRedirectTargetByClient: Map<string, string | null> = new Map();\n private cleanupTimer: ioBroker.Interval | null = null;\n /**\n * v1.14.0 (H8): bind once im Constructor statt bei jedem Property-Access\n * via getter \u2014 vorher allokierte jeder `s.inject({...})`-Call eine neue\n * gebundene Funktion. Tests rufen das in Loops auf \u2014 unn\u00F6tiger GC-Druck.\n */\n public readonly inject!: FastifyInstance['inject'];\n public readonly instanceUuid: string;\n /** ioBroker system language for the setup page \u2014 resolved on startup. */\n public readonly systemLanguage: string;\n /** Set of IPs whose reverse DNS lookup is already in-flight \u2014 prevents duplicate work. */\n private readonly dnsInFlight = new Set<string>();\n /**\n * Per-message cooldown timestamps for 5xx error logging. First occurrence\n * of a unique message logs at warn; repeats within {@link REQUEST_ERROR_COOLDOWN_MS}\n * fall to debug to prevent log-spam under attack/probe traffic.\n */\n private readonly errorLogCooldown: Map<string, number> = new Map();\n\n /**\n * @param adapter Adapter instance used for logging, timers and namespace.\n * @param config Resolved runtime config.\n * @param registry Multi-client registry.\n * @param globalConfig Global redirect override.\n * @param instanceUuid Stable UUID shared with the mDNS advert.\n * @param systemLanguage ioBroker system language (`en`, `de`, \u2026) used for the setup page.\n */\n constructor(\n adapter: WebServerAdapter,\n config: AdapterConfig,\n registry: ClientRegistry,\n globalConfig: GlobalConfig,\n instanceUuid: string,\n systemLanguage: string = 'en',\n ) {\n this.adapter = adapter;\n this.config = config;\n this.registry = registry;\n this.globalConfig = globalConfig;\n this.instanceUuid = instanceUuid;\n this.systemLanguage = systemLanguage;\n // v1.25.0 (C11): trustProxy ist Opt-In \u00FCber config \u2014 nur aktivieren\n // wenn der Adapter HINTER einem trusted Reverse-Proxy mit TLS-\n // Termination l\u00E4uft. Mit trustProxy=true holt Fastify `req.ip` aus\n // `X-Forwarded-For` (statt aus dem Socket), `req.protocol` aus\n // `X-Forwarded-Proto` etc. \u2014 Voraussetzung: der Proxy bereinigt diese\n // Header (sonst kann jeder Client den Lockout umgehen).\n this.app = Fastify({ logger: false, trustProxy: this.config.trustProxy === true });\n // v1.14.0 (H8): inject einmal binden, nicht pro Getter-Access.\n (this as { inject: FastifyInstance['inject'] }).inject = this.app.inject.bind(this.app);\n }\n\n /** Human-readable service name advertised in responses and mDNS. */\n get serviceName(): string {\n return this.config.serviceName || 'ioBroker';\n }\n\n /** Resolved listener address once `start()` has completed, or null otherwise. */\n get boundAddress(): { address: string; port: number } | null {\n const addr = this.app.server.address();\n if (!addr || typeof addr === 'string') {\n return null;\n }\n return { address: addr.address, port: addr.port };\n }\n\n // --- lifecycle ---\n\n /** Registers plugins and starts the HTTP listener. */\n async start(): Promise<void> {\n // v1.14.0 (H9): defensive \u2014 wenn start() jemals doppelt gerufen wird\n // (Refactor, Test-Setup-Bug), Timer aus dem Vorlauf clearen statt zu\n // leaken.\n if (this.cleanupTimer) {\n this.adapter.clearInterval(this.cleanupTimer);\n this.cleanupTimer = null;\n }\n await this.app.register(fastifyCookie);\n // OAuth2-Spec verlangt `application/x-www-form-urlencoded` f\u00FCr `/auth/token`.\n // Echte HA-Reference-Clients (frontend/Wall Display SDK) folgen dem.\n // Fastify hat by-default nur einen JSON-Bodyparser \u2014 ohne diesen Plugin\n // beantwortet `/auth/token` mit form-Body 415 und der Login bleibt komplett\n // h\u00E4ngen. Tests via `app.inject({payload:{...}})` serialisieren zu JSON\n // und maskieren das.\n await this.app.register(fastifyFormbody);\n this.setupAuthGuard();\n this.setupErrorHandler();\n this.setupRoutes();\n\n const bindAddress = this.config.bindAddress || '0.0.0.0';\n try {\n await this.app.listen({ port: this.config.port, host: bindAddress });\n } catch (err) {\n const e = err as NodeJS.ErrnoException;\n const msg =\n e.code === 'EADDRINUSE'\n ? `Port ${this.config.port} is already in use \u2014 another service is bound to it`\n : `Server error during startup: ${e.message}`;\n this.adapter.log.error(msg);\n throw err;\n }\n this.adapter.log.debug(`Web server listening on ${bindAddress}:${this.config.port}`);\n\n this.cleanupTimer = this.adapter.setInterval(() => this.cleanupSessions(), CLEANUP_INTERVAL_MS) ?? null;\n }\n\n /** Stops the listener and cancels the session cleanup timer. */\n async stop(): Promise<void> {\n if (this.cleanupTimer) {\n this.adapter.clearInterval(this.cleanupTimer);\n this.cleanupTimer = null;\n }\n try {\n await this.app.close();\n this.adapter.log.debug('Web server stopped');\n } catch (err) {\n // v1.18.0 (G6+G8): debug statt error \u2014 bei intended shutdown\n // (onUnload) ist ein close-error meist ein \"already-closed\"-Race\n // ohne Konsequenz. Caller (main.ts onUnload) loggt nicht doppelt.\n this.adapter.log.debug(`Web server stop error: ${String(err)}`);\n }\n // v1.28.3 (HW1): drop in-flight DNS markers so a slow reverse-lookup\n // started just before stop() doesn't keep an IP entry pinned for the\n // whole process lifetime. The Promise.race(timeout) finally-handler\n // would do that eventually, but only after up to 5s \u2014 racy if the\n // adapter is restarted during that window.\n this.dnsInFlight.clear();\n }\n\n // v1.14.0 (H8): `inject` ist jetzt ein readonly Field (oben deklariert,\n // im Constructor einmalig gebunden). Der fr\u00FChere Getter allokierte bei\n // jedem Access eine neue Funktion.\n\n /** Periodic cleanup of expired in-flight auth sessions and stale lockouts. */\n public cleanupSessions(): void {\n const now = Date.now();\n let cleanedSessions = 0;\n for (const [key, session] of this.sessions) {\n if (now - session.created > SESSION_TTL_MS) {\n this.sessions.delete(key);\n cleanedSessions++;\n }\n }\n if (cleanedSessions > 0) {\n this.adapter.log.debug(`Session cleanup: removed ${cleanedSessions} expired sessions`);\n }\n\n // v1.32.0 F1: prune lastRedirectTargetByClient against currently known\n // clients. A removed client leaves a stale entry that would never get\n // cleared otherwise \u2014 bounded growth over months.\n const activeClients = new Set(this.registry.listAll().map(r => r.id));\n let prunedTargets = 0;\n for (const clientId of this.lastRedirectTargetByClient.keys()) {\n if (!activeClients.has(clientId)) {\n this.lastRedirectTargetByClient.delete(clientId);\n prunedTargets++;\n }\n }\n if (prunedTargets > 0) {\n this.adapter.log.debug(`Cleanup: pruned ${prunedTargets} stale redirect-target entries`);\n }\n }\n\n /**\n * Drops the oldest entry of a Map if it would exceed `cap` after the next insert.\n * Map iteration order in JS is insertion order, so `keys().next()` is the oldest.\n *\n * @param map Map to evict from.\n * @param cap Hard cap; when `map.size >= cap`, the oldest entry is removed.\n */\n /**\n * Cooldown-Decision f\u00FCr 5xx-Error-Logging. Liefert `true` f\u00FCr die erste\n * Beobachtung pro `key` innerhalb {@link REQUEST_ERROR_COOLDOWN_MS} und\n * markiert den Eintrag \u2014 Wiederholungen liefern `false` bis das Fenster\n * abgelaufen ist. Map ist FIFO-gedeckelt auf {@link REQUEST_ERROR_COOLDOWN_CAP}.\n *\n * @param key Eindeutiger Error-Identifier (\u00FCblicherweise `error.message`).\n * @param now Aktuelle Zeit in ms (testbar).\n */\n public shouldEmitRequestErrorWarn(key: string, now: number): boolean {\n const lastSeen = this.errorLogCooldown.get(key) ?? 0;\n if (lastSeen !== 0 && now - lastSeen <= REQUEST_ERROR_COOLDOWN_MS) {\n return false;\n }\n if (!this.errorLogCooldown.has(key)) {\n evictOldest(this.errorLogCooldown, REQUEST_ERROR_COOLDOWN_CAP);\n }\n this.errorLogCooldown.set(key, now);\n return true;\n }\n\n /**\n * Inserts a session, dropping the oldest entry if {@link SESSIONS_CAP} is exceeded.\n *\n * @param key Session key (flow id or auth code).\n * @param data Session payload.\n */\n private storeSession(key: string, data: SessionData): void {\n evictOldest(this.sessions, SESSIONS_CAP);\n this.sessions.set(key, data);\n }\n\n // --- client identification ---\n\n /**\n * v1.15.0 (F6): zentraler Extract `req.ip \u2192 coerced string|null`. Vorher\n * 3\u00D7 inline `coerceString(req.ip)` in identify/login/token-Handlern.\n *\n * @param req Fastify request (uses `req.ip`).\n */\n private static getClientIp(req: FastifyRequest): string | null {\n return coerceString(req.ip);\n }\n\n private async identify(req: FastifyRequest, reply: FastifyReply): Promise<ClientRecord> {\n const cookie = coerceUuid(req.cookies?.[CLIENT_COOKIE]);\n const ip = WebServer.getClientIp(req);\n // v1.17.0 (C8): UA durchreichen damit NAT-Co-Located Displays nicht\n // im selben Pending-Lock landen (siehe identifyOrCreate-Kommentar).\n const userAgent = coerceString(req.headers['user-agent']);\n const record = await this.registry.identifyOrCreate(cookie, ip, null, userAgent);\n // v1.32.0 A1: cookie-state explizit traced. Drei Branches:\n // hit \u2014 cookie matched a known client, no setCookie needed\n // stale/new \u2014 cookie present but unknown, OR no cookie at all \u2192 new client created\n if (cookie === record.cookie) {\n this.adapter.log.debug(`identify: cookie-hit client=${record.id} ip=${ip ?? '?'}`);\n } else {\n const reason = cookie ? 'cookie-stale (unknown)' : 'no-cookie';\n this.adapter.log.debug(`identify: ${reason}, new client=${record.id} ip=${ip ?? '?'}`);\n }\n if (cookie !== record.cookie) {\n // v1.25.0 (C11): Cookie `secure: true` wenn TLS \u2014 Browser sendet\n // den Cookie dann nur \u00FCber HTTPS. Bei trustProxy=true kommt\n // `req.protocol` aus `X-Forwarded-Proto`-Header. Default ohne\n // trustProxy: `req.protocol === 'http'` (Adapter ist HTTP only),\n // also Cookie nicht-secure \u2014 sonst w\u00FCrde der Browser ihn nie senden.\n const useSecure = req.protocol === 'https';\n // v1.32.0 A2: Cookie-Secure-Decision tracen \u2014 wenn trustProxy-config\n // falsch ist, kriegt Display den Cookie evtl. nie zur\u00FCck.\n this.adapter.log.debug(`identify: setting cookie secure=${useSecure} (req.protocol=${req.protocol})`);\n reply.setCookie(CLIENT_COOKIE, record.cookie, {\n path: '/',\n httpOnly: true,\n sameSite: 'lax',\n secure: useSecure,\n maxAge: COOKIE_MAX_AGE_S,\n });\n }\n if (ip) {\n this.resolveHostnameAsync(record, ip);\n }\n return record;\n }\n\n private resolveHostnameAsync(record: ClientRecord, ip: string): void {\n if (record.hostname || this.dnsInFlight.has(ip)) {\n return;\n }\n this.dnsInFlight.add(ip);\n // v1.8.1 (D5): DNS-Lookup mit hartem 5s-Timeout. Default-Node-DNS hat\n // KEIN Timeout \u2014 bei broken Resolver (Captive-Portal, Misconfig) blieb\n // der Promise unendlich pending \u2192 IP f\u00FCr Adapter-Lifetime in dnsInFlight\n // blockiert, hostname auf record.ip gefroren.\n const timeout = new Promise<string[]>((_, reject) =>\n setTimeout(() => reject(new Error('dns reverse-lookup timeout')), 5_000),\n );\n Promise.race([dns.reverse(ip), timeout])\n .then(names => {\n const name = names[0];\n if (name) {\n // v1.32.0 A4: Success-Trace \u2014 bei Diagnose \u201Ewarum hat Display\n // X den hostname Y?\" ist die IP\u2192hostname-Aufl\u00F6sung der Anker.\n this.adapter.log.debug(`resolveHostname: ip=${ip} \u2192 hostname=${name}`);\n this.registry.identifyOrCreate(record.cookie, ip, name).catch(() => {\n /* registry itself logs */\n });\n }\n })\n .catch(err => {\n // v1.32.0 A3: vorher silent. Reverse DNS scheitert auf LAN oft\n // legitim \u2014 daher debug-only (kein warn-Spam), aber jetzt mit\n // Diagnose-Anker f\u00FCr \u201Ehostname fehlt\"-Reports.\n this.adapter.log.debug(\n `resolveHostname: ip=${ip} failed \u2014 ${err instanceof Error ? err.message : String(err)}`,\n );\n })\n .finally(() => {\n this.dnsInFlight.delete(ip);\n });\n }\n\n // --- auth guard ---\n\n /**\n * Pre-handler hook der `/api/*`-Routen sch\u00FCtzt wenn `authRequired=true`.\n *\n * Vorher: `/api/states`, `/api/services`, `/api/events`, `/api/error_log`,\n * `/api/discovery_info` lieferten unauthenticated alle ihre Daten \u2014\n * pure Information-Disclosure. Echte HA verlangt `Authorization: Bearer\n * <token>` f\u00FCr alle `/api/*` au\u00DFer dem `/api/`-Heartbeat.\n *\n * Whitelist (kein Auth n\u00F6tig):\n * - `/`, `/manifest.json`, `/health`, `/api/` \u2014 public Endpoints (Heartbeat, PWA)\n * - `/api/discovery_info` \u2014 HA-Clients fragen das VOR dem Auth-Flow ab um\n * zu erkennen ob `requires_api_password` true ist (Spec-Verhalten)\n * - `/auth/*` \u2014 der Auth-Flow selbst\n *\n * Bei `authRequired=false`: Hook macht nichts (no-op), bestehender Verhalten.\n */\n private setupAuthGuard(): void {\n this.app.addHook('preHandler', async (req, reply) => {\n if (!this.config.authRequired) {\n return;\n }\n const path = (req.url ?? '/').split('?')[0];\n // Public endpoints \u2014 explicitly allowed\n if (\n path === '/' ||\n path === '/api/' ||\n path === '/api/discovery_info' ||\n path === '/manifest.json' ||\n path === '/health' ||\n path.startsWith('/auth/') ||\n // v1.29.1: Mobile-App webhooks carry the secret in the URL\n // (`webhookId`) \u2014 HA core also serves these unauthenticated.\n // Source: home-assistant/core/.../mobile_app/webhook.py.\n path.startsWith('/api/webhook/')\n ) {\n return;\n }\n // From here on: protected (`/api/*` apart from `/api/`)\n const authHeader = req.headers.authorization;\n if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) {\n this.adapter.log.debug(`Auth required for ${path} \u2014 missing Bearer token`);\n reply.status(401).send({ error: 'unauthorized' });\n return;\n }\n const token = authHeader.substring('Bearer '.length).trim();\n const client = this.registry.getByToken(token);\n if (!client) {\n this.adapter.log.debug(`Auth required for ${path} \u2014 unknown Bearer token`);\n reply.status(401).send({ error: 'invalid_token' });\n return;\n }\n // OK \u2014 handler runs\n });\n }\n\n // --- error handling ---\n\n private setupErrorHandler(): void {\n this.app.setErrorHandler((err, _req, reply) => {\n const error = err as Error & { validation?: unknown; statusCode?: number };\n if (error.validation) {\n this.adapter.log.debug(`Validation error: ${error.message}`);\n reply.status(400).send({ error: 'Invalid request', details: error.message });\n return;\n }\n // Fastify body-parsing / client errors already set statusCode in 4xx range\n const code = typeof error.statusCode === 'number' ? error.statusCode : 500;\n if (code >= 400 && code < 500) {\n this.adapter.log.debug(`Client error ${code}: ${error.message}`);\n reply.status(code).send({ error: error.message });\n return;\n }\n // 5xx: ein attacker kann mit malformed paths/oversized bodies viele\n // 500er triggern. Per-Message-Dedup-Map mit 60s-Cooldown \u2014 das erste\n // Auftreten pro unique message kommt als warn, alle Wiederholungen\n // im 60s-Fenster auf debug. Memory `feedback_no_log_spam`.\n const key = error.message || 'unknown';\n if (this.shouldEmitRequestErrorWarn(key, Date.now())) {\n this.adapter.log.warn(`Request error: ${error.message}`);\n } else {\n this.adapter.log.debug(`Request error (repeat): ${error.message}`);\n }\n reply.status(500).send({ error: 'Internal server error' });\n });\n }\n\n // --- routes ---\n\n private setupRoutes(): void {\n this.setupApiRoutes();\n this.setupAuthRoutes();\n this.setupMiscRoutes();\n this.setupNotFound();\n }\n\n private setupApiRoutes(): void {\n // CRITICAL: trailing slash \u2014 HA clients check this endpoint for discovery\n this.app.get('/api/', () => ({ message: 'API running.' }));\n\n this.app.get('/api/config', () => ({\n // `mobile_app` advertises the integration the HA Companion App\n // probes for during onboarding (v1.29.1, Shelly FW 2.6.0+).\n components: ['http', 'api', 'frontend', 'homeassistant', 'mobile_app'],\n config_dir: '/config',\n elevation: 0,\n latitude: 0,\n longitude: 0,\n location_name: this.serviceName,\n time_zone: 'UTC',\n unit_system: { length: 'km', mass: 'g', temperature: '\u00B0C', volume: 'L' },\n version: HA_VERSION,\n whitelist_external_dirs: [],\n }));\n\n this.app.get('/api/discovery_info', () => {\n // v1.17.0 (E11): NICHT mehr `req.hostname` \u2014 der Host-Header ist\n // client-controlled und ein Angreifer k\u00F6nnte mit `Host: attacker.lan`\n // andere HA-Clients zur falschen URL umleiten. Stattdessen die\n // tats\u00E4chlich gebundene Adresse: bindAddress (ggf. wildcard) oder\n // ersten lokalen non-internal IPv4 via getLocalIp.\n const isWildcard = !this.config.bindAddress || isWildcardBind(this.config.bindAddress);\n const host = isWildcard ? getLocalIp() : this.config.bindAddress;\n const baseUrl = `http://${host}:${this.config.port}`;\n return {\n base_url: baseUrl,\n external_url: null,\n internal_url: baseUrl,\n location_name: this.serviceName,\n // Vorher hardcoded `true` unabh\u00E4ngig von authRequired \u2014 strict HA-Clients\n // versuchten Auth auch bei authRequired=false und scheiterten am leeren Login-Flow.\n requires_api_password: this.config.authRequired,\n uuid: this.instanceUuid,\n version: HA_VERSION,\n };\n });\n\n for (const path of ['/api/states', '/api/services', '/api/events']) {\n this.app.get(path, () => []);\n }\n this.app.get('/api/error_log', () => '');\n\n // ---- Mobile-App integration (HA Companion + Shelly FW 2.6.0+) ----\n //\n // Source: home-assistant/android IntegrationRepositoryImpl.kt:120-159\n // calls POST /api/mobile_app/registrations after the OAuth2 sign-in.\n // A 404 here surfaces as \u201EMobile-App-Integration nicht verf\u00FCgbar\" in\n // the App's onboarding screen and blocks the display from finishing\n // setup. Detail in Ressourcen/hassemu/oauth2-browser-flow-shelly-fw26.md.\n //\n // The Bearer-token check is already done by the existing auth\n // pre-handler \u2014 `/api/mobile_app/registrations` is protected by\n // default, so by the time the handler runs we know the caller has\n // a valid access_token from /auth/token.\n this.app.post<{\n Body: {\n app_id?: string;\n app_name?: string;\n device_name?: string;\n device_id?: string;\n manufacturer?: string;\n model?: string;\n os_name?: string;\n os_version?: string;\n };\n }>('/api/mobile_app/registrations', async (req, reply) => {\n const body = req.body ?? {};\n // Identify by Bearer token \u2014 the pre-handler already validated it.\n const authHeader = (req.headers.authorization as string) ?? '';\n const token = authHeader.startsWith('Bearer ') ? authHeader.substring(7).trim() : '';\n const client = this.registry.getByToken(token);\n const ownerId = client?.id ?? '';\n\n const webhookId = crypto.randomUUID().replace(/-/g, '');\n evictOldest(this.webhookRegistrations, WEBHOOK_REGISTRATIONS_CAP);\n this.webhookRegistrations.set(webhookId, ownerId);\n\n this.adapter.log.debug(\n `Mobile-App registration \u2014 client=${ownerId} app_id=${body.app_id ?? '?'} device_name=${body.device_name ?? '?'} \u2192 webhook=${webhookId}`,\n );\n\n // Response shape: home-assistant/android RegisterDeviceResponse.kt\n // cloudhookUrl: String? (null \u2014 no Nabu Casa cloud)\n // remoteUiUrl: String? (null \u2014 no remote-UI)\n // secret: String? (null \u2014 webhookId itself is the secret)\n // webhookId: String (required, non-null)\n reply.status(201);\n return {\n webhook_id: webhookId,\n cloudhook_url: null,\n remote_ui_url: null,\n secret: null,\n };\n });\n\n // PUT and DELETE on /api/mobile_app/registrations/:webhookId \u2014 the App\n // calls PUT to update its registration on token refresh or sensor\n // re-register. We treat both as no-ops that return success so the\n // Companion App doesn't show registration-failure banners.\n this.app.put<{ Params: { webhookId: string } }>(\n '/api/mobile_app/registrations/:webhookId',\n async (req, reply) => {\n const id = req.params.webhookId;\n if (!this.webhookRegistrations.has(id)) {\n // v1.32.0 E1: stale-id signaliert dass Companion einen Token\n // aus Pre-Restart-Era hat \u2014 diagnostisch wertvoll f\u00FCr\n // re-registration-loop-Bugs.\n this.adapter.log.debug(\n `Mobile-App PUT registration: unknown webhookId=${id.substring(0, 8)}\u2026 \u2014 returning 404`,\n );\n reply.status(404);\n return { error: 'unknown_registration' };\n }\n return { webhook_id: id, cloudhook_url: null, remote_ui_url: null, secret: null };\n },\n );\n\n this.app.delete<{ Params: { webhookId: string } }>(\n '/api/mobile_app/registrations/:webhookId',\n async (req, reply) => {\n const id = req.params.webhookId;\n const wasPresent = this.webhookRegistrations.has(id);\n this.webhookRegistrations.delete(id);\n // v1.32.0 E2: Companion-Maintenance-Trace.\n this.adapter.log.debug(\n `Mobile-App DELETE registration: webhookId=${id.substring(0, 8)}\u2026 removed (was-present=${wasPresent})`,\n );\n reply.status(204);\n return null;\n },\n );\n\n // POST /api/webhook/:webhookId \u2014 Companion-App sensor updates,\n // location pings, registration updates etc. Public by design (URL\n // contains the webhookId secret). HA core dispatches on `type` field\n // in the JSON body and returns shape per type. For hassemu we accept\n // any payload and respond with the minimal-correct success per type;\n // the display use-case doesn't need actual state propagation, but\n // returning 200 prevents the App from re-trying in a loop and\n // surfacing onboarding-failure banners.\n this.app.post<{\n Params: { webhookId: string };\n Body: { type?: string; data?: unknown };\n }>('/api/webhook/:webhookId', async (req, reply) => {\n const id = req.params.webhookId;\n if (!this.webhookRegistrations.has(id)) {\n // Unknown webhookId \u2014 match HA's 200-empty for stale webhooks\n // so the App falls back to `update_registration` (which on\n // hassemu re-issues a new registration). Source:\n // home-assistant/android IntegrationRepositoryImpl.kt:170 \u2014\n // 200 with empty body triggers `maybeReregisterDeviceOnFailedUpdate`.\n // v1.32.0 E3: stale-id ist DAS Symptom f\u00FCr re-registration-loop \u2014\n // Companion macht webhook-call mit Token aus Pre-Restart-Era.\n this.adapter.log.debug(\n `Webhook fallthrough: stale id=${id.substring(0, 8)}\u2026 \u2014 App will trigger re-registration`,\n );\n reply.status(200);\n return null;\n }\n const body = req.body ?? {};\n const type = typeof body.type === 'string' ? body.type : '';\n this.adapter.log.debug(`Webhook ${id.substring(0, 8)}\u2026 type=${type || '(no type)'}`);\n\n switch (type) {\n case 'get_config':\n return {\n components: ['http', 'api', 'frontend', 'homeassistant', 'mobile_app'],\n latitude: 0,\n longitude: 0,\n elevation: 0,\n unit_system: { length: 'km', mass: 'g', temperature: '\u00B0C', volume: 'L' },\n location_name: this.serviceName,\n time_zone: 'UTC',\n version: HA_VERSION,\n };\n case 'get_zones':\n return [];\n case 'render_template':\n return {};\n case 'update_registration':\n return { webhook_id: id, cloudhook_url: null, remote_ui_url: null, secret: null };\n case 'register_sensor':\n return { success: true };\n case 'update_sensor_states':\n return {};\n default:\n // Generic success for unknown types \u2014 fire_event,\n // call_service, conversation_process, update_location,\n // get_zones-with-data, etc. The display doesn't need\n // their semantics, just an HTTP 200 acknowledgement.\n return {};\n }\n });\n }\n\n /**\n * Issue a fresh authorization code and persist it in the sessions map.\n *\n * Single source for both the JSON login flow (`/auth/login_flow/<flowId>`\n * \u2192 `create_entry`) and the browser OAuth2 flow (`/auth/authorize` \u2192\n * 302). The code is exchanged for tokens at `/auth/token` (`grant_type =\n * authorization_code`); the existing token-view consumes the same map.\n *\n * @param clientId Identity cookie value of the requesting display, or\n * undefined for headless OAuth2-only flows.\n */\n private issueAuthorizationCode(clientId: string | null): string {\n const code = crypto.randomUUID();\n this.storeSession(code, { created: Date.now(), clientId });\n return code;\n }\n\n private setupAuthRoutes(): void {\n this.app.get('/auth/providers', () => [{ name: 'Home Assistant Local', type: 'homeassistant', id: null }]);\n\n // Browser-OAuth2 flow at GET/POST /auth/authorize. Needed by the\n // HA Companion Android App (Shelly Wall Display FW 2.6.0+ embeds\n // the Companion App). Source-verified flow:\n // home-assistant/android UrlUtil.kt:buildAuthenticationUrl\n // home-assistant/core indieauth.py:verify_redirect_uri\n // home-assistant/frontend src/data/auth.ts:redirectWithAuthCode\n // Detail: Ressourcen/hassemu/oauth2-browser-flow-shelly-fw26.md\n this.app.get<{\n Querystring: { response_type?: string; client_id?: string; redirect_uri?: string; state?: string };\n }>('/auth/authorize', async (req, reply) => {\n const { response_type, client_id, redirect_uri, state } = req.query ?? {};\n\n if (response_type !== 'code') {\n // v1.32.0 D2: rejection-Pfade traced \u2014 Triage \u201Ewarum bricht OAuth ab\"\n this.adapter.log.debug(\n `Authorize GET rejected: response_type=${String(response_type)} (expected 'code')`,\n );\n reply.status(400).type('text/html');\n return renderAuthorizeError(\n 'unsupported_response_type',\n 'This authorization server supports `response_type=code` only.',\n );\n }\n if (typeof client_id !== 'string' || typeof redirect_uri !== 'string') {\n this.adapter.log.debug(\n `Authorize GET rejected: missing client_id or redirect_uri (cid=${typeof client_id}, ru=${typeof redirect_uri})`,\n );\n reply.status(400).type('text/html');\n return renderAuthorizeError(\n 'invalid_request',\n 'Missing or invalid `client_id` or `redirect_uri` parameter.',\n );\n }\n if (!isValidRedirectUri(client_id, redirect_uri)) {\n this.adapter.log.debug(\n `Authorize rejected: redirect_uri \"${redirect_uri}\" not allowed for client_id \"${client_id}\"`,\n );\n reply.status(400).type('text/html');\n return renderAuthorizeError(\n 'invalid_redirect_uri',\n 'The `redirect_uri` parameter is not on the allowlist for this client.',\n );\n }\n\n const client = await this.identify(req, reply);\n\n // No auth required \u2192 issue the code right away and redirect.\n if (!this.config.authRequired) {\n const code = this.issueAuthorizationCode(client.id);\n const target = buildRedirectUrl(redirect_uri, code, state);\n this.adapter.log.debug(`Authorize auto-grant \u2014 client ${client.id}`);\n reply.type('text/html');\n return renderAuthorizeRedirect(target);\n }\n\n // v1.32.0 D1: Form-render Trace \u2014 wenn Companion die Form nie absendet,\n // sieht User hier dass sie \u00FCberhaupt gerendert wurde.\n let redirectHost = '?';\n try {\n redirectHost = new URL(redirect_uri).host || redirect_uri;\n } catch {\n redirectHost = redirect_uri;\n }\n this.adapter.log.debug(\n `Authorize form rendered \u2014 client_id=${client_id} redirect_uri-host=${redirectHost}`,\n );\n reply.type('text/html');\n return renderAuthorizeForm({ clientId: client_id, redirectUri: redirect_uri, state });\n });\n\n this.app.post<{\n Body: {\n response_type?: string;\n client_id?: string;\n redirect_uri?: string;\n state?: string;\n username?: string;\n password?: string;\n };\n }>('/auth/authorize', async (req, reply) => {\n const { response_type, client_id, redirect_uri, state, username, password } = req.body ?? {};\n\n if (response_type !== 'code') {\n this.adapter.log.debug(\n `Authorize POST rejected: response_type=${String(response_type)} (expected 'code')`,\n );\n reply.status(400).type('text/html');\n return renderAuthorizeError('unsupported_response_type', 'Only `response_type=code` is supported.');\n }\n if (typeof client_id !== 'string' || typeof redirect_uri !== 'string') {\n this.adapter.log.debug(\n `Authorize POST rejected: missing client_id or redirect_uri (cid=${typeof client_id}, ru=${typeof redirect_uri})`,\n );\n reply.status(400).type('text/html');\n return renderAuthorizeError(\n 'invalid_request',\n 'Missing or invalid `client_id` or `redirect_uri` parameter.',\n );\n }\n if (!isValidRedirectUri(client_id, redirect_uri)) {\n this.adapter.log.debug(\n `Authorize POST rejected: redirect_uri \"${redirect_uri}\" not allowed for client_id \"${client_id}\"`,\n );\n reply.status(400).type('text/html');\n return renderAuthorizeError(\n 'invalid_redirect_uri',\n 'The `redirect_uri` parameter is not on the allowlist for this client.',\n );\n }\n\n const client = await this.identify(req, reply);\n\n // No auth required \u2192 straight to redirect even on POST.\n if (!this.config.authRequired) {\n const code = this.issueAuthorizationCode(client.id);\n const target = buildRedirectUrl(redirect_uri, code, state);\n reply.type('text/html');\n return renderAuthorizeRedirect(target);\n }\n\n const ip = WebServer.getClientIp(req);\n const userOk = typeof username === 'string' && safeStringEqual(username, this.config.username);\n const passOk = typeof password === 'string' && safeStringEqual(password, this.config.password);\n if (!userOk || !passOk) {\n const ipSuffix = ip ? ` (IP ${ip})` : '';\n this.adapter.log.warn(`Invalid credentials${ipSuffix}`);\n reply.status(401).type('text/html');\n return renderAuthorizeForm(\n { clientId: client_id, redirectUri: redirect_uri, state },\n 'Invalid username or password.',\n );\n }\n\n const code = this.issueAuthorizationCode(client.id);\n const target = buildRedirectUrl(redirect_uri, code, state);\n this.adapter.log.debug(`Authorize grant \u2014 client ${client.id}`);\n reply.type('text/html');\n return renderAuthorizeRedirect(target);\n });\n\n this.app.post('/auth/login_flow', async (req, reply) => {\n const client = await this.identify(req, reply);\n const flowId = crypto.randomUUID();\n this.storeSession(flowId, { created: Date.now(), clientId: client.id });\n this.adapter.log.debug(`Auth flow created: ${flowId} for client ${client.id}`);\n\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n description_placeholders: null,\n errors: null,\n };\n });\n\n this.app.post<{\n Params: { flowId: string };\n Body: { username?: string; password?: string };\n }>(\n '/auth/login_flow/:flowId',\n {\n schema: {\n params: {\n type: 'object',\n properties: { flowId: { type: 'string', minLength: 1 } },\n required: ['flowId'],\n },\n },\n },\n async (req, reply) => {\n const flowId = req.params.flowId;\n const session = this.sessions.get(flowId);\n if (!session) {\n // v1.8.0: nach Session-TTL (10 min) feuert das bei jedem\n // legit returning user \u2014 nicht actionable. debug, nicht warn.\n this.adapter.log.debug(`Unknown flow_id: ${flowId}`);\n reply.status(400);\n return { type: 'abort', flow_id: flowId, reason: 'unknown_flow' };\n }\n\n if (this.config.authRequired) {\n const ip = WebServer.getClientIp(req);\n const { username, password } = req.body ?? {};\n const userOk = typeof username === 'string' && safeStringEqual(username, this.config.username);\n const passOk = typeof password === 'string' && safeStringEqual(password, this.config.password);\n if (!userOk || !passOk) {\n const ipSuffix = ip ? ` (IP ${ip})` : '';\n this.adapter.log.warn(`Invalid credentials${ipSuffix}`);\n reply.status(400);\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n errors: { base: 'invalid_auth' },\n description_placeholders: null,\n };\n }\n }\n\n this.sessions.delete(flowId);\n const code = crypto.randomUUID();\n this.storeSession(code, { created: Date.now(), clientId: session.clientId });\n this.adapter.log.debug('Auth flow completed \u2014 code issued');\n\n return {\n version: 1,\n type: 'create_entry',\n flow_id: flowId,\n handler: ['homeassistant', null],\n result: code,\n description: null,\n description_placeholders: null,\n };\n },\n );\n\n this.app.post<{ Body: { code?: string; grant_type?: string; refresh_token?: string } }>(\n '/auth/token',\n async (req, reply) => {\n const { code, grant_type, refresh_token } = req.body ?? {};\n\n if (grant_type === 'authorization_code' && code && this.sessions.has(code)) {\n const session = this.sessions.get(code)!;\n this.sessions.delete(code);\n const token = crypto.randomUUID();\n const refreshToken = crypto.randomUUID();\n if (session.clientId) {\n // Persist VOR Response-Build: ein Crash zwischen Issue + Persist\n // w\u00FCrde sonst dem Client einen Token in der Hand lassen, den der\n // Server nicht kennt \u2014 beim ersten Refresh dann invalid_grant.\n await this.registry.setToken(session.clientId, token);\n await this.registry.setRefreshToken(session.clientId, refreshToken);\n this.adapter.log.debug(`Display authenticated \u2014 client ${session.clientId}`);\n }\n return {\n access_token: token,\n token_type: 'Bearer',\n refresh_token: refreshToken,\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n if (grant_type === 'refresh_token') {\n // Validate the refresh token against issued ones \u2014 was previously\n // accepting any string and minting a new access_token (security fix v1.2.0).\n const incoming = typeof refresh_token === 'string' ? refresh_token : '';\n const ownerRecord = incoming ? this.registry.getByRefreshToken(incoming) : null;\n if (!ownerRecord) {\n this.adapter.log.debug('Refresh token rejected \u2014 unknown or missing');\n reply.status(400);\n return { error: 'invalid_grant', error_description: 'Invalid refresh token' };\n }\n // v1.31.0: refresh_token bleibt valid (NICHT mehr rotated). HA Core\n // selbst (homeassistant/components/auth/__init__.py:334-348) liefert\n // beim refresh-grant nie einen neuen refresh_token, nur access_token\n // + token_type + expires_in. HA Android Companion\n // (AuthenticationRepositoryImpl.kt:147) speichert beim Refresh den\n // GESENDETEN refresh_token (Function-Parameter), ignoriert den in der\n // Response zur\u00FCckgegebenen \u2014 Companion beh\u00E4lt daher immer ihren\n // initialen refresh_token. v1.28.3 (HW5) Rotation war RFC 6819\n // \u00A75.2.2.3-konform aber inkompatibel mit dem Companion-Datenmodell:\n // Server-Rotation killte den Companion-Token beim ersten Refresh.\n const newAccess = crypto.randomUUID();\n await this.registry.setToken(ownerRecord.id, newAccess);\n this.adapter.log.debug(`Refresh-token-grant \u2014 client=${ownerRecord.id} new access_token issued`);\n return {\n access_token: newAccess,\n token_type: 'Bearer',\n refresh_token: incoming,\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n // v1.8.0: \u201Ewrong grant_type\" ist ein Client-Format-Fehler,\n // nicht ein Server-Concern \u2014 debug. KEIN Lockout-Counter\n // (legitimer Client-Bug soll nicht zu IP-Sperre f\u00FChren).\n this.adapter.log.debug(`Token exchange failed: grant_type=${String(grant_type)}`);\n reply.status(400);\n return { error: 'invalid_request', error_description: 'Invalid or expired code' };\n },\n );\n }\n\n private setupMiscRoutes(): void {\n // Liveness only \u2014 no config leak. Earlier versions exposed the global\n // redirect URL via /health which is unauthenticated; removed in v1.2.0.\n // v1.5.0: auch der `config: { mdns, auth }`-Block raus \u2014 Auth-Status leakte\n // unauthenticated und lie\u00DF sich von einem Network-Attacker zur Reconnaissance\n // nutzen (auth-disabled Instances quickly mappen).\n this.app.get('/health', () => ({\n status: 'ok',\n adapter: 'hassemu',\n version: HA_VERSION,\n }));\n\n this.app.get('/manifest.json', () => ({\n // `name` MUST be \"Home Assistant\" exactly \u2014 the HA Companion App\n // verifies the server identity by parsing this field. Source:\n // home-assistant/android DefaultConnectivityChecker.kt:isHomeAssistant\n // checks `name === \"Home Assistant\"`. Anything else (e.g. `serviceName`\n // = \"ioBroker\") fails the onboarding probe with \"Server ist nicht\n // Home Assistant\". Detail in Ressourcen/hassemu/oauth2-browser-flow-shelly-fw26.md.\n name: 'Home Assistant',\n short_name: 'Home Assistant',\n start_url: '/',\n display: 'standalone',\n background_color: '#ffffff',\n theme_color: '#03a9f4',\n }));\n\n // Root \u2014 HTML-Wrapper (iframe + auto-reload), oder Landing-Page wenn keine URL.\n //\n // v1.7.0 (A3): statt 302 liefern wir ein iframe-HTML + 30s-poll auf\n // /api/redirect_check. Wenn die Mode-/URL-Config sich \u00E4ndert (User edit\n // im Adapter), pollt das Display den Wechsel und macht `location.reload()`\n // \u2014 ohne Soft-Reboot des Displays. Vorher musste der User das Display\n // manuell rebooten.\n //\n // WebViews wie Shelly Wall Display rendern iframes + JavaScript korrekt.\n // Falls ein User direkten 302-Redirect will (Browser-Test, Bookmarklet\n // etc.), kann er die Target-URL direkt eingeben \u2014 der Wrapper l\u00E4uft nur\n // beim Aufruf von `/`.\n this.app.get('/', async (req, reply) => {\n const client = await this.identify(req, reply);\n // v1.32.0 B1: Resolver-Chain als Triage-Anker. Ohne Chain musste der\n // Maintainer den Resolver-Code lesen um zu verstehen warum genau\n // diese URL f\u00FCr diesen Client gew\u00E4hlt wurde.\n const { url, chain } = this.globalConfig.resolveUrlForWithChain(client);\n if (!url) {\n this.adapter.log.debug(`GET / client=${client.id} \u2192 landing (chain=${chain})`);\n return reply\n .status(200)\n .type('text/html; charset=utf-8')\n .send(renderLandingPage(client.id, this.adapter.namespace, this.systemLanguage, client.ip));\n }\n this.adapter.log.debug(`GET / client=${client.id} \u2192 URL (chain=${chain})`);\n return reply.status(200).type('text/html; charset=utf-8').send(renderRedirectWrapper(url));\n });\n\n // /api/redirect_check \u2014 Display polled das alle 30s; wenn der target\n // sich ge\u00E4ndert hat (User edit), gibt der Wrapper `location.reload()`\n // ab. Cookie-basiert \u2014 Display schickt seinen `hassemu_client`-Cookie\n // automatisch mit.\n this.app.get('/api/redirect_check', async (req, reply) => {\n const client = await this.identify(req, reply);\n const url = this.globalConfig.resolveUrlFor(client);\n // v1.32.0 F1: only-on-change-Trace. Jeder Poll (alle 30s \u00D7 N Displays)\n // w\u00E4re Flood \u2014 diagnostisch wertvoll ist nur der Target-Wechsel.\n // First-time-poll-pro-restart wird auch geloggt weil Map leer ist.\n const prev = this.lastRedirectTargetByClient.get(client.id);\n const next = url ?? null;\n if (prev !== next) {\n this.adapter.log.debug(\n `redirect_check client=${client.id}: ${prev === undefined ? 'first-poll' : (prev ?? 'none')} \u2192 ${next ?? 'none'}`,\n );\n this.lastRedirectTargetByClient.set(client.id, next);\n }\n return { target: next };\n });\n }\n\n private setupNotFound(): void {\n this.app.setNotFoundHandler((req, reply) => {\n this.adapter.log.debug(`404: ${req.method} ${req.url}`);\n reply.status(404).send({ error: 'Not Found', path: req.url });\n });\n }\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import crypto from 'node:crypto';\nimport dns from 'node:dns/promises';\nimport fastifyCookie from '@fastify/cookie';\nimport fastifyFormbody from '@fastify/formbody';\nimport Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';\nimport {\n HA_VERSION,\n SESSION_TTL_MS,\n CLEANUP_INTERVAL_MS,\n LOGIN_SCHEMA,\n OAUTH_ACCESS_TOKEN_TTL_S,\n SESSIONS_CAP,\n WEBHOOK_REGISTRATIONS_CAP,\n REQUEST_ERROR_COOLDOWN_MS,\n REQUEST_ERROR_COOLDOWN_CAP,\n COOKIE_MAX_AGE_S,\n} from './constants';\nimport { coerceString, coerceUuid, evictOldest, isValidRedirectUri, safeStringEqual } from './coerce';\nimport { buildRedirectUrl, renderAuthorizeError, renderAuthorizeForm, renderAuthorizeRedirect } from './auth-page';\nimport type { ClientRegistry } from './client-registry';\nimport type { GlobalConfig } from './global-config';\nimport { renderLandingPage } from './landing-page';\nimport { getLocalIp, isWildcardBind } from './network';\nimport { renderRedirectWrapper } from './redirect-wrapper';\nimport type { AdapterConfig, AdapterInterface, ClientRecord, SessionData } from './types';\n\n// v1.22.0 (F5): `safeStringEqual` ist nach `coerce.ts` verschoben \u2014 generischer\n// crypto-Helper, kein webserver-spezifischer Belang.\n\n// v1.32.0: `renderRedirectWrapper` ist nach `lib/redirect-wrapper.ts` ausgelagert\n// f\u00FCr Symmetrie zu `landing-page.ts` / `auth-page.ts`. `evictOldest` ist shared\n// helper aus `coerce.ts`.\n\n/** Adapter surface the WebServer depends on \u2014 adds `namespace` for the setup page. */\nexport type WebServerAdapter = AdapterInterface & Pick<ioBroker.Adapter, 'namespace'>;\n\n/** Browser cookie name. Client identity lives here \u2014 auto-sent on every page navigation. */\nexport const CLIENT_COOKIE = 'hassemu_client';\n\n/**\n * Fastify web server emulating the HA REST API.\n *\n * Each incoming request is identified by cookie \u2192 {@link ClientRegistry} entry; new clients\n * get a channel created on first hit. Express was swapped for Fastify in 1.1.0 for first-party\n * cookie support, schema validation and a lighter runtime.\n */\nexport class WebServer {\n private readonly adapter: WebServerAdapter;\n private readonly config: AdapterConfig;\n private readonly registry: ClientRegistry;\n private readonly globalConfig: GlobalConfig;\n private readonly app: FastifyInstance;\n public readonly sessions: Map<string, SessionData> = new Map();\n /**\n * Mobile-App webhook registrations from `POST /api/mobile_app/registrations`\n * (v1.29.1). Key = webhookId (URL secret), Value = owning client cookie id.\n * Subsequent `POST /api/webhook/<id>` requests are validated against this\n * map. FIFO-capped at {@link WEBHOOK_REGISTRATIONS_CAP}.\n *\n * Reused for Shelly Wall Display FW 2.6.0+ onboarding \u2014 the on-device HA\n * Companion App requires this endpoint to complete device registration\n * after the OAuth2 sign-in. Without it the App refuses to proceed with a\n * \"Mobile-App-Integration nicht verf\u00FCgbar\" error.\n *\n * **Design \u2014 in-memory only, by intent.** The map is NOT persisted across\n * adapter restarts. Restart-recovery relies on the\n * `POST /api/webhook/<unknown-id>` branch returning HTTP 200 with an\n * empty body \u2014 the HA Companion App reads that as a stale webhook and\n * re-runs `update_registration`, which on hassemu issues a fresh\n * webhookId. (Source: home-assistant/android\n * IntegrationRepositoryImpl.kt:170 \u2014 `200 with empty body triggers\n * maybeReregisterDeviceOnFailedUpdate`.)\n *\n * If a future refactor changes the unknown-webhookId response from\n * `200 empty` to `404`, displays will silently break across adapter\n * restarts. Keep that response shape OR add real persistence here.\n */\n public readonly webhookRegistrations: Map<string, string> = new Map();\n /**\n * v1.32.0 F1: last redirect-target seen per client by `/api/redirect_check`.\n * Used to log only-on-change (instead of every 30s poll). Pruned in\n * {@link cleanupSessions} against `registry.listAll()` \u2014 stale entries from\n * removed clients are dropped within max 5 min.\n */\n private readonly lastRedirectTargetByClient: Map<string, string | null> = new Map();\n private cleanupTimer: ioBroker.Interval | null = null;\n /**\n * v1.14.0 (H8): bind once im Constructor statt bei jedem Property-Access\n * via getter \u2014 vorher allokierte jeder `s.inject({...})`-Call eine neue\n * gebundene Funktion. Tests rufen das in Loops auf \u2014 unn\u00F6tiger GC-Druck.\n */\n public readonly inject!: FastifyInstance['inject'];\n public readonly instanceUuid: string;\n /** ioBroker system language for the setup page \u2014 resolved on startup. */\n public readonly systemLanguage: string;\n /** Set of IPs whose reverse DNS lookup is already in-flight \u2014 prevents duplicate work. */\n private readonly dnsInFlight = new Set<string>();\n /**\n * Per-message cooldown timestamps for 5xx error logging. First occurrence\n * of a unique message logs at warn; repeats within {@link REQUEST_ERROR_COOLDOWN_MS}\n * fall to debug to prevent log-spam under attack/probe traffic.\n */\n private readonly errorLogCooldown: Map<string, number> = new Map();\n\n /**\n * @param adapter Adapter instance used for logging, timers and namespace.\n * @param config Resolved runtime config.\n * @param registry Multi-client registry.\n * @param globalConfig Global redirect override.\n * @param instanceUuid Stable UUID shared with the mDNS advert.\n * @param systemLanguage ioBroker system language (`en`, `de`, \u2026) used for the setup page.\n */\n constructor(\n adapter: WebServerAdapter,\n config: AdapterConfig,\n registry: ClientRegistry,\n globalConfig: GlobalConfig,\n instanceUuid: string,\n systemLanguage: string = 'en',\n ) {\n this.adapter = adapter;\n this.config = config;\n this.registry = registry;\n this.globalConfig = globalConfig;\n this.instanceUuid = instanceUuid;\n this.systemLanguage = systemLanguage;\n // v1.25.0 (C11): trustProxy ist Opt-In \u00FCber config \u2014 nur aktivieren\n // wenn der Adapter HINTER einem trusted Reverse-Proxy mit TLS-\n // Termination l\u00E4uft. Mit trustProxy=true holt Fastify `req.ip` aus\n // `X-Forwarded-For` (statt aus dem Socket), `req.protocol` aus\n // `X-Forwarded-Proto` etc. \u2014 Voraussetzung: der Proxy bereinigt diese\n // Header (sonst kann jeder Client den Lockout umgehen).\n this.app = Fastify({ logger: false, trustProxy: this.config.trustProxy === true });\n // v1.14.0 (H8): inject einmal binden, nicht pro Getter-Access.\n (this as { inject: FastifyInstance['inject'] }).inject = this.app.inject.bind(this.app);\n }\n\n /** Human-readable service name advertised in responses and mDNS. */\n get serviceName(): string {\n return this.config.serviceName || 'ioBroker';\n }\n\n /** Resolved listener address once `start()` has completed, or null otherwise. */\n get boundAddress(): { address: string; port: number } | null {\n const addr = this.app.server.address();\n if (!addr || typeof addr === 'string') {\n return null;\n }\n return { address: addr.address, port: addr.port };\n }\n\n // --- lifecycle ---\n\n /** Registers plugins and starts the HTTP listener. */\n async start(): Promise<void> {\n // v1.14.0 (H9): defensive \u2014 wenn start() jemals doppelt gerufen wird\n // (Refactor, Test-Setup-Bug), Timer aus dem Vorlauf clearen statt zu\n // leaken.\n if (this.cleanupTimer) {\n this.adapter.clearInterval(this.cleanupTimer);\n this.cleanupTimer = null;\n }\n await this.app.register(fastifyCookie);\n // OAuth2-Spec verlangt `application/x-www-form-urlencoded` f\u00FCr `/auth/token`.\n // Echte HA-Reference-Clients (frontend/Wall Display SDK) folgen dem.\n // Fastify hat by-default nur einen JSON-Bodyparser \u2014 ohne diesen Plugin\n // beantwortet `/auth/token` mit form-Body 415 und der Login bleibt komplett\n // h\u00E4ngen. Tests via `app.inject({payload:{...}})` serialisieren zu JSON\n // und maskieren das.\n await this.app.register(fastifyFormbody);\n this.setupAuthGuard();\n this.setupErrorHandler();\n this.setupRoutes();\n\n const bindAddress = this.config.bindAddress || '0.0.0.0';\n try {\n await this.app.listen({ port: this.config.port, host: bindAddress });\n } catch (err) {\n const e = err as NodeJS.ErrnoException;\n const msg =\n e.code === 'EADDRINUSE'\n ? `Port ${this.config.port} is already in use \u2014 another service is bound to it`\n : `Server error during startup: ${e.message}`;\n this.adapter.log.error(msg);\n throw err;\n }\n this.adapter.log.debug(`Web server listening on ${bindAddress}:${this.config.port}`);\n\n this.cleanupTimer = this.adapter.setInterval(() => this.cleanupSessions(), CLEANUP_INTERVAL_MS) ?? null;\n }\n\n /** Stops the listener and cancels the session cleanup timer. */\n async stop(): Promise<void> {\n if (this.cleanupTimer) {\n this.adapter.clearInterval(this.cleanupTimer);\n this.cleanupTimer = null;\n }\n try {\n await this.app.close();\n this.adapter.log.debug('Web server stopped');\n } catch (err) {\n // v1.18.0 (G6+G8): debug statt error \u2014 bei intended shutdown\n // (onUnload) ist ein close-error meist ein \"already-closed\"-Race\n // ohne Konsequenz. Caller (main.ts onUnload) loggt nicht doppelt.\n this.adapter.log.debug(`Web server stop error: ${String(err)}`);\n }\n // v1.28.3 (HW1): drop in-flight DNS markers so a slow reverse-lookup\n // started just before stop() doesn't keep an IP entry pinned for the\n // whole process lifetime. The Promise.race(timeout) finally-handler\n // would do that eventually, but only after up to 5s \u2014 racy if the\n // adapter is restarted during that window.\n this.dnsInFlight.clear();\n }\n\n // v1.14.0 (H8): `inject` ist jetzt ein readonly Field (oben deklariert,\n // im Constructor einmalig gebunden). Der fr\u00FChere Getter allokierte bei\n // jedem Access eine neue Funktion.\n\n /** Periodic cleanup of expired in-flight auth sessions and stale lockouts. */\n public cleanupSessions(): void {\n const now = Date.now();\n let cleanedSessions = 0;\n for (const [key, session] of this.sessions) {\n if (now - session.created > SESSION_TTL_MS) {\n this.sessions.delete(key);\n cleanedSessions++;\n }\n }\n if (cleanedSessions > 0) {\n this.adapter.log.debug(`Session cleanup: removed ${cleanedSessions} expired sessions`);\n }\n\n // v1.32.0 F1: prune lastRedirectTargetByClient against currently known\n // clients. A removed client leaves a stale entry that would never get\n // cleared otherwise \u2014 bounded growth over months.\n const activeClients = new Set(this.registry.listAll().map(r => r.id));\n let prunedTargets = 0;\n for (const clientId of this.lastRedirectTargetByClient.keys()) {\n if (!activeClients.has(clientId)) {\n this.lastRedirectTargetByClient.delete(clientId);\n prunedTargets++;\n }\n }\n if (prunedTargets > 0) {\n this.adapter.log.debug(`Cleanup: pruned ${prunedTargets} stale redirect-target entries`);\n }\n }\n\n /**\n * Drops the oldest entry of a Map if it would exceed `cap` after the next insert.\n * Map iteration order in JS is insertion order, so `keys().next()` is the oldest.\n *\n * @param map Map to evict from.\n * @param cap Hard cap; when `map.size >= cap`, the oldest entry is removed.\n */\n /**\n * Cooldown-Decision f\u00FCr 5xx-Error-Logging. Liefert `true` f\u00FCr die erste\n * Beobachtung pro `key` innerhalb {@link REQUEST_ERROR_COOLDOWN_MS} und\n * markiert den Eintrag \u2014 Wiederholungen liefern `false` bis das Fenster\n * abgelaufen ist. Map ist FIFO-gedeckelt auf {@link REQUEST_ERROR_COOLDOWN_CAP}.\n *\n * @param key Eindeutiger Error-Identifier (\u00FCblicherweise `error.message`).\n * @param now Aktuelle Zeit in ms (testbar).\n */\n public shouldEmitRequestErrorWarn(key: string, now: number): boolean {\n const lastSeen = this.errorLogCooldown.get(key) ?? 0;\n if (lastSeen !== 0 && now - lastSeen <= REQUEST_ERROR_COOLDOWN_MS) {\n return false;\n }\n if (!this.errorLogCooldown.has(key)) {\n evictOldest(this.errorLogCooldown, REQUEST_ERROR_COOLDOWN_CAP);\n }\n this.errorLogCooldown.set(key, now);\n return true;\n }\n\n /**\n * Inserts a session, dropping the oldest entry if {@link SESSIONS_CAP} is exceeded.\n *\n * @param key Session key (flow id or auth code).\n * @param data Session payload.\n */\n private storeSession(key: string, data: SessionData): void {\n evictOldest(this.sessions, SESSIONS_CAP);\n this.sessions.set(key, data);\n }\n\n // --- client identification ---\n\n /**\n * v1.15.0 (F6): zentraler Extract `req.ip \u2192 coerced string|null`. Vorher\n * 3\u00D7 inline `coerceString(req.ip)` in identify/login/token-Handlern.\n *\n * @param req Fastify request (uses `req.ip`).\n */\n private static getClientIp(req: FastifyRequest): string | null {\n return coerceString(req.ip);\n }\n\n private async identify(req: FastifyRequest, reply: FastifyReply): Promise<ClientRecord> {\n const cookie = coerceUuid(req.cookies?.[CLIENT_COOKIE]);\n const ip = WebServer.getClientIp(req);\n // v1.17.0 (C8): UA durchreichen damit NAT-Co-Located Displays nicht\n // im selben Pending-Lock landen (siehe identifyOrCreate-Kommentar).\n const userAgent = coerceString(req.headers['user-agent']);\n const record = await this.registry.identifyOrCreate(cookie, ip, null, userAgent);\n // v1.32.0 A1: cookie-state explizit traced. Drei Branches:\n // hit \u2014 cookie matched a known client, no setCookie needed\n // stale/new \u2014 cookie present but unknown, OR no cookie at all \u2192 new client created\n if (cookie === record.cookie) {\n this.adapter.log.debug(`identify: cookie-hit client=${record.id} ip=${ip ?? '?'}`);\n } else {\n const reason = cookie ? 'cookie-stale (unknown)' : 'no-cookie';\n this.adapter.log.debug(`identify: ${reason}, new client=${record.id} ip=${ip ?? '?'}`);\n }\n if (cookie !== record.cookie) {\n // v1.25.0 (C11): Cookie `secure: true` wenn TLS \u2014 Browser sendet\n // den Cookie dann nur \u00FCber HTTPS. Bei trustProxy=true kommt\n // `req.protocol` aus `X-Forwarded-Proto`-Header. Default ohne\n // trustProxy: `req.protocol === 'http'` (Adapter ist HTTP only),\n // also Cookie nicht-secure \u2014 sonst w\u00FCrde der Browser ihn nie senden.\n const useSecure = req.protocol === 'https';\n // v1.32.0 A2: Cookie-Secure-Decision tracen \u2014 wenn trustProxy-config\n // falsch ist, kriegt Display den Cookie evtl. nie zur\u00FCck.\n this.adapter.log.debug(`identify: setting cookie secure=${useSecure} (req.protocol=${req.protocol})`);\n reply.setCookie(CLIENT_COOKIE, record.cookie, {\n path: '/',\n httpOnly: true,\n sameSite: 'lax',\n secure: useSecure,\n maxAge: COOKIE_MAX_AGE_S,\n });\n }\n if (ip) {\n this.resolveHostnameAsync(record, ip);\n }\n return record;\n }\n\n private resolveHostnameAsync(record: ClientRecord, ip: string): void {\n if (record.hostname || this.dnsInFlight.has(ip)) {\n return;\n }\n this.dnsInFlight.add(ip);\n // v1.8.1 (D5): DNS-Lookup mit hartem 5s-Timeout. Default-Node-DNS hat\n // KEIN Timeout \u2014 bei broken Resolver (Captive-Portal, Misconfig) blieb\n // der Promise unendlich pending \u2192 IP f\u00FCr Adapter-Lifetime in dnsInFlight\n // blockiert, hostname auf record.ip gefroren.\n const timeout = new Promise<string[]>((_, reject) =>\n setTimeout(() => reject(new Error('dns reverse-lookup timeout')), 5_000),\n );\n Promise.race([dns.reverse(ip), timeout])\n .then(names => {\n const name = names[0];\n if (name) {\n // v1.32.0 A4: Success-Trace \u2014 bei Diagnose \u201Ewarum hat Display\n // X den hostname Y?\" ist die IP\u2192hostname-Aufl\u00F6sung der Anker.\n this.adapter.log.debug(`resolveHostname: ip=${ip} \u2192 hostname=${name}`);\n this.registry.identifyOrCreate(record.cookie, ip, name).catch(() => {\n /* registry itself logs */\n });\n }\n })\n .catch(err => {\n // v1.32.0 A3: vorher silent. Reverse DNS scheitert auf LAN oft\n // legitim \u2014 daher debug-only (kein warn-Spam), aber jetzt mit\n // Diagnose-Anker f\u00FCr \u201Ehostname fehlt\"-Reports.\n this.adapter.log.debug(\n `resolveHostname: ip=${ip} failed \u2014 ${err instanceof Error ? err.message : String(err)}`,\n );\n })\n .finally(() => {\n this.dnsInFlight.delete(ip);\n });\n }\n\n // --- auth guard ---\n\n /**\n * Pre-handler hook der `/api/*`-Routen sch\u00FCtzt wenn `authRequired=true`.\n *\n * Vorher: `/api/states`, `/api/services`, `/api/events`, `/api/error_log`,\n * `/api/discovery_info` lieferten unauthenticated alle ihre Daten \u2014\n * pure Information-Disclosure. Echte HA verlangt `Authorization: Bearer\n * <token>` f\u00FCr alle `/api/*` au\u00DFer dem `/api/`-Heartbeat.\n *\n * Whitelist (kein Auth n\u00F6tig):\n * - `/`, `/manifest.json`, `/health`, `/api/` \u2014 public Endpoints (Heartbeat, PWA)\n * - `/api/discovery_info` \u2014 HA-Clients fragen das VOR dem Auth-Flow ab um\n * zu erkennen ob `requires_api_password` true ist (Spec-Verhalten)\n * - `/auth/*` \u2014 der Auth-Flow selbst\n *\n * Bei `authRequired=false`: Hook macht nichts (no-op), bestehender Verhalten.\n */\n private setupAuthGuard(): void {\n this.app.addHook('preHandler', async (req, reply) => {\n if (!this.config.authRequired) {\n return;\n }\n const path = (req.url ?? '/').split('?')[0];\n // Public endpoints \u2014 explicitly allowed\n if (\n path === '/' ||\n path === '/api/' ||\n path === '/api/discovery_info' ||\n path === '/manifest.json' ||\n path === '/health' ||\n path.startsWith('/auth/') ||\n // v1.29.1: Mobile-App webhooks carry the secret in the URL\n // (`webhookId`) \u2014 HA core also serves these unauthenticated.\n // Source: home-assistant/core/.../mobile_app/webhook.py.\n path.startsWith('/api/webhook/')\n ) {\n return;\n }\n // From here on: protected (`/api/*` apart from `/api/`)\n const authHeader = req.headers.authorization;\n if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) {\n this.adapter.log.debug(`Auth required for ${path} \u2014 missing Bearer token`);\n reply.status(401).send({ error: 'unauthorized' });\n return;\n }\n const token = authHeader.substring('Bearer '.length).trim();\n const client = this.registry.getByToken(token);\n if (!client) {\n this.adapter.log.debug(`Auth required for ${path} \u2014 unknown Bearer token`);\n reply.status(401).send({ error: 'invalid_token' });\n return;\n }\n // OK \u2014 handler runs\n });\n }\n\n // --- error handling ---\n\n private setupErrorHandler(): void {\n this.app.setErrorHandler((err, _req, reply) => {\n const error = err as Error & { validation?: unknown; statusCode?: number };\n if (error.validation) {\n this.adapter.log.debug(`Validation error: ${error.message}`);\n reply.status(400).send({ error: 'Invalid request', details: error.message });\n return;\n }\n // Fastify body-parsing / client errors already set statusCode in 4xx range\n const code = typeof error.statusCode === 'number' ? error.statusCode : 500;\n if (code >= 400 && code < 500) {\n this.adapter.log.debug(`Client error ${code}: ${error.message}`);\n reply.status(code).send({ error: error.message });\n return;\n }\n // 5xx: ein attacker kann mit malformed paths/oversized bodies viele\n // 500er triggern. Per-Message-Dedup-Map mit 60s-Cooldown \u2014 das erste\n // Auftreten pro unique message kommt als warn, alle Wiederholungen\n // im 60s-Fenster auf debug. Memory `feedback_no_log_spam`.\n const key = error.message || 'unknown';\n if (this.shouldEmitRequestErrorWarn(key, Date.now())) {\n this.adapter.log.warn(`Request error: ${error.message}`);\n } else {\n this.adapter.log.debug(`Request error (repeat): ${error.message}`);\n }\n reply.status(500).send({ error: 'Internal server error' });\n });\n }\n\n // --- routes ---\n\n private setupRoutes(): void {\n this.setupApiRoutes();\n this.setupAuthRoutes();\n this.setupMiscRoutes();\n this.setupNotFound();\n }\n\n private setupApiRoutes(): void {\n // CRITICAL: trailing slash \u2014 HA clients check this endpoint for discovery\n this.app.get('/api/', () => ({ message: 'API running.' }));\n\n this.app.get('/api/config', () => ({\n // `mobile_app` advertises the integration the HA Companion App\n // probes for during onboarding (v1.29.1, Shelly FW 2.6.0+).\n components: ['http', 'api', 'frontend', 'homeassistant', 'mobile_app'],\n config_dir: '/config',\n elevation: 0,\n latitude: 0,\n longitude: 0,\n location_name: this.serviceName,\n time_zone: 'UTC',\n unit_system: { length: 'km', mass: 'g', temperature: '\u00B0C', volume: 'L' },\n version: HA_VERSION,\n whitelist_external_dirs: [],\n }));\n\n this.app.get('/api/discovery_info', () => {\n // v1.17.0 (E11): NICHT mehr `req.hostname` \u2014 der Host-Header ist\n // client-controlled und ein Angreifer k\u00F6nnte mit `Host: attacker.lan`\n // andere HA-Clients zur falschen URL umleiten. Stattdessen die\n // tats\u00E4chlich gebundene Adresse: bindAddress (ggf. wildcard) oder\n // ersten lokalen non-internal IPv4 via getLocalIp.\n const isWildcard = !this.config.bindAddress || isWildcardBind(this.config.bindAddress);\n const host = isWildcard ? getLocalIp() : this.config.bindAddress;\n const baseUrl = `http://${host}:${this.config.port}`;\n return {\n base_url: baseUrl,\n external_url: null,\n internal_url: baseUrl,\n location_name: this.serviceName,\n // Vorher hardcoded `true` unabh\u00E4ngig von authRequired \u2014 strict HA-Clients\n // versuchten Auth auch bei authRequired=false und scheiterten am leeren Login-Flow.\n requires_api_password: this.config.authRequired,\n uuid: this.instanceUuid,\n version: HA_VERSION,\n };\n });\n\n for (const path of ['/api/states', '/api/services', '/api/events']) {\n this.app.get(path, () => []);\n }\n this.app.get('/api/error_log', () => '');\n\n // ---- Mobile-App integration (HA Companion + Shelly FW 2.6.0+) ----\n //\n // Source: home-assistant/android IntegrationRepositoryImpl.kt:120-159\n // calls POST /api/mobile_app/registrations after the OAuth2 sign-in.\n // A 404 here surfaces as \u201EMobile-App-Integration nicht verf\u00FCgbar\" in\n // the App's onboarding screen and blocks the display from finishing\n // setup. Detail in Ressourcen/hassemu/oauth2-browser-flow-shelly-fw26.md.\n //\n // The Bearer-token check is already done by the existing auth\n // pre-handler \u2014 `/api/mobile_app/registrations` is protected by\n // default, so by the time the handler runs we know the caller has\n // a valid access_token from /auth/token.\n this.app.post<{\n Body: {\n app_id?: string;\n app_name?: string;\n device_name?: string;\n device_id?: string;\n manufacturer?: string;\n model?: string;\n os_name?: string;\n os_version?: string;\n };\n }>('/api/mobile_app/registrations', async (req, reply) => {\n const body = req.body ?? {};\n // Identify by Bearer token \u2014 the pre-handler already validated it.\n const authHeader = (req.headers.authorization as string) ?? '';\n const token = authHeader.startsWith('Bearer ') ? authHeader.substring(7).trim() : '';\n const client = this.registry.getByToken(token);\n const ownerId = client?.id ?? '';\n\n const webhookId = crypto.randomUUID().replace(/-/g, '');\n evictOldest(this.webhookRegistrations, WEBHOOK_REGISTRATIONS_CAP);\n this.webhookRegistrations.set(webhookId, ownerId);\n\n this.adapter.log.debug(\n `Mobile-App registration \u2014 client=${ownerId} app_id=${body.app_id ?? '?'} device_name=${body.device_name ?? '?'} \u2192 webhook=${webhookId}`,\n );\n\n // Response shape: home-assistant/android RegisterDeviceResponse.kt\n // cloudhookUrl: String? (null \u2014 no Nabu Casa cloud)\n // remoteUiUrl: String? (null \u2014 no remote-UI)\n // secret: String? (null \u2014 webhookId itself is the secret)\n // webhookId: String (required, non-null)\n reply.status(201);\n return {\n webhook_id: webhookId,\n cloudhook_url: null,\n remote_ui_url: null,\n secret: null,\n };\n });\n\n // PUT and DELETE on /api/mobile_app/registrations/:webhookId \u2014 the App\n // calls PUT to update its registration on token refresh or sensor\n // re-register. We treat both as no-ops that return success so the\n // Companion App doesn't show registration-failure banners.\n this.app.put<{ Params: { webhookId: string } }>(\n '/api/mobile_app/registrations/:webhookId',\n async (req, reply) => {\n const id = req.params.webhookId;\n if (!this.webhookRegistrations.has(id)) {\n // v1.32.0 E1: stale-id signaliert dass Companion einen Token\n // aus Pre-Restart-Era hat \u2014 diagnostisch wertvoll f\u00FCr\n // re-registration-loop-Bugs.\n this.adapter.log.debug(\n `Mobile-App PUT registration: unknown webhookId=${id.substring(0, 8)}\u2026 \u2014 returning 404`,\n );\n reply.status(404);\n return { error: 'unknown_registration' };\n }\n return { webhook_id: id, cloudhook_url: null, remote_ui_url: null, secret: null };\n },\n );\n\n this.app.delete<{ Params: { webhookId: string } }>(\n '/api/mobile_app/registrations/:webhookId',\n async (req, reply) => {\n const id = req.params.webhookId;\n const wasPresent = this.webhookRegistrations.has(id);\n this.webhookRegistrations.delete(id);\n // v1.32.0 E2: Companion-Maintenance-Trace.\n this.adapter.log.debug(\n `Mobile-App DELETE registration: webhookId=${id.substring(0, 8)}\u2026 removed (was-present=${wasPresent})`,\n );\n reply.status(204);\n return null;\n },\n );\n\n // POST /api/webhook/:webhookId \u2014 Companion-App sensor updates,\n // location pings, registration updates etc. Public by design (URL\n // contains the webhookId secret). HA core dispatches on `type` field\n // in the JSON body and returns shape per type. For hassemu we accept\n // any payload and respond with the minimal-correct success per type;\n // the display use-case doesn't need actual state propagation, but\n // returning 200 prevents the App from re-trying in a loop and\n // surfacing onboarding-failure banners.\n this.app.post<{\n Params: { webhookId: string };\n Body: { type?: string; data?: unknown };\n }>('/api/webhook/:webhookId', async (req, reply) => {\n const id = req.params.webhookId;\n if (!this.webhookRegistrations.has(id)) {\n // Unknown webhookId \u2014 match HA's 200-empty for stale webhooks\n // so the App falls back to `update_registration` (which on\n // hassemu re-issues a new registration). Source:\n // home-assistant/android IntegrationRepositoryImpl.kt:170 \u2014\n // 200 with empty body triggers `maybeReregisterDeviceOnFailedUpdate`.\n // v1.32.0 E3: stale-id ist DAS Symptom f\u00FCr re-registration-loop \u2014\n // Companion macht webhook-call mit Token aus Pre-Restart-Era.\n this.adapter.log.debug(\n `Webhook fallthrough: stale id=${id.substring(0, 8)}\u2026 \u2014 App will trigger re-registration`,\n );\n reply.status(200);\n return null;\n }\n const body = req.body ?? {};\n const type = typeof body.type === 'string' ? body.type : '';\n this.adapter.log.debug(`Webhook ${id.substring(0, 8)}\u2026 type=${type || '(no type)'}`);\n\n switch (type) {\n case 'get_config':\n return {\n components: ['http', 'api', 'frontend', 'homeassistant', 'mobile_app'],\n latitude: 0,\n longitude: 0,\n elevation: 0,\n unit_system: { length: 'km', mass: 'g', temperature: '\u00B0C', volume: 'L' },\n location_name: this.serviceName,\n time_zone: 'UTC',\n version: HA_VERSION,\n };\n case 'get_zones':\n return [];\n case 'render_template':\n return {};\n case 'update_registration':\n return { webhook_id: id, cloudhook_url: null, remote_ui_url: null, secret: null };\n case 'register_sensor':\n return { success: true };\n case 'update_sensor_states':\n return {};\n default:\n // Generic success for unknown types \u2014 fire_event,\n // call_service, conversation_process, update_location,\n // get_zones-with-data, etc. The display doesn't need\n // their semantics, just an HTTP 200 acknowledgement.\n return {};\n }\n });\n }\n\n /**\n * Issue a fresh authorization code and persist it in the sessions map.\n *\n * Single source for both the JSON login flow (`/auth/login_flow/<flowId>`\n * \u2192 `create_entry`) and the browser OAuth2 flow (`/auth/authorize` \u2192\n * 302). The code is exchanged for tokens at `/auth/token` (`grant_type =\n * authorization_code`); the existing token-view consumes the same map.\n *\n * @param clientId Identity cookie value of the requesting display, or\n * undefined for headless OAuth2-only flows.\n */\n private issueAuthorizationCode(clientId: string | null): string {\n const code = crypto.randomUUID();\n this.storeSession(code, { created: Date.now(), clientId });\n return code;\n }\n\n private setupAuthRoutes(): void {\n this.app.get('/auth/providers', () => [{ name: 'Home Assistant Local', type: 'homeassistant', id: null }]);\n\n // Browser-OAuth2 flow at GET/POST /auth/authorize. Needed by the\n // HA Companion Android App (Shelly Wall Display FW 2.6.0+ embeds\n // the Companion App). Source-verified flow:\n // home-assistant/android UrlUtil.kt:buildAuthenticationUrl\n // home-assistant/core indieauth.py:verify_redirect_uri\n // home-assistant/frontend src/data/auth.ts:redirectWithAuthCode\n // Detail: Ressourcen/hassemu/oauth2-browser-flow-shelly-fw26.md\n this.app.get<{\n Querystring: { response_type?: string; client_id?: string; redirect_uri?: string; state?: string };\n }>('/auth/authorize', async (req, reply) => {\n const { response_type, client_id, redirect_uri, state } = req.query ?? {};\n\n if (response_type !== 'code') {\n // v1.32.0 D2: rejection-Pfade traced \u2014 Triage \u201Ewarum bricht OAuth ab\"\n this.adapter.log.debug(\n `Authorize GET rejected: response_type=${String(response_type)} (expected 'code')`,\n );\n reply.status(400).type('text/html');\n return renderAuthorizeError(\n 'unsupported_response_type',\n 'This authorization server supports `response_type=code` only.',\n );\n }\n if (typeof client_id !== 'string' || typeof redirect_uri !== 'string') {\n this.adapter.log.debug(\n `Authorize GET rejected: missing client_id or redirect_uri (cid=${typeof client_id}, ru=${typeof redirect_uri})`,\n );\n reply.status(400).type('text/html');\n return renderAuthorizeError(\n 'invalid_request',\n 'Missing or invalid `client_id` or `redirect_uri` parameter.',\n );\n }\n if (!isValidRedirectUri(client_id, redirect_uri)) {\n this.adapter.log.debug(\n `Authorize rejected: redirect_uri \"${redirect_uri}\" not allowed for client_id \"${client_id}\"`,\n );\n reply.status(400).type('text/html');\n return renderAuthorizeError(\n 'invalid_redirect_uri',\n 'The `redirect_uri` parameter is not on the allowlist for this client.',\n );\n }\n\n const client = await this.identify(req, reply);\n\n // No auth required \u2192 issue the code right away and redirect.\n if (!this.config.authRequired) {\n const code = this.issueAuthorizationCode(client.id);\n const target = buildRedirectUrl(redirect_uri, code, state);\n this.adapter.log.debug(`Authorize auto-grant \u2014 client ${client.id}`);\n reply.type('text/html');\n return renderAuthorizeRedirect(target);\n }\n\n // v1.32.0 D1: Form-render Trace \u2014 wenn Companion die Form nie absendet,\n // sieht User hier dass sie \u00FCberhaupt gerendert wurde.\n let redirectHost = '?';\n try {\n redirectHost = new URL(redirect_uri).host || redirect_uri;\n } catch {\n redirectHost = redirect_uri;\n }\n this.adapter.log.debug(\n `Authorize form rendered \u2014 client_id=${client_id} redirect_uri-host=${redirectHost}`,\n );\n reply.type('text/html');\n return renderAuthorizeForm({ clientId: client_id, redirectUri: redirect_uri, state });\n });\n\n this.app.post<{\n Body: {\n response_type?: string;\n client_id?: string;\n redirect_uri?: string;\n state?: string;\n username?: string;\n password?: string;\n };\n }>('/auth/authorize', async (req, reply) => {\n const { response_type, client_id, redirect_uri, state, username, password } = req.body ?? {};\n\n if (response_type !== 'code') {\n this.adapter.log.debug(\n `Authorize POST rejected: response_type=${String(response_type)} (expected 'code')`,\n );\n reply.status(400).type('text/html');\n return renderAuthorizeError('unsupported_response_type', 'Only `response_type=code` is supported.');\n }\n if (typeof client_id !== 'string' || typeof redirect_uri !== 'string') {\n this.adapter.log.debug(\n `Authorize POST rejected: missing client_id or redirect_uri (cid=${typeof client_id}, ru=${typeof redirect_uri})`,\n );\n reply.status(400).type('text/html');\n return renderAuthorizeError(\n 'invalid_request',\n 'Missing or invalid `client_id` or `redirect_uri` parameter.',\n );\n }\n if (!isValidRedirectUri(client_id, redirect_uri)) {\n this.adapter.log.debug(\n `Authorize POST rejected: redirect_uri \"${redirect_uri}\" not allowed for client_id \"${client_id}\"`,\n );\n reply.status(400).type('text/html');\n return renderAuthorizeError(\n 'invalid_redirect_uri',\n 'The `redirect_uri` parameter is not on the allowlist for this client.',\n );\n }\n\n const client = await this.identify(req, reply);\n\n // No auth required \u2192 straight to redirect even on POST.\n if (!this.config.authRequired) {\n const code = this.issueAuthorizationCode(client.id);\n const target = buildRedirectUrl(redirect_uri, code, state);\n reply.type('text/html');\n return renderAuthorizeRedirect(target);\n }\n\n const ip = WebServer.getClientIp(req);\n const userOk = typeof username === 'string' && safeStringEqual(username, this.config.username);\n const passOk = typeof password === 'string' && safeStringEqual(password, this.config.password);\n if (!userOk || !passOk) {\n const ipSuffix = ip ? ` (IP ${ip})` : '';\n this.adapter.log.warn(`Invalid credentials${ipSuffix}`);\n reply.status(401).type('text/html');\n return renderAuthorizeForm(\n { clientId: client_id, redirectUri: redirect_uri, state },\n 'Invalid username or password.',\n );\n }\n\n const code = this.issueAuthorizationCode(client.id);\n const target = buildRedirectUrl(redirect_uri, code, state);\n this.adapter.log.debug(`Authorize grant \u2014 client ${client.id}`);\n reply.type('text/html');\n return renderAuthorizeRedirect(target);\n });\n\n this.app.post('/auth/login_flow', async (req, reply) => {\n const client = await this.identify(req, reply);\n const flowId = crypto.randomUUID();\n this.storeSession(flowId, { created: Date.now(), clientId: client.id });\n this.adapter.log.debug(`Auth flow created: ${flowId} for client ${client.id}`);\n\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n description_placeholders: null,\n errors: null,\n };\n });\n\n this.app.post<{\n Params: { flowId: string };\n Body: { username?: string; password?: string };\n }>(\n '/auth/login_flow/:flowId',\n {\n schema: {\n params: {\n type: 'object',\n properties: { flowId: { type: 'string', minLength: 1 } },\n required: ['flowId'],\n },\n },\n },\n async (req, reply) => {\n const flowId = req.params.flowId;\n const session = this.sessions.get(flowId);\n if (!session) {\n // v1.8.0: nach Session-TTL (10 min) feuert das bei jedem\n // legit returning user \u2014 nicht actionable. debug, nicht warn.\n this.adapter.log.debug(`Unknown flow_id: ${flowId}`);\n reply.status(400);\n return { type: 'abort', flow_id: flowId, reason: 'unknown_flow' };\n }\n\n if (this.config.authRequired) {\n const ip = WebServer.getClientIp(req);\n const { username, password } = req.body ?? {};\n const userOk = typeof username === 'string' && safeStringEqual(username, this.config.username);\n const passOk = typeof password === 'string' && safeStringEqual(password, this.config.password);\n if (!userOk || !passOk) {\n const ipSuffix = ip ? ` (IP ${ip})` : '';\n this.adapter.log.warn(`Invalid credentials${ipSuffix}`);\n reply.status(400);\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n errors: { base: 'invalid_auth' },\n description_placeholders: null,\n };\n }\n }\n\n this.sessions.delete(flowId);\n const code = crypto.randomUUID();\n this.storeSession(code, { created: Date.now(), clientId: session.clientId });\n this.adapter.log.debug('Auth flow completed \u2014 code issued');\n\n return {\n version: 1,\n type: 'create_entry',\n flow_id: flowId,\n handler: ['homeassistant', null],\n result: code,\n description: null,\n description_placeholders: null,\n };\n },\n );\n\n this.app.post<{ Body: { code?: string; grant_type?: string; refresh_token?: string } }>(\n '/auth/token',\n async (req, reply) => {\n const { code, grant_type, refresh_token } = req.body ?? {};\n\n if (grant_type === 'authorization_code' && code && this.sessions.has(code)) {\n const session = this.sessions.get(code)!;\n this.sessions.delete(code);\n const token = crypto.randomUUID();\n const refreshToken = crypto.randomUUID();\n if (session.clientId) {\n // Persist VOR Response-Build: ein Crash zwischen Issue + Persist\n // w\u00FCrde sonst dem Client einen Token in der Hand lassen, den der\n // Server nicht kennt \u2014 beim ersten Refresh dann invalid_grant.\n await this.registry.setToken(session.clientId, token);\n await this.registry.setRefreshToken(session.clientId, refreshToken);\n this.adapter.log.debug(`Display authenticated \u2014 client ${session.clientId}`);\n }\n return {\n access_token: token,\n token_type: 'Bearer',\n refresh_token: refreshToken,\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n if (grant_type === 'refresh_token') {\n // Validate the refresh token against issued ones \u2014 was previously\n // accepting any string and minting a new access_token (security fix v1.2.0).\n const incoming = typeof refresh_token === 'string' ? refresh_token : '';\n const ownerRecord = incoming ? this.registry.getByRefreshToken(incoming) : null;\n if (!ownerRecord) {\n this.adapter.log.debug('Refresh token rejected \u2014 unknown or missing');\n reply.status(400);\n return { error: 'invalid_grant', error_description: 'Invalid refresh token' };\n }\n // v1.31.0: refresh_token bleibt valid (NICHT mehr rotated). HA Core\n // selbst (homeassistant/components/auth/__init__.py:334-348) liefert\n // beim refresh-grant nie einen neuen refresh_token, nur access_token\n // + token_type + expires_in. HA Android Companion\n // (AuthenticationRepositoryImpl.kt:147) speichert beim Refresh den\n // GESENDETEN refresh_token (Function-Parameter), ignoriert den in der\n // Response zur\u00FCckgegebenen \u2014 Companion beh\u00E4lt daher immer ihren\n // initialen refresh_token. v1.28.3 (HW5) Rotation war RFC 6819\n // \u00A75.2.2.3-konform aber inkompatibel mit dem Companion-Datenmodell:\n // Server-Rotation killte den Companion-Token beim ersten Refresh.\n const newAccess = crypto.randomUUID();\n await this.registry.setToken(ownerRecord.id, newAccess);\n this.adapter.log.debug(`Refresh-token-grant \u2014 client=${ownerRecord.id} new access_token issued`);\n return {\n access_token: newAccess,\n token_type: 'Bearer',\n refresh_token: incoming,\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n // v1.8.0: \u201Ewrong grant_type\" ist ein Client-Format-Fehler,\n // nicht ein Server-Concern \u2014 debug. KEIN Lockout-Counter\n // (legitimer Client-Bug soll nicht zu IP-Sperre f\u00FChren).\n this.adapter.log.debug(`Token exchange failed: grant_type=${String(grant_type)}`);\n reply.status(400);\n return { error: 'invalid_request', error_description: 'Invalid or expired code' };\n },\n );\n }\n\n private setupMiscRoutes(): void {\n // Liveness only \u2014 no config leak. Earlier versions exposed the global\n // redirect URL via /health which is unauthenticated; removed in v1.2.0.\n // v1.5.0: auch der `config: { mdns, auth }`-Block raus \u2014 Auth-Status leakte\n // unauthenticated und lie\u00DF sich von einem Network-Attacker zur Reconnaissance\n // nutzen (auth-disabled Instances quickly mappen).\n this.app.get('/health', () => ({\n status: 'ok',\n adapter: 'hassemu',\n version: HA_VERSION,\n }));\n\n this.app.get('/manifest.json', () => ({\n // `name` MUST be \"Home Assistant\" exactly \u2014 the HA Companion App\n // verifies the server identity by parsing this field. Source:\n // home-assistant/android DefaultConnectivityChecker.kt:isHomeAssistant\n // checks `name === \"Home Assistant\"`. Anything else (e.g. `serviceName`\n // = \"ioBroker\") fails the onboarding probe with \"Server ist nicht\n // Home Assistant\". Detail in Ressourcen/hassemu/oauth2-browser-flow-shelly-fw26.md.\n name: 'Home Assistant',\n short_name: 'Home Assistant',\n start_url: '/',\n display: 'standalone',\n background_color: '#ffffff',\n theme_color: '#03a9f4',\n }));\n\n // Root \u2014 HTML-Wrapper (iframe + auto-reload), oder Landing-Page wenn keine URL.\n //\n // v1.7.0 (A3): statt 302 liefern wir ein iframe-HTML + 30s-poll auf\n // /api/redirect_check. Wenn die Mode-/URL-Config sich \u00E4ndert (User edit\n // im Adapter), pollt das Display den Wechsel und macht `location.reload()`\n // \u2014 ohne Soft-Reboot des Displays. Vorher musste der User das Display\n // manuell rebooten.\n //\n // WebViews wie Shelly Wall Display rendern iframes + JavaScript korrekt.\n // Falls ein User direkten 302-Redirect will (Browser-Test, Bookmarklet\n // etc.), kann er die Target-URL direkt eingeben \u2014 der Wrapper l\u00E4uft nur\n // beim Aufruf von `/`.\n this.app.get('/', async (req, reply) => {\n const client = await this.identify(req, reply);\n // v1.32.0 B1: Resolver-Chain als Triage-Anker. Ohne Chain musste der\n // Maintainer den Resolver-Code lesen um zu verstehen warum genau\n // diese URL f\u00FCr diesen Client gew\u00E4hlt wurde.\n const { url, chain } = this.globalConfig.resolveUrlForWithChain(client);\n if (!url) {\n this.adapter.log.debug(`GET / client=${client.id} \u2192 landing (chain=${chain})`);\n return reply\n .status(200)\n .type('text/html; charset=utf-8')\n .send(renderLandingPage(client.id, this.adapter.namespace, this.systemLanguage, client.ip));\n }\n this.adapter.log.debug(`GET / client=${client.id} \u2192 URL (chain=${chain})`);\n return reply\n .status(200)\n .type('text/html; charset=utf-8')\n .send(renderRedirectWrapper(url, client.id, this.adapter.namespace, this.systemLanguage, client.ip));\n });\n\n // /api/redirect_check \u2014 Display polled das alle 30s; wenn der target\n // sich ge\u00E4ndert hat (User edit), gibt der Wrapper `location.reload()`\n // ab. Cookie-basiert \u2014 Display schickt seinen `hassemu_client`-Cookie\n // automatisch mit.\n this.app.get('/api/redirect_check', async (req, reply) => {\n const client = await this.identify(req, reply);\n const url = this.globalConfig.resolveUrlFor(client);\n // v1.32.0 F1: only-on-change-Trace. Jeder Poll (alle 30s \u00D7 N Displays)\n // w\u00E4re Flood \u2014 diagnostisch wertvoll ist nur der Target-Wechsel.\n // First-time-poll-pro-restart wird auch geloggt weil Map leer ist.\n const prev = this.lastRedirectTargetByClient.get(client.id);\n const next = url ?? null;\n if (prev !== next) {\n this.adapter.log.debug(\n `redirect_check client=${client.id}: ${prev === undefined ? 'first-poll' : (prev ?? 'none')} \u2192 ${next ?? 'none'}`,\n );\n this.lastRedirectTargetByClient.set(client.id, next);\n }\n return { target: next };\n });\n }\n\n private setupNotFound(): void {\n this.app.setNotFoundHandler((req, reply) => {\n this.adapter.log.debug(`404: ${req.method} ${req.url}`);\n reply.status(404).send({ error: 'Not Found', path: req.url });\n });\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["Fastify", "fastifyCookie", "fastifyFormbody", "dns", "crypto", "code", "target"]
|
|
7
7
|
}
|
package/io-package.json
CHANGED
|
@@ -1,20 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "hassemu",
|
|
4
|
-
"version": "1.32.
|
|
4
|
+
"version": "1.32.2",
|
|
5
5
|
"news": {
|
|
6
|
+
"1.32.2": {
|
|
7
|
+
"en": "Internal cleanup. No user-facing changes.",
|
|
8
|
+
"de": "Interne Aufräumarbeiten. Keine sichtbaren Änderungen.",
|
|
9
|
+
"ru": "Внутренняя оптимизация. Без видимых изменений.",
|
|
10
|
+
"pt": "Limpeza interna. Sem alterações visíveis.",
|
|
11
|
+
"nl": "Interne opruiming. Geen zichtbare wijzigingen.",
|
|
12
|
+
"fr": "Nettoyage interne. Aucun changement visible.",
|
|
13
|
+
"it": "Pulizia interna. Nessuna modifica visibile.",
|
|
14
|
+
"es": "Limpieza interna. Sin cambios visibles.",
|
|
15
|
+
"pl": "Wewnętrzne porządki. Brak widocznych zmian.",
|
|
16
|
+
"uk": "Внутрішнє прибирання. Без видимих змін.",
|
|
17
|
+
"zh-cn": "内部清理。无用户可见更改。"
|
|
18
|
+
},
|
|
19
|
+
"1.32.1": {
|
|
20
|
+
"en": "If the adapter goes offline while the display is running, the display now switches to a clear offline page with a reload button instead of just stopping to update.",
|
|
21
|
+
"de": "Wenn der Adapter offline geht während das Display läuft, wechselt das Display jetzt auf eine klare Offline-Seite mit Neu-Laden-Button — statt einfach nicht mehr zu aktualisieren.",
|
|
22
|
+
"ru": "Если адаптер уходит в офлайн во время работы дисплея, дисплей теперь переключается на понятную офлайн-страницу с кнопкой обновления, а не просто перестаёт обновляться.",
|
|
23
|
+
"pt": "Se o adaptador ficar offline enquanto o ecrã está em uso, o ecrã muda agora para uma página offline clara com botão de recarregar — em vez de simplesmente parar de atualizar.",
|
|
24
|
+
"nl": "Als de adapter offline gaat terwijl het display in gebruik is, schakelt het display nu over op een duidelijke offline-pagina met een herlaad-knop in plaats van simpelweg niet meer bij te werken.",
|
|
25
|
+
"fr": "Si l'adaptateur passe hors ligne pendant l'utilisation, l'écran bascule désormais sur une page hors ligne explicite avec bouton de rechargement, au lieu de simplement cesser de se mettre à jour.",
|
|
26
|
+
"it": "Se l'adattatore va offline mentre il display è in uso, il display passa ora a una pagina offline chiara con pulsante di ricarica, invece di smettere semplicemente di aggiornarsi.",
|
|
27
|
+
"es": "Si el adaptador queda sin conexión mientras la pantalla está en uso, ahora la pantalla cambia a una página offline clara con botón de recarga, en lugar de dejar simplemente de actualizarse.",
|
|
28
|
+
"pl": "Jeśli adapter przestanie być dostępny podczas pracy wyświetlacza, ekran przechodzi teraz na czytelną stronę offline z przyciskiem ponownego załadowania — zamiast po prostu przestać się aktualizować.",
|
|
29
|
+
"uk": "Якщо адаптер стає недоступним поки дисплей працює, екран тепер переходить на чітку офлайн-сторінку з кнопкою перезавантаження, а не просто перестає оновлюватись.",
|
|
30
|
+
"zh-cn": "如果在显示器运行时适配器变得不可用,显示器现在会切换到带有「重新加载」按钮的清晰离线页面,而不是只停止更新。"
|
|
31
|
+
},
|
|
6
32
|
"1.32.0": {
|
|
7
|
-
"en": "
|
|
8
|
-
"de": "
|
|
9
|
-
"ru": "
|
|
10
|
-
"pt": "
|
|
11
|
-
"nl": "
|
|
12
|
-
"fr": "
|
|
13
|
-
"it": "
|
|
14
|
-
"es": "
|
|
15
|
-
"pl": "
|
|
16
|
-
"uk": "
|
|
17
|
-
"zh-cn": "
|
|
33
|
+
"en": "Two state descriptions in the object tree are now complete again. Internal cleanup, no further user-facing changes.",
|
|
34
|
+
"de": "Zwei abgeschnittene State-Beschreibungen im Objektbaum sind wieder vollständig. Interne Aufräumarbeiten, sonst keine sichtbaren Änderungen.",
|
|
35
|
+
"ru": "Две обрезанные подписи стейтов в дереве объектов снова полные. Внутренняя оптимизация, без других видимых изменений.",
|
|
36
|
+
"pt": "Duas descrições de estado truncadas na árvore de objetos voltam a estar completas. Limpeza interna, sem outras mudanças visíveis.",
|
|
37
|
+
"nl": "Twee afgekapte state-beschrijvingen in de objectboom zijn weer compleet. Interne opruiming, geen andere zichtbare wijzigingen.",
|
|
38
|
+
"fr": "Deux descriptions d'état tronquées dans l'arbre des objets sont à nouveau complètes. Nettoyage interne, pas d'autres changements visibles.",
|
|
39
|
+
"it": "Due descrizioni di stato troncate nell'albero degli oggetti sono di nuovo complete. Pulizia interna, nessun'altra modifica visibile.",
|
|
40
|
+
"es": "Dos descripciones de estado truncadas en el árbol de objetos vuelven a estar completas. Limpieza interna, sin otros cambios visibles.",
|
|
41
|
+
"pl": "Dwa skrócone opisy stanów w drzewie obiektów są znów kompletne. Wewnętrzne porządki, bez innych widocznych zmian.",
|
|
42
|
+
"uk": "Два обрізані описи станів у дереві об'єктів знову повні. Внутрішнє прибирання, без інших видимих змін.",
|
|
43
|
+
"zh-cn": "对象树中两个被截断的状态描述现已完整。其余为内部清理,无用户可见更改。"
|
|
18
44
|
},
|
|
19
45
|
"1.31.1": {
|
|
20
46
|
"en": "Debug log traces every decision point that was previously silent: cookie identity, OAuth2 validation, URL discovery skips, resolver chain, mobile-app webhook flow.",
|
|
@@ -67,32 +93,6 @@
|
|
|
67
93
|
"pl": "Popup błędu połączenia na Shelly Wall Display 2.6.0+ nie pojawia się także gdy wyświetlana jest strona startowa. Strona startowa pokazuje teraz prawdziwe logo ioBroker.",
|
|
68
94
|
"uk": "Попап помилки з'єднання на Shelly Wall Display 2.6.0+ більше не з'являється і коли показується стартова сторінка. На стартовій сторінці справжній логотип ioBroker.",
|
|
69
95
|
"zh-cn": "Shelly Wall Display 2.6.0+ 上的连接错误弹窗在显示落地页时也不再出现。落地页现在使用真正的 ioBroker 品牌标志。"
|
|
70
|
-
},
|
|
71
|
-
"1.29.2": {
|
|
72
|
-
"en": "Shelly Wall Display 2.6.0+ no longer shows the connection-error popup after the page loaded. Aura is now auto-detected in the mode dropdown. Landing page carries the ioBroker logo.",
|
|
73
|
-
"de": "Shelly Wall Display unter Firmware 2.6.0+ zeigt nach geladenem Display kein Verbindungs-Fehler-Popup mehr. Aura wird jetzt automatisch im Mode-Dropdown erkannt. Landing-Page trägt das ioBroker-Logo.",
|
|
74
|
-
"ru": "Shelly Wall Display 2.6.0+ больше не показывает попап ошибки после загрузки. Aura теперь определяется автоматически в Mode-списке. Логотип ioBroker на стартовой странице.",
|
|
75
|
-
"pt": "Shelly Wall Display com firmware 2.6.0+ deixa de mostrar o popup de erro depois de a página carregar. O Aura é agora detetado automaticamente. A landing page traz o logótipo do ioBroker.",
|
|
76
|
-
"nl": "Shelly Wall Display met firmware 2.6.0+ toont na het laden geen verbinding-foutpopup meer. Aura wordt nu automatisch herkend. Landingspagina toont het ioBroker-logo.",
|
|
77
|
-
"fr": "Le Shelly Wall Display sous firmware 2.6.0+ n'affiche plus la popup d'erreur après le chargement. Aura est désormais détecté automatiquement. Le logo ioBroker apparaît sur la page d'accueil.",
|
|
78
|
-
"it": "Shelly Wall Display con firmware 2.6.0+ non mostra più il popup di errore dopo il caricamento. Aura viene ora rilevato automaticamente. Logo ioBroker sulla landing page.",
|
|
79
|
-
"es": "Shelly Wall Display con firmware 2.6.0+ ya no muestra el popup de error tras la carga. Aura ahora se detecta automáticamente. Logotipo de ioBroker en la página de inicio.",
|
|
80
|
-
"pl": "Shelly Wall Display z firmware 2.6.0+ nie pokazuje już popupu błędu po załadowaniu strony. Aura jest teraz automatycznie wykrywana. Logo ioBroker na stronie startowej.",
|
|
81
|
-
"uk": "Shelly Wall Display з прошивкою 2.6.0+ більше не показує попап помилки після завантаження. Aura тепер виявляється автоматично. Логотип ioBroker на стартовій сторінці.",
|
|
82
|
-
"zh-cn": "运行固件 2.6.0+ 的 Shelly Wall Display 在页面加载后不再显示连接错误弹窗。Aura 现在可在 Mode 下拉框中自动发现。落地页带有 ioBroker 徽标。"
|
|
83
|
-
},
|
|
84
|
-
"1.29.1": {
|
|
85
|
-
"en": "Shelly Wall Display onboarding under firmware 2.6.0 and newer now completes — the on-device Home Assistant app needs a device-registration step that the adapter now provides.",
|
|
86
|
-
"de": "Shelly Wall Display Onboarding unter Firmware 2.6.0 und neuer läuft jetzt durch — die Home-Assistant-App auf dem Display braucht einen Geräte-Registrierungs-Schritt den der Adapter jetzt bedient.",
|
|
87
|
-
"ru": "Настройка Shelly Wall Display на прошивке 2.6.0 и новее теперь завершается — приложению Home Assistant на устройстве нужен шаг регистрации устройства, который теперь обрабатывается.",
|
|
88
|
-
"pt": "O onboarding do Shelly Wall Display com firmware 2.6.0+ conclui-se agora — a app Home Assistant no dispositivo precisa de um passo de registo que o adaptador agora fornece.",
|
|
89
|
-
"nl": "Onboarding van het Shelly Wall Display met firmware 2.6.0 en nieuwer loopt nu door — de Home-Assistant-app op het apparaat heeft een apparaatregistratiestap nodig die de adapter nu levert.",
|
|
90
|
-
"fr": "L'onboarding du Shelly Wall Display sous firmware 2.6.0+ se termine — l'app Home Assistant sur l'appareil a besoin d'un enregistrement que l'adaptateur fournit désormais.",
|
|
91
|
-
"it": "L'onboarding di Shelly Wall Display con firmware 2.6.0 e successivo ora si completa — l'app Home Assistant sul dispositivo richiede un passo di registrazione che l'adapter ora gestisce.",
|
|
92
|
-
"es": "El onboarding del Shelly Wall Display con firmware 2.6.0 o superior ahora se completa — la aplicación Home Assistant en el dispositivo necesita un paso de registro que el adaptador ya proporciona.",
|
|
93
|
-
"pl": "Onboarding wyświetlacza Shelly Wall Display z firmware 2.6.0 i nowszym kończy się teraz — aplikacja Home Assistant na urządzeniu wymaga rejestracji urządzenia, którą adapter teraz obsługuje.",
|
|
94
|
-
"uk": "Налаштування Shelly Wall Display з прошивкою 2.6.0 і новіше тепер завершується — додаток Home Assistant на пристрої потребує крок реєстрації, який тепер обробляється.",
|
|
95
|
-
"zh-cn": "运行固件 2.6.0 及更新版本的 Shelly Wall Display 现在可以完成初始设置——设备上的 Home Assistant 应用需要一个设备注册步骤,适配器现已支持。"
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
"titleLang": {
|
package/package.json
CHANGED