@linanw/homebridge-mijia-remote-camera 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -7
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/backend/AudioCodecs.java +179 -0
- package/backend/AudioStreamServer.java +182 -0
- package/backend/Bridge.java +63 -7
- package/backend/NightVisionControl.java +239 -0
- package/backend/Talkback.java +83 -0
- package/backend/bridge.jar +0 -0
- package/config.schema.json +35 -0
- package/homebridge-ui/public/index.html +154 -25
- package/homebridge-ui/server.js +12 -1
- package/lib/camera.mjs +261 -5
- package/lib/cloud-control.mjs +250 -0
- package/lib/diagnostics.mjs +32 -1
- package/lib/matter-device.mjs +519 -0
- package/lib/matter-diagnostics.mjs +30 -0
- package/lib/matter-media.mjs +296 -0
- package/lib/matter-service.mjs +117 -0
- package/lib/night-vision.mjs +165 -0
- package/lib/platform.mjs +31 -4
- package/lib/rpc.mjs +5 -2
- package/lib/runtime-factory.mjs +11 -3
- package/lib/runtime.mjs +135 -9
- package/npm-shrinkwrap.json +533 -17
- package/package.json +10 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
插件:`@linanw/homebridge-mijia-remote-camera`,平台:`MijiaRemoteCamera`。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
登录、家庭选择、远程摄像头连接、快照、HomeKit 视频、声音与双向对讲由插件管理,无需独立的 viewer / relay 容器或另一个摄像头插件。摄像头可以位于其他网络。
|
|
6
6
|
|
|
7
7
|
目前后端仅实现 CS2/vendor 4。已在一台小米智能摄像机 3 Pro 云台版上验证跨网络远程取流,以及真实 Apple“家庭”的配对、预览和直播。具体测试环境见下文“验证范围”;其他型号、地区和网络环境尚未逐一验证。
|
|
8
8
|
|
|
@@ -54,9 +54,38 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
|
|
|
54
54
|
|
|
55
55
|
摄像头作为独立配件发布。在 iPhone“家庭 → 添加配件 → 更多选项”中选择它,使用该摄像头启动日志中 `Please add [...] manually in Home app. Setup Code:` 后面的配对码。插件运行在子桥时,这个码可能与 Homebridge 首页的主桥配对码不同。
|
|
56
56
|
|
|
57
|
-
默认最高 640×360、12 fps、700 kbps
|
|
57
|
+
默认最高 640×360、12 fps、700 kbps,最多一路实时视频。0.1.2 起接入摄像头实际返回的 PCMA 或 Opus 音频,经 AAC-ELD 16 kHz 发送到 Apple“家庭”;双向对讲会将“家庭”回传的声音转换为摄像头协议,并通过同一远程会话发送。没有收到真实音频时不会生成替代声音,视频可独立播放。尚未接入云台、运动事件、录像回放及 HomeKit Secure Video。
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
## 声音、对讲与夜视
|
|
60
|
+
|
|
61
|
+
默认开启现场声音、双向对讲和夜视开关。升级后重启摄像头子桥即可,保留已有 HomeKit 配对。在“家庭”直播页面使用对讲按钮,并允许 iPhone 访问麦克风;松开或结束对讲后会停止向摄像头发送声音。声音静音和音量控制会作用于实际音频转发。插件设置页显示是否已收到真实摄像头声音,以及当前协议是否支持对讲。
|
|
62
|
+
|
|
63
|
+
设置页可修改“收听摄像头现场声音”“允许双向对讲”“在家庭中显示夜视开关”;保存后需重启摄像头子桥。对应配置项为 `enableAudio`、`enableTalkback`、`enableNightVision`,默认均为 `true`。关闭现场声音也会禁用对讲。
|
|
64
|
+
|
|
65
|
+
夜视目前仅支持已核对 MIOT 定义的 `chuangmi.camera.061a01`/`chuangmi.camera.061a03`。插件设置页先读取设备实际状态,再选择“自动/开启/关闭”并应用;写入后会回读确认。Apple“家庭”的“夜视”开关表示允许夜视:自动模式显示为开,重复开不会把自动改为强制开启;关掉后重新打开会设置为始终开启。需要恢复自动时,请在插件设置页选择。该开关表示夜视策略,不表示红外灯此刻是否点亮。启动和轮询只读状态,不更改策略。
|
|
66
|
+
|
|
67
|
+
## Matter 摄像头(0.2.0,实验性)
|
|
68
|
+
|
|
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
|
+
|
|
71
|
+
在插件设置页勾选“启用 Matter 摄像头”,选定 NAS 的局域网接口(群晖 host 容器通常为 `eth0`),保存并重启摄像头子桥。默认端口为 `5540`,需可用且允许同一局域网的 TCP/UDP;mDNS 与 IPv6 也需可用。高级配置示例:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"platform": "MijiaRemoteCamera",
|
|
76
|
+
"enableMatter": true,
|
|
77
|
+
"matterInterface": "eth0",
|
|
78
|
+
"matterPort": 5540
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
升级默认不开启 Matter。启用后,从受保护的插件设置页点击显示 Matter 配对信息,将手动配对码或 `MT:` 内容输入控制器。它与 HomeKit Setup Code 不同。已配对后增加其他控制器时可在设置页重新打开配对窗口,操作完成后可以关闭窗口。配对码和 Matter 私钥不会写入脱敏报告。Matter fabric/设备身份保存在插件私有存储的 `matter/` 内,升级和正常重启保留身份;删除该目录会使已有配对失效。
|
|
83
|
+
|
|
84
|
+
这是未认证的开发设备,使用 SDK 的测试 VID/开发证书。matter-server 默认会拒绝这种证书;只在明确信任此开发设备时,使用它的 `--enable-test-net-dcl`(或 `ENABLE_TEST_NET_DCL=true`)选项。插件不冒充通过认证的产品,客户端普通启动应保留严格验证。
|
|
85
|
+
|
|
86
|
+
Matter 信令及 WebRTC 播放目前面向同一局域网,无外部 STUN/TURN,不能据此承诺跨网 Matter 播放。远端摄像头与 NAS 可以继续处于不同网络,由原有小米远程连接取流。最多一路 Matter 实时会话,默认视频上限 640×360、12 fps、700 kbps,可使用已有 `maxWidth`/`maxHeight`/`maxFPS`/`maxBitrate` 配置调整。没有声明录像、云台、运动事件或云存储能力。Matter 和 HomeKit 共享同一个远程源,分别编码;群晖上同时播放会增加 CPU 使用。
|
|
87
|
+
|
|
88
|
+
打开直播时客户端会唤起插件取流;小米中继仍可能间歇连接失败,与是否先打开米家 App 无关。连接失败时会结束当前 Matter 会话,待源恢复后在客户端重新打开。当前未完成长时间稳定性验证。
|
|
60
89
|
|
|
61
90
|
## 数据和运行管理
|
|
62
91
|
|
|
@@ -68,23 +97,24 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
|
|
|
68
97
|
selection.json
|
|
69
98
|
region.json
|
|
70
99
|
runtime/
|
|
100
|
+
matter/
|
|
71
101
|
```
|
|
72
102
|
|
|
73
|
-
私有目录权限为 `0700`,凭据和选择文件为 `0600`。小米 Token 不写入 Homebridge `config.json`。设置页通过 Homebridge 的受保护接口和本机 Unix socket
|
|
103
|
+
私有目录权限为 `0700`,凭据和选择文件为 `0600`。小米 Token 不写入 Homebridge `config.json`。设置页通过 Homebridge 的受保护接口和本机 Unix socket 通信;原始视频和音频仅监听临时的本机回环端口,不保存录音。对讲接口也仅允许本机访问。Homebridge 启动后恢复所选摄像头,关闭时清理 Java、FFmpeg 和 UDP 会话,临时网络故障会自动重连。
|
|
74
104
|
|
|
75
105
|
插件按单个账号、单个摄像头设计,按所选家庭及实际所有者查询完整设备分页。迁移前请备份 Homebridge 存储目录。
|
|
76
106
|
|
|
77
107
|
## 验证范围
|
|
78
108
|
|
|
79
|
-
实际设备验证使用一台小米智能摄像机 3 Pro 云台版,摄像头与 NAS 位于不同网络。Synology DS1618+ 上的 Homebridge Docker 使用 host 网络,Homebridge 2.0.2 / HAP 2.1.6;远程源为 HEVC,由 NAS 解码并转换为 HomeKit 视频。用户已确认 iOS 26.6.2 的 Apple“家庭”预览和直播正常。
|
|
109
|
+
实际设备验证使用一台小米智能摄像机 3 Pro 云台版,摄像头与 NAS 位于不同网络。Synology DS1618+ 上的 Homebridge Docker 使用 host 网络,Homebridge 2.0.2 / HAP 2.1.6;远程源为 HEVC,由 NAS 解码并转换为 HomeKit 视频。用户已确认 iOS 26.6.2 的 Apple“家庭”预览和直播正常。0.1.2 在同一台摄像头上确认收到真实 Opus 48 kHz 双声道音频并成功独立解码;HomeKit 音频发送和手机回传的对讲音频也已在实机日志中确认,摄像头扬声器的可听效果仍需现场确认。夜视已在实机通过读取、关闭、回读确认、恢复原模式的往返验证,操作约需 0.6–2.2 秒。
|
|
80
110
|
|
|
81
|
-
本地测试另覆盖 Homebridge 1/2 的原生 HAP CameraController
|
|
111
|
+
本地测试另覆盖 Homebridge 1/2 的原生 HAP CameraController、加密快照、视频与双向音频的 SRTP 发送/独立接收解码、夜视读取与写后回读、进程清理、授权取消与原子保存、家庭作用域、真实 Homebridge 设置页 IPC 和本地 Java 启动。
|
|
82
112
|
|
|
83
113
|
远程中继仍可能间歇断开并自动重连,重连期间预览和直播可能暂不可用。目前尚未完成长时间稳定性验证。
|
|
84
114
|
|
|
85
115
|
## 连接诊断
|
|
86
116
|
|
|
87
|
-
设置页可展开“连接诊断”,查看连接阶段、重试次数、上次失败原因及 HomeKit
|
|
117
|
+
设置页可展开“连接诊断”,查看连接阶段、重试次数、上次失败原因及 HomeKit 快照/直播/音频/对讲请求计数。“已收到摄像头视频”表示米家视频源已就绪;快照和直播计数则显示 HomeKit 是否已请求媒体。也可在 Homebridge 终端运行:
|
|
88
118
|
|
|
89
119
|
```sh
|
|
90
120
|
mijia-camera-diagnose --storage /var/lib/homebridge
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -29,3 +29,12 @@ are separate from the package and retain their original rights and notices.
|
|
|
29
29
|
|
|
30
30
|
The HomeKit streaming delegate uses Homebridge's public HAP API and requires
|
|
31
31
|
neither `homebridge-camera-ffmpeg` nor an independent relay service.
|
|
32
|
+
|
|
33
|
+
The optional Matter camera implementation resolves `@matter/main` and
|
|
34
|
+
`@matter/nodejs` 0.18.0 (Apache-2.0) and `werift` 0.25.1 (MIT), together with
|
|
35
|
+
their transitive npm dependencies under their own licenses. These libraries
|
|
36
|
+
are installed by npm, not bundled in this package tarball. Matter development
|
|
37
|
+
attestation credentials are supplied by the SDK. This package is an experimental
|
|
38
|
+
development device and makes no certification claim. WebRTC media encryption and
|
|
39
|
+
signaling use these libraries; FFmpeg conversion continues to use the separately
|
|
40
|
+
imported local binary described above.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
package mijia.bridge;
|
|
2
|
+
|
|
3
|
+
import java.io.ByteArrayOutputStream;
|
|
4
|
+
import java.io.EOFException;
|
|
5
|
+
import java.io.IOException;
|
|
6
|
+
import java.io.InputStream;
|
|
7
|
+
import java.nio.ByteBuffer;
|
|
8
|
+
import java.nio.ByteOrder;
|
|
9
|
+
import java.nio.charset.StandardCharsets;
|
|
10
|
+
import java.util.ArrayList;
|
|
11
|
+
import java.util.Arrays;
|
|
12
|
+
import java.util.List;
|
|
13
|
+
|
|
14
|
+
/** Owned, bounded G.711 A-law and Ogg/Opus framing. It never decodes or stores camera content on disk. */
|
|
15
|
+
final class AudioCodecs {
|
|
16
|
+
static final int MAX_PACKET = 16384;
|
|
17
|
+
private AudioCodecs() { }
|
|
18
|
+
|
|
19
|
+
static short decodeAlaw(byte value) {
|
|
20
|
+
int a = (value & 255) ^ 0x55;
|
|
21
|
+
int t = (a & 15) << 4, segment = (a & 0x70) >>> 4;
|
|
22
|
+
if (segment == 0) t += 8;
|
|
23
|
+
else { t += 0x108; if (segment > 1) t <<= segment - 1; }
|
|
24
|
+
return (short) ((a & 0x80) != 0 ? t : -t);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static byte encodeAlaw(short value) {
|
|
28
|
+
int pcm = value, mask;
|
|
29
|
+
if (pcm >= 0) mask = 0xD5;
|
|
30
|
+
else { mask = 0x55; pcm = -pcm - 1; }
|
|
31
|
+
pcm = Math.min(pcm, 32767);
|
|
32
|
+
int segment = 0;
|
|
33
|
+
for (int end = 0xFF; segment < 8 && pcm > end; segment++, end = (end << 1) | 1) { }
|
|
34
|
+
if (segment >= 8) return (byte) (0x7F ^ mask);
|
|
35
|
+
int a = (segment << 4) | ((pcm >>> (segment < 2 ? 4 : segment + 3)) & 15);
|
|
36
|
+
return (byte) (a ^ mask);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static byte[] pcmFromAlaw(byte[] bytes) {
|
|
40
|
+
if (bytes.length > MAX_PACKET) throw new IllegalArgumentException("audio_packet_size");
|
|
41
|
+
ByteBuffer out = ByteBuffer.allocate(bytes.length * 2).order(ByteOrder.LITTLE_ENDIAN);
|
|
42
|
+
for (byte value : bytes) out.putShort(decodeAlaw(value));
|
|
43
|
+
return out.array();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static byte[] wavHeader(int sampleRate) {
|
|
47
|
+
if (sampleRate != 8000 && sampleRate != 16000) throw new IllegalArgumentException("audio_rate");
|
|
48
|
+
ByteBuffer out = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN);
|
|
49
|
+
out.put("RIFF".getBytes(StandardCharsets.US_ASCII)).putInt(-1);
|
|
50
|
+
out.put("WAVEfmt ".getBytes(StandardCharsets.US_ASCII)).putInt(16).putShort((short) 1).putShort((short) 1);
|
|
51
|
+
out.putInt(sampleRate).putInt(sampleRate * 2).putShort((short) 2).putShort((short) 16);
|
|
52
|
+
out.put("data".getBytes(StandardCharsets.US_ASCII)).putInt(-1);
|
|
53
|
+
return out.array();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** RFC 6716 section 3: samples represented by an Opus packet at the 48 kHz clock. */
|
|
57
|
+
static int opusSamples(byte[] packet) {
|
|
58
|
+
if (packet.length == 0 || packet.length > MAX_PACKET) throw new IllegalArgumentException("opus_packet_size");
|
|
59
|
+
int toc = packet[0] & 255, config = toc >>> 3, frame;
|
|
60
|
+
if (config >= 16) frame = 120 << (config & 3);
|
|
61
|
+
else if (config >= 12) frame = 480 << (config & 1);
|
|
62
|
+
else frame = switch (config & 3) { case 0 -> 480; case 1 -> 960; case 2 -> 1920; default -> 2880; };
|
|
63
|
+
int count = switch (toc & 3) {
|
|
64
|
+
case 0 -> 1;
|
|
65
|
+
case 1, 2 -> 2;
|
|
66
|
+
default -> packet.length >= 2 ? packet[1] & 63 : 0;
|
|
67
|
+
};
|
|
68
|
+
int samples = frame * count;
|
|
69
|
+
if (count == 0 || samples > 5760) throw new IllegalArgumentException("opus_duration");
|
|
70
|
+
return samples;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static int oggCrc(byte[] bytes) {
|
|
74
|
+
int crc = 0;
|
|
75
|
+
for (byte value : bytes) {
|
|
76
|
+
crc ^= (value & 255) << 24;
|
|
77
|
+
for (int bit = 0; bit < 8; bit++) crc = (crc & 0x80000000) != 0 ? (crc << 1) ^ 0x04C11DB7 : crc << 1;
|
|
78
|
+
}
|
|
79
|
+
return crc;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static final class OggWriter {
|
|
83
|
+
private final int serial;
|
|
84
|
+
private int sequence;
|
|
85
|
+
private long samples;
|
|
86
|
+
OggWriter(int serial) { this.serial = serial; }
|
|
87
|
+
byte[] headers() {
|
|
88
|
+
ByteBuffer head = ByteBuffer.allocate(19).order(ByteOrder.LITTLE_ENDIAN);
|
|
89
|
+
head.put("OpusHead".getBytes(StandardCharsets.US_ASCII)).put((byte) 1).put((byte) 2);
|
|
90
|
+
head.putShort((short) 0).putInt(48000).putShort((short) 0).put((byte) 0);
|
|
91
|
+
byte[] vendor = "mijia-homebridge".getBytes(StandardCharsets.US_ASCII);
|
|
92
|
+
ByteBuffer tags = ByteBuffer.allocate(16 + vendor.length).order(ByteOrder.LITTLE_ENDIAN);
|
|
93
|
+
tags.put("OpusTags".getBytes(StandardCharsets.US_ASCII)).putInt(vendor.length).put(vendor).putInt(0);
|
|
94
|
+
byte[] first = page(head.array(), 2, 0), second = page(tags.array(), 0, 0);
|
|
95
|
+
byte[] result = Arrays.copyOf(first, first.length + second.length);
|
|
96
|
+
System.arraycopy(second, 0, result, first.length, second.length);
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
byte[] packet(byte[] bytes) {
|
|
100
|
+
samples += opusSamples(bytes);
|
|
101
|
+
return page(bytes, 0, samples);
|
|
102
|
+
}
|
|
103
|
+
private byte[] page(byte[] packet, int flags, long granule) {
|
|
104
|
+
if (packet.length > MAX_PACKET) throw new IllegalArgumentException("ogg_packet_size");
|
|
105
|
+
int segments = packet.length / 255 + 1;
|
|
106
|
+
ByteBuffer page = ByteBuffer.allocate(27 + segments + packet.length).order(ByteOrder.LITTLE_ENDIAN);
|
|
107
|
+
page.put("OggS".getBytes(StandardCharsets.US_ASCII)).put((byte) 0).put((byte) flags).putLong(granule);
|
|
108
|
+
page.putInt(serial).putInt(sequence++).putInt(0).put((byte) segments);
|
|
109
|
+
for (int left = packet.length, i = 0; i < segments; i++) { int n = Math.min(255, left); page.put((byte) n); left -= n; }
|
|
110
|
+
page.put(packet);
|
|
111
|
+
byte[] result = page.array();
|
|
112
|
+
ByteBuffer.wrap(result).order(ByteOrder.LITTLE_ENDIAN).putInt(22, oggCrc(result));
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A bounded Ogg demuxer for the local FFmpeg talkback POST, not arbitrary remote media. */
|
|
118
|
+
static final class OggReader {
|
|
119
|
+
private final InputStream input;
|
|
120
|
+
private final ByteArrayOutputStream partial = new ByteArrayOutputStream();
|
|
121
|
+
private final List<byte[]> ready = new ArrayList<>();
|
|
122
|
+
private Integer serial;
|
|
123
|
+
private int sequence, headers;
|
|
124
|
+
OggReader(InputStream input) { this.input = input; }
|
|
125
|
+
byte[] next() throws IOException {
|
|
126
|
+
for (;;) {
|
|
127
|
+
if (!ready.isEmpty()) return ready.remove(0);
|
|
128
|
+
byte[] header = input.readNBytes(27);
|
|
129
|
+
if (header.length == 0) {
|
|
130
|
+
if (partial.size() != 0) throw new EOFException("ogg_truncated");
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (header.length != 27 || !Arrays.equals(Arrays.copyOf(header, 4), "OggS".getBytes(StandardCharsets.US_ASCII)) || header[4] != 0)
|
|
134
|
+
throw new IOException("ogg_header");
|
|
135
|
+
int flags = header[5] & 255;
|
|
136
|
+
if ((flags & ~7) != 0 || ((flags & 1) != 0) != (partial.size() != 0)) throw new IOException("ogg_continuation");
|
|
137
|
+
ByteBuffer fields = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN);
|
|
138
|
+
int currentSerial = fields.getInt(14), currentSequence = fields.getInt(18), crc = fields.getInt(22);
|
|
139
|
+
if (serial == null) { if (currentSequence != 0 || (flags & 2) == 0) throw new IOException("ogg_begin"); serial = currentSerial; }
|
|
140
|
+
if (serial != currentSerial || currentSequence != sequence++) throw new IOException("ogg_sequence");
|
|
141
|
+
int count = header[26] & 255;
|
|
142
|
+
byte[] lacing = input.readNBytes(count);
|
|
143
|
+
if (lacing.length != count) throw new EOFException("ogg_truncated");
|
|
144
|
+
int size = 0; for (byte value : lacing) size += value & 255;
|
|
145
|
+
if (size > 65536) throw new IOException("ogg_page_size");
|
|
146
|
+
byte[] body = input.readNBytes(size);
|
|
147
|
+
if (body.length != size) throw new EOFException("ogg_truncated");
|
|
148
|
+
byte[] page = new byte[27 + count + size];
|
|
149
|
+
System.arraycopy(header, 0, page, 0, 27); Arrays.fill(page, 22, 26, (byte) 0);
|
|
150
|
+
System.arraycopy(lacing, 0, page, 27, count); System.arraycopy(body, 0, page, 27 + count, size);
|
|
151
|
+
if (oggCrc(page) != crc) throw new IOException("ogg_crc");
|
|
152
|
+
int offset = 0;
|
|
153
|
+
for (byte value : lacing) {
|
|
154
|
+
int length = value & 255;
|
|
155
|
+
if (partial.size() + length > MAX_PACKET) throw new IOException("ogg_packet_size");
|
|
156
|
+
partial.write(body, offset, length); offset += length;
|
|
157
|
+
if (length < 255) {
|
|
158
|
+
byte[] packet = partial.toByteArray(); partial.reset();
|
|
159
|
+
if (headers == 0) {
|
|
160
|
+
if (packet.length < 19 || !starts(packet, "OpusHead") || packet[8] != 1 || packet[9] != 2 || packet[18] != 0)
|
|
161
|
+
throw new IOException("opus_header");
|
|
162
|
+
headers++;
|
|
163
|
+
} else if (headers == 1) {
|
|
164
|
+
if (!starts(packet, "OpusTags")) throw new IOException("opus_tags");
|
|
165
|
+
headers++;
|
|
166
|
+
} else {
|
|
167
|
+
try { opusSamples(packet); } catch (IllegalArgumentException invalid) { throw new IOException("opus_packet"); }
|
|
168
|
+
ready.add(packet);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
private static boolean starts(byte[] packet, String value) {
|
|
175
|
+
byte[] prefix = value.getBytes(StandardCharsets.US_ASCII);
|
|
176
|
+
return packet.length >= prefix.length && Arrays.equals(Arrays.copyOf(packet, prefix.length), prefix);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
package mijia.bridge;
|
|
2
|
+
|
|
3
|
+
import com.sun.net.httpserver.HttpExchange;
|
|
4
|
+
import com.sun.net.httpserver.HttpServer;
|
|
5
|
+
import micam.miss.MediaPacket;
|
|
6
|
+
|
|
7
|
+
import java.io.Closeable;
|
|
8
|
+
import java.io.IOException;
|
|
9
|
+
import java.io.InputStream;
|
|
10
|
+
import java.io.OutputStream;
|
|
11
|
+
import java.net.InetSocketAddress;
|
|
12
|
+
import java.security.SecureRandom;
|
|
13
|
+
import java.util.Arrays;
|
|
14
|
+
import java.util.Set;
|
|
15
|
+
import java.util.concurrent.ArrayBlockingQueue;
|
|
16
|
+
import java.util.concurrent.CopyOnWriteArraySet;
|
|
17
|
+
import java.util.concurrent.Executors;
|
|
18
|
+
import java.util.concurrent.ScheduledExecutorService;
|
|
19
|
+
import java.util.concurrent.Semaphore;
|
|
20
|
+
import java.util.concurrent.ThreadPoolExecutor;
|
|
21
|
+
import java.util.concurrent.TimeUnit;
|
|
22
|
+
import java.util.concurrent.atomic.AtomicBoolean;
|
|
23
|
+
import java.util.concurrent.atomic.AtomicReference;
|
|
24
|
+
|
|
25
|
+
/** Loopback-only live audio and one bounded talkback POST. Camera readout never waits for a listener. */
|
|
26
|
+
final class AudioStreamServer implements Closeable {
|
|
27
|
+
record Format(String codec, int sampleRate, int channels) {
|
|
28
|
+
static Format of(MediaPacket packet) {
|
|
29
|
+
if (packet.payload().length == 0 || packet.payload().length > AudioCodecs.MAX_PACKET) return null;
|
|
30
|
+
if (packet.codecId() == MediaPacket.CODEC_PCMA)
|
|
31
|
+
return new Format("PCMA", ((packet.flags() >>> 3) & 15) == 0 ? 8000 : 16000, 1);
|
|
32
|
+
if (packet.codecId() == MediaPacket.CODEC_OPUS) {
|
|
33
|
+
try { AudioCodecs.opusSamples(packet.payload()); } catch (IllegalArgumentException invalid) { return null; }
|
|
34
|
+
return new Format("OPUS", 48000, 2);
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
String path() { return codec.equals("PCMA") ? "/audio.wav" : "/audio.ogg"; }
|
|
39
|
+
String talkPath() { return codec.equals("PCMA") ? "/talk.pcm" : "/talk.ogg"; }
|
|
40
|
+
String talkFormat() { return codec.equals("PCMA") ? "s16le" : "ogg"; }
|
|
41
|
+
int talkSampleRate() { return codec.equals("PCMA") ? 8000 : 48000; }
|
|
42
|
+
int talkChannels() { return codec.equals("PCMA") ? 1 : 2; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private final HttpServer server;
|
|
46
|
+
private final ThreadPoolExecutor executor;
|
|
47
|
+
private final ScheduledExecutorService timer;
|
|
48
|
+
private final Format format;
|
|
49
|
+
private final Talkback talkback;
|
|
50
|
+
private final Set<Viewer> viewers = new CopyOnWriteArraySet<>();
|
|
51
|
+
private final Semaphore viewerSlots = new Semaphore(4);
|
|
52
|
+
private final AtomicBoolean talking = new AtomicBoolean();
|
|
53
|
+
private final AtomicReference<HttpExchange> talkExchange = new AtomicReference<>();
|
|
54
|
+
private volatile long lastTalkActivity, talkStarted;
|
|
55
|
+
private volatile boolean closed;
|
|
56
|
+
|
|
57
|
+
AudioStreamServer(Format format, Talkback talkback) throws IOException {
|
|
58
|
+
this.format = format; this.talkback = talkback;
|
|
59
|
+
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 8);
|
|
60
|
+
executor = new ThreadPoolExecutor(6, 6, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(16), runnable -> {
|
|
61
|
+
Thread thread = new Thread(runnable, "mijia-audio-http"); thread.setDaemon(true); return thread;
|
|
62
|
+
}, new ThreadPoolExecutor.AbortPolicy());
|
|
63
|
+
server.setExecutor(executor);
|
|
64
|
+
server.createContext("/", this::handle);
|
|
65
|
+
timer = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
|
66
|
+
Thread thread = new Thread(runnable, "mijia-talk-timeout"); thread.setDaemon(true); return thread;
|
|
67
|
+
});
|
|
68
|
+
timer.scheduleAtFixedRate(() -> {
|
|
69
|
+
HttpExchange exchange = talkExchange.get();
|
|
70
|
+
long now = System.nanoTime();
|
|
71
|
+
if (exchange != null && (now - lastTalkActivity > TimeUnit.SECONDS.toNanos(5)
|
|
72
|
+
|| now - talkStarted > TimeUnit.MINUTES.toNanos(2))) exchange.close();
|
|
73
|
+
}, 1, 1, TimeUnit.SECONDS);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
int port() { return server.getAddress().getPort(); }
|
|
77
|
+
Format format() { return format; }
|
|
78
|
+
void start() { server.start(); }
|
|
79
|
+
|
|
80
|
+
void publish(MediaPacket packet) {
|
|
81
|
+
if (closed || !format.equals(Format.of(packet))) return;
|
|
82
|
+
byte[] payload = packet.payload();
|
|
83
|
+
for (Viewer viewer : viewers) viewer.offer(payload);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private void handle(HttpExchange exchange) throws IOException {
|
|
87
|
+
String path = exchange.getRequestURI().getRawPath();
|
|
88
|
+
String host = exchange.getRequestHeaders().getFirst("Host");
|
|
89
|
+
if (closed || !exchange.getRemoteAddress().getAddress().isLoopbackAddress()
|
|
90
|
+
|| !("127.0.0.1:" + port()).equals(host) || exchange.getRequestURI().getRawQuery() != null
|
|
91
|
+
|| exchange.getRequestHeaders().containsKey("Origin")) { reject(exchange, 403); return; }
|
|
92
|
+
if (path.equals(format.path()) && exchange.getRequestMethod().equals("GET")) { listen(exchange); return; }
|
|
93
|
+
if (path.equals(format.talkPath()) && exchange.getRequestMethod().equals("POST") && talkback != null) { talk(exchange); return; }
|
|
94
|
+
reject(exchange, path.equals(format.path()) || path.equals(format.talkPath()) ? 405 : 404);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private void listen(HttpExchange exchange) throws IOException {
|
|
98
|
+
if (!viewerSlots.tryAcquire()) { reject(exchange, 503); return; }
|
|
99
|
+
Viewer viewer = new Viewer();
|
|
100
|
+
viewers.add(viewer);
|
|
101
|
+
try {
|
|
102
|
+
exchange.getResponseHeaders().set("Content-Type", format.codec.equals("PCMA") ? "audio/wav" : "audio/ogg");
|
|
103
|
+
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
|
104
|
+
exchange.sendResponseHeaders(200, 0);
|
|
105
|
+
try (OutputStream output = exchange.getResponseBody()) {
|
|
106
|
+
AudioCodecs.OggWriter ogg = format.codec.equals("OPUS") ? new AudioCodecs.OggWriter(new SecureRandom().nextInt()) : null;
|
|
107
|
+
output.write(ogg == null ? AudioCodecs.wavHeader(format.sampleRate) : ogg.headers()); output.flush();
|
|
108
|
+
long lastPacket = System.nanoTime();
|
|
109
|
+
while (!closed) {
|
|
110
|
+
byte[] packet;
|
|
111
|
+
try { packet = viewer.queue.poll(250, TimeUnit.MILLISECONDS); }
|
|
112
|
+
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); return; }
|
|
113
|
+
if (packet == null) {
|
|
114
|
+
if (System.nanoTime() - lastPacket > TimeUnit.SECONDS.toNanos(10)) return;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
lastPacket = System.nanoTime();
|
|
118
|
+
try {
|
|
119
|
+
byte[] encoded = ogg == null ? AudioCodecs.pcmFromAlaw(packet) : ogg.packet(packet);
|
|
120
|
+
try { output.write(encoded); output.flush(); }
|
|
121
|
+
finally { Arrays.fill(encoded, (byte) 0); }
|
|
122
|
+
} finally { Arrays.fill(packet, (byte) 0); }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch (IOException ignored) { }
|
|
126
|
+
finally { viewers.remove(viewer); viewer.clear(); viewerSlots.release(); exchange.close(); }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private void talk(HttpExchange exchange) throws IOException {
|
|
130
|
+
if (!talking.compareAndSet(false, true)) { reject(exchange, 409); return; }
|
|
131
|
+
talkStarted = lastTalkActivity = System.nanoTime(); talkExchange.set(exchange);
|
|
132
|
+
boolean started = false;
|
|
133
|
+
// The response acknowledges the local transport; speaker/codec correctness is verified separately on the real camera.
|
|
134
|
+
try {
|
|
135
|
+
exchange.getResponseHeaders().set("Cache-Control", "no-store"); exchange.sendResponseHeaders(200, 0);
|
|
136
|
+
try (InputStream input = exchange.getRequestBody(); OutputStream response = exchange.getResponseBody()) {
|
|
137
|
+
if (format.codec.equals("PCMA")) {
|
|
138
|
+
byte[] pcm;
|
|
139
|
+
while (!closed && (pcm = input.readNBytes(640)).length > 0) {
|
|
140
|
+
lastTalkActivity = System.nanoTime();
|
|
141
|
+
if (pcm.length != 640) { Arrays.fill(pcm, (byte) 0); break; }
|
|
142
|
+
if (!started) { talkback.start(); started = true; }
|
|
143
|
+
try { talkback.pcma(pcm); } finally { Arrays.fill(pcm, (byte) 0); }
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
AudioCodecs.OggReader ogg = new AudioCodecs.OggReader(input);
|
|
147
|
+
byte[] packet;
|
|
148
|
+
while (!closed && (packet = ogg.next()) != null) {
|
|
149
|
+
lastTalkActivity = System.nanoTime();
|
|
150
|
+
if (!started) { talkback.start(); started = true; }
|
|
151
|
+
try { talkback.opus(packet); } finally { Arrays.fill(packet, (byte) 0); }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch (IOException | RuntimeException ignored) { }
|
|
156
|
+
finally {
|
|
157
|
+
if (started) talkback.stop();
|
|
158
|
+
talkExchange.compareAndSet(exchange, null); talking.set(false); exchange.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private static void reject(HttpExchange exchange, int status) throws IOException {
|
|
163
|
+
exchange.sendResponseHeaders(status, -1); exchange.close();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@Override public void close() {
|
|
167
|
+
if (closed) return; closed = true;
|
|
168
|
+
HttpExchange exchange = talkExchange.getAndSet(null); if (exchange != null) exchange.close();
|
|
169
|
+
server.stop(0); timer.shutdownNow(); executor.shutdownNow();
|
|
170
|
+
if (talkback != null) talkback.close();
|
|
171
|
+
for (Viewer viewer : viewers) viewer.clear(); viewers.clear();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private static final class Viewer {
|
|
175
|
+
final ArrayBlockingQueue<byte[]> queue = new ArrayBlockingQueue<>(64);
|
|
176
|
+
void offer(byte[] bytes) {
|
|
177
|
+
byte[] own = Arrays.copyOf(bytes, bytes.length);
|
|
178
|
+
if (!queue.offer(own)) { byte[] dropped = queue.poll(); if (dropped != null) Arrays.fill(dropped, (byte) 0); if (!queue.offer(own)) Arrays.fill(own, (byte) 0); }
|
|
179
|
+
}
|
|
180
|
+
void clear() { byte[] bytes; while ((bytes = queue.poll()) != null) Arrays.fill(bytes, (byte) 0); }
|
|
181
|
+
}
|
|
182
|
+
}
|
package/backend/Bridge.java
CHANGED
|
@@ -106,9 +106,12 @@ public final class Bridge {
|
|
|
106
106
|
if (!Set.of("homes", "devices", "stream").contains(action)) throw new Failure(7, "invalid_request");
|
|
107
107
|
if (!(action.equals("homes") && args.length == 2)
|
|
108
108
|
&& !(action.equals("devices") && args.length == 4)
|
|
109
|
-
&& !(action.equals("stream") && args.length == 7)) throw new Failure(7, "invalid_request");
|
|
109
|
+
&& !(action.equals("stream") && (args.length == 7 || args.length == 8))) throw new Failure(7, "invalid_request");
|
|
110
110
|
if (args.length >= 4) { identifier(args[2]); identifier(args[3]); }
|
|
111
|
-
if (action.equals("stream")) {
|
|
111
|
+
if (action.equals("stream")) {
|
|
112
|
+
identifier(args[4]); number(args[5], 1024, 65535); number(args[6], 0, 3);
|
|
113
|
+
if (args.length == 8) number(args[7], 0, 1);
|
|
114
|
+
}
|
|
112
115
|
try { credentials = store.load(); }
|
|
113
116
|
catch (RuntimeException invalid) { throw new Failure(2, "authentication_required"); }
|
|
114
117
|
credentials.region = args[1];
|
|
@@ -130,12 +133,13 @@ public final class Bridge {
|
|
|
130
133
|
"online", device.online())).toList()));
|
|
131
134
|
return 0;
|
|
132
135
|
}
|
|
133
|
-
if (!action.equals("stream") || args.length != 7) throw new Failure(7, "invalid_request");
|
|
136
|
+
if (!action.equals("stream") || (args.length != 7 && args.length != 8)) throw new Failure(7, "invalid_request");
|
|
134
137
|
String did = identifier(args[4]);
|
|
135
138
|
int port = number(args[5], 1024, 65535);
|
|
136
139
|
int quality = number(args[6], 0, 3);
|
|
137
140
|
Device device = cameras.stream().filter(camera -> did.equals(camera.did())).findFirst()
|
|
138
141
|
.orElseThrow(() -> new Failure(7, "camera_not_in_home"));
|
|
142
|
+
NightVisionControl.cacheVerifiedCapability(credentials, home, device);
|
|
139
143
|
if (!device.online()) throw new Failure(4, "camera_offline");
|
|
140
144
|
progress(Stage.PROVISIONING);
|
|
141
145
|
Provisioning provision = request(() -> cloud.missGetVendor(did));
|
|
@@ -143,7 +147,7 @@ public final class Bridge {
|
|
|
143
147
|
emit(Map.of("success", false, "reason", "unsupported_transport", "vendor", provision.vendorId()));
|
|
144
148
|
return 1;
|
|
145
149
|
}
|
|
146
|
-
return stream(provision, port, quality);
|
|
150
|
+
return stream(provision, port, quality, args.length == 8 && "1".equals(args[7]));
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
private <T> T request(Supplier<T> call) {
|
|
@@ -174,7 +178,7 @@ public final class Bridge {
|
|
|
174
178
|
} catch (Exception failure) { throw new Failure(2, "authentication_required"); }
|
|
175
179
|
}
|
|
176
180
|
|
|
177
|
-
private int stream(Provisioning provision, int port, int quality) {
|
|
181
|
+
private int stream(Provisioning provision, int port, int quality, boolean audioEnabled) {
|
|
178
182
|
Resources resources = new Resources();
|
|
179
183
|
AtomicBoolean running = new AtomicBoolean(true);
|
|
180
184
|
Thread hook = new Thread(() -> { running.set(false); resources.close(); }, "mijia-bridge-stop");
|
|
@@ -188,13 +192,59 @@ public final class Bridge {
|
|
|
188
192
|
resources.camera = new MissClient(resources.connection);
|
|
189
193
|
progress(Stage.AUTHENTICATION, provision.vendorId());
|
|
190
194
|
resources.camera.authenticate(provision.clientPublic(), provision.sign(), Duration.ofSeconds(15));
|
|
191
|
-
resources.
|
|
195
|
+
resources.sessionKey = MissCrypto.sessionKey(provision.clientPrivate(), provision.devicePublic());
|
|
196
|
+
resources.camera.useSessionKey(resources.sessionKey);
|
|
192
197
|
progress(Stage.WAITING_VIDEO, provision.vendorId());
|
|
193
|
-
resources.camera.startVideo(quality,
|
|
198
|
+
resources.camera.startVideo(quality, audioEnabled);
|
|
194
199
|
VideoWatchdog watchdog = new VideoWatchdog(System::nanoTime, Duration.ofSeconds(10), Duration.ofSeconds(15));
|
|
200
|
+
long lastAudio = 0, lastAudioStatus = 0;
|
|
201
|
+
boolean audioPresent = false, unsupportedReported = false;
|
|
195
202
|
while (running.get()) {
|
|
196
203
|
MediaPacket frame = resources.camera.nextMedia(Duration.ofSeconds(1));
|
|
197
204
|
watchdog.observe(frame != null && frame.isVideo(), frame != null && frame.isVideo() && frame.isKeyframe());
|
|
205
|
+
long now = System.nanoTime();
|
|
206
|
+
if (audioEnabled && frame != null && !frame.isVideo()) {
|
|
207
|
+
AudioStreamServer.Format format = AudioStreamServer.Format.of(frame);
|
|
208
|
+
if (format != null) {
|
|
209
|
+
if (resources.audio == null) {
|
|
210
|
+
try {
|
|
211
|
+
resources.talkback = new Talkback(resources.camera, resources.connection, resources.sessionKey);
|
|
212
|
+
resources.audio = new AudioStreamServer(format, resources.talkback);
|
|
213
|
+
resources.audio.start();
|
|
214
|
+
emit(Map.ofEntries(Map.entry("success", true), Map.entry("event", "audio_ready"),
|
|
215
|
+
Map.entry("audioPort", resources.audio.port()), Map.entry("audioCodec", format.codec()),
|
|
216
|
+
Map.entry("format", format.codec().equals("PCMA") ? "wav" : "ogg"),
|
|
217
|
+
Map.entry("sampleRate", format.sampleRate()), Map.entry("channels", format.channels()),
|
|
218
|
+
Map.entry("talkFormat", format.talkFormat()), Map.entry("talkSampleRate", format.talkSampleRate()),
|
|
219
|
+
Map.entry("talkChannels", format.talkChannels()), Map.entry("talkPath", format.talkPath()),
|
|
220
|
+
Map.entry("flags", frame.flags()), Map.entry("payloadBytes", frame.payload().length)));
|
|
221
|
+
} catch (IOException | RuntimeException failure) {
|
|
222
|
+
if (resources.audio != null) resources.audio.close();
|
|
223
|
+
resources.audio = null;
|
|
224
|
+
if (resources.talkback != null) resources.talkback.close();
|
|
225
|
+
resources.talkback = null;
|
|
226
|
+
emit(Map.of("success", true, "event", "audio_status", "audioReceived", false));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (resources.audio != null && resources.audio.format().equals(format)) {
|
|
230
|
+
resources.audio.publish(frame);
|
|
231
|
+
lastAudio = now;
|
|
232
|
+
if (!audioPresent || now - lastAudioStatus >= Duration.ofSeconds(5).toNanos()) {
|
|
233
|
+
emit(Map.of("success", true, "event", "audio_status", "audioReceived", true));
|
|
234
|
+
lastAudioStatus = now;
|
|
235
|
+
}
|
|
236
|
+
audioPresent = true;
|
|
237
|
+
}
|
|
238
|
+
} else if (!unsupportedReported) {
|
|
239
|
+
emit(Map.of("success", true, "event", "audio_unsupported", "codecId", frame.codecId(),
|
|
240
|
+
"flags", frame.flags(), "payloadBytes", frame.payload().length));
|
|
241
|
+
unsupportedReported = true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (audioPresent && now - lastAudio >= Duration.ofSeconds(10).toNanos()) {
|
|
245
|
+
emit(Map.of("success", true, "event", "audio_status", "audioReceived", false));
|
|
246
|
+
audioPresent = false;
|
|
247
|
+
}
|
|
198
248
|
if (frame == null || !frame.isVideo()) {
|
|
199
249
|
continue;
|
|
200
250
|
}
|
|
@@ -223,11 +273,17 @@ public final class Bridge {
|
|
|
223
273
|
volatile PpppConnection connection;
|
|
224
274
|
volatile MissClient camera;
|
|
225
275
|
volatile HttpStreamServer server;
|
|
276
|
+
volatile AudioStreamServer audio;
|
|
277
|
+
volatile Talkback talkback;
|
|
278
|
+
volatile byte[] sessionKey;
|
|
226
279
|
synchronized void close() {
|
|
280
|
+
if (audio != null) { try { audio.close(); } catch (Exception ignored) { } audio = null; }
|
|
281
|
+
if (talkback != null) { try { talkback.close(); } catch (Exception ignored) { } talkback = null; }
|
|
227
282
|
if (camera != null) { try { camera.stopVideo(); } catch (Exception ignored) { } camera = null; }
|
|
228
283
|
if (server != null) { try { server.close(); } catch (Exception ignored) { } server = null; }
|
|
229
284
|
if (connection != null) { try { connection.close(); } catch (Exception ignored) { } connection = null; }
|
|
230
285
|
if (relay != null) { try { relay.close(); } catch (Exception ignored) { } relay = null; }
|
|
286
|
+
if (sessionKey != null) { java.util.Arrays.fill(sessionKey, (byte) 0); sessionKey = null; }
|
|
231
287
|
}
|
|
232
288
|
}
|
|
233
289
|
|