@linanw/homebridge-mijia-remote-camera 0.1.0 → 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 +31 -7
- package/THIRD_PARTY_NOTICES.md +4 -1
- package/backend/AudioCodecs.java +179 -0
- package/backend/AudioStreamServer.java +182 -0
- package/backend/Bridge.java +115 -12
- package/backend/NightVisionControl.java +239 -0
- package/backend/Talkback.java +83 -0
- package/backend/bridge.jar +0 -0
- package/bin/diagnose.mjs +22 -0
- package/config.schema.json +15 -0
- package/homebridge-ui/public/index.html +180 -10
- package/homebridge-ui/server.js +7 -1
- package/lib/camera.mjs +479 -54
- package/lib/cloud-control.mjs +250 -0
- package/lib/diagnostics.mjs +104 -0
- package/lib/night-vision.mjs +165 -0
- package/lib/platform.mjs +10 -1
- package/lib/rpc.mjs +3 -2
- package/lib/runtime-factory.mjs +13 -4
- package/lib/runtime.mjs +185 -14
- package/npm-shrinkwrap.json +4 -3
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
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
|
|
|
9
9
|
要求 Linux x86_64、Node.js 22 或 24、Homebridge 1.9+ 或 2.x,以及支持自定义插件设置页的 Homebridge UI。适用 Synology DS1618+ 上以 host 网络运行的 Homebridge Docker。支持 glibc 和 musl;其他架构不支持。
|
|
10
10
|
|
|
11
11
|
## 安装和首次导入
|
|
12
12
|
|
|
13
|
-
公开 npm
|
|
13
|
+
公开 npm 包包含插件代码和由这些源码编译的自有 Java 适配器,**首次需要从已有的本地运行包导入组件**。运行组件不随 npm 分发,也不会自动从网上下载;详情见 `THIRD_PARTY_NOTICES.md`。目前支持指定版本的本地包 `homebridge-mijia-remote-camera-0.1.0.tgz`,已有该包的用户可按以下步骤迁移。没有该本地包时,npm 包本身不能提供视频功能。
|
|
14
14
|
|
|
15
15
|
先停止此前连接这台摄像头的 `viewer` / `relay` 容器。若已经安装旧的无 scope 插件,先在 Homebridge 管理界面的终端内卸载并重启 Homebridge,释放旧进程:
|
|
16
16
|
|
|
@@ -52,9 +52,17 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
|
|
|
52
52
|
|
|
53
53
|
已有有效插件会话时可点击“使用已有登录,读取家庭”。取消或失败的授权保留原凭据。更换已发布的摄像头需要重启 Homebridge;同一摄像头更新登录或家庭选择可以刷新连接。
|
|
54
54
|
|
|
55
|
-
摄像头作为独立配件发布。在 iPhone“家庭 → 添加配件 →
|
|
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
|
+
|
|
59
|
+
## 声音、对讲与夜视
|
|
60
|
+
|
|
61
|
+
默认开启现场声音、双向对讲和夜视开关。升级后重启摄像头子桥即可,保留已有 HomeKit 配对。在“家庭”直播页面使用对讲按钮,并允许 iPhone 访问麦克风;松开或结束对讲后会停止向摄像头发送声音。声音静音和音量控制会作用于实际音频转发。插件设置页显示是否已收到真实摄像头声音,以及当前协议是否支持对讲。
|
|
62
|
+
|
|
63
|
+
设置页可修改“收听摄像头现场声音”“允许双向对讲”“在家庭中显示夜视开关”;保存后需重启摄像头子桥。对应配置项为 `enableAudio`、`enableTalkback`、`enableNightVision`,默认均为 `true`。关闭现场声音也会禁用对讲。
|
|
64
|
+
|
|
65
|
+
夜视目前仅支持已核对 MIOT 定义的 `chuangmi.camera.061a01`/`chuangmi.camera.061a03`。插件设置页先读取设备实际状态,再选择“自动/开启/关闭”并应用;写入后会回读确认。Apple“家庭”的“夜视”开关表示允许夜视:自动模式显示为开,重复开不会把自动改为强制开启;关掉后重新打开会设置为始终开启。需要恢复自动时,请在插件设置页选择。该开关表示夜视策略,不表示红外灯此刻是否点亮。启动和轮询只读状态,不更改策略。
|
|
58
66
|
|
|
59
67
|
## 数据和运行管理
|
|
60
68
|
|
|
@@ -68,10 +76,26 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
|
|
|
68
76
|
runtime/
|
|
69
77
|
```
|
|
70
78
|
|
|
71
|
-
私有目录权限为 `0700`,凭据和选择文件为 `0600`。小米 Token 不写入 Homebridge `config.json`。设置页通过 Homebridge 的受保护接口和本机 Unix socket
|
|
79
|
+
私有目录权限为 `0700`,凭据和选择文件为 `0600`。小米 Token 不写入 Homebridge `config.json`。设置页通过 Homebridge 的受保护接口和本机 Unix socket 通信;原始视频和音频仅监听临时的本机回环端口,不保存录音。对讲接口也仅允许本机访问。Homebridge 启动后恢复所选摄像头,关闭时清理 Java、FFmpeg 和 UDP 会话,临时网络故障会自动重连。
|
|
72
80
|
|
|
73
81
|
插件按单个账号、单个摄像头设计,按所选家庭及实际所有者查询完整设备分页。迁移前请备份 Homebridge 存储目录。
|
|
74
82
|
|
|
75
83
|
## 验证范围
|
|
76
84
|
|
|
77
|
-
|
|
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 秒。
|
|
86
|
+
|
|
87
|
+
本地测试另覆盖 Homebridge 1/2 的原生 HAP CameraController、加密快照、视频与双向音频的 SRTP 发送/独立接收解码、夜视读取与写后回读、进程清理、授权取消与原子保存、家庭作用域、真实 Homebridge 设置页 IPC 和本地 Java 启动。
|
|
88
|
+
|
|
89
|
+
远程中继仍可能间歇断开并自动重连,重连期间预览和直播可能暂不可用。目前尚未完成长时间稳定性验证。
|
|
90
|
+
|
|
91
|
+
## 连接诊断
|
|
92
|
+
|
|
93
|
+
设置页可展开“连接诊断”,查看连接阶段、重试次数、上次失败原因及 HomeKit 快照/直播/音频/对讲请求计数。“已收到摄像头视频”表示米家视频源已就绪;快照和直播计数则显示 HomeKit 是否已请求媒体。也可在 Homebridge 终端运行:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
mijia-camera-diagnose --storage /var/lib/homebridge
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`--storage` 必须是实例真实的存储目录,也可能是 `/homebridge`。输出不包含小米凭据、设备 ID 或视频地址。HomeKit 快照请求不会跳过连接重试间隔。
|
|
100
|
+
|
|
101
|
+
能在插件中收到视频、但 Apple“家庭”添加失败时,请检查摄像头日志中的独立 HomeKit 端口及 Setup Code;摄像头端口与主桥/子桥端口不同。
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# Distribution and components
|
|
2
2
|
|
|
3
3
|
The code distributed in this npm package is licensed under MIT. It does not
|
|
4
|
-
contain the micam backend,
|
|
4
|
+
contain the micam backend, third-party Java runtime, FFmpeg binary, or
|
|
5
5
|
ffmpeg-for-homebridge installer. The runtime manifest contains provenance and
|
|
6
6
|
SHA-256 identifiers only; it does not grant rights to the identified components.
|
|
7
|
+
The included `backend/bridge.jar` contains only the original MIT-licensed
|
|
8
|
+
`mijia.bridge.Bridge` adapter classes built from the supplied Java source.
|
|
9
|
+
No micam or Jackson implementation classes are included in that JAR.
|
|
7
10
|
|
|
8
11
|
The local setup command imports runtime files already available on the user's
|
|
9
12
|
machine. It does not download them or upload them to npm. Local runtime files
|
|
@@ -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
|
+
}
|