homebridge-powerview-universal 4.3.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/CHANGELOG.md +338 -0
- package/LICENSE +18 -0
- package/README.md +129 -0
- package/RELEASE_NOTES.md +257 -0
- package/config.schema.json +131 -0
- package/dist/errors.d.ts +35 -0
- package/dist/errors.js +57 -0
- package/dist/errors.js.map +1 -0
- package/dist/hubCapabilities.d.ts +21 -0
- package/dist/hubCapabilities.js +78 -0
- package/dist/hubCapabilities.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +113 -0
- package/dist/platform.js +797 -0
- package/dist/platform.js.map +1 -0
- package/dist/platformAccessory.d.ts +52 -0
- package/dist/platformAccessory.js +410 -0
- package/dist/platformAccessory.js.map +1 -0
- package/dist/powerviewHub.d.ts +133 -0
- package/dist/powerviewHub.js +459 -0
- package/dist/powerviewHub.js.map +1 -0
- package/dist/settings.d.ts +102 -0
- package/dist/settings.js +75 -0
- package/dist/settings.js.map +1 -0
- package/dist/shadeUtils.d.ts +138 -0
- package/dist/shadeUtils.js +292 -0
- package/dist/shadeUtils.js.map +1 -0
- package/package.json +58 -0
package/dist/platform.js
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import { HubError, HubErrorCode, formatError, isHubError, logError, registerProcessErrorHandlers, } from './errors.js';
|
|
2
|
+
import { probeHubCapabilities } from './hubCapabilities.js';
|
|
3
|
+
import { HubPosition, PowerViewHub, } from './powerviewHub.js';
|
|
4
|
+
import { PowerViewPlatformAccessory, } from './platformAccessory.js';
|
|
5
|
+
import { POSITION_KIND_ERROR, decodeBase64Name, isWithinQuietHours, lookupPosition, msUntilNextDailyRun, positionMapsEqual, resolveBatteryPollSettings, resolveQuietHours, resolveShadeCapability, shadeKindForCapability, sanitizePositionMap, } from './shadeUtils.js';
|
|
6
|
+
import { PLUGIN_NAME, PLATFORM_NAME, SHADE_POLL_INTERVAL_MS, SHADE_REMOVAL_THRESHOLD, BACKGROUND_REFRESH_INTERVAL_MS, SHADE_FULL_TRAVEL_MS, SHADE_MIN_TRAVEL_MS, FULLY_SUPPORTED_KINDS, ShadeKind, SUBTYPE, } from './settings.js';
|
|
7
|
+
function shadeIdArray(value) {
|
|
8
|
+
return Array.isArray(value) ? value.filter((id) => typeof id === 'number') : [];
|
|
9
|
+
}
|
|
10
|
+
export class PowerViewPlatform {
|
|
11
|
+
log;
|
|
12
|
+
api;
|
|
13
|
+
Service;
|
|
14
|
+
Characteristic;
|
|
15
|
+
accessories = new Map();
|
|
16
|
+
handlers = new Map();
|
|
17
|
+
hubVersion;
|
|
18
|
+
hubCapabilities;
|
|
19
|
+
hubName;
|
|
20
|
+
hub;
|
|
21
|
+
batteryPoll;
|
|
22
|
+
quietHours;
|
|
23
|
+
syncPositionsOnStart;
|
|
24
|
+
exposeScenes;
|
|
25
|
+
refreshShades;
|
|
26
|
+
pollShadesForUpdate;
|
|
27
|
+
strictErrors;
|
|
28
|
+
forceRollerShades;
|
|
29
|
+
forceTopBottomShades;
|
|
30
|
+
forceHorizontalShades;
|
|
31
|
+
forceVerticalShades;
|
|
32
|
+
lastPositions = new Map();
|
|
33
|
+
/** Consecutive shade lists a known shade has been missing from. */
|
|
34
|
+
missingFromList = new Map();
|
|
35
|
+
positionsOmittedLogged = new Set();
|
|
36
|
+
unsupportedCapabilityLogged = new Set();
|
|
37
|
+
/** Shades with a background refresh already in flight, so reads don't pile up. */
|
|
38
|
+
pendingRefresh = new Set();
|
|
39
|
+
/** When each shade last had a background read, so bursts don't stack up. */
|
|
40
|
+
lastBackgroundRefresh = new Map();
|
|
41
|
+
/** Travel timers per shade/position, so a re-target cancels the old arrival. */
|
|
42
|
+
travelTimers = new Map();
|
|
43
|
+
sceneAccessories = new Map();
|
|
44
|
+
batteryRefreshDisabled = new Set();
|
|
45
|
+
posKindErrorLogged = new Set();
|
|
46
|
+
batteryPollTimer;
|
|
47
|
+
shadePollTimer;
|
|
48
|
+
shuttingDown = false;
|
|
49
|
+
constructor(log, config, api) {
|
|
50
|
+
this.log = log;
|
|
51
|
+
this.api = api;
|
|
52
|
+
this.Service = api.hap.Service;
|
|
53
|
+
this.Characteristic = api.hap.Characteristic;
|
|
54
|
+
this.log.info('PowerView init');
|
|
55
|
+
registerProcessErrorHandlers(this.log, PLUGIN_NAME);
|
|
56
|
+
const host = typeof config.host === 'string' && config.host.length > 0
|
|
57
|
+
? config.host
|
|
58
|
+
: 'powerview-hub.local';
|
|
59
|
+
const requestIntervalMs = typeof config.requestIntervalMs === 'number'
|
|
60
|
+
&& Number.isFinite(config.requestIntervalMs)
|
|
61
|
+
&& config.requestIntervalMs >= 0
|
|
62
|
+
? config.requestIntervalMs
|
|
63
|
+
: undefined;
|
|
64
|
+
this.hub = new PowerViewHub(log, host, requestIntervalMs);
|
|
65
|
+
this.batteryPoll = resolveBatteryPollSettings(config);
|
|
66
|
+
this.quietHours = resolveQuietHours(config);
|
|
67
|
+
this.syncPositionsOnStart = config.syncPositionsOnStart !== false;
|
|
68
|
+
this.exposeScenes = config.exposeScenes !== false;
|
|
69
|
+
this.refreshShades = config.refreshShades === true;
|
|
70
|
+
this.pollShadesForUpdate = config.pollShadesForUpdate === true;
|
|
71
|
+
this.strictErrors = config.strictErrors === true;
|
|
72
|
+
this.forceRollerShades = shadeIdArray(config.forceRollerShades);
|
|
73
|
+
this.forceTopBottomShades = shadeIdArray(config.forceTopBottomShades);
|
|
74
|
+
this.forceHorizontalShades = shadeIdArray(config.forceHorizontalShades);
|
|
75
|
+
this.forceVerticalShades = shadeIdArray(config.forceVerticalShades);
|
|
76
|
+
this.api.on('didFinishLaunching', () => {
|
|
77
|
+
void this.onLaunch();
|
|
78
|
+
});
|
|
79
|
+
this.api.on('shutdown', () => {
|
|
80
|
+
// The flag matters as much as the timers: updateShades() may be in
|
|
81
|
+
// flight, and its finally block would otherwise schedule the next poll
|
|
82
|
+
// after shutdown has already cleared the handle.
|
|
83
|
+
this.shuttingDown = true;
|
|
84
|
+
if (this.batteryPollTimer) {
|
|
85
|
+
clearTimeout(this.batteryPollTimer);
|
|
86
|
+
this.batteryPollTimer = undefined;
|
|
87
|
+
}
|
|
88
|
+
if (this.shadePollTimer) {
|
|
89
|
+
clearTimeout(this.shadePollTimer);
|
|
90
|
+
this.shadePollTimer = undefined;
|
|
91
|
+
}
|
|
92
|
+
for (const timer of this.travelTimers.values()) {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
this.travelTimers.clear();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
async onLaunch() {
|
|
99
|
+
try {
|
|
100
|
+
await this.updateHubInfo();
|
|
101
|
+
if (this.pollShadesForUpdate) {
|
|
102
|
+
this.pollShades();
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
await this.updateShades();
|
|
106
|
+
}
|
|
107
|
+
await this.updateScenes();
|
|
108
|
+
this.scheduleBatteryPoll();
|
|
109
|
+
// Deliberately not awaited. The sync does an RF read per shade the hub has
|
|
110
|
+
// no position for — seconds each, five motors on a cold cache — and
|
|
111
|
+
// Homebridge should not sit unlaunched through it.
|
|
112
|
+
void this.syncPositionsAtStartup().catch((err) => {
|
|
113
|
+
logError(this.log, 'Startup position sync failed:', err);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
logError(this.log, 'Failed to start PowerView platform:', err);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
cachePositions(shadeId, positions) {
|
|
121
|
+
if (!positions) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const existing = this.lastPositions.get(shadeId) ?? {};
|
|
125
|
+
const merged = { ...existing, ...positions };
|
|
126
|
+
this.lastPositions.set(shadeId, merged);
|
|
127
|
+
// Persisted so a restart answers HomeKit with the last known position rather
|
|
128
|
+
// than resolvePositionValue()'s 0, which reads as "fully closed" until the
|
|
129
|
+
// background refresh lands — and sticks if that refresh times out.
|
|
130
|
+
//
|
|
131
|
+
// Mutating context alone does not write cachedAccessories to disk;
|
|
132
|
+
// updatePlatformAccessories() is what schedules the save. Only call it when
|
|
133
|
+
// the map actually changed, so ordinary reads don't rewrite the cache file.
|
|
134
|
+
const accessory = this.accessories.get(shadeId);
|
|
135
|
+
if (accessory && !positionMapsEqual(accessory.context.lastPositions, merged)) {
|
|
136
|
+
accessory.context.lastPositions = merged;
|
|
137
|
+
this.api.updatePlatformAccessories([accessory]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
getCachedPosition(shadeId, position) {
|
|
141
|
+
const cached = this.lastPositions.get(shadeId)?.[position];
|
|
142
|
+
return typeof cached === 'number' && Number.isFinite(cached) ? cached : undefined;
|
|
143
|
+
}
|
|
144
|
+
shadeType(shade) {
|
|
145
|
+
if (this.forceRollerShades.includes(shade.id)) {
|
|
146
|
+
return ShadeKind.ROLLER;
|
|
147
|
+
}
|
|
148
|
+
if (this.forceTopBottomShades.includes(shade.id)) {
|
|
149
|
+
return ShadeKind.TOP_BOTTOM;
|
|
150
|
+
}
|
|
151
|
+
if (this.forceHorizontalShades.includes(shade.id)) {
|
|
152
|
+
return ShadeKind.HORIZONTAL;
|
|
153
|
+
}
|
|
154
|
+
if (this.forceVerticalShades.includes(shade.id)) {
|
|
155
|
+
return ShadeKind.VERTICAL;
|
|
156
|
+
}
|
|
157
|
+
const capability = resolveShadeCapability(shade);
|
|
158
|
+
if (capability === undefined) {
|
|
159
|
+
this.log.warn('Shade %d has undocumented type %s; treating as roller. '
|
|
160
|
+
+ 'Override with forceRollerShades / forceTopBottomShades / '
|
|
161
|
+
+ 'forceHorizontalShades / forceVerticalShades if that is wrong.', shade.id, String(shade.type ?? 'none'));
|
|
162
|
+
return ShadeKind.ROLLER;
|
|
163
|
+
}
|
|
164
|
+
const kind = shadeKindForCapability(capability) ?? ShadeKind.ROLLER;
|
|
165
|
+
if (!FULLY_SUPPORTED_KINDS.includes(kind)) {
|
|
166
|
+
// Say so rather than silently driving it as a roller: capability 6 runs
|
|
167
|
+
// its primary rail reversed, 2 and 5 tilt through 180 rather than 90,
|
|
168
|
+
// and 8/9 have overlapped panels. Positions will be wrong for these.
|
|
169
|
+
this.warnUnsupportedCapabilityOnce(shade.id, capability, kind);
|
|
170
|
+
}
|
|
171
|
+
return kind;
|
|
172
|
+
}
|
|
173
|
+
warnUnsupportedCapabilityOnce(shadeId, capability, kind) {
|
|
174
|
+
if (this.unsupportedCapabilityLogged.has(shadeId)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.unsupportedCapabilityLogged.add(shadeId);
|
|
178
|
+
this.log.warn('Shade %d is capability %d (%s), which this plugin does not fully implement. '
|
|
179
|
+
+ 'Its reported position or tilt may be wrong. Logged once per shade.', shadeId, capability, ShadeKind[kind]);
|
|
180
|
+
}
|
|
181
|
+
configureAccessory(accessory) {
|
|
182
|
+
try {
|
|
183
|
+
const sceneId = accessory.context.sceneId;
|
|
184
|
+
if (typeof sceneId === 'number' && Number.isFinite(sceneId)) {
|
|
185
|
+
const sceneAccessory = accessory;
|
|
186
|
+
this.log.info('Cached scene %d: %s', sceneId, sceneAccessory.displayName);
|
|
187
|
+
this.wireSceneSwitch(sceneAccessory);
|
|
188
|
+
this.sceneAccessories.set(sceneId, sceneAccessory);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const shadeAccessory = accessory;
|
|
192
|
+
const shadeId = shadeAccessory.context.shadeId;
|
|
193
|
+
if (typeof shadeId !== 'number' || !Number.isFinite(shadeId)) {
|
|
194
|
+
this.log.error('Cached accessory "%s" is missing a valid shadeId; remove it from Homebridge or re-discover shades.', accessory.displayName);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.log.info('Cached shade %d: %s', shadeId, shadeAccessory.displayName);
|
|
198
|
+
this.ensureWindowCoveringCategory(shadeAccessory);
|
|
199
|
+
if (!shadeAccessory.context.shadeType) {
|
|
200
|
+
const topService = shadeAccessory.getServiceById(this.Service.WindowCovering, SUBTYPE.TOP);
|
|
201
|
+
shadeAccessory.context.shadeType = topService ? ShadeKind.TOP_BOTTOM : ShadeKind.ROLLER;
|
|
202
|
+
}
|
|
203
|
+
if (shadeAccessory.context.jogSupported === undefined) {
|
|
204
|
+
shadeAccessory.context.jogSupported = true;
|
|
205
|
+
}
|
|
206
|
+
const restored = sanitizePositionMap(shadeAccessory.context.lastPositions);
|
|
207
|
+
if (Object.keys(restored).length > 0) {
|
|
208
|
+
this.lastPositions.set(shadeId, restored);
|
|
209
|
+
}
|
|
210
|
+
this.registerHandler(shadeAccessory);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
logError(this.log, `Failed to configure cached accessory "${accessory.displayName}":`, err);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
ensureWindowCoveringCategory(accessory) {
|
|
217
|
+
const category = 14 /* this.api.hap.Categories.WINDOW_COVERING */;
|
|
218
|
+
if (accessory.category !== category) {
|
|
219
|
+
accessory.category = category;
|
|
220
|
+
this.api.updatePlatformAccessories([accessory]);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
registerHandler(accessory) {
|
|
224
|
+
const shadeId = accessory.context.shadeId;
|
|
225
|
+
this.accessories.set(shadeId, accessory);
|
|
226
|
+
try {
|
|
227
|
+
const handler = new PowerViewPlatformAccessory(this, accessory, this.log);
|
|
228
|
+
this.handlers.set(shadeId, handler);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
this.handlers.delete(shadeId);
|
|
232
|
+
logError(this.log, `Failed to register shade handler for ${shadeId}:`, err);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
addShadeAccessory(shade) {
|
|
236
|
+
const name = decodeBase64Name(shade.name, `Shade ${shade.id}`);
|
|
237
|
+
this.log.info('Adding shade %d: %s', shade.id, name);
|
|
238
|
+
const uuid = this.api.hap.uuid.generate(shade.id.toString());
|
|
239
|
+
const accessory = new this.api.platformAccessory(name, uuid, 14 /* this.api.hap.Categories.WINDOW_COVERING */);
|
|
240
|
+
accessory.context.shadeId = shade.id;
|
|
241
|
+
accessory.context.shadeType = this.shadeType(shade);
|
|
242
|
+
accessory.context.jogSupported = true;
|
|
243
|
+
this.registerHandler(accessory);
|
|
244
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
245
|
+
return accessory;
|
|
246
|
+
}
|
|
247
|
+
updateShadeAccessory(shade, accessory) {
|
|
248
|
+
this.log.info('Updating shade %d: %s', shade.id, accessory.displayName);
|
|
249
|
+
const newType = this.shadeType(shade);
|
|
250
|
+
const handler = this.handlers.get(shade.id);
|
|
251
|
+
if (newType !== accessory.context.shadeType && handler) {
|
|
252
|
+
this.log.info('Shade changed type %d -> %d', accessory.context.shadeType, newType);
|
|
253
|
+
handler.shadeType = newType;
|
|
254
|
+
handler.configure();
|
|
255
|
+
this.api.updatePlatformAccessories([accessory]);
|
|
256
|
+
}
|
|
257
|
+
return accessory;
|
|
258
|
+
}
|
|
259
|
+
removeShadeAccessory(accessory) {
|
|
260
|
+
this.log.info('Removing shade %d: %s', accessory.context.shadeId, accessory.displayName);
|
|
261
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
262
|
+
this.accessories.delete(accessory.context.shadeId);
|
|
263
|
+
this.handlers.delete(accessory.context.shadeId);
|
|
264
|
+
this.lastPositions.delete(accessory.context.shadeId);
|
|
265
|
+
this.batteryRefreshDisabled.delete(accessory.context.shadeId);
|
|
266
|
+
}
|
|
267
|
+
async updateShades() {
|
|
268
|
+
let shadeData;
|
|
269
|
+
try {
|
|
270
|
+
shadeData = await this.hub.getShades();
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
logError(this.log, 'Failed to list shades from hub:', err);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const newShades = {};
|
|
277
|
+
for (const shade of shadeData) {
|
|
278
|
+
if (typeof shade.id !== 'number' || !Number.isFinite(shade.id)) {
|
|
279
|
+
this.log.warn('Skipping shade with invalid id from hub response');
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const existing = this.accessories.get(shade.id);
|
|
284
|
+
newShades[shade.id] = existing
|
|
285
|
+
? this.updateShadeAccessory(shade, existing)
|
|
286
|
+
: this.addShadeAccessory(shade);
|
|
287
|
+
if (!this.handlers.has(shade.id)) {
|
|
288
|
+
this.registerHandler(newShades[shade.id]);
|
|
289
|
+
}
|
|
290
|
+
const handler = this.handlers.get(shade.id);
|
|
291
|
+
if (handler) {
|
|
292
|
+
handler.updateShadeValues(shade);
|
|
293
|
+
// Only when the list entry carried nothing to work with. /api/shades
|
|
294
|
+
// already returns positions on hubs that report them, and every hub
|
|
295
|
+
// call is serialised — so refetching each shade doubled the requests
|
|
296
|
+
// per poll cycle for no new information.
|
|
297
|
+
if (!shade.positions) {
|
|
298
|
+
try {
|
|
299
|
+
const shadeState = await this.hub.getShade(shade.id);
|
|
300
|
+
handler.updateShadeValues(shadeState);
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
logError(this.log, `Failed to fetch shade ${shade.id} state:`, err);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
logError(this.log, `Failed to process shade ${shade.id}:`, err);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Unregistering an accessory is destructive — HomeKit loses its room,
|
|
313
|
+
// its name and any automations referencing it, and none of that comes back
|
|
314
|
+
// when the shade reappears. A hub under load can answer with a short list,
|
|
315
|
+
// which once cost three of five shades. So a shade has to be missing from
|
|
316
|
+
// several consecutive lists before it is treated as genuinely gone, and an
|
|
317
|
+
// empty list is never evidence of anything.
|
|
318
|
+
if (shadeData.length === 0) {
|
|
319
|
+
this.log.debug('Hub returned no shades; keeping all %d accessories', this.accessories.size);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
for (const [id, accessory] of [...this.accessories]) {
|
|
323
|
+
if (newShades[id]) {
|
|
324
|
+
this.missingFromList.delete(id);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const misses = (this.missingFromList.get(id) ?? 0) + 1;
|
|
328
|
+
this.missingFromList.set(id, misses);
|
|
329
|
+
if (misses < SHADE_REMOVAL_THRESHOLD) {
|
|
330
|
+
this.log.warn('Shade %d (%s) missing from the hub list (%d/%d). Keeping it: a partial '
|
|
331
|
+
+ 'response would otherwise destroy the accessory.', id, accessory.displayName, misses, SHADE_REMOVAL_THRESHOLD);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
this.missingFromList.delete(id);
|
|
335
|
+
this.removeShadeAccessory(accessory);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
pollShades() {
|
|
339
|
+
if (this.shuttingDown) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
void this.updateShades()
|
|
343
|
+
.catch((err) => {
|
|
344
|
+
logError(this.log, 'Failed to poll shades from hub:', err);
|
|
345
|
+
})
|
|
346
|
+
.finally(() => {
|
|
347
|
+
if (this.shuttingDown) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
this.shadePollTimer = setTimeout(() => this.pollShades(), SHADE_POLL_INTERVAL_MS);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Re-reads positions once at startup for shades the hub has no position for.
|
|
355
|
+
*
|
|
356
|
+
* The hub only holds a position for a while after a set or refresh, and a
|
|
357
|
+
* shade moved by remote is never reported at all — so without this, a
|
|
358
|
+
* restored cache would be served to HomeKit indefinitely with nothing ever
|
|
359
|
+
* correcting it. Startup is the one moment where an RF wake per shade buys
|
|
360
|
+
* accuracy without recurring cost; quiet hours guard the unattended restart.
|
|
361
|
+
*/
|
|
362
|
+
async syncPositionsAtStartup() {
|
|
363
|
+
if (!this.syncPositionsOnStart) {
|
|
364
|
+
this.log.info('Startup position sync: disabled');
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const { startHour, endHour } = this.quietHours;
|
|
368
|
+
if (isWithinQuietHours(new Date(), startHour, endHour)) {
|
|
369
|
+
this.log.info('Startup position sync: skipped, quiet hours');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
let refreshed = 0;
|
|
373
|
+
for (const id of [...this.accessories.keys()]) {
|
|
374
|
+
if (this.shuttingDown) {
|
|
375
|
+
this.log.debug('Startup position sync abandoned: shutting down');
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
const { positions } = await this.updateShade(id);
|
|
380
|
+
if (positions && Object.keys(positions).length > 0) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
await this.updateShade(id, true);
|
|
384
|
+
refreshed++;
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
logError(this.log, `Startup position sync failed for shade ${id}:`, err);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
this.log.info('Startup position sync: refreshed %d shade(s) over RF', refreshed);
|
|
391
|
+
}
|
|
392
|
+
async stopShade(shadeId) {
|
|
393
|
+
this.log.info('stopShade %d', shadeId);
|
|
394
|
+
const shade = await this.hub.stopShade(shadeId);
|
|
395
|
+
const handler = this.handlers.get(shadeId);
|
|
396
|
+
this.cachePositions(shadeId, handler?.updateShadeValues(shade) ?? null);
|
|
397
|
+
}
|
|
398
|
+
scheduleBatteryPoll() {
|
|
399
|
+
if (this.batteryPollTimer) {
|
|
400
|
+
clearTimeout(this.batteryPollTimer);
|
|
401
|
+
this.batteryPollTimer = undefined;
|
|
402
|
+
}
|
|
403
|
+
if (!this.batteryPoll.enabled) {
|
|
404
|
+
this.log.info('Battery poll: disabled');
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const { hour, minute } = this.batteryPoll.at;
|
|
408
|
+
// Recomputed from `now` after every run, never by adding 24h, so the poll
|
|
409
|
+
// stays pinned to the same wall-clock time across DST changes.
|
|
410
|
+
const delay = msUntilNextDailyRun(new Date(), hour, minute);
|
|
411
|
+
this.log.info('Battery poll: next run %02d:%02d local, in %dh%02dm', hour, minute, Math.floor(delay / (60 * 60 * 1000)), Math.floor((delay % (60 * 60 * 1000)) / (60 * 1000)));
|
|
412
|
+
this.batteryPollTimer = setTimeout(() => {
|
|
413
|
+
void this.pollBatteryLevels().finally(() => {
|
|
414
|
+
this.scheduleBatteryPoll();
|
|
415
|
+
});
|
|
416
|
+
}, delay);
|
|
417
|
+
}
|
|
418
|
+
async pollBatteryLevels() {
|
|
419
|
+
// Logged at info deliberately: this moves physical hardware, so it must be
|
|
420
|
+
// attributable from the default log without enabling debug.
|
|
421
|
+
this.log.info('Battery poll: refreshing %d shades', this.accessories.size);
|
|
422
|
+
for (const id of [...this.accessories.keys()]) {
|
|
423
|
+
if (this.batteryRefreshDisabled.has(id)) {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
const shade = await this.hub.getShade(id, { updateBatteryLevel: true });
|
|
428
|
+
const handler = this.handlers.get(id);
|
|
429
|
+
handler?.updateShadeValues(shade);
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
if (isHubError(err) && (err.code === HubErrorCode.NotFound || err.code === HubErrorCode.BadRequest)) {
|
|
433
|
+
this.batteryRefreshDisabled.add(id);
|
|
434
|
+
this.log.debug('Battery refresh not supported for shade %d', id);
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
this.log.warn('Battery refresh failed for shade %d:', id, err instanceof Error ? err.message : String(err));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
async updateHubInfo() {
|
|
443
|
+
const userData = await this.hub.getUserData();
|
|
444
|
+
this.hubName = decodeBase64Name(userData.hubName, 'PowerView Hub');
|
|
445
|
+
if (userData.firmware?.mainProcessor) {
|
|
446
|
+
this.hubVersion = userData.firmware.mainProcessor.name;
|
|
447
|
+
}
|
|
448
|
+
this.log.info('Hub: %s', this.hubName);
|
|
449
|
+
try {
|
|
450
|
+
this.hubCapabilities = await probeHubCapabilities(this.hub, this.log, this.hubVersion);
|
|
451
|
+
this.log.info('Hub capabilities: fwversion=%s scenes=%s sceneCollections=%s generation=%s', this.hubCapabilities.fwVersionSupported, this.hubCapabilities.scenesSupported, this.hubCapabilities.sceneCollectionsSupported, this.hubCapabilities.generationHint);
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
logError(this.log, 'Hub capability probe failed (continuing with shades only):', err);
|
|
455
|
+
}
|
|
456
|
+
for (const handler of this.handlers.values()) {
|
|
457
|
+
handler.updateAccessoryInformation();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
async updateShade(shadeId, refresh = false) {
|
|
461
|
+
const shade = await this.hub.getShade(shadeId, { refresh });
|
|
462
|
+
const handler = this.handlers.get(shadeId);
|
|
463
|
+
const positions = handler?.updateShadeValues(shade) ?? null;
|
|
464
|
+
this.cachePositions(shadeId, positions);
|
|
465
|
+
return { positions, timedOut: refresh ? shade.timedOut : undefined };
|
|
466
|
+
}
|
|
467
|
+
async getPosition(shadeId, position, callback) {
|
|
468
|
+
this.log.debug('getPosition %d/%d', shadeId, position);
|
|
469
|
+
// Answer from cache without awaiting the hub at all. Even a cached hub read
|
|
470
|
+
// is a serialised round-trip, and HomeKit reads every characteristic of
|
|
471
|
+
// every shade at once — the later ones then exceed its read budget and log
|
|
472
|
+
// "read handler was slow to respond". The cache survives restarts, so it is
|
|
473
|
+
// warm from the first read. refreshShades and strictErrors both opt into
|
|
474
|
+
// hitting the hub, so they keep the blocking path.
|
|
475
|
+
if (!this.refreshShades && !this.strictErrors) {
|
|
476
|
+
const cached = this.getCachedPosition(shadeId, position);
|
|
477
|
+
if (cached != null) {
|
|
478
|
+
// Answer first. scheduleBackgroundRefresh runs synchronously up to its
|
|
479
|
+
// first await, so scheduling ahead of the callback puts a fetch on the
|
|
480
|
+
// stack before HomeKit has its value.
|
|
481
|
+
callback(null, cached);
|
|
482
|
+
this.scheduleBackgroundRefresh(shadeId, position);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const value = await this.updatePosition(shadeId, position, this.refreshShades);
|
|
488
|
+
if (!this.refreshShades && value == null) {
|
|
489
|
+
if (this.strictErrors) {
|
|
490
|
+
// Strict mode opts into surfacing hub failures, so keep the blocking refresh.
|
|
491
|
+
this.log.debug('refresh %d/%d', shadeId, position);
|
|
492
|
+
const refreshed = await this.updatePosition(shadeId, position, true);
|
|
493
|
+
callback(null, this.resolvePositionValue(shadeId, position, refreshed));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// Answer HomeKit now from cache. A blocking refresh wakes the motor over RF
|
|
497
|
+
// and routinely exceeds HomeKit's read budget, which logged "read handler
|
|
498
|
+
// didn't respond at all" and stalled the accessory. updateShade() pushes the
|
|
499
|
+
// real value via updateCharacteristic as soon as the hub answers.
|
|
500
|
+
this.scheduleBackgroundRefresh(shadeId, position, true);
|
|
501
|
+
callback(null, this.resolvePositionValue(shadeId, position, null));
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
callback(null, this.resolvePositionValue(shadeId, position, value));
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
if (this.strictErrors) {
|
|
509
|
+
logError(this.log, `getPosition failed for shade ${shadeId}/${position}:`, err);
|
|
510
|
+
callback(err instanceof Error ? err : new Error(formatError(err)));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
logError(this.log, `getPosition failed for shade ${shadeId}/${position}, using cache:`, err);
|
|
514
|
+
callback(null, this.resolvePositionValue(shadeId, position, null));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Refreshes a shade off the HomeKit read path, deduped per shade and rate
|
|
519
|
+
* limited per shade.
|
|
520
|
+
*
|
|
521
|
+
* `refresh` is the expensive kind: it wakes the motor over RF, takes seconds,
|
|
522
|
+
* and is what used to move shades at 05:54. It is only justified when the hub
|
|
523
|
+
* has no position at all. Servicing an ordinary read uses the cheap cached
|
|
524
|
+
* read, which never touches the radio.
|
|
525
|
+
*/
|
|
526
|
+
scheduleBackgroundRefresh(shadeId, position, refresh = false) {
|
|
527
|
+
if (this.pendingRefresh.has(shadeId)) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const last = this.lastBackgroundRefresh.get(shadeId) ?? 0;
|
|
531
|
+
if (!refresh && Date.now() - last < BACKGROUND_REFRESH_INTERVAL_MS) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
this.pendingRefresh.add(shadeId);
|
|
535
|
+
this.lastBackgroundRefresh.set(shadeId, Date.now());
|
|
536
|
+
void (async () => {
|
|
537
|
+
try {
|
|
538
|
+
this.log.debug('background refresh %d/%d (rf=%s)', shadeId, position, refresh);
|
|
539
|
+
await this.updatePosition(shadeId, position, refresh);
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
logError(this.log, `Background refresh failed for shade ${shadeId}:`, err);
|
|
543
|
+
}
|
|
544
|
+
finally {
|
|
545
|
+
this.pendingRefresh.delete(shadeId);
|
|
546
|
+
}
|
|
547
|
+
})();
|
|
548
|
+
}
|
|
549
|
+
resolvePositionValue(shadeId, position, value) {
|
|
550
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
551
|
+
return value;
|
|
552
|
+
}
|
|
553
|
+
const cached = this.getCachedPosition(shadeId, position);
|
|
554
|
+
if (cached != null) {
|
|
555
|
+
return cached;
|
|
556
|
+
}
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
async updatePosition(shadeId, position, refresh) {
|
|
560
|
+
const { positions, timedOut } = await this.updateShade(shadeId, refresh);
|
|
561
|
+
if (refresh && timedOut) {
|
|
562
|
+
this.log.warn('Shade %d did not respond to refresh; using cached position for %d', shadeId, position);
|
|
563
|
+
if (this.strictErrors) {
|
|
564
|
+
throw new HubError('Shade refresh timed out', HubErrorCode.HttpError);
|
|
565
|
+
}
|
|
566
|
+
return this.getCachedPosition(shadeId, position) ?? null;
|
|
567
|
+
}
|
|
568
|
+
if (!positions) {
|
|
569
|
+
this.log.warn('Hub did not return positions for %d/%d', shadeId, position);
|
|
570
|
+
return this.getCachedPosition(shadeId, position) ?? null;
|
|
571
|
+
}
|
|
572
|
+
const lookup = lookupPosition(positions, position);
|
|
573
|
+
if (lookup.kind === 'ok') {
|
|
574
|
+
this.log.debug('updatePosition %d/%d: %d', shadeId, position, lookup.value);
|
|
575
|
+
return lookup.value;
|
|
576
|
+
}
|
|
577
|
+
if (lookup.kind === 'invalid') {
|
|
578
|
+
this.log.warn('Invalid position value for %d/%d: %s', shadeId, position, String(lookup.value));
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
// Normal on a Gen 2 hub: a cached read carries no `positions` at all, so
|
|
582
|
+
// the value arrives later via background refresh. Not an error.
|
|
583
|
+
this.log.debug('Hub omitted position %d/%d; answering from cache', shadeId, position);
|
|
584
|
+
this.notePositionsOmittedOnce(shadeId);
|
|
585
|
+
}
|
|
586
|
+
return this.getCachedPosition(shadeId, position) ?? null;
|
|
587
|
+
}
|
|
588
|
+
async setPosition(shadeId, position, value, callback) {
|
|
589
|
+
this.log.info('setPosition %d/%d = %d', shadeId, position, value);
|
|
590
|
+
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) {
|
|
591
|
+
callback(new Error(`Invalid value: ${value}`));
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
let hubValue;
|
|
595
|
+
switch (position) {
|
|
596
|
+
case HubPosition.BOTTOM:
|
|
597
|
+
case HubPosition.TOP:
|
|
598
|
+
hubValue = Math.round(65535 * value / 100);
|
|
599
|
+
break;
|
|
600
|
+
case HubPosition.VANES: {
|
|
601
|
+
const accessory = this.accessories.get(shadeId);
|
|
602
|
+
if (!accessory) {
|
|
603
|
+
// A set can still arrive for an accessory Homebridge removed between
|
|
604
|
+
// HomeKit's read and its write. Without this the throw escapes the
|
|
605
|
+
// handler as an unhandled rejection, since it precedes the try below.
|
|
606
|
+
callback(new Error(`Unknown shade: ${shadeId}`));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (accessory.context.shadeType === ShadeKind.VERTICAL) {
|
|
610
|
+
hubValue = Math.abs(Math.round(65535 * (value - 90) / 180));
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
hubValue = Math.round(32767 * value / 90);
|
|
614
|
+
}
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
default:
|
|
618
|
+
callback(new Error(`Unknown position: ${position}`));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
try {
|
|
622
|
+
const handler = this.handlers.get(shadeId);
|
|
623
|
+
const from = this.getCachedPosition(shadeId, position) ?? 0;
|
|
624
|
+
await this.hub.putShade(shadeId, position, hubValue, value);
|
|
625
|
+
// Deliberately not feeding the hub's response into CurrentPosition. Its
|
|
626
|
+
// PUT reply echoes the target, so doing that reported arrival the instant
|
|
627
|
+
// the command was sent while the motor still had seconds to run.
|
|
628
|
+
handler?.reportMovement(position, from, value);
|
|
629
|
+
this.scheduleArrival(shadeId, position, from, value);
|
|
630
|
+
callback(null);
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
logError(this.log, `setPosition failed for shade ${shadeId}/${position}:`, err);
|
|
634
|
+
callback(err instanceof Error ? err : new Error(formatError(err)));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Publishes one stateless switch per hub scene.
|
|
639
|
+
*
|
|
640
|
+
* A scene is the only way to move a group together: it is a single call the
|
|
641
|
+
* hub expands itself, so every motor gets its RF command at once. Driving the
|
|
642
|
+
* same shades individually costs one serialised write each and they visibly
|
|
643
|
+
* stagger.
|
|
644
|
+
*/
|
|
645
|
+
async updateScenes() {
|
|
646
|
+
if (!this.exposeScenes) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
let scenes;
|
|
650
|
+
try {
|
|
651
|
+
scenes = (await this.hub.getScenes()).sceneData;
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
logError(this.log, 'Failed to list scenes from hub:', err);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const seen = new Set();
|
|
658
|
+
for (const scene of scenes) {
|
|
659
|
+
if (typeof scene.id !== 'number' || !Number.isFinite(scene.id)) {
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
seen.add(scene.id);
|
|
663
|
+
if (!this.sceneAccessories.has(scene.id)) {
|
|
664
|
+
this.addSceneAccessory(scene);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// Same reasoning as shades: an empty or short list is not evidence a scene
|
|
668
|
+
// was deleted, and unregistering is irreversible for the user.
|
|
669
|
+
if (scenes.length === 0) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
for (const [id, accessory] of [...this.sceneAccessories]) {
|
|
673
|
+
if (!seen.has(id)) {
|
|
674
|
+
this.log.info('Removing scene %d: %s', id, accessory.displayName);
|
|
675
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
676
|
+
this.sceneAccessories.delete(id);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
addSceneAccessory(scene) {
|
|
681
|
+
const name = decodeBase64Name(scene.name, `Scene ${scene.id}`);
|
|
682
|
+
this.log.info('Adding scene %d: %s', scene.id, name);
|
|
683
|
+
const uuid = this.api.hap.uuid.generate(`scene:${scene.id}`);
|
|
684
|
+
const accessory = new this.api.platformAccessory(name, uuid);
|
|
685
|
+
accessory.context.sceneId = scene.id;
|
|
686
|
+
this.wireSceneSwitch(accessory);
|
|
687
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
688
|
+
this.sceneAccessories.set(scene.id, accessory);
|
|
689
|
+
}
|
|
690
|
+
wireSceneSwitch(accessory) {
|
|
691
|
+
const sceneId = accessory.context.sceneId;
|
|
692
|
+
const service = accessory.getService(this.Service.Switch)
|
|
693
|
+
?? accessory.addService(this.Service.Switch, accessory.displayName);
|
|
694
|
+
service
|
|
695
|
+
.getCharacteristic(this.Characteristic.On)
|
|
696
|
+
.removeAllListeners('set')
|
|
697
|
+
.on('set', (value, callback) => {
|
|
698
|
+
if (value !== true) {
|
|
699
|
+
callback(null);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
void this.hub.activateScene(sceneId)
|
|
703
|
+
.then((shadeIds) => {
|
|
704
|
+
if (shadeIds) {
|
|
705
|
+
this.log.info('Scene %d activated (%d shade(s))', sceneId, shadeIds.length);
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
this.log.info('Scene %d activated', sceneId);
|
|
709
|
+
}
|
|
710
|
+
callback(null);
|
|
711
|
+
})
|
|
712
|
+
.catch((err) => {
|
|
713
|
+
logError(this.log, `Failed to activate scene ${sceneId}:`, err);
|
|
714
|
+
callback(err instanceof Error ? err : new Error(formatError(err)));
|
|
715
|
+
})
|
|
716
|
+
.finally(() => {
|
|
717
|
+
// A scene is a button, not a state. Reset so it can be fired again.
|
|
718
|
+
service.updateCharacteristic(this.Characteristic.On, false);
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Marks the shade arrived once its estimated travel has elapsed.
|
|
724
|
+
*
|
|
725
|
+
* Estimated rather than polled: this hub reports no positions on a cached
|
|
726
|
+
* read, so confirming arrival would mean an RF round-trip that wakes the motor
|
|
727
|
+
* again the moment it stopped. The startup sync and any later refresh correct
|
|
728
|
+
* the value if the shade did not make it.
|
|
729
|
+
*/
|
|
730
|
+
scheduleArrival(shadeId, position, from, to) {
|
|
731
|
+
const key = `${shadeId}:${position}`;
|
|
732
|
+
const existing = this.travelTimers.get(key);
|
|
733
|
+
if (existing) {
|
|
734
|
+
// A re-target mid-travel: the old timer would otherwise land later and
|
|
735
|
+
// park the shade at a position it was already steered away from.
|
|
736
|
+
clearTimeout(existing);
|
|
737
|
+
}
|
|
738
|
+
const distance = Math.abs(to - from);
|
|
739
|
+
const travel = Math.max(SHADE_MIN_TRAVEL_MS, Math.round(SHADE_FULL_TRAVEL_MS * distance / 100));
|
|
740
|
+
this.travelTimers.set(key, setTimeout(() => {
|
|
741
|
+
this.travelTimers.delete(key);
|
|
742
|
+
if (this.shuttingDown) {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
this.handlers.get(shadeId)?.reportArrival(position, to);
|
|
746
|
+
this.cachePositions(shadeId, { [position]: to });
|
|
747
|
+
}, travel));
|
|
748
|
+
}
|
|
749
|
+
async jogShade(shadeId) {
|
|
750
|
+
const accessory = this.accessories.get(shadeId);
|
|
751
|
+
if (accessory?.context.jogSupported === false) {
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
const shade = await this.hub.jogShade(shadeId);
|
|
756
|
+
const handler = this.handlers.get(shadeId);
|
|
757
|
+
const positions = handler?.updateShadeValues(shade) ?? null;
|
|
758
|
+
this.cachePositions(shadeId, positions);
|
|
759
|
+
return positions;
|
|
760
|
+
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
if (isHubError(err)
|
|
763
|
+
&& (err.code === HubErrorCode.NotFound || err.code === HubErrorCode.BadRequest)) {
|
|
764
|
+
if (accessory) {
|
|
765
|
+
accessory.context.jogSupported = false;
|
|
766
|
+
}
|
|
767
|
+
this.log.warn('Jog motion not supported for shade %d', shadeId);
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
throw err;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
async jogShadeOnIdentify(shadeId) {
|
|
774
|
+
try {
|
|
775
|
+
await this.jogShade(shadeId);
|
|
776
|
+
}
|
|
777
|
+
catch (err) {
|
|
778
|
+
logError(this.log, `Identify/jog failed for shade ${shadeId}:`, err);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
notePositionsOmittedOnce(shadeId) {
|
|
782
|
+
if (this.positionsOmittedLogged.has(shadeId)) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
this.positionsOmittedLogged.add(shadeId);
|
|
786
|
+
this.log.info('Shade %d: hub returns no positions on cached reads; position will follow from a background '
|
|
787
|
+
+ 'refresh. Logged once per shade.', shadeId);
|
|
788
|
+
}
|
|
789
|
+
warnPositionKindErrorOnce(shadeId) {
|
|
790
|
+
if (this.posKindErrorLogged.has(shadeId)) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
this.posKindErrorLogged.add(shadeId);
|
|
794
|
+
this.log.warn('Shade %d reported position kind error (%d) from hub', shadeId, POSITION_KIND_ERROR);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
//# sourceMappingURL=platform.js.map
|