@linanw/homebridge-mijia-remote-camera 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,7 +64,7 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
64
64
 
65
65
  夜视目前仅支持已核对 MIOT 定义的 `chuangmi.camera.061a01`/`chuangmi.camera.061a03`。插件设置页先读取设备实际状态,再选择“自动/开启/关闭”并应用;写入后会回读确认。Apple“家庭”的“夜视”开关表示允许夜视:自动模式显示为开,重复开不会把自动改为强制开启;关掉后重新打开会设置为始终开启。需要恢复自动时,请在插件设置页选择。该开关表示夜视策略,不表示红外灯此刻是否点亮。启动和轮询只读状态,不更改策略。
66
66
 
67
- ## Matter 摄像头(0.2.0,实验性)
67
+ ## Matter 摄像头(0.2.1,实验性)
68
68
 
69
69
  可在保留现有 HomeKit 配对的同时启用独立的 Matter Camera(设备类型 `0x0142`,端点 `1`)。它实现 Camera AV Stream Management 与 WebRTC Transport Provider,包含 H.264 视频、Opus 现场声音、双向对讲、JPEG 快照、音量/静音和支持型号的夜视模式。当前使用 matter.js 0.18.0 的 Matter 1.6.1 模型,已与 matter-server 1.5.3 控制器进行协议对接。MatterDesk Qt 客户端是当前目标客户端;客户端需要实现摄像头播放。Apple“家庭”仍使用本插件原有的 HomeKit 配件。
70
70
 
@@ -79,7 +79,7 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
79
79
  }
80
80
  ```
81
81
 
82
- 升级默认不开启 Matter。启用后,从受保护的插件设置页点击显示 Matter 配对信息,将手动配对码或 `MT:` 内容输入控制器。它与 HomeKit Setup Code 不同。已配对后增加其他控制器时可在设置页重新打开配对窗口,操作完成后可以关闭窗口。配对码和 Matter 私钥不会写入脱敏报告。Matter fabric/设备身份保存在插件私有存储的 `matter/` 内,升级和正常重启保留身份;删除该目录会使已有配对失效。
82
+ 0.2.1 修复了仅视频/仅音频会话的资源清理,并支持浏览器早到的 ICE 候选、重复启动,以及 Chromium 多 H264 profile 的正常协商。升级默认不开启 Matter。启用后,从受保护的插件设置页点击显示 Matter 配对信息,将手动配对码或 `MT:` 内容输入控制器。它与 HomeKit Setup Code 不同。已配对后增加其他控制器时可在设置页重新打开配对窗口,操作完成后可以关闭窗口。配对码和 Matter 私钥不会写入脱敏报告。Matter fabric/设备身份保存在插件私有存储的 `matter/` 内,升级和正常重启保留身份;删除该目录会使已有配对失效。
83
83
 
84
84
  这是未认证的开发设备,使用 SDK 的测试 VID/开发证书。matter-server 默认会拒绝这种证书;只在明确信任此开发设备时,使用它的 `--enable-test-net-dcl`(或 `ENABLE_TEST_NET_DCL=true`)选项。插件不冒充通过认证的产品,客户端普通启动应保留严格验证。
85
85
 
@@ -100,6 +100,10 @@
100
100
  if(record(value.media)){
101
101
  const media={};for(const key of ['sessions','failures','videoPackets','audioPackets','talkPackets','talkBytes'])if(count(value.media[key]))media[key]=value.media[key];
102
102
  for(const key of ['active','microphoneMuted','speakerMuted'])if(typeof value.media[key]==='boolean')media[key]=value.media[key];
103
+ const source=value.media;
104
+ for (const [key, allowed] of Object.entries({"lastFailure": ["negotiation_failed", "media_not_offered", "webrtc_timeout", "webrtc_failed", "webrtc_disconnected", "media_unavailable", "udp_error", "ffmpeg_unavailable", "ffmpeg_exit", "video_timeout"], "lastFailureStage": ["remote_description", "replace_video_track", "replace_audio_track", "create_answer", "create_offer", "local_description", "remote_answer"], "lastFailureType": ["Error", "TypeError", "RangeError", "OperationError", "InvalidStateError", "InvalidModificationError", "InvalidAccessError", "NotSupportedError", "SyntaxError", "AbortError"]})) {
105
+ if (allowed.includes(source[key])) media[key] = source[key];
106
+ }
103
107
  if(Object.keys(media).length)result.media=media;
104
108
  }
105
109
  return result;
@@ -75,7 +75,7 @@ export class MatterCameraResources {
75
75
  const id = this.nextSession;
76
76
  this.nextSession = this.nextSession % 65534 + 1;
77
77
  const item = { id, owner: { ...owner }, peerEndpointId, video, audio, state: 'connecting', pendingCandidates: [], ready: false,
78
- mediaId: `${owner.fabricIndex}:${owner.nodeId}:${id}` };
78
+ pendingRemoteCandidates: [], mediaCreated: false, mediaId: `${owner.fabricIndex}:${owner.nodeId}:${id}` };
79
79
  this.sessions.set(id, item);
80
80
  for (const entry of [video, audio]) if (entry) entry.stream.referenceCount++;
81
81
  return item;
@@ -85,6 +85,8 @@ export class MatterCameraResources {
85
85
  if (this.sessions.get(item.id) !== item || item.ending) return false;
86
86
  item.ending = true;
87
87
  clearTimeout(item.timer);
88
+ item.pendingCandidates.length = 0;
89
+ item.pendingRemoteCandidates.length = 0;
88
90
  this.sessions.delete(item.id);
89
91
  for (const entry of [item.video, item.audio]) if (entry) entry.stream.referenceCount = Math.max(0, entry.stream.referenceCount - 1);
90
92
  return true;
@@ -136,6 +138,42 @@ export function validateMatterTransportRequest(request) {
136
138
  reject(Status.InvalidCommand, 'Requestor endpoint is required');
137
139
  }
138
140
 
141
+ export function describeMatterSession(item) {
142
+ return { id: item.id, peerNodeId: item.owner.nodeId, peerEndpointId: item.peerEndpointId,
143
+ fabricIndex: item.owner.fabricIndex, streamUsage: LIVE, metadataEnabled: false,
144
+ videoStreamId: item.video?.stream.videoStreamId ?? null, audioStreamId: item.audio?.stream.audioStreamId ?? null,
145
+ ...(item.video ? { videoStreams: [item.video.stream.videoStreamId] } : {}),
146
+ ...(item.audio ? { audioStreams: [item.audio.stream.audioStreamId] } : {}) };
147
+ }
148
+
149
+ /** Keep external encoder accounting atomic with Matter's own cluster-state transaction. */
150
+ export function enrollMatterSession({ resources, item, transaction, publish, start, afterRollback,
151
+ schedule = setImmediate }) {
152
+ let launchScheduled = false;
153
+ const rollback = () => { resources.detachSession(item); };
154
+ try {
155
+ transaction.addParticipants({
156
+ toString: () => `camera-session-create#${item.id}`,
157
+ rollback,
158
+ conclusion(outcome) {
159
+ if (outcome === 'committed' && !launchScheduled) {
160
+ launchScheduled = true;
161
+ schedule(() => { if (resources.sessions.get(item.id) === item && !item.ending) start(); });
162
+ } else if (outcome !== 'committed') {
163
+ rollback();
164
+ if (afterRollback) schedule(afterRollback);
165
+ }
166
+ },
167
+ });
168
+ publish();
169
+ } catch (error) {
170
+ rollback();
171
+ // The enclosing transaction also rolls back. Restore its view immediately when synchronous validation failed.
172
+ try { publish(); } catch {}
173
+ throw error;
174
+ }
175
+ }
176
+
139
177
  /**
140
178
  * A real Matter Camera (0x0142), using the Matter 1.6.1 model in matter.js 0.18.
141
179
  * Matter carries authenticated signaling; media is encrypted WebRTC supplied by matter-media.
@@ -206,11 +244,7 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
206
244
  }
207
245
  function streamList(kind) { return [...allocated[kind].values()].map(item => clone(item.stream)); }
208
246
  function describeSession(item) {
209
- return { id: item.id, peerNodeId: item.owner.nodeId, peerEndpointId: item.peerEndpointId,
210
- fabricIndex: item.owner.fabricIndex, streamUsage: LIVE, metadataEnabled: false,
211
- videoStreamId: item.video?.stream.videoStreamId ?? null, audioStreamId: item.audio?.stream.audioStreamId ?? null,
212
- videoStreams: item.video ? [item.video.stream.videoStreamId] : [],
213
- audioStreams: item.audio ? [item.audio.stream.audioStreamId] : [] };
247
+ return describeMatterSession(item);
214
248
  }
215
249
  async function syncState() {
216
250
  if (!endpoint || closed) return;
@@ -251,6 +285,7 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
251
285
  if (notify) await invokeRequestor(item, 'end', { webRtcSessionId: item.id, reason }).catch(() => {});
252
286
  }
253
287
  function fail(item, code) {
288
+ if (sessions.get(item.id) !== item) return;
254
289
  lastError = code;
255
290
  void end(item, code === 'matter_session_timeout' ? 1 : 0, true);
256
291
  }
@@ -352,11 +387,13 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
352
387
  if (isOffer && (typeof request.sdp !== 'string' || request.sdp.length > 65535)) reject(Status.ConstraintError, 'Invalid SDP');
353
388
  const item = resources.openSession({ owner, peerEndpointId: request.originatingEndpointId, video, audio }), { id } = item;
354
389
  lastError = undefined;
355
- this.state.currentSessions = [...sessions.values()].map(describeSession);
356
- // Commit the response before sending the camera-initiated Requestor commands.
357
- setImmediate(() => { void (async () => {
390
+ const startMedia = () => {
391
+ item.timer = setTimeout(() => fail(item, 'matter_session_timeout'), 30000);
392
+ item.timer.unref?.();
393
+ void (async () => {
358
394
  if (closed || !sessions.has(id)) return;
359
395
  await syncState();
396
+ if (closed || sessions.get(id) !== item || item.ending) return;
360
397
  const v = video?.stream, a = audio?.stream;
361
398
  const result = await media.createSession({ id: item.mediaId, ...(isOffer ? { offer: request.sdp } : {}),
362
399
  video: v ? { id: v.videoStreamId, codec: 'h264', width: Math.min(width, v.maxResolution.width),
@@ -365,13 +402,15 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
365
402
  audio: a ? { id: a.audioStreamId, codec: 'opus', sampleRate: 48000, channels: 1, bitRate: a.bitRate } : undefined,
366
403
  iceServers: request.iceServers, iceTransportPolicy: request.iceTransportPolicy || 'all',
367
404
  onCandidate(candidate) {
368
- if (!candidate?.candidate || closed || !sessions.has(id)) return;
405
+ if (!candidate?.candidate || closed || sessions.get(id) !== item) return;
369
406
  const normalized = { candidate: candidate.candidate, sdpMid: candidate.sdpMid ?? null,
370
407
  sdpmLineIndex: candidate.sdpMLineIndex ?? candidate.sdpmLineIndex ?? null };
371
- if (!item.ready) item.pendingCandidates.push(normalized);
372
- else void invokeRequestor(item, 'iceCandidates', { webRtcSessionId: id, iceCandidates: [normalized] }).catch(() => fail(item, 'matter_signaling_failed'));
408
+ if (!item.ready) {
409
+ if (item.pendingCandidates.length < 128) item.pendingCandidates.push(normalized);
410
+ } else void invokeRequestor(item, 'iceCandidates', { webRtcSessionId: id, iceCandidates: [normalized] }).catch(() => fail(item, 'matter_signaling_failed'));
373
411
  },
374
412
  onState(state) {
413
+ if (closed || sessions.get(id) !== item || item.ending) return;
375
414
  item.state = state;
376
415
  if (state === 'connected') {
377
416
  clearTimeout(item.timer);
@@ -379,16 +418,22 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
379
418
  item.timer.unref?.();
380
419
  } else if (state === 'failed' || state === 'closed') void end(item, state === 'failed' ? 0 : 2, true);
381
420
  } });
382
- if (!sessions.has(id) || closed) { await media.closeSession(item.mediaId); return; }
421
+ if (sessions.get(id) !== item || closed) { await media.closeSession(item.mediaId); return; }
422
+ item.mediaCreated = true;
423
+ while (item.pendingRemoteCandidates.length) await media.addCandidates(item.mediaId, item.pendingRemoteCandidates.splice(0, 32));
383
424
  const sdp = isOffer ? result.answer : result.offer;
384
425
  if (typeof sdp !== 'string' || !sdp.length) throw new Error('matter_signaling_failed');
385
426
  await invokeRequestor(item, isOffer ? 'answer' : 'offer', { webRtcSessionId: id, sdp });
386
427
  item.ready = true;
387
428
  if (item.pendingCandidates.length) await invokeRequestor(item, 'iceCandidates', {
388
429
  webRtcSessionId: id, iceCandidates: item.pendingCandidates.splice(0) });
389
- })().catch(() => fail(item, 'matter_signaling_failed')); });
390
- item.timer = setTimeout(() => fail(item, 'matter_session_timeout'), 30000);
391
- item.timer.unref?.();
430
+ })().catch(() => fail(item, 'matter_signaling_failed'));
431
+ };
432
+ enrollMatterSession({ resources, item, transaction: this.context.transaction,
433
+ publish: () => { this.state.currentSessions = [...sessions.values()].map(describeSession); },
434
+ start: startMedia, afterRollback: () => {
435
+ void Promise.resolve(media.closeSession(item.mediaId)).catch(() => {}).then(() => syncState()).catch(() => {});
436
+ } });
392
437
  const response = { webRtcSessionId: id };
393
438
  if (!isOffer) response.deferredOffer = false;
394
439
  if ('videoStreamId' in request) response.videoStreamId = video?.stream.videoStreamId ?? null;
@@ -405,8 +450,13 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
405
450
  async provideIceCandidates({ webRtcSessionId, iceCandidates }) {
406
451
  const item = sessionFor(this, webRtcSessionId);
407
452
  if (!iceCandidates.length || iceCandidates.length > 32) reject(Status.ConstraintError, 'Invalid ICE candidates');
408
- try { await media.addCandidates(item.mediaId, iceCandidates.map(candidate => ({ candidate: candidate.candidate,
409
- sdpMid: candidate.sdpMid ?? undefined, sdpMLineIndex: candidate.sdpmLineIndex ?? undefined }))); }
453
+ const normalized = iceCandidates.map(candidate => ({ candidate: candidate.candidate,
454
+ sdpMid: candidate.sdpMid ?? undefined, sdpMLineIndex: candidate.sdpmLineIndex ?? undefined }));
455
+ if (!item.mediaCreated) {
456
+ if (item.pendingRemoteCandidates.length + normalized.length > 128) reject(Status.ResourceExhausted, 'Too many pending ICE candidates');
457
+ item.pendingRemoteCandidates.push(...normalized); return;
458
+ }
459
+ try { await media.addCandidates(item.mediaId, normalized); }
410
460
  catch { reject(Status.Failure, 'Invalid ICE candidate'); }
411
461
  }
412
462
  async endSession({ webRtcSessionId, reason }) {
@@ -425,7 +475,7 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
425
475
  ...(config.listeningAddressIpv6 ? { listeningAddressIpv6: config.listeningAddressIpv6 } : {}) },
426
476
  basicInformation: { vendorName: 'linanw', vendorId: VendorId(0xfff1), productName: 'Mijia Remote Camera',
427
477
  productId: 0x8001, nodeLabel: text(name, 'Mijia Camera'), serialNumber: text(serialNumber, identifier),
428
- hardwareVersion: 1, softwareVersion: 200, softwareVersionString: '0.2.0' },
478
+ hardwareVersion: 1, softwareVersion: 201, softwareVersionString: '0.2.1' },
429
479
  });
430
480
  const audioCapabilities = { maxNumberOfChannels: 1, supportedCodecs: [0], supportedSampleRates: [48000], supportedBitDepths: [16] };
431
481
  endpoint = new Endpoint(CameraDevice.with(MijiaAvServer, MijiaWebRtcServer, WebRtcTransportRequestorClient), {
@@ -23,6 +23,9 @@ export function safeMatterStatus(value = {}) {
23
23
  if (count(source[key])) media[key] = source[key];
24
24
  }
25
25
  for (const key of ['active', 'microphoneMuted', 'speakerMuted']) if (typeof source[key] === 'boolean') media[key] = source[key];
26
+ for (const [key, allowed] of Object.entries({"lastFailure": ["negotiation_failed", "media_not_offered", "webrtc_timeout", "webrtc_failed", "webrtc_disconnected", "media_unavailable", "udp_error", "ffmpeg_unavailable", "ffmpeg_exit", "video_timeout"], "lastFailureStage": ["remote_description", "replace_video_track", "replace_audio_track", "create_answer", "create_offer", "local_description", "remote_answer"], "lastFailureType": ["Error", "TypeError", "RangeError", "OperationError", "InvalidStateError", "InvalidModificationError", "InvalidAccessError", "NotSupportedError", "SyntaxError", "AbortError"]})) {
27
+ if (allowed.includes(source[key])) media[key] = source[key];
28
+ }
26
29
  if (Object.keys(media).length) result.media = media;
27
30
  }
28
31
  return result;
@@ -8,6 +8,7 @@ const integer = (value, fallback, min, max) => Number.isInteger(value) && value
8
8
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
9
9
  const timer = (callback, ms) => { const value = setTimeout(callback, ms); value.unref(); return value; };
10
10
  const isRtp = bytes => Buffer.isBuffer(bytes) && bytes.length >= 12 && bytes.length <= 2048 && (bytes[0] >> 6) === 2;
11
+ const exceptionTypes = new Set(['Error', 'TypeError', 'RangeError', 'OperationError', 'InvalidStateError', 'InvalidModificationError', 'InvalidAccessError', 'NotSupportedError', 'SyntaxError', 'AbortError']);
11
12
  function localUrl(value, paths) {
12
13
  try {
13
14
  const url = new URL(value);
@@ -113,26 +114,45 @@ export class MatterMedia {
113
114
  if (track.kind === 'audio') track.onReceiveRtp.subscribe(packet => this.receiveTalk(session, packet));
114
115
  });
115
116
  try {
116
- if (offer !== undefined) await peer.setRemoteDescription({ type: 'offer', sdp: offer });
117
+ // Install source codec constraints before applying an offer. With no local
118
+ // source werift chooses the first H264 MIME match, which can be a different
119
+ // profile from the encoded RTP source in a browser's multi-profile offer.
117
120
  for (const [kind, enabled] of [['video', session.video], ['audio', session.audio]]) {
118
121
  if (!enabled) continue;
122
+ session.negotiationStage = `replace_${kind}_track`;
119
123
  const track = new MediaStreamTrack({ kind, codec: codecs[kind][0] }); session[`${kind}Track`] = track;
120
- const existing = offer !== undefined ? peer.getTransceivers().find(t => t.kind === kind && !t.stopped) : undefined;
121
124
  const direction = kind === 'audio' && this.config.enableTalkback !== false ? 'sendrecv' : 'sendonly';
122
- if (existing) { await existing.sender.replaceTrack(track); existing.direction = direction; session[`${kind}Sender`] = existing.sender; }
123
- else if (offer === undefined) session[`${kind}Sender`] = peer.addTransceiver(track, { direction }).sender;
124
- else throw fault('media_not_offered');
125
+ session[`${kind}Sender`] = peer.addTransceiver(track, { direction }).sender;
125
126
  }
126
- await peer.setLocalDescription(offer !== undefined ? await peer.createAnswer() : await peer.createOffer());
127
+ if (offer !== undefined) {
128
+ session.negotiationStage = 'remote_description'; await peer.setRemoteDescription({ type: 'offer', sdp: offer });
129
+ for (const kind of ['video', 'audio']) {
130
+ if (session[kind] && !peer.getTransceivers().some(t => t.sender === session[`${kind}Sender`] && t.mid != null && !t.stopped && !t.rejected)) throw fault('media_not_offered');
131
+ }
132
+ }
133
+ session.negotiationStage = offer !== undefined ? 'create_answer' : 'create_offer';
134
+ const description = offer !== undefined ? await peer.createAnswer() : await peer.createOffer();
135
+ session.negotiationStage = 'local_description'; await peer.setLocalDescription(description);
127
136
  if (!this.alive(session)) throw fault('session_closed');
128
137
  return offer !== undefined ? { answer: peer.localDescription.sdp } : { offer: peer.localDescription.sdp };
129
- } catch (error) { await this.closeSession(id); throw fault(error?.reason === 'media_not_offered' ? 'media_not_offered' : 'negotiation_failed'); }
138
+ } catch (error) {
139
+ const reason = error?.reason === 'media_not_offered' ? 'media_not_offered' : 'negotiation_failed';
140
+ this.negotiationFailure(session, error, reason);
141
+ await this.closeSession(id); throw fault(reason);
142
+ }
143
+ }
144
+ negotiationFailure(session, error, reason = 'negotiation_failed') {
145
+ this.counters.failures++; this.counters.lastFailure = reason;
146
+ this.counters.lastFailureStage = session.negotiationStage || 'create_offer';
147
+ this.counters.lastFailureType = exceptionTypes.has(error?.name) ? error.name : 'Error';
130
148
  }
131
149
  getSession(id) { const session = this.session; if (!session || session.id !== id || session.closed) throw fault('unknown_session'); return session; }
132
150
  async provideAnswer(id, value) {
133
151
  const session = this.getSession(id); sdp(value);
134
152
  if (session.peer.signalingState !== 'have-local-offer') throw fault('invalid_session_state');
135
- try { await session.peer.setRemoteDescription({ type: 'answer', sdp: value }); } catch { this.fail(session, 'negotiation_failed'); throw fault('negotiation_failed'); }
153
+ session.negotiationStage = 'remote_answer';
154
+ try { await session.peer.setRemoteDescription({ type: 'answer', sdp: value }); }
155
+ catch (error) { this.negotiationFailure(session, error); await this.closeSession(id); throw fault('negotiation_failed'); }
136
156
  }
137
157
  async addCandidates(id, candidates) {
138
158
  const session = this.getSession(id);
package/lib/platform.mjs CHANGED
@@ -95,7 +95,7 @@ export class MijiaRemoteCameraPlatform {
95
95
  ?.setCharacteristic(this.api.hap.Characteristic.Manufacturer, 'Xiaomi')
96
96
  .setCharacteristic(this.api.hap.Characteristic.Model, label(selected.model || '米家远程摄像头'))
97
97
  .setCharacteristic(this.api.hap.Characteristic.SerialNumber, `MiCam-${opaqueId}`)
98
- .setCharacteristic(this.api.hap.Characteristic.FirmwareRevision, '0.2.0');
98
+ .setCharacteristic(this.api.hap.Characteristic.FirmwareRevision, '0.2.1');
99
99
  this.delegate = this.delegateFactory({ api: this.api, log: this.log, runtime: this.runtime,
100
100
  config: this.config, ffmpegPath: this.runtime.ffmpegPath });
101
101
  accessory.configureController(this.delegate.controller);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@linanw/homebridge-mijia-remote-camera",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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.2.1",
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.2.1",
5
5
  "description": "Standalone Xiaomi remote camera plugin with HomeKit and experimental Matter camera streaming",
6
6
  "license": "MIT",
7
7
  "author": "linanw",