@linanw/homebridge-mijia-remote-camera 0.4.2 → 0.4.3

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.3 — 2026-10-24
4
+
5
+ - Name the existing HomeKit night-vision control 手动夜视 and add 自动夜视, with distinct `Name` and `ConfiguredName` values and the original service identity preserved.
6
+ - Reflect manual, automatic and off policies as two mutually exclusive switches. Serialize fresh mode checks and conditional changes with other camera-control requests, so an already-off switch does not disable the other mode.
7
+ - Synchronize both controls from confirmed Xiaomi/UI/Matter mode observations and prevent an older in-flight read from overwriting a newer observed mode.
8
+
3
9
  ## 0.4.2 — 2026-10-24
4
10
 
5
11
  - Publish explicit HomeKit `ConfiguredName` values for the four camera direction switches: 云台向上, 云台向下, 云台向左 and 云台向右.
package/README.md CHANGED
@@ -69,9 +69,13 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
69
69
 
70
70
  默认开启现场声音、双向对讲和夜视开关。升级后重启摄像头子桥即可,保留已有 HomeKit 配对。在“家庭”直播页面使用对讲按钮,并允许 iPhone 访问麦克风;松开或结束对讲后会停止向摄像头发送声音。声音静音和音量控制会作用于实际音频转发。插件设置页显示是否已收到真实摄像头声音,以及当前协议是否支持对讲。
71
71
 
72
- 设置页可修改“收听摄像头现场声音”“允许双向对讲”“在家庭中显示夜视开关”;保存后需重启摄像头子桥。对应配置项为 `enableAudio`、`enableTalkback`、`enableNightVision`,默认均为 `true`。关闭现场声音也会禁用对讲。
72
+ 设置页可修改“收听摄像头现场声音”“允许双向对讲”“在家庭中显示手动/自动夜视开关”;保存后需重启摄像头子桥。对应配置项为 `enableAudio`、`enableTalkback`、`enableNightVision`,默认均为 `true`。关闭现场声音也会禁用对讲。
73
73
 
74
- 夜视目前仅支持已核对 MIOT 定义的 `chuangmi.camera.061a01`/`chuangmi.camera.061a03`。插件设置页先读取设备实际状态,再选择“自动/开启/关闭”并应用;写入后会回读确认。Apple“家庭”的“夜视”开关表示允许夜视:自动模式显示为开,重复开不会把自动改为强制开启;关掉后重新打开会设置为始终开启。需要恢复自动时,请在插件设置页选择。该开关表示夜视策略,不表示红外灯此刻是否点亮。启动和轮询只读状态,不更改策略。
74
+ 夜视目前仅支持已核对 MIOT 定义的 `chuangmi.camera.061a01`/`chuangmi.camera.061a03`。插件设置页先读取设备实际状态,再选择“自动/开启/关闭”并应用;写入后会回读确认。
75
+
76
+ 0.4.3 起 Apple“家庭”提供两个互斥开关:“手动夜视”开启时设置为始终开启;“自动夜视”开启时由摄像头按环境光自动决定是否启用红外。关闭当前开启的开关会关闭夜视,两者均显示关。重复关闭已经关闭的开关会保留另一个模式。两个开关反映夜视策略,不表示红外灯此刻是否点亮;米家 App、插件设置或 Matter 修改模式后,它们会同步更新。启动和轮询只读状态,不更改策略。
77
+
78
+ 原夜视开关保留配对和服务编号,名称改为“手动夜视”;自动模式下它现在显示关,“自动夜视”显示开。已有自动化中打开原开关表示强制开启夜视;需要自动模式的场景请使用新增开关。
75
79
 
76
80
  ## 云台方向控制(0.3.0)
77
81
 
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "enableNightVision": {
83
83
  "type": "boolean",
84
- "title": "在家庭中显示夜视开关",
84
+ "title": "在家庭中显示手动/自动夜视开关",
85
85
  "default": true
86
86
  },
87
87
  "enablePtzSwitches": {
@@ -45,10 +45,10 @@
45
45
  <section><div class="step">03 · 声音与夜视</div><h2>摄像头功能</h2>
46
46
  <label for="nightMode">夜视模式</label><select id="nightMode" disabled><option value="auto">自动</option><option value="on">开启</option><option value="off">关闭</option></select>
47
47
  <div class="row"><button id="nightRead" class="secondary">读取夜视状态</button><button id="nightSave" disabled>应用夜视模式</button></div><p id="nightState" class="small" aria-live="polite" style="margin-top:12px">读取摄像头实际模式后可修改。</p>
48
- <p class="small">Apple“家庭”中的夜视开关表示允许夜视;自动模式显示为开。关闭后再次打开会设为始终开启;恢复自动请在这里选择。</p>
48
+ <p class="small">Apple“家庭”提供“手动夜视”和“自动夜视”两个互斥开关,分别设置常开和自动模式。关闭当前开启的开关会关闭夜视;两者表示模式,不代表红外灯此刻是否点亮。</p>
49
49
  <label><input id="enableAudio" type="checkbox" style="width:auto"> 收听摄像头现场声音</label>
50
50
  <label><input id="enableTalkback" type="checkbox" style="width:auto"> 允许 Apple“家庭”双向对讲</label>
51
- <label><input id="enableNightVision" type="checkbox" style="width:auto"> 在 Apple“家庭”中显示夜视开关</label>
51
+ <label><input id="enableNightVision" type="checkbox" style="width:auto"> 在 Apple“家庭”中显示手动/自动夜视开关</label>
52
52
  <div class="row"><button id="saveFeatures" class="secondary">保存功能配置</button></div><p id="featureState" class="small" aria-live="polite" style="margin-top:12px">功能配置保存后需重启摄像头子桥。对讲时,在“家庭”直播页面使用对讲按钮。</p>
53
53
  </section>
54
54
  <section id="ptzSection"><div class="step">04 · 云台</div><h2>调整摄像头方向</h2>
@@ -11,11 +11,12 @@ export const supportsNightVision = model => MODELS.has(model);
11
11
 
12
12
  /** Reads real camera policy. Registration/polling never changes that policy. */
13
13
  export class NightVisionManager {
14
- constructor({ getMode, setMode, cacheMs = 10000, pollIntervalMs = 60000, timeoutMs = 7200,
14
+ constructor({ getMode, setMode, setModeSwitch, cacheMs = 10000, pollIntervalMs = 60000, timeoutMs = 7200,
15
15
  now = Date.now, onChange, onFailure } = {}) {
16
16
  if (typeof getMode !== 'function' || typeof setMode !== 'function') throw failure();
17
17
  this.getMode = getMode;
18
18
  this.setMode = setMode;
19
+ this.setModeSwitch = setModeSwitch;
19
20
  this.cacheMs = cacheMs;
20
21
  this.pollIntervalMs = pollIntervalMs;
21
22
  this.timeoutMs = timeoutMs;
@@ -64,6 +65,12 @@ export class NightVisionManager {
64
65
  return this.status();
65
66
  }
66
67
 
68
+ observe(mode) {
69
+ if (this.closed || !MODES.has(mode)) return;
70
+ ++this.generation;
71
+ return this.accept(mode);
72
+ }
73
+
67
74
  async get({ force = false } = {}) {
68
75
  await this.mutations;
69
76
  if (this.closed) throw failure('runtime_closed');
@@ -118,6 +125,21 @@ export class NightVisionManager {
118
125
  });
119
126
  }
120
127
 
128
+ setModeEnabled(mode, enabled) {
129
+ if (!['on', 'auto'].includes(mode) || typeof enabled !== 'boolean')
130
+ return Promise.reject(failure('night_vision_rejected'));
131
+ if (typeof this.setModeSwitch !== 'function') return Promise.reject(failure());
132
+ return this.enqueue(async () => {
133
+ const confirmed = await this.operation(async () => {
134
+ const result = await this.setModeSwitch(mode, enabled);
135
+ if (enabled ? result?.mode !== mode : result?.mode === mode)
136
+ throw failure('night_vision_not_applied');
137
+ return result;
138
+ });
139
+ return this.accept(confirmed);
140
+ });
141
+ }
142
+
121
143
  start() {
122
144
  if (this.closed || this.timer) return;
123
145
  void this.get({ force: true }).catch(() => {});
@@ -133,19 +155,36 @@ export class NightVisionManager {
133
155
  }
134
156
  }
135
157
 
136
- /** The switch enables the night-vision policy; Auto is enabled, not a real-time IR-lamp reading. */
137
- export function configureNightVision({ api, accessory, runtime, model, name = '夜视', log,
158
+ /** Separate manual and automatic policy switches; neither reports the live IR-lamp state. */
159
+ export function configureNightVision({ api, accessory, runtime, model, name = '手动夜视', log,
138
160
  cacheMs, pollIntervalMs, timeoutMs } = {}) {
139
161
  if (!supportsNightVision(model)) return null;
140
- const service = accessory.getServiceById?.(api.hap.Service.Switch, 'mijia-night-vision')
141
- || accessory.addService(api.hap.Service.Switch, name, 'mijia-night-vision');
142
- const on = service.getCharacteristic(api.hap.Characteristic.On);
162
+ const controls = [
163
+ { mode: 'on', name, subtype: 'mijia-night-vision' },
164
+ { mode: 'auto', name: '自动夜视', subtype: 'mijia-night-vision-auto' },
165
+ ].map(control => {
166
+ const service = accessory.getServiceById?.(api.hap.Service.Switch, control.subtype)
167
+ || accessory.addService(api.hap.Service.Switch, control.name, control.subtype);
168
+ service.displayName = control.name;
169
+ service.updateCharacteristic(api.hap.Characteristic.Name, control.name);
170
+ if (!service.testCharacteristic(api.hap.Characteristic.ConfiguredName))
171
+ service.addOptionalCharacteristic(api.hap.Characteristic.ConfiguredName);
172
+ const configuredName = service.getCharacteristic(api.hap.Characteristic.ConfiguredName);
173
+ if (typeof configuredName.value !== 'string' || !configuredName.value.trim())
174
+ configuredName.updateValue(control.name);
175
+ return { ...control, service, on: service.getCharacteristic(api.hap.Characteristic.On) };
176
+ });
143
177
  let lastLoggedFailure;
144
178
  const manager = new NightVisionManager({
145
179
  getMode: () => runtime.getNightVision(),
146
180
  setMode: mode => runtime.setNightVision(mode),
181
+ setModeSwitch: (mode, enabled) => runtime.setNightVisionSwitch(mode, enabled),
147
182
  cacheMs, pollIntervalMs, timeoutMs,
148
- onChange: status => { lastLoggedFailure = null; service.updateCharacteristic(api.hap.Characteristic.On, status.enabled); },
183
+ onChange: status => {
184
+ lastLoggedFailure = null;
185
+ for (const control of controls)
186
+ control.service.updateCharacteristic(api.hap.Characteristic.On, status.mode === control.mode);
187
+ },
149
188
  onFailure: reason => {
150
189
  if (reason !== lastLoggedFailure) {
151
190
  lastLoggedFailure = reason;
@@ -154,9 +193,15 @@ export function configureNightVision({ api, accessory, runtime, model, name = '
154
193
  },
155
194
  });
156
195
  const hapFailure = () => new api.hap.HapStatusError(api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
157
- on.onGet(async () => { try { return (await manager.get()).enabled; } catch { throw hapFailure(); } });
158
- on.onSet(async value => { try { await manager.setEnabled(value); } catch { throw hapFailure(); } });
159
- const observed = value => { if (!manager.closed && MODES.has(value?.mode)) manager.accept(value.mode); };
196
+ for (const control of controls) {
197
+ control.on.onGet(async () => {
198
+ try { return (await manager.get()).mode === control.mode; } catch { throw hapFailure(); }
199
+ });
200
+ control.on.onSet(async value => {
201
+ try { await manager.setModeEnabled(control.mode, value); } catch { throw hapFailure(); }
202
+ });
203
+ }
204
+ const observed = value => manager.observe(value?.mode);
160
205
  runtime.on?.('night-mode', observed);
161
206
  const shutdown = manager.shutdown.bind(manager);
162
207
  manager.shutdown = () => { runtime.off?.('night-mode', observed); shutdown(); };
package/lib/platform.mjs CHANGED
@@ -107,14 +107,14 @@ export class MijiaRemoteCameraPlatform {
107
107
  ?.setCharacteristic(this.api.hap.Characteristic.Manufacturer, 'Xiaomi')
108
108
  .setCharacteristic(this.api.hap.Characteristic.Model, label(selected.model || '米家远程摄像头'))
109
109
  .setCharacteristic(this.api.hap.Characteristic.SerialNumber, `MiCam-${opaqueId}`)
110
- .setCharacteristic(this.api.hap.Characteristic.FirmwareRevision, '0.4.2');
110
+ .setCharacteristic(this.api.hap.Characteristic.FirmwareRevision, '0.4.3');
111
111
  this.delegate = this.delegateFactory({ api: this.api, log: this.log, runtime: this.runtime,
112
112
  config: this.config, ffmpegPath: this.runtime.ffmpegPath });
113
113
  accessory.configureController(this.delegate.controller);
114
114
  if (this.delegate.audioCompatibility) this.delegate.controller.setMicrophoneMuted(this.config.enableAudio === false);
115
115
  if (this.delegate.talkbackEnabled) this.delegate.controller.setSpeakerMuted(false);
116
116
  if (this.config.enableNightVision !== false) this.nightVision = configureNightVision({ api: this.api,
117
- accessory, runtime: this.runtime, model: selected.model, name: '夜视', log: this.log });
117
+ accessory, runtime: this.runtime, model: selected.model, name: '手动夜视', log: this.log });
118
118
  if (this.config.enablePtzSwitches === true) this.ptz = await configurePtz({ api: this.api,
119
119
  accessory, runtime: this.runtime, selection: selected, log: this.log });
120
120
  if (this.stopping) { this.ptz?.shutdown?.(); this.nightVision?.shutdown?.(); this.delegate?.shutdown(); return; }
package/lib/runtime.mjs CHANGED
@@ -405,7 +405,11 @@ export class RuntimeManager extends EventEmitter {
405
405
  if (!['on', 'off', 'auto'].includes(mode)) return Promise.reject(new RuntimeError('invalid_request'));
406
406
  return this._nightVision(mode);
407
407
  }
408
- _nightVision(mode) {
408
+ setNightVisionSwitch(mode, enabled) {
409
+ if (!['on', 'auto'].includes(mode) || typeof enabled !== 'boolean') return Promise.reject(new RuntimeError('invalid_request'));
410
+ return this._nightVision(mode, enabled);
411
+ }
412
+ _nightVision(mode, enabled) {
409
413
  if (this.metadataWriteBusy) return Promise.reject(new RuntimeError('metadata_busy'));
410
414
  if (this.closed) return Promise.reject(new RuntimeError('runtime_closed'));
411
415
  if (this.paused) return Promise.reject(new RuntimeError('auth_in_progress'));
@@ -414,43 +418,58 @@ export class RuntimeManager extends EventEmitter {
414
418
  if (!selected) return Promise.reject(new RuntimeError('not_selected'));
415
419
  const deadline = Date.now() + 6500, region = this.region, generation = this.generation;
416
420
  this.controlRequests.add(controller);
421
+ let cancelled = false, timer;
417
422
  const valid = () => {
418
423
  const current = this.selected();
419
424
  return !cancelled && !controller.signal.aborted && this.region === region && this.generation === generation && current
420
425
  && ['deviceId', 'homeId', 'homeOwner', 'region'].every(key => current[key] === selected[key]);
421
426
  };
422
- let cancelled = false, timer;
423
- const task = async () => {
427
+ const check = () => {
424
428
  if (cancelled || Date.now() >= deadline) throw new RuntimeError('query_timeout');
425
429
  if (this.closed) throw new RuntimeError('runtime_closed');
426
430
  if (this.paused) throw new RuntimeError('auth_in_progress');
427
- const current = this.selected();
428
431
  if (!valid()) throw new RuntimeError('query_cancelled');
429
- const args = [current.homeId, current.homeOwner, current.deviceId, ...(mode ? [mode] : [])];
432
+ };
433
+ const request = async requestedMode => {
434
+ check();
435
+ const current = this.selected();
436
+ const args = [current.homeId, current.homeOwner, current.deviceId, ...(requestedMode ? [requestedMode] : [])];
430
437
  let reply;
431
438
  if (this.cloudControl) {
432
439
  await this.open();
433
- if (Date.now() >= deadline) throw new RuntimeError('query_timeout');
434
- if (!valid() || this.paused || this.closed) throw new RuntimeError('query_cancelled');
440
+ check();
435
441
  try {
436
- reply = mode ? await this.cloudControl.setNightVision(current, region, mode, { signal: controller.signal })
442
+ reply = requestedMode ? await this.cloudControl.setNightVision(current, region, requestedMode, { signal: controller.signal })
437
443
  : await this.cloudControl.getNightVision(current, region, { signal: controller.signal });
438
444
  } catch (error) {
439
445
  if (error?.reason === 'session_expired') this._renewControlSession();
440
446
  else if (error?.capabilityRequired === true && this.ready) this._refreshControlCapability(current, region);
441
- throw error;
447
+ throw new RuntimeError(error?.reason, error?.code);
442
448
  }
443
- } else reply = await this._query(mode ? 'set' : 'get', args,
444
- { mainClass: 'mijia.bridge.NightVisionControl', deadline, guard: valid });
445
- if (!valid()) throw new RuntimeError('query_cancelled');
449
+ } else reply = await this._query(requestedMode ? 'set' : 'get', args,
450
+ { mainClass: 'mijia.bridge.NightVisionControl', deadline, guard: valid, signal: controller.signal });
451
+ check();
446
452
  if (!['on', 'off', 'auto'].includes(reply?.mode)) throw new RuntimeError('invalid_backend_response');
453
+ if (requestedMode && reply.mode !== requestedMode) throw new RuntimeError('night_vision_not_applied');
454
+ return { mode: reply.mode };
455
+ };
456
+ const task = async () => {
457
+ let reply;
458
+ if (typeof enabled === 'boolean') {
459
+ // Keep the fresh observation and its conditional mutation in one queue
460
+ // task so UI/Matter writes cannot change the mode between these steps.
461
+ reply = await request();
462
+ const target = enabled ? mode : reply.mode === mode ? 'off' : reply.mode;
463
+ if (target !== reply.mode) reply = await request(target);
464
+ } else reply = await request(mode);
465
+ check();
447
466
  this.emit('night-mode', { mode: reply.mode });
448
467
  return { mode: reply.mode };
449
468
  };
450
469
  const result = this.queryChain.then(task, task);
451
470
  this.queryChain = result.catch(() => {});
452
471
  const timeout = new Promise((_, reject) => {
453
- timer = setTimeout(() => { cancelled = true; controller.abort(); reject(new RuntimeError('query_timeout')); }, 6500);
472
+ timer = setTimeout(() => { cancelled = true; controller.abort(); reject(new RuntimeError('query_timeout')); }, Math.max(1, deadline - Date.now()));
454
473
  });
455
474
  return Promise.race([result, timeout]).finally(() => { clearTimeout(timer); this.controlRequests.delete(controller); });
456
475
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@linanw/homebridge-mijia-remote-camera",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@linanw/homebridge-mijia-remote-camera",
9
- "version": "0.4.2",
9
+ "version": "0.4.3",
10
10
  "cpu": [
11
11
  "x64"
12
12
  ],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linanw/homebridge-mijia-remote-camera",
3
3
  "displayName": "米家远程摄像头",
4
- "version": "0.4.2",
4
+ "version": "0.4.3",
5
5
  "description": "Xiaomi remote camera plugin with HomeKit, experimental Matter streaming and Mi Home name/room sync",
6
6
  "license": "MIT",
7
7
  "author": "linanw",