@linanw/homebridge-mijia-remote-camera 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  插件:`@linanw/homebridge-mijia-remote-camera`,平台:`MijiaRemoteCamera`。
4
4
 
5
- 登录、家庭选择、远程摄像头连接、快照和 HomeKit 视频由插件管理,无需独立的 viewer / relay 容器或另一个摄像头插件。摄像头可以位于其他网络。
5
+ 登录、家庭选择、远程摄像头连接、快照、HomeKit 视频、声音与双向对讲由插件管理,无需独立的 viewer / relay 容器或另一个摄像头插件。摄像头可以位于其他网络。
6
6
 
7
7
  目前后端仅实现 CS2/vendor 4。已在一台小米智能摄像机 3 Pro 云台版上验证跨网络远程取流,以及真实 Apple“家庭”的配对、预览和直播。具体测试环境见下文“验证范围”;其他型号、地区和网络环境尚未逐一验证。
8
8
 
@@ -54,9 +54,15 @@ 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,最多一路实时视频。当前提供实时视频和快照,未接入声音、对讲、云台、运动事件、录像回放及 HomeKit Secure Video。
57
+ 默认最高 640×360、12 fps、700 kbps,最多一路实时视频。0.1.2 起接入摄像头实际返回的 PCMA 或 Opus 音频,经 AAC-ELD 16 kHz 发送到 Apple“家庭”;双向对讲会将“家庭”回传的声音转换为摄像头协议,并通过同一远程会话发送。没有收到真实音频时不会生成替代声音,视频可独立播放。尚未接入云台、运动事件、录像回放及 HomeKit Secure Video。
58
58
 
59
- 为兼容 Apple“家庭”的媒体能力协商,插件默认公布 AAC-ELD 16 kHz 音频能力和静音 Microphone 服务;实际不采集或发送摄像头声音。家庭中可能显示麦克风控件,目前仍只提供视频。
59
+ ## 声音、对讲与夜视
60
+
61
+ 默认开启现场声音、双向对讲和夜视开关。升级后重启摄像头子桥即可,保留已有 HomeKit 配对。在“家庭”直播页面使用对讲按钮,并允许 iPhone 访问麦克风;松开或结束对讲后会停止向摄像头发送声音。声音静音和音量控制会作用于实际音频转发。插件设置页显示是否已收到真实摄像头声音,以及当前协议是否支持对讲。
62
+
63
+ 设置页可修改“收听摄像头现场声音”“允许双向对讲”“在家庭中显示夜视开关”;保存后需重启摄像头子桥。对应配置项为 `enableAudio`、`enableTalkback`、`enableNightVision`,默认均为 `true`。关闭现场声音也会禁用对讲。
64
+
65
+ 夜视目前仅支持已核对 MIOT 定义的 `chuangmi.camera.061a01`/`chuangmi.camera.061a03`。插件设置页先读取设备实际状态,再选择“自动/开启/关闭”并应用;写入后会回读确认。Apple“家庭”的“夜视”开关表示允许夜视:自动模式显示为开,重复开不会把自动改为强制开启;关掉后重新打开会设置为始终开启。需要恢复自动时,请在插件设置页选择。该开关表示夜视策略,不表示红外灯此刻是否点亮。启动和轮询只读状态,不更改策略。
60
66
 
61
67
  ## 数据和运行管理
62
68
 
@@ -70,21 +76,21 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
70
76
  runtime/
71
77
  ```
72
78
 
73
- 私有目录权限为 `0700`,凭据和选择文件为 `0600`。小米 Token 不写入 Homebridge `config.json`。设置页通过 Homebridge 的受保护接口和本机 Unix socket 通信;原始视频仅监听临时的本机回环端口。Homebridge 启动后恢复所选摄像头,关闭时清理 Java、FFmpeg 和 UDP 会话,临时网络故障会自动重连。
79
+ 私有目录权限为 `0700`,凭据和选择文件为 `0600`。小米 Token 不写入 Homebridge `config.json`。设置页通过 Homebridge 的受保护接口和本机 Unix socket 通信;原始视频和音频仅监听临时的本机回环端口,不保存录音。对讲接口也仅允许本机访问。Homebridge 启动后恢复所选摄像头,关闭时清理 Java、FFmpeg 和 UDP 会话,临时网络故障会自动重连。
74
80
 
75
81
  插件按单个账号、单个摄像头设计,按所选家庭及实际所有者查询完整设备分页。迁移前请备份 Homebridge 存储目录。
76
82
 
77
83
  ## 验证范围
78
84
 
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“家庭”预览和直播正常。
85
+ 实际设备验证使用一台小米智能摄像机 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
86
 
81
- 本地测试另覆盖 Homebridge 1/2 的原生 HAP CameraController、加密快照与 SRTP 发送/独立接收解码、进程清理、授权取消与原子保存、家庭作用域、真实 Homebridge 设置页 IPC 和本地 Java 启动。
87
+ 本地测试另覆盖 Homebridge 1/2 的原生 HAP CameraController、加密快照、视频与双向音频的 SRTP 发送/独立接收解码、夜视读取与写后回读、进程清理、授权取消与原子保存、家庭作用域、真实 Homebridge 设置页 IPC 和本地 Java 启动。
82
88
 
83
89
  远程中继仍可能间歇断开并自动重连,重连期间预览和直播可能暂不可用。目前尚未完成长时间稳定性验证。
84
90
 
85
91
  ## 连接诊断
86
92
 
87
- 设置页可展开“连接诊断”,查看连接阶段、重试次数、上次失败原因及 HomeKit 快照/直播请求计数。“已收到摄像头视频”表示米家视频源已就绪;快照和直播计数则显示 HomeKit 是否已请求媒体。也可在 Homebridge 终端运行:
93
+ 设置页可展开“连接诊断”,查看连接阶段、重试次数、上次失败原因及 HomeKit 快照/直播/音频/对讲请求计数。“已收到摄像头视频”表示米家视频源已就绪;快照和直播计数则显示 HomeKit 是否已请求媒体。也可在 Homebridge 终端运行:
88
94
 
89
95
  ```sh
90
96
  mijia-camera-diagnose --storage /var/lib/homebridge
@@ -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
+ }
@@ -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")) { identifier(args[4]); number(args[5], 1024, 65535); number(args[6], 0, 3); }
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.camera.useSessionKey(MissCrypto.sessionKey(provision.clientPrivate(), provision.devicePublic()));
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, false);
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