@linanw/homebridge-mijia-remote-camera 0.2.0 → 0.3.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.
@@ -0,0 +1,59 @@
1
+ import { request as httpRequest } from 'node:http';
2
+
3
+ const DIRECTIONS = ['up', 'down', 'left', 'right'];
4
+ // This exact model's four directions and self-stopping single-step behavior were physically verified.
5
+ const VERIFIED_MODELS = new Set(['chuangmi.camera.061a01']);
6
+ const REASONS = new Set(['invalid_request', 'ptz_unsupported', 'ptz_unavailable', 'ptz_rejected', 'ptz_timeout', 'ptz_busy', 'query_cancelled']);
7
+ const failure = reason => Object.assign(new Error(REASONS.has(reason) ? reason : 'ptz_unavailable'), {
8
+ reason: REASONS.has(reason) ? reason : 'ptz_unavailable',
9
+ });
10
+ export const validPtzDirection = value => DIRECTIONS.includes(value);
11
+ export const ptzModelSupported = model => VERIFIED_MODELS.has(model);
12
+ export function ptzCapabilities(model, available = false, supports = ptzModelSupported) {
13
+ const supported = supports(model) === true;
14
+ return { supported, available: available === true, directions: supported ? [...DIRECTIONS] : [],
15
+ relativeStep: supported, zoom: false, absolutePosition: false };
16
+ }
17
+
18
+ /** Authenticated loopback only. Public callers cannot provide endpoints, operation IDs, speed or duration. */
19
+ export function requestPtzStep(control, direction, { signal, timeoutMs = 2600, request = httpRequest } = {}) {
20
+ if (!validPtzDirection(direction) || !Number.isInteger(control?.port) || control.port < 1024 || control.port > 65535
21
+ || typeof control?.token !== 'string' || !/^[a-f0-9]{64}$/.test(control.token)) return Promise.reject(failure('invalid_request'));
22
+ return new Promise((resolve, reject) => {
23
+ let child, response, timer, done = false, size = 0;
24
+ const chunks = [];
25
+ const finish = (error, value) => {
26
+ if (done) return; done = true; clearTimeout(timer); signal?.removeEventListener('abort', cancelled);
27
+ for (const chunk of chunks) chunk.fill(0);
28
+ error ? reject(error) : resolve(value);
29
+ };
30
+ const cancel = reason => { child?.destroy(); response?.destroy(); finish(failure(reason)); };
31
+ const cancelled = () => cancel('query_cancelled');
32
+ if (signal?.aborted) { finish(failure('query_cancelled')); return; }
33
+ try {
34
+ const body = JSON.stringify({ direction });
35
+ child = request({ hostname: '127.0.0.1', port: control.port, path: '/ptz/move', method: 'POST',
36
+ headers: { Authorization: `Bearer ${control.token}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, incoming => {
37
+ response = incoming;
38
+ incoming.on('data', chunk => {
39
+ if (done) return;
40
+ size += chunk.length;
41
+ if (size > 2048) { cancel('ptz_unavailable'); return; }
42
+ chunks.push(chunk);
43
+ });
44
+ incoming.once('error', () => finish(failure('ptz_unavailable')));
45
+ incoming.once('end', () => {
46
+ let value;
47
+ try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { finish(failure('ptz_unavailable')); return; }
48
+ if (incoming.statusCode !== 200) { finish(failure(value?.reason)); return; }
49
+ if (value?.accepted !== true || value.direction !== direction || value.movement !== 'step') { finish(failure('ptz_unavailable')); return; }
50
+ finish(null, { accepted: true, direction, movement: 'step' });
51
+ });
52
+ });
53
+ child.once('error', () => finish(failure('ptz_unavailable')));
54
+ signal?.addEventListener('abort', cancelled, { once: true });
55
+ timer = setTimeout(() => cancel('ptz_timeout'), Math.max(1, Math.min(3000, timeoutMs)));
56
+ child.end(body);
57
+ } catch { cancel('ptz_unavailable'); }
58
+ });
59
+ }
package/lib/ptz.mjs ADDED
@@ -0,0 +1,56 @@
1
+ const DIRECTIONS = ['up', 'down', 'left', 'right'];
2
+ const LABELS = { up: '云台向上', down: '云台向下', left: '云台向左', right: '云台向右' };
3
+ const SAFE_REASONS = new Set(['ptz_unsupported', 'ptz_unavailable', 'ptz_rejected', 'ptz_timeout', 'ptz_busy']);
4
+
5
+ /** Optional Home app controls for a verified, finite camera direction step. */
6
+ export async function configurePtz({ api, accessory, runtime, selection, log } = {}) {
7
+ let capabilities;
8
+ try { capabilities = await runtime.getPtzCapabilities(); } catch { return null; }
9
+ if (capabilities?.supported !== true || capabilities.relativeStep !== true
10
+ || !Array.isArray(capabilities.directions) || !DIRECTIONS.every(d => capabilities.directions.includes(d))) return null;
11
+ const expected = selection && { deviceId: selection.deviceId, homeId: selection.homeId, homeOwner: selection.homeOwner };
12
+ let closed = false, busy = false, lastFailure;
13
+ const services = [];
14
+ const hapError = reason => new api.hap.HapStatusError(reason === 'ptz_busy'
15
+ ? api.hap.HAPStatus.RESOURCE_BUSY : api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
16
+ for (const direction of DIRECTIONS) {
17
+ const subtype = `mijia-ptz-${direction}`;
18
+ const service = accessory.getServiceById?.(api.hap.Service.Switch, subtype)
19
+ || accessory.addService(api.hap.Service.Switch, LABELS[direction], subtype);
20
+ services.push(service);
21
+ const on = service.getCharacteristic(api.hap.Characteristic.On);
22
+ service.updateCharacteristic(api.hap.Characteristic.On, false);
23
+ on.onGet(() => false);
24
+ on.onSet(async value => {
25
+ if (value === false || value === 0) return;
26
+ if (value !== true && value !== 1) throw hapError('ptz_rejected');
27
+ if (closed) throw hapError('ptz_unavailable');
28
+ if (busy) throw hapError('ptz_busy');
29
+ busy = true;
30
+ try {
31
+ if (expected) {
32
+ const current = await runtime.selected();
33
+ if (closed || !current || Object.keys(expected).some(k => expected[k] !== current[k]))
34
+ throw Object.assign(new Error('ptz_unavailable'), { reason: 'ptz_unavailable' });
35
+ }
36
+ if (closed) throw Object.assign(new Error('ptz_unavailable'), { reason: 'ptz_unavailable' });
37
+ const result = await runtime.movePtz(direction);
38
+ if (result?.accepted !== true || result.direction !== direction)
39
+ throw Object.assign(new Error('ptz_rejected'), { reason: 'ptz_rejected' });
40
+ lastFailure = undefined;
41
+ } catch (error) {
42
+ const reason = SAFE_REASONS.has(error?.reason) ? error.reason : 'ptz_unavailable';
43
+ if (!closed && lastFailure !== reason) { lastFailure = reason; log?.warn?.(`云台控制未完成(${reason})。`); }
44
+ throw hapError(reason);
45
+ } finally {
46
+ busy = false;
47
+ setImmediate(() => { if (!closed) service.updateCharacteristic(api.hap.Characteristic.On, false); });
48
+ }
49
+ });
50
+ }
51
+ return { shutdown() {
52
+ if (closed) return;
53
+ closed = true;
54
+ for (const service of services) service.updateCharacteristic(api.hap.Characteristic.On, false);
55
+ } };
56
+ }
package/lib/rpc.mjs CHANGED
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { StringDecoder } from 'node:string_decoder';
6
6
 
7
- const METHODS = new Set(['status', 'homes', 'devices', 'select', 'pause', 'resume', 'auth.start', 'auth.status', 'auth.cancel', 'night.get', 'night.set', 'matter.pairing', 'matter.openWindow', 'matter.closeWindow']);
7
+ const METHODS = new Set(['status', 'homes', 'devices', 'select', 'pause', 'resume', 'auth.start', 'auth.status', 'auth.cancel', 'night.get', 'night.set', 'ptz.capabilities', 'ptz.move', 'matter.pairing', 'matter.openWindow', 'matter.closeWindow']);
8
8
  const MAX_REQUEST = 32768;
9
9
  const MAX_RESPONSE = 1048576;
10
10
  const REASONS = new Set(['operation_failed', 'invalid_request', 'response_too_large', 'invalid_response', 'plugin_not_running',
@@ -17,6 +17,7 @@ const REASONS = new Set(['operation_failed', 'invalid_request', 'response_too_la
17
17
  'invalid_region', 'unsafe_url', 'network_error', 'request_timeout', 'unexpected_response', 'login_expired', 'login_cancelled',
18
18
  'login_not_approved', 'incomplete_session', 'credentials_write_failed', 'unknown_login',
19
19
  'night_vision_unsupported', 'night_vision_unavailable', 'night_vision_rejected', 'night_vision_not_applied', 'night_vision_timeout',
20
+ 'ptz_unsupported', 'ptz_unavailable', 'ptz_rejected', 'ptz_timeout', 'ptz_busy',
20
21
  'matter_disabled', 'matter_not_ready', 'matter_start_failed', 'matter_port_in_use', 'matter_configuration_invalid',
21
22
  'matter_pairing_unavailable', 'matter_window_failed', 'matter_closed']);
22
23
 
@@ -56,7 +56,9 @@ export async function attachServices(runtime, auth, storagePath) {
56
56
  if (method === 'matter.pairing') return runtime.matterPairing?.() ?? Promise.reject(failure('matter_not_ready'));
57
57
  if (method === 'matter.openWindow') return runtime.matterOpenWindow?.() ?? Promise.reject(failure('matter_not_ready'));
58
58
  if (method === 'matter.closeWindow') return runtime.matterCloseWindow?.() ?? Promise.reject(failure('matter_not_ready'));
59
- if (authorizing && ['homes', 'devices', 'select', 'resume', 'night.get', 'night.set'].includes(method)) throw failure('auth_in_progress');
59
+ if (authorizing && ['homes', 'devices', 'select', 'resume', 'night.get', 'night.set', 'ptz.capabilities', 'ptz.move'].includes(method)) throw failure('auth_in_progress');
60
+ if (method === 'ptz.capabilities') return runtime.getPtzCapabilities();
61
+ if (method === 'ptz.move') return runtime.movePtz(payload.direction);
60
62
  if (method === 'night.get') return runtime.getNightVision();
61
63
  if (method === 'night.set') return runtime.setNightVision(payload.mode);
62
64
  if (method === 'homes') return runtime.discoverHomes();
package/lib/runtime.mjs CHANGED
@@ -6,6 +6,7 @@ import { readFileSync, existsSync, statSync } from 'node:fs';
6
6
  import * as fs from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
  import { acquireRuntimeLock } from './process-lock.mjs';
9
+ import { ptzCapabilities, ptzModelSupported, validPtzDirection, requestPtzStep } from './ptz-control.mjs';
9
10
 
10
11
  const REGIONS = new Set(['cn', 'ru', 'de', 'sg', 'us', 'i2']);
11
12
  const REASONS = new Set(['not_selected', 'starting', 'ready', 'stopped', 'retrying', 'connect_timeout',
@@ -14,7 +15,8 @@ const REASONS = new Set(['not_selected', 'starting', 'ready', 'stopped', 'retryi
14
15
  'device_pagination_incomplete', 'home_request_failed', 'home_request_rejected', 'discovery_failed',
15
16
  'backend_failed', 'connection_failed', 'no_video', 'port_in_use', 'runtime_in_use', 'runtime_closed',
16
17
  'runtime_unavailable', 'query_timeout', 'query_cancelled', 'invalid_backend_response', 'storage_failed',
17
- 'night_vision_unsupported', 'night_vision_unavailable', 'night_vision_rejected', 'night_vision_not_applied', 'night_vision_timeout']);
18
+ 'night_vision_unsupported', 'night_vision_unavailable', 'night_vision_rejected', 'night_vision_not_applied', 'night_vision_timeout',
19
+ 'ptz_unsupported', 'ptz_unavailable', 'ptz_rejected', 'ptz_timeout', 'ptz_busy']);
18
20
  REASONS.add('auth_in_progress');
19
21
  const TRANSIENT_CODES = new Set([0, 3, 4, 5, 6]);
20
22
  const STAGES = ['device_lookup', 'provisioning', 'relay_connect', 'authentication', 'waiting_video'];
@@ -82,7 +84,7 @@ async function terminate(child) {
82
84
  export class RuntimeManager extends EventEmitter {
83
85
  constructor({ storagePath, javaPath, jarPath, bridgePath, log, region = 'cn', quality = 1, enableAudio = true, enableTalkback = true, cloudControl,
84
86
  spawn = spawnProcess, choosePort = freePort, terminateProcess = terminate, retryDelays = [15000, 30000, 60000],
85
- connectTimeoutMs = 180000, queryTimeoutMs = 90000 } = {}) {
87
+ connectTimeoutMs = 180000, queryTimeoutMs = 90000, ptzRequest = requestPtzStep, ptzSupported = ptzModelSupported } = {}) {
86
88
  super();
87
89
  if (![storagePath, javaPath, jarPath, bridgePath].every(value => typeof value === 'string' && value.length)) {
88
90
  throw new RuntimeError('runtime_unavailable');
@@ -101,6 +103,7 @@ export class RuntimeManager extends EventEmitter {
101
103
  this.audioInfo = null;
102
104
  this.audioSeenAt = null;
103
105
  this.cloudControl = cloudControl; this.controlRequests = new Set();
106
+ this.ptzRequest = ptzRequest; this.ptzSupported = ptzSupported; this.ptzControl = null; this.ptzBusy = false; this.lastPtzAt = null;
104
107
  this.spawn = spawn; this.choosePort = choosePort; this.terminateProcess = terminateProcess;
105
108
  this.retryDelays = retryDelays; this.connectTimeoutMs = connectTimeoutMs; this.queryTimeoutMs = queryTimeoutMs;
106
109
  this.state = 'idle'; this.reason = 'stopped'; this.ready = false; this.videoReceived = false;
@@ -176,7 +179,7 @@ export class RuntimeManager extends EventEmitter {
176
179
  if (state === 'retrying' || state === 'blocked') {
177
180
  this.lastFailure = this.reason; this.lastFailureAt = Date.now();
178
181
  }
179
- if (extra.ready === false) { this.audioInfo = null; this.audioSeenAt = null; }
182
+ if (extra.ready === false) { this.audioInfo = null; this.audioSeenAt = null; this.ptzControl = null; }
180
183
  Object.assign(this, extra); this.emit('status', this.status());
181
184
  }
182
185
  audioSource() {
@@ -233,6 +236,48 @@ export class RuntimeManager extends EventEmitter {
233
236
  });
234
237
  }
235
238
  getNightVision() { return this._nightVision(); }
239
+ async getPtzCapabilities() {
240
+ if (this.closed) throw new RuntimeError('runtime_closed');
241
+ if (this.paused) throw new RuntimeError('auth_in_progress');
242
+ const selected = this.selected();
243
+ return ptzCapabilities(selected?.model, Boolean(selected && this.ready && this.child && this.ptzControl), this.ptzSupported);
244
+ }
245
+ async movePtz(direction) {
246
+ if (!validPtzDirection(direction)) throw new RuntimeError('invalid_request');
247
+ if (this.closed) throw new RuntimeError('runtime_closed');
248
+ if (this.paused) throw new RuntimeError('auth_in_progress');
249
+ const selected = this.selected();
250
+ if (!selected) throw new RuntimeError('not_selected');
251
+ if (!this.ptzSupported(selected.model)) throw new RuntimeError('ptz_unsupported');
252
+ if (!this.ready || !this.child || !this.ptzControl) throw new RuntimeError('ptz_unavailable');
253
+ if (this.ptzBusy || this.lastPtzAt !== null && Date.now() - this.lastPtzAt < 1800) throw new RuntimeError('ptz_busy');
254
+ const generation = this.generation, region = this.region, control = this.ptzControl, controller = new AbortController();
255
+ const valid = () => !this.closed && !this.paused && !controller.signal.aborted && this.generation === generation
256
+ && this.region === region && this.ptzControl === control
257
+ && ['homeId', 'homeOwner', 'deviceId', 'model', 'region'].every(key => this.selected()?.[key] === selected[key]);
258
+ let timer; this.ptzBusy = true; this.controlRequests.add(controller);
259
+ try {
260
+ const task = async () => {
261
+ if (!this.cloudControl?.context) throw new RuntimeError('ptz_unavailable');
262
+ await this.cloudControl.context(selected, region);
263
+ if (!valid()) throw new RuntimeError('query_cancelled');
264
+ this.lastPtzAt = Date.now();
265
+ return this.ptzRequest(control, direction, { signal: controller.signal });
266
+ };
267
+ const timeout = new Promise((_, reject) => {
268
+ timer = setTimeout(() => { controller.abort(); reject(new RuntimeError('ptz_timeout')); }, 3000);
269
+ });
270
+ const reply = await Promise.race([task(), timeout]);
271
+ if (!valid()) throw new RuntimeError('query_cancelled');
272
+ if (reply?.accepted !== true || reply.direction !== direction || reply.movement !== 'step') throw new RuntimeError('ptz_unavailable');
273
+ return { accepted: true, direction, movement: 'step' };
274
+ } catch (error) {
275
+ if (error?.reason === 'session_expired') this._renewControlSession();
276
+ if (error?.capabilityRequired === true && this.ready) this._refreshControlCapability(selected, region);
277
+ const reason = error?.reason?.startsWith('night_vision_') ? 'ptz_unavailable' : error?.reason;
278
+ throw new RuntimeError(REASONS.has(reason) ? reason : 'ptz_unavailable');
279
+ } finally { clearTimeout(timer); controller.abort(); this.controlRequests.delete(controller); this.ptzBusy = false; }
280
+ }
236
281
  setNightVision(mode) {
237
282
  if (!['on', 'off', 'auto'].includes(mode)) return Promise.reject(new RuntimeError('invalid_request'));
238
283
  return this._nightVision(mode);
@@ -407,6 +452,12 @@ export class RuntimeManager extends EventEmitter {
407
452
  if (value?.success === false) {
408
453
  eventFailure = REASONS.has(value.reason) ? value.reason : 'backend_failed';
409
454
  if (Number.isInteger(value.vendor) && value.vendor >= 0 && value.vendor <= 100) this.vendor = value.vendor;
455
+ } else if (value?.event === 'control_ready' && value.success === true
456
+ && Number.isInteger(value.controlPort) && value.controlPort >= 1024 && value.controlPort <= 65535
457
+ && typeof value.controlToken === 'string' && /^[a-f0-9]{64}$/.test(value.controlToken)) {
458
+ this.ptzControl = { port: value.controlPort, token: value.controlToken };
459
+ } else if (value?.event === 'control_unavailable' && value.success === true) {
460
+ this.ptzControl = null;
410
461
  } else if (value?.event === 'audio_ready' && value.success === true && this.enableAudio
411
462
  && Number.isInteger(value.audioPort) && value.audioPort >= 1024 && value.audioPort <= 65535
412
463
  && ['PCMA', 'OPUS'].includes(value.audioCodec) && [8000, 16000, 48000].includes(value.sampleRate)
@@ -490,6 +541,7 @@ export class RuntimeManager extends EventEmitter {
490
541
  }
491
542
  async stop() {
492
543
  this.wantRunning = false; this.generation++;
544
+ this.ptzControl = null;
493
545
  for (const controller of this.controlRequests) controller.abort();
494
546
  this.controlRequests.clear();
495
547
  clearTimeout(this.retryTimer); clearTimeout(this.bootTimer); this.retryTimer = null; this.bootTimer = null;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@linanw/homebridge-mijia-remote-camera",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@linanw/homebridge-mijia-remote-camera",
9
- "version": "0.2.0",
9
+ "version": "0.3.0",
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.2.0",
4
+ "version": "0.3.0",
5
5
  "description": "Standalone Xiaomi remote camera plugin with HomeKit and experimental Matter camera streaming",
6
6
  "license": "MIT",
7
7
  "author": "linanw",