@impact0815/node-red-contrib-home-connect 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BUILD-INFO.json +23 -0
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/NOTICE +19 -0
- package/README.de.md +326 -0
- package/README.md +325 -2
- package/SECURITY.md +47 -0
- package/docs/API-COVERAGE.md +59 -0
- package/docs/ARCHITECTURE.md +95 -0
- package/docs/DASHBOARD.de.md +121 -0
- package/docs/DASHBOARD.md +121 -0
- package/docs/FIRST-PUSH.md +42 -0
- package/docs/LOCAL-LAN.de.md +150 -0
- package/docs/LOCAL-LAN.md +150 -0
- package/docs/MESSAGE-API.md +111 -0
- package/docs/RELEASE.md +78 -0
- package/docs/TROUBLESHOOTING.de.md +230 -0
- package/docs/TROUBLESHOOTING.md +230 -0
- package/docs/images/README.md +10 -0
- package/docs/images/dashboard-details-en.png +0 -0
- package/docs/images/dashboard-overview-en.png +0 -0
- package/docs/official-api-contract.json +418 -0
- package/examples/01-first-steps-device-list.json +148 -0
- package/examples/02-events.json +165 -0
- package/examples/03-safe-read-write.json +155 -0
- package/examples/04-local-read-only.json +214 -0
- package/examples/dashboard-dishwasher-washer-oven.json +628 -0
- package/examples/dashboard-screenshot-simulated.json +405 -0
- package/examples/demo-home-connect-flowfuse-1.34.1-scriptless.json +390 -0
- package/lib/client.js +600 -0
- package/lib/constants.js +66 -0
- package/lib/dashboard-i18n.js +255 -0
- package/lib/device-label.js +39 -0
- package/lib/errors.js +51 -0
- package/lib/health.js +525 -0
- package/lib/local-client.js +1014 -0
- package/lib/local-crypto.js +122 -0
- package/lib/local-diagnostics.js +115 -0
- package/lib/local-profile.js +706 -0
- package/lib/local-trace.js +103 -0
- package/lib/oauth.js +23 -0
- package/lib/rate-limiter.js +186 -0
- package/lib/simulator.js +290 -0
- package/lib/sse.js +58 -0
- package/lib/validation.js +194 -0
- package/lib/websocket-transport.js +374 -0
- package/nodes/home-connect.html +795 -0
- package/nodes/home-connect.js +699 -0
- package/nodes/locales/de/home-connect.html +19 -0
- package/nodes/locales/de/home-connect.json +10 -0
- package/nodes/locales/en-US/home-connect.json +10 -0
- package/package.json +71 -4
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
const SECRET_KEY = /(?:psk|initialization.?vector|iv64|^iv$|secret|token|password|credential|authorization|cookie|nonce|ship.?ski|private.?key|client.?id)/i;
|
|
8
|
+
const IDENTIFIER_KEY = /^(?:deviceID|serialNumber|mac|euiAddress|ssid|ipAddress|ipv4|ipv6)$/i;
|
|
9
|
+
const MAX_TRACE_ENTRIES = 500;
|
|
10
|
+
|
|
11
|
+
function sanitizeValue(value, key = '', seen = new WeakSet()) {
|
|
12
|
+
if (SECRET_KEY.test(String(key))) return '[REDACTED]';
|
|
13
|
+
if (IDENTIFIER_KEY.test(String(key))) return '[REDACTED_IDENTIFIER]';
|
|
14
|
+
if (value === null || value === undefined) return value;
|
|
15
|
+
if (Buffer.isBuffer(value)) return `[BUFFER ${value.length} bytes]`;
|
|
16
|
+
if (typeof value !== 'object') return value;
|
|
17
|
+
if (seen.has(value)) return '[CIRCULAR]';
|
|
18
|
+
seen.add(value);
|
|
19
|
+
if (Array.isArray(value)) return value.slice(0, 200).map(item => sanitizeValue(item, '', seen));
|
|
20
|
+
const result = {};
|
|
21
|
+
for (const [name, item] of Object.entries(value).slice(0, 200)) result[name] = sanitizeValue(item, name, seen);
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sanitizeProtocolMessage(message) {
|
|
26
|
+
const safe = sanitizeValue(message);
|
|
27
|
+
if (String(message?.resource || '') === '/ci/authentication' && Object.hasOwn(safe || {}, 'data')) {
|
|
28
|
+
safe.data = '[REDACTED_AUTHENTICATION_DATA]';
|
|
29
|
+
}
|
|
30
|
+
return safe;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveTraceFile(configuredPath) {
|
|
34
|
+
const value = String(configuredPath || '').trim();
|
|
35
|
+
if (!value) return '';
|
|
36
|
+
if (!path.isAbsolute(value)) return '';
|
|
37
|
+
try {
|
|
38
|
+
const stat = fs.existsSync(value) ? fs.statSync(value) : null;
|
|
39
|
+
return stat?.isDirectory() ? path.join(value, 'home-connect-local-protocol.jsonl') : value;
|
|
40
|
+
} catch {
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class LocalProtocolTrace {
|
|
46
|
+
constructor(options = {}) {
|
|
47
|
+
this.enabled = options.enabled === true;
|
|
48
|
+
this.profile = `sha256:${crypto.createHash('sha256').update(String(options.profile || 'local-appliance')).digest('hex').slice(0, 12)}`;
|
|
49
|
+
this.maxEntries = Math.max(20, Math.min(MAX_TRACE_ENTRIES, Number(options.maxEntries || 250)));
|
|
50
|
+
this.entries = [];
|
|
51
|
+
this.file = this.enabled ? resolveTraceFile(options.path) : '';
|
|
52
|
+
this.writeError = null;
|
|
53
|
+
if (this.file) {
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
56
|
+
const handle = fs.openSync(this.file, 'a', 0o600);
|
|
57
|
+
fs.closeSync(handle);
|
|
58
|
+
fs.chmodSync(this.file, 0o600);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
this.writeError = { code: error.code || 'TRACE_OPEN_FAILED', message: error.message };
|
|
61
|
+
this.file = '';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
record(direction, message, metadata = {}) {
|
|
67
|
+
if (!this.enabled) return;
|
|
68
|
+
const entry = {
|
|
69
|
+
timestamp: new Date().toISOString(),
|
|
70
|
+
direction: String(direction),
|
|
71
|
+
profile: this.profile,
|
|
72
|
+
...sanitizeValue(metadata),
|
|
73
|
+
message: sanitizeProtocolMessage(message)
|
|
74
|
+
};
|
|
75
|
+
this.entries.push(entry);
|
|
76
|
+
if (this.entries.length > this.maxEntries) this.entries.splice(0, this.entries.length - this.maxEntries);
|
|
77
|
+
if (this.file) {
|
|
78
|
+
try { fs.appendFileSync(this.file, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', mode: 0o600 }); }
|
|
79
|
+
catch (error) { this.writeError = { code: error.code || 'TRACE_WRITE_FAILED', message: error.message }; }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
event(type, details = {}) {
|
|
84
|
+
this.record('internal', { type, details }, { kind: 'lifecycle' });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
snapshot() {
|
|
88
|
+
return {
|
|
89
|
+
enabled: this.enabled,
|
|
90
|
+
file: this.file || undefined,
|
|
91
|
+
writeError: this.writeError || undefined,
|
|
92
|
+
entries: this.entries.map(entry => structuredClone(entry))
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
LocalProtocolTrace,
|
|
99
|
+
sanitizeProtocolMessage,
|
|
100
|
+
sanitizeValue,
|
|
101
|
+
resolveTraceFile,
|
|
102
|
+
MAX_TRACE_ENTRIES
|
|
103
|
+
};
|
package/lib/oauth.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
function base64url(input) {
|
|
6
|
+
return Buffer.from(input).toString('base64url');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function createState() {
|
|
10
|
+
return base64url(crypto.randomBytes(32));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function buildAuthorizationUrl(options) {
|
|
14
|
+
const url = new URL('/security/oauth/authorize', options.baseUrl);
|
|
15
|
+
url.searchParams.set('client_id', options.clientId);
|
|
16
|
+
url.searchParams.set('redirect_uri', options.redirectUri);
|
|
17
|
+
url.searchParams.set('response_type', 'code');
|
|
18
|
+
url.searchParams.set('scope', options.scope);
|
|
19
|
+
url.searchParams.set('state', options.state);
|
|
20
|
+
return url.toString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { base64url, createState, buildAuthorizationUrl };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs/promises');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { HomeConnectError, retryAfterSeconds } = require('./errors');
|
|
7
|
+
|
|
8
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const DEFAULT_MAX_REQUESTS = 900;
|
|
10
|
+
const DEFAULT_MINIMUM_INTERVAL = 250;
|
|
11
|
+
|
|
12
|
+
const sleep = ms => new Promise(resolve => {
|
|
13
|
+
const timer = setTimeout(resolve, ms);
|
|
14
|
+
timer.unref?.();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function integerInRange(value, fallback, minimum, maximum) {
|
|
18
|
+
const number = Number(value);
|
|
19
|
+
if (!Number.isFinite(number)) return fallback;
|
|
20
|
+
return Math.min(maximum, Math.max(minimum, Math.floor(number)));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createFileRateLimitStore(directory, key) {
|
|
24
|
+
if (!directory || !key) return undefined;
|
|
25
|
+
const digest = crypto.createHash('sha256').update(String(key)).digest('hex').slice(0, 24);
|
|
26
|
+
const file = path.join(directory, `rate-limit-${digest}.json`);
|
|
27
|
+
return {
|
|
28
|
+
file,
|
|
29
|
+
async load() {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code === 'ENOENT' || error.name === 'SyntaxError') return undefined;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
async save(state) {
|
|
38
|
+
await fs.mkdir(directory, { recursive: true });
|
|
39
|
+
const temporary = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
40
|
+
await fs.writeFile(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
41
|
+
await fs.rename(temporary, file);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class SlidingWindowRateLimiter {
|
|
47
|
+
constructor(options = {}) {
|
|
48
|
+
this.windowMs = integerInRange(options.windowMs, DAY_MS, 1000, DAY_MS);
|
|
49
|
+
this.configure(options);
|
|
50
|
+
this.store = options.store;
|
|
51
|
+
this.now = options.now || Date.now;
|
|
52
|
+
this.wait = options.wait || sleep;
|
|
53
|
+
this.state = { version: 1, timestamps: [], blockedUntil: 0 };
|
|
54
|
+
this.lastRequestAt = 0;
|
|
55
|
+
this.queue = Promise.resolve();
|
|
56
|
+
this.ready = this._load();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
configure(options = {}) {
|
|
60
|
+
this.maxRequests = integerInRange(options.maxRequests, this.maxRequests || DEFAULT_MAX_REQUESTS, 1, 1000);
|
|
61
|
+
this.minimumIntervalMs = integerInRange(
|
|
62
|
+
options.minimumIntervalMs,
|
|
63
|
+
this.minimumIntervalMs || DEFAULT_MINIMUM_INTERVAL,
|
|
64
|
+
100,
|
|
65
|
+
60000
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async _load() {
|
|
70
|
+
if (!this.store?.load) return;
|
|
71
|
+
const saved = await this.store.load();
|
|
72
|
+
if (!saved || typeof saved !== 'object') return;
|
|
73
|
+
this.state.timestamps = Array.isArray(saved.timestamps)
|
|
74
|
+
? saved.timestamps.map(Number).filter(Number.isFinite).sort((a, b) => a - b)
|
|
75
|
+
: [];
|
|
76
|
+
this.state.blockedUntil = Number.isFinite(Number(saved.blockedUntil)) ? Number(saved.blockedUntil) : 0;
|
|
77
|
+
this._prune(this.now());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_prune(now) {
|
|
81
|
+
const threshold = now - this.windowMs;
|
|
82
|
+
this.state.timestamps = this.state.timestamps.filter(timestamp => timestamp > threshold);
|
|
83
|
+
if (this.state.blockedUntil <= now) this.state.blockedUntil = 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async _save() {
|
|
87
|
+
if (this.store?.save) await this.store.save(this.state);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
_limitError(now, blockedUntil, source) {
|
|
91
|
+
const retryAfter = Math.max(1, Math.ceil((blockedUntil - now) / 1000));
|
|
92
|
+
return new HomeConnectError(
|
|
93
|
+
`Home Connect request blocked by the local rate limiter. Retry in ${retryAfter} seconds.`,
|
|
94
|
+
{
|
|
95
|
+
code: 'LOCAL_RATE_LIMITED',
|
|
96
|
+
status: 429,
|
|
97
|
+
retryAfter: String(retryAfter),
|
|
98
|
+
details: {
|
|
99
|
+
source,
|
|
100
|
+
maxRequests: this.maxRequests,
|
|
101
|
+
periodSeconds: Math.ceil(this.windowMs / 1000),
|
|
102
|
+
usedRequests: this.state.timestamps.length,
|
|
103
|
+
remainingRequests: Math.max(0, this.maxRequests - this.state.timestamps.length),
|
|
104
|
+
resetAt: new Date(blockedUntil).toISOString()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async _acquire() {
|
|
111
|
+
await this.ready;
|
|
112
|
+
let now = this.now();
|
|
113
|
+
this._prune(now);
|
|
114
|
+
if (this.state.blockedUntil > now) {
|
|
115
|
+
throw this._limitError(now, this.state.blockedUntil, 'server');
|
|
116
|
+
}
|
|
117
|
+
if (this.state.timestamps.length >= this.maxRequests) {
|
|
118
|
+
this.state.blockedUntil = this.state.timestamps[0] + this.windowMs;
|
|
119
|
+
await this._save();
|
|
120
|
+
throw this._limitError(now, this.state.blockedUntil, 'local-budget');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const delay = Math.max(0, this.minimumIntervalMs - (now - this.lastRequestAt));
|
|
124
|
+
if (delay) await this.wait(delay);
|
|
125
|
+
now = this.now();
|
|
126
|
+
this._prune(now);
|
|
127
|
+
if (this.state.blockedUntil > now) {
|
|
128
|
+
throw this._limitError(now, this.state.blockedUntil, 'server');
|
|
129
|
+
}
|
|
130
|
+
if (this.state.timestamps.length >= this.maxRequests) {
|
|
131
|
+
this.state.blockedUntil = this.state.timestamps[0] + this.windowMs;
|
|
132
|
+
await this._save();
|
|
133
|
+
throw this._limitError(now, this.state.blockedUntil, 'local-budget');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.state.timestamps.push(now);
|
|
137
|
+
this.lastRequestAt = now;
|
|
138
|
+
await this._save();
|
|
139
|
+
return this.status();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
acquire() {
|
|
143
|
+
const run = this.queue.then(() => this._acquire(), () => this._acquire());
|
|
144
|
+
this.queue = run;
|
|
145
|
+
return run;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async recordServerLimit(value) {
|
|
149
|
+
await this.ready;
|
|
150
|
+
const now = this.now();
|
|
151
|
+
this._prune(now);
|
|
152
|
+
const seconds = retryAfterSeconds(value, now) || Math.ceil(this.windowMs / 1000);
|
|
153
|
+
this.state.blockedUntil = Math.max(this.state.blockedUntil, now + seconds * 1000);
|
|
154
|
+
await this._save();
|
|
155
|
+
return this.status();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async status() {
|
|
159
|
+
await this.ready;
|
|
160
|
+
const now = this.now();
|
|
161
|
+
this._prune(now);
|
|
162
|
+
const localReset = this.state.timestamps.length >= this.maxRequests
|
|
163
|
+
? this.state.timestamps[0] + this.windowMs
|
|
164
|
+
: 0;
|
|
165
|
+
const resetAt = Math.max(localReset, this.state.blockedUntil);
|
|
166
|
+
return {
|
|
167
|
+
maxRequests: this.maxRequests,
|
|
168
|
+
periodSeconds: Math.ceil(this.windowMs / 1000),
|
|
169
|
+
minimumIntervalMs: this.minimumIntervalMs,
|
|
170
|
+
usedRequests: this.state.timestamps.length,
|
|
171
|
+
remainingRequests: Math.max(0, this.maxRequests - this.state.timestamps.length),
|
|
172
|
+
blocked: resetAt > now,
|
|
173
|
+
retryAfter: resetAt > now ? String(Math.max(1, Math.ceil((resetAt - now) / 1000))) : undefined,
|
|
174
|
+
resetAt: resetAt > now ? new Date(resetAt).toISOString() : undefined
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = {
|
|
180
|
+
DAY_MS,
|
|
181
|
+
DEFAULT_MAX_REQUESTS,
|
|
182
|
+
DEFAULT_MINIMUM_INTERVAL,
|
|
183
|
+
SlidingWindowRateLimiter,
|
|
184
|
+
createFileRateLimitStore,
|
|
185
|
+
integerInRange
|
|
186
|
+
};
|
package/lib/simulator.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('node:events');
|
|
4
|
+
const { validateWrite } = require('./validation');
|
|
5
|
+
const { HomeConnectError } = require('./errors');
|
|
6
|
+
|
|
7
|
+
const clone = value => value == null ? value : structuredClone(value);
|
|
8
|
+
const item = (key, value, extra = {}) => ({ key, value, ...extra });
|
|
9
|
+
const bool = allowed => ({ type: 'Boolean', constraints: { allowedvalues: allowed } });
|
|
10
|
+
|
|
11
|
+
function makeDevices() {
|
|
12
|
+
return [
|
|
13
|
+
{
|
|
14
|
+
appliance: { haId: 'dishwasher-001', name: 'Geschirrspülmaschine', type: 'Dishwasher', brand: 'Bosch', vib: 'SIM-DW-001', connected: true },
|
|
15
|
+
status: { items: [
|
|
16
|
+
item('BSH.Common.Status.RemoteControlActive', true),
|
|
17
|
+
item('BSH.Common.Status.RemoteControlStartAllowed', true),
|
|
18
|
+
item('BSH.Common.Status.LocalControlActive', false),
|
|
19
|
+
item('BSH.Common.Status.OperationState', 'BSH.Common.EnumType.OperationState.Ready'),
|
|
20
|
+
item('BSH.Common.Status.DoorState', 'BSH.Common.EnumType.DoorState.Closed')
|
|
21
|
+
] },
|
|
22
|
+
settings: { items: [item('BSH.Common.Setting.PowerState', 'BSH.Common.EnumType.PowerState.On', { constraints: { allowedvalues: ['BSH.Common.EnumType.PowerState.On', 'BSH.Common.EnumType.PowerState.Standby'] } })] },
|
|
23
|
+
programs: { available: [
|
|
24
|
+
{ key: 'Dishcare.Dishwasher.Program.Eco50', name: 'Eco 50 °C', options: [
|
|
25
|
+
item('BSH.Common.Option.StartInRelative', 0, { unit: 'seconds', constraints: { min: 0, max: 86400, stepsize: 60, default: 0 } }),
|
|
26
|
+
item('Dishcare.Dishwasher.Option.VarioSpeedPlus', false, bool([true, false])),
|
|
27
|
+
item('Dishcare.Dishwasher.Option.IntensivZone', false, bool([true, false]))
|
|
28
|
+
] },
|
|
29
|
+
{ key: 'Dishcare.Dishwasher.Program.Auto2', name: 'Auto 45–65 °C', options: [item('Dishcare.Dishwasher.Option.VarioSpeedPlus', false, bool([true, false]))] },
|
|
30
|
+
{ key: 'Dishcare.Dishwasher.Program.Intensiv70', name: 'Intensiv 70 °C', options: [item('Dishcare.Dishwasher.Option.IntensivZone', true, bool([true, false]))] }
|
|
31
|
+
], active: null, selected: null },
|
|
32
|
+
commands: { items: [
|
|
33
|
+
item('BSH.Common.Command.PauseProgram', true, bool([true])),
|
|
34
|
+
item('BSH.Common.Command.ResumeProgram', true, bool([true]))
|
|
35
|
+
] }
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
appliance: { haId: 'washer-001', name: 'Waschmaschine', type: 'Washer', brand: 'Siemens', vib: 'SIM-WM-001', connected: true },
|
|
39
|
+
status: { items: [
|
|
40
|
+
item('BSH.Common.Status.RemoteControlActive', true), item('BSH.Common.Status.RemoteControlStartAllowed', true),
|
|
41
|
+
item('BSH.Common.Status.LocalControlActive', false), item('BSH.Common.Status.OperationState', 'BSH.Common.EnumType.OperationState.Ready'),
|
|
42
|
+
item('BSH.Common.Status.DoorState', 'BSH.Common.EnumType.DoorState.Closed')
|
|
43
|
+
] },
|
|
44
|
+
settings: { items: [item('BSH.Common.Setting.PowerState', 'BSH.Common.EnumType.PowerState.On', { constraints: { allowedvalues: ['BSH.Common.EnumType.PowerState.On', 'BSH.Common.EnumType.PowerState.Standby'] } })] },
|
|
45
|
+
programs: { available: [
|
|
46
|
+
{ key: 'LaundryCare.Washer.Program.Cotton', name: 'Baumwolle', options: [
|
|
47
|
+
item('LaundryCare.Washer.Option.Temperature', 'LaundryCare.Washer.EnumType.Temperature.GC40', { constraints: { allowedvalues: ['LaundryCare.Washer.EnumType.Temperature.GC30', 'LaundryCare.Washer.EnumType.Temperature.GC40', 'LaundryCare.Washer.EnumType.Temperature.GC60'] } }),
|
|
48
|
+
item('LaundryCare.Washer.Option.SpinSpeed', 'LaundryCare.Washer.EnumType.SpinSpeed.RPM1400', { constraints: { allowedvalues: ['LaundryCare.Washer.EnumType.SpinSpeed.RPM800', 'LaundryCare.Washer.EnumType.SpinSpeed.RPM1200', 'LaundryCare.Washer.EnumType.SpinSpeed.RPM1400'] } })
|
|
49
|
+
] },
|
|
50
|
+
{ key: 'LaundryCare.Washer.Program.EasyCare', name: 'Pflegeleicht', options: [] },
|
|
51
|
+
{ key: 'LaundryCare.Washer.Program.Mix', name: 'Mix', options: [] }
|
|
52
|
+
], active: null, selected: null },
|
|
53
|
+
commands: { items: [item('BSH.Common.Command.PauseProgram', true, bool([true])), item('BSH.Common.Command.ResumeProgram', true, bool([true]))] }
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
appliance: { haId: 'oven-001', name: 'Backofen', type: 'Oven', brand: 'Neff', vib: 'SIM-OV-001', connected: true },
|
|
57
|
+
status: { items: [
|
|
58
|
+
item('BSH.Common.Status.RemoteControlActive', true), item('BSH.Common.Status.RemoteControlStartAllowed', true),
|
|
59
|
+
item('BSH.Common.Status.LocalControlActive', false), item('BSH.Common.Status.OperationState', 'BSH.Common.EnumType.OperationState.Ready'),
|
|
60
|
+
item('BSH.Common.Status.DoorState', 'BSH.Common.EnumType.DoorState.Closed'),
|
|
61
|
+
item('Cooking.Oven.Status.CurrentCavityTemperature', 22, { unit: '°C' })
|
|
62
|
+
] },
|
|
63
|
+
settings: { items: [item('BSH.Common.Setting.PowerState', 'BSH.Common.EnumType.PowerState.On', { constraints: { allowedvalues: ['BSH.Common.EnumType.PowerState.On', 'BSH.Common.EnumType.PowerState.Standby'] } })] },
|
|
64
|
+
programs: { available: [
|
|
65
|
+
{ key: 'Cooking.Oven.Program.HeatingMode.HotAir', name: 'Heißluft', options: [
|
|
66
|
+
item('Cooking.Oven.Option.SetpointTemperature', 180, { unit: '°C', constraints: { min: 30, max: 250, stepsize: 5, default: 180 } }),
|
|
67
|
+
item('BSH.Common.Option.Duration', 1800, { unit: 'seconds', constraints: { min: 60, max: 10800, stepsize: 60, default: 1800 } }),
|
|
68
|
+
item('Cooking.Oven.Option.FastPreHeat', false, bool([true, false]))
|
|
69
|
+
] },
|
|
70
|
+
{ key: 'Cooking.Oven.Program.HeatingMode.TopBottomHeating', name: 'Ober-/Unterhitze', options: [item('Cooking.Oven.Option.SetpointTemperature', 180, { unit: '°C', constraints: { min: 30, max: 275, stepsize: 5, default: 180 } })] },
|
|
71
|
+
{ key: 'Cooking.Oven.Program.HeatingMode.PreHeating', name: 'Vorheizen', options: [item('Cooking.Oven.Option.SetpointTemperature', 200, { unit: '°C', constraints: { min: 50, max: 250, stepsize: 5, default: 200 } })] }
|
|
72
|
+
], active: null, selected: null },
|
|
73
|
+
commands: { items: [item('BSH.Common.Command.PauseProgram', true, bool([true])), item('BSH.Common.Command.ResumeProgram', true, bool([true]))] }
|
|
74
|
+
}
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class BuiltinSimulator extends EventEmitter {
|
|
79
|
+
constructor(options = {}) {
|
|
80
|
+
super();
|
|
81
|
+
this.language = options.language || 'de-DE';
|
|
82
|
+
this.devices = new Map(makeDevices().map(device => [device.appliance.haId, device]));
|
|
83
|
+
this.timers = new Map();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get environment() { return 'builtin-simulator'; }
|
|
87
|
+
get requiresOAuth() { return false; }
|
|
88
|
+
|
|
89
|
+
_device(haId) {
|
|
90
|
+
const device = this.devices.get(haId);
|
|
91
|
+
if (!device) throw new HomeConnectError(`Unknown simulated appliance: ${haId}`, { code: 'APPLIANCE_NOT_FOUND', status: 404 });
|
|
92
|
+
return device;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_emit(haId, event, items) {
|
|
96
|
+
const payload = { haId, event, data: { items: clone(items) }, simulated: true, timestamp: new Date().toISOString() };
|
|
97
|
+
this.emit(`event:${haId}`, payload);
|
|
98
|
+
this.emit('event:*', payload);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_setStatus(device, key, value, extra = {}) {
|
|
102
|
+
const existing = device.status.items.find(entry => entry.key === key);
|
|
103
|
+
if (existing) Object.assign(existing, { value, ...extra });
|
|
104
|
+
else device.status.items.push(item(key, value, extra));
|
|
105
|
+
this._emit(device.appliance.haId, 'STATUS', [item(key, value, extra)]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async listHomeAppliances() { return clone(Array.from(this.devices.values(), device => device.appliance)); }
|
|
109
|
+
async getHomeAppliance(haId) { return clone(this._device(haId).appliance); }
|
|
110
|
+
async getStatus(haId) { return clone(this._device(haId).status); }
|
|
111
|
+
async getStatusValue(haId, key) {
|
|
112
|
+
const value = this._device(haId).status.items.find(entry => entry.key === key);
|
|
113
|
+
if (!value) throw new HomeConnectError(`Status not found: ${key}`, { code: 'STATUS_NOT_AVAILABLE', status: 404 });
|
|
114
|
+
return clone(value);
|
|
115
|
+
}
|
|
116
|
+
async getSettings(haId) { return clone(this._device(haId).settings); }
|
|
117
|
+
async getSetting(haId, key) {
|
|
118
|
+
const value = this._device(haId).settings.items.find(entry => entry.key === key);
|
|
119
|
+
if (!value) throw new HomeConnectError(`Setting not found: ${key}`, { code: 'SETTING_NOT_AVAILABLE', status: 404 });
|
|
120
|
+
return clone(value);
|
|
121
|
+
}
|
|
122
|
+
async getAllPrograms(haId) {
|
|
123
|
+
const programs = this._device(haId).programs;
|
|
124
|
+
return clone({ programs: programs.available, active: programs.active, selected: programs.selected });
|
|
125
|
+
}
|
|
126
|
+
async getAvailablePrograms(haId) { return { programs: clone(this._device(haId).programs.available.map(({ key, name }) => ({ key, name }))) }; }
|
|
127
|
+
async getAvailableProgram(haId, key) {
|
|
128
|
+
const program = this._device(haId).programs.available.find(entry => entry.key === key);
|
|
129
|
+
if (!program) throw new HomeConnectError(`Program not found: ${key}`, { code: 'PROGRAM_NOT_AVAILABLE', status: 404 });
|
|
130
|
+
return clone(program);
|
|
131
|
+
}
|
|
132
|
+
async getActiveProgram(haId) { return clone(this._device(haId).programs.active); }
|
|
133
|
+
async getSelectedProgram(haId) { return clone(this._device(haId).programs.selected); }
|
|
134
|
+
async getProgramOptions(haId, kind) {
|
|
135
|
+
const program = this._device(haId).programs[kind];
|
|
136
|
+
if (!program) throw new HomeConnectError(`No ${kind} program is available.`, { code: kind === 'active' ? 'NO_ACTIVE_PROGRAM' : 'NO_SELECTED_PROGRAM', status: 409 });
|
|
137
|
+
return { options: clone(program.options || []) };
|
|
138
|
+
}
|
|
139
|
+
async getProgramOption(haId, kind, key) {
|
|
140
|
+
const options = await this.getProgramOptions(haId, kind);
|
|
141
|
+
const option = options.options.find(entry => entry.key === key);
|
|
142
|
+
if (!option) throw new HomeConnectError(`Option not found: ${key}`, { code: 'OPTION_NOT_AVAILABLE', status: 404 });
|
|
143
|
+
return clone(option);
|
|
144
|
+
}
|
|
145
|
+
async getCommands(haId) { return clone(this._device(haId).commands); }
|
|
146
|
+
async getImages(haId) { this._device(haId); return { images: [] }; }
|
|
147
|
+
async getImage(haId, imageKey) { this._device(haId); throw new HomeConnectError(`Image not found: ${imageKey}`, { code: 'IMAGE_NOT_AVAILABLE', status: 404 }); }
|
|
148
|
+
async getMaps(haId) { this._device(haId); return []; }
|
|
149
|
+
async getMap(haId, mapId) { this._device(haId); throw new HomeConnectError(`Map not found: ${mapId}`, { code: 'MAP_NOT_AVAILABLE', status: 404 }); }
|
|
150
|
+
async getMapResource(haId, mapId) { return this.getMap(haId, mapId); }
|
|
151
|
+
async getMapGlobalResource(haId, resource) { this._device(haId); throw new HomeConnectError(`Map resource not found: ${resource}`, { code: 'MAP_RESOURCE_NOT_AVAILABLE', status: 404 }); }
|
|
152
|
+
|
|
153
|
+
async discoverAppliance(haId) {
|
|
154
|
+
const device = this._device(haId);
|
|
155
|
+
return clone({ ...device, discoveredAt: new Date().toISOString(), simulated: true });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async discoverAll() {
|
|
159
|
+
return Promise.all(Array.from(this.devices.keys(), id => this.discoverAppliance(id)));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async read(haId, operation = 'snapshot', key, subKey) {
|
|
163
|
+
if (operation === 'appliances') return this.listHomeAppliances();
|
|
164
|
+
if (operation === 'snapshot' || operation === 'capabilities') return this.discoverAppliance(haId);
|
|
165
|
+
if (operation === 'appliance') return this.getHomeAppliance(haId);
|
|
166
|
+
if (operation === 'status') return this.getStatus(haId);
|
|
167
|
+
if (operation === 'status-item') return this.getStatusValue(haId, key);
|
|
168
|
+
if (operation === 'settings') return this.getSettings(haId);
|
|
169
|
+
if (operation === 'setting') return this.getSetting(haId, key);
|
|
170
|
+
if (operation === 'all-programs') return this.getAllPrograms(haId);
|
|
171
|
+
if (operation === 'programs') return this.getAvailablePrograms(haId);
|
|
172
|
+
if (operation === 'program') return this.getAvailableProgram(haId, key);
|
|
173
|
+
if (operation === 'active-program') return this.getActiveProgram(haId);
|
|
174
|
+
if (operation === 'active-options') return this.getProgramOptions(haId, 'active');
|
|
175
|
+
if (operation === 'active-option') return this.getProgramOption(haId, 'active', key);
|
|
176
|
+
if (operation === 'selected-program') return this.getSelectedProgram(haId);
|
|
177
|
+
if (operation === 'selected-options') return this.getProgramOptions(haId, 'selected');
|
|
178
|
+
if (operation === 'selected-option') return this.getProgramOption(haId, 'selected', key);
|
|
179
|
+
if (operation === 'commands') return this.getCommands(haId);
|
|
180
|
+
if (operation === 'images') return this.getImages(haId);
|
|
181
|
+
if (operation === 'image') return this.getImage(haId, key);
|
|
182
|
+
if (operation === 'maps') return this.getMaps(haId);
|
|
183
|
+
if (operation === 'map') return this.getMap(haId, key);
|
|
184
|
+
if (operation === 'map-occupancy-grid') return this.getMapResource(haId, key, 'occupancyGrid');
|
|
185
|
+
if (operation === 'map-layout') return this.getMapResource(haId, key, 'layout');
|
|
186
|
+
if (operation === 'map-cleaning-status') return this.getMapResource(haId, key, 'cleaningStatus');
|
|
187
|
+
if (operation === 'map-detected-objects') return this.getMapResource(haId, key, 'cleaningStatus/objects');
|
|
188
|
+
if (operation === 'map-detected-object') return this.getMapResource(haId, key, 'cleaningStatus/objects', subKey);
|
|
189
|
+
if (operation === 'map-rooms') return this.getMapResource(haId, key, 'rooms');
|
|
190
|
+
if (operation === 'map-room') return this.getMapResource(haId, key, 'rooms', subKey);
|
|
191
|
+
if (operation === 'map-zones') return this.getMapResource(haId, key, 'zones');
|
|
192
|
+
if (operation === 'map-zone') return this.getMapResource(haId, key, 'zones', subKey);
|
|
193
|
+
if (operation === 'map-robot-position') return this.getMapGlobalResource(haId, 'robot/position');
|
|
194
|
+
if (operation === 'map-config-restrictions') return this.getMapGlobalResource(haId, 'config/restrictions');
|
|
195
|
+
if (operation === 'map-cleaning-summaries') return this.getMapGlobalResource(haId, 'cleaningSummary');
|
|
196
|
+
if (operation === 'map-cleaning-summary') return this.getMapGlobalResource(haId, 'cleaningSummary', key);
|
|
197
|
+
throw new HomeConnectError(`Unsupported read operation: ${operation}`, { code: 'READ_ACTION_NOT_ALLOWED' });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async write(request, capability) {
|
|
201
|
+
const device = this._device(request.haId);
|
|
202
|
+
const cap = capability || await this.discoverAppliance(request.haId);
|
|
203
|
+
validateWrite(cap, request);
|
|
204
|
+
if (request.action === 'set-setting') {
|
|
205
|
+
const target = device.settings.items.find(entry => entry.key === request.key);
|
|
206
|
+
target.value = request.value;
|
|
207
|
+
this._emit(request.haId, 'NOTIFY', [clone(target)]);
|
|
208
|
+
} else if (request.action === 'select-program') {
|
|
209
|
+
const program = device.programs.available.find(entry => entry.key === request.program);
|
|
210
|
+
const supplied = new Map((request.options || []).map(option => [option.key, option]));
|
|
211
|
+
const options = (program.options || []).map(option => ({
|
|
212
|
+
...clone(option),
|
|
213
|
+
value: supplied.get(option.key)?.value ?? option.constraints?.default ?? option.value
|
|
214
|
+
}));
|
|
215
|
+
device.programs.selected = { key: program.key, name: program.name, options };
|
|
216
|
+
this._emit(request.haId, 'NOTIFY', [item('BSH.Common.Root.SelectedProgram', program.key)]);
|
|
217
|
+
} else if (request.action === 'start-program') {
|
|
218
|
+
const program = device.programs.available.find(entry => entry.key === request.program);
|
|
219
|
+
const defaults = (program.options || []).map(option => ({
|
|
220
|
+
...clone(option), value: option.constraints?.default ?? option.value
|
|
221
|
+
}));
|
|
222
|
+
const supplied = new Map((request.options || []).map(option => [option.key, option]));
|
|
223
|
+
const options = defaults.map(option => supplied.has(option.key)
|
|
224
|
+
? { ...option, value: supplied.get(option.key).value }
|
|
225
|
+
: option);
|
|
226
|
+
for (const option of request.options || []) if (!options.some(entry => entry.key === option.key)) options.push(option);
|
|
227
|
+
device.programs.active = { key: program.key, name: program.name, options };
|
|
228
|
+
device.programs.selected = clone(device.programs.active);
|
|
229
|
+
this._setStatus(device, 'BSH.Common.Status.OperationState', 'BSH.Common.EnumType.OperationState.Run');
|
|
230
|
+
const duration = options.find(option => option.key === 'BSH.Common.Option.Duration')?.value || 5400;
|
|
231
|
+
this._startTimer(device, duration);
|
|
232
|
+
} else if (request.action === 'stop-program') {
|
|
233
|
+
this._stopTimer(request.haId);
|
|
234
|
+
device.programs.active = null;
|
|
235
|
+
this._setStatus(device, 'BSH.Common.Status.OperationState', 'BSH.Common.EnumType.OperationState.Ready');
|
|
236
|
+
} else if (request.action === 'command') {
|
|
237
|
+
this._emit(request.haId, 'EVENT', [item(request.key, request.value)]);
|
|
238
|
+
} else if (request.action === 'set-active-option' || request.action === 'set-selected-option') {
|
|
239
|
+
const kind = request.action === 'set-active-option' ? 'active' : 'selected';
|
|
240
|
+
const program = device.programs[kind];
|
|
241
|
+
const target = program.options.find(entry => entry.key === request.key);
|
|
242
|
+
target.value = request.value;
|
|
243
|
+
this._emit(request.haId, 'NOTIFY', [clone(target)]);
|
|
244
|
+
}
|
|
245
|
+
return { accepted: true, simulated: true, request: clone(request), at: new Date().toISOString() };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
_startTimer(device, seconds) {
|
|
249
|
+
this._stopTimer(device.appliance.haId);
|
|
250
|
+
let remaining = seconds;
|
|
251
|
+
this._setStatus(device, 'BSH.Common.Option.RemainingProgramTime', remaining, { unit: 'seconds' });
|
|
252
|
+
const timer = setInterval(() => {
|
|
253
|
+
remaining = Math.max(0, remaining - 60);
|
|
254
|
+
this._setStatus(device, 'BSH.Common.Option.RemainingProgramTime', remaining, { unit: 'seconds' });
|
|
255
|
+
if (device.appliance.type === 'Oven') {
|
|
256
|
+
const target = device.programs.active?.options?.find(option => option.key === 'Cooking.Oven.Option.SetpointTemperature')?.value || 180;
|
|
257
|
+
const current = device.status.items.find(entry => entry.key === 'Cooking.Oven.Status.CurrentCavityTemperature');
|
|
258
|
+
const next = Math.min(target, (current?.value || 22) + 10);
|
|
259
|
+
this._setStatus(device, 'Cooking.Oven.Status.CurrentCavityTemperature', next, { unit: '°C' });
|
|
260
|
+
if (next === target) this._emit(device.appliance.haId, 'EVENT', [item('Cooking.Oven.Event.PreheatFinished', true)]);
|
|
261
|
+
}
|
|
262
|
+
if (remaining === 0) {
|
|
263
|
+
this._stopTimer(device.appliance.haId);
|
|
264
|
+
device.programs.active = null;
|
|
265
|
+
this._setStatus(device, 'BSH.Common.Status.OperationState', 'BSH.Common.EnumType.OperationState.Finished');
|
|
266
|
+
}
|
|
267
|
+
}, 5000);
|
|
268
|
+
timer.unref?.();
|
|
269
|
+
this.timers.set(device.appliance.haId, timer);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_stopTimer(haId) {
|
|
273
|
+
if (this.timers.has(haId)) clearInterval(this.timers.get(haId));
|
|
274
|
+
this.timers.delete(haId);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
openEvents(haId, onEvent) {
|
|
278
|
+
const channel = `event:${haId || '*'}`;
|
|
279
|
+
this.on(channel, onEvent);
|
|
280
|
+
queueMicrotask(() => onEvent({ haId: haId || '*', event: 'CONNECTED', data: { items: [] }, simulated: true, timestamp: new Date().toISOString() }));
|
|
281
|
+
return () => this.off(channel, onEvent);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
close() {
|
|
285
|
+
for (const id of this.timers.keys()) this._stopTimer(id);
|
|
286
|
+
this.removeAllListeners();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = { BuiltinSimulator, makeDevices };
|
package/lib/sse.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function parseSSEBlock(block) {
|
|
4
|
+
const message = { event: 'message', data: '', id: undefined, retry: undefined, comment: undefined };
|
|
5
|
+
const comments = [];
|
|
6
|
+
for (const line of block.split(/\r?\n/)) {
|
|
7
|
+
if (!line) continue;
|
|
8
|
+
if (line.startsWith(':')) { comments.push(line.slice(1).trim()); continue; }
|
|
9
|
+
const separator = line.indexOf(':');
|
|
10
|
+
const field = separator === -1 ? line : line.slice(0, separator);
|
|
11
|
+
let value = separator === -1 ? '' : line.slice(separator + 1);
|
|
12
|
+
if (value.startsWith(' ')) value = value.slice(1);
|
|
13
|
+
if (field === 'event') message.event = value;
|
|
14
|
+
if (field === 'data') message.data += `${value}\n`;
|
|
15
|
+
if (field === 'id') message.id = value;
|
|
16
|
+
if (field === 'retry' && /^\d+$/.test(value)) message.retry = Number(value);
|
|
17
|
+
}
|
|
18
|
+
if (message.data.endsWith('\n')) message.data = message.data.slice(0, -1);
|
|
19
|
+
if (comments.length) message.comment = comments.join('\n');
|
|
20
|
+
if (comments.length && message.event === 'message' && !message.data) message.event = 'KEEP-ALIVE';
|
|
21
|
+
return message;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function* streamChunks(body) {
|
|
25
|
+
if (body && typeof body.getReader === 'function') {
|
|
26
|
+
const reader = body.getReader();
|
|
27
|
+
try {
|
|
28
|
+
while (true) {
|
|
29
|
+
const { done, value } = await reader.read();
|
|
30
|
+
if (done) break;
|
|
31
|
+
yield value;
|
|
32
|
+
}
|
|
33
|
+
} finally {
|
|
34
|
+
reader.releaseLock();
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
for await (const chunk of body) yield chunk;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function* parseSSE(body) {
|
|
42
|
+
const decoder = new TextDecoder();
|
|
43
|
+
let buffer = '';
|
|
44
|
+
for await (const chunk of streamChunks(body)) {
|
|
45
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
46
|
+
let boundary;
|
|
47
|
+
while ((boundary = buffer.search(/\r?\n\r?\n/)) !== -1) {
|
|
48
|
+
const block = buffer.slice(0, boundary);
|
|
49
|
+
const match = buffer.slice(boundary).match(/^\r?\n\r?\n/);
|
|
50
|
+
buffer = buffer.slice(boundary + match[0].length);
|
|
51
|
+
if (block.trim()) yield parseSSEBlock(block);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
buffer += decoder.decode();
|
|
55
|
+
if (buffer.trim()) yield parseSSEBlock(buffer);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { parseSSE, parseSSEBlock };
|