30 Commits
Author SHA1 Message Date
David Ventura b59c77729c add notes 2024-05-03 23:07:51 +02:00
David Ventura 21189a8ae9 call npm tests from make 2024-05-03 23:02:39 +02:00
David Ventura 8c71e84875 add integration test with mock server 2024-05-03 23:02:25 +02:00
David Ventura bac2b4fd4f allow the socket to be closed from the EE 2024-05-03 23:02:04 +02:00
David Ventura c81d7d4182 ignore Drw data packets that are too short to be valid 2024-05-03 18:10:42 +02:00
David Ventura a93564cd11 do not consider p2palive messages for camera liveness 2024-05-03 15:57:55 +02:00
David Ventura d7d3b91528 add debug message on corrupt frames 2024-05-03 15:57:23 +02:00
David Ventura 204677ceae implement simple retransmission scheme 2024-05-03 12:35:29 +02:00
David Ventura 03893f8d81 remove LanSearchExt, increase search interval 2024-05-03 12:35:07 +02:00
David Ventura dc43a66209 ask for wifi settings after pairing 2024-05-03 11:46:14 +02:00
David Ventura 6bed63b61f tolerate empty answers from listwifi 2024-05-03 11:45:57 +02:00
David Ventura 2a3d047f48 add logging on unhandled command acks 2024-05-03 11:38:55 +02:00
David Ventura 1edb7370cf add 2 ack types 2024-05-03 11:38:30 +02:00
David Ventura 6c7856054e remove port form log 2024-05-03 11:19:09 +02:00
David Ventura f29206a1e1 fix comments 2024-05-03 11:18:31 +02:00
David Ventura 449210ec23 send reboot command after pairing 2024-05-03 00:15:14 +02:00
David Ventura 3d003be371 update logic to consider the JPEG type stream, still consider all untagged data as JPEG 2024-05-02 22:38:01 +02:00
David Ventura 7b153d6ff8 impl startswith 2024-05-02 22:29:02 +02:00
David Ventura f5da7a34bd reduce loglevel 2024-05-02 22:28:55 +02:00
David Ventura e496b33ff5 add hdr type 2024-05-02 22:28:44 +02:00
David Ventura f9e2dc63dc add framed headers 2024-05-02 20:53:33 +02:00
David Ventura 80e50560e6 add some more fields 2024-05-02 14:53:07 +02:00
David Ventura 2ec4d603c6 remove incorrect workarounds for undetected cameras 2024-05-02 13:23:52 +02:00
David Ventura 45b8b159d1 add features 2024-05-02 13:20:51 +02:00
David Ventura c51e4f8a7a implement rotation via injecting exif headers (#19) 2024-05-02 13:18:28 +02:00
David Ventura 68fe0fb798 warn on unhandled stream packets 2024-05-02 13:18:15 +02:00
David Ventura 7ca795c917 move example file 2024-05-02 10:02:44 +02:00
David Ventura d14e16c53d warn if node version <16 2024-05-02 10:01:11 +02:00
Adriano Cunha 6ca18b057b Fix sending favicon in http_server (#21)
`favicon` is now an array, and `end()` expects a Buffer or String.
2024-05-02 09:50:08 +02:00
David Ventura 1439d5f632 reply on the port we should 2024-05-01 23:54:33 +02:00
20 changed files with 415 additions and 145 deletions
+1
View File
@@ -3,3 +3,4 @@ venv
node_modules node_modules
build build
dist/ dist/
cameras.txt
+1 -1
View File
@@ -23,7 +23,7 @@ node_modules:
npm install npm install
test: node_modules test: node_modules
./node_modules/.bin/mocha --require ts-node/register tests/fn.test.js npm run test
install-wireshark-dissector: install-wireshark-dissector:
mkdir -p ~/.local/lib/wireshark/plugins mkdir -p ~/.local/lib/wireshark/plugins
+8
View File
@@ -6,6 +6,14 @@ Re-implementation of the "iLnk"/"iLnkP2P"/"PPPP" protocol used on some cheap (\<
Per pictures of the [X5](https://github.com/DavidVentura/cam-reverse/blob/master/pics/pcb.jpg?raw=true), [A9](https://github.com/DavidVentura/cam-reverse/blob/master/pics/pcb_a9.jpg?raw=true) the main chip is TXW817 ([chinese](https://www.taixin-semi.com/Product/ProductDetail?productId=306), [eng, google translate](https://www-taixin--semi-com.translate.goog/Product/ProductDetail?productId=306&_x_tr_sl=auto&_x_tr_tl=en&_x_tr_hl=en&_x_tr_pto=wapp)) Per pictures of the [X5](https://github.com/DavidVentura/cam-reverse/blob/master/pics/pcb.jpg?raw=true), [A9](https://github.com/DavidVentura/cam-reverse/blob/master/pics/pcb_a9.jpg?raw=true) the main chip is TXW817 ([chinese](https://www.taixin-semi.com/Product/ProductDetail?productId=306), [eng, google translate](https://www-taixin--semi-com.translate.goog/Product/ProductDetail?productId=306&_x_tr_sl=auto&_x_tr_tl=en&_x_tr_hl=en&_x_tr_pto=wapp))
## Features
- Multi camera support
- Audio & video streaming
- Rotation / mirroring of video streams
- Friendly names for cameras
- Ability to configure "blank" cameras with Wifi settings
## Building ## Building
Run `make build` or `npm run build` to build the server artifact. You can also find some pre-built files [in the CI results](https://github.com/DavidVentura/cam-reverse/actions) or [in the releases](https://github.com/DavidVentura/cam-reverse/releases/) Run `make build` or `npm run build` to build the server artifact. You can also find some pre-built files [in the CI results](https://github.com/DavidVentura/cam-reverse/actions) or [in the releases](https://github.com/DavidVentura/cam-reverse/releases/)
+8 -8
View File
@@ -3,8 +3,10 @@
<link rel="shortcut icon" href="/favicon.ico"> <link rel="shortcut icon" href="/favicon.ico">
<title>${name}</title> <title>${name}</title>
</head><body> </head><body>
<h2>${name}</h2><a href="/camera/${id}"><img src="/camera/${id}"/></a><hr/> <h2>${name}</h2><a href="/camera/${id}"><img style="height: 640px" src="/camera/${id}"/></a><hr/>
<button onclick="toggle_audio()" id=audio disabled=true>Audio: disabled</button> <button onclick="toggle_audio()" id=audio disabled=true>Audio: disabled</button>
<button onclick="fetch('/rotate/${id}')">Rotate</button>
<button onclick="fetch('/mirror/${id}')">Mirror</button>
<script> <script>
const alaw_to_s16_table = [ const alaw_to_s16_table = [
-5504, -5248, -6016, -5760, -4480, -4224, -4992, -4736, -7552, -7296, -8064, -7808, -6528, -6272, -7040, -6784, -2752, -5504, -5248, -6016, -5760, -4480, -4224, -4992, -4736, -7552, -7296, -8064, -7808, -6528, -6272, -7040, -6784, -2752,
@@ -49,13 +51,11 @@
audio_context.resume(); audio_context.resume();
const evtSource = new EventSource("/audio/${id}"); const evtSource = new EventSource("/audio/${id}");
if (${debug}) { evtSource.onopen = (e) => {
evtSource.onopen = (e) => { console.log("evtsource open");
console.log("evtsource open"); }
} evtSource.onerror = (e) => {
evtSource.onerror = (e) => { console.log("evtsource error", e);
console.log("evtsource error", e);
}
} }
let endsAt = 0; let endsAt = 0;
let startAt = 0; let startAt = 0;
View File
+12 -5
View File
@@ -1,3 +1,4 @@
import process from "node:process";
import { hideBin } from "yargs/helpers"; import { hideBin } from "yargs/helpers";
import yargs from "yargs/yargs"; import yargs from "yargs/yargs";
@@ -5,7 +6,9 @@ import { serveHttp } from "../http_server.js";
import { opt } from "../options.js"; import { opt } from "../options.js";
import { pair } from "../pair.js"; import { pair } from "../pair.js";
import { buildLogger } from "../logger.js"; import { buildLogger, logger } from "../logger.js";
const majorVersion = process.versions.node.split(".").map(Number)[0];
yargs(hideBin(process.argv)) yargs(hideBin(process.argv))
.command( .command(
@@ -14,10 +17,9 @@ yargs(hideBin(process.argv))
(yargs) => { (yargs) => {
return yargs return yargs
.option("ansi", { default: false }) .option("ansi", { default: false })
.option("slow_startup", { default: false })
.option("audio", { describe: "Also stream audio from camera", default: true }) .option("audio", { describe: "Also stream audio from camera", default: true })
.option("color", { describe: "Use color in logs", default: undefined }) .option("color", { describe: "Use color in logs", default: undefined })
.boolean(["ansi", "audio", "slow_startup", "color"]) .boolean(["ansi", "audio", "color"])
.option("log_level", { describe: "Set log level", default: "info" }) .option("log_level", { describe: "Set log level", default: "info" })
.option("discovery_ip", { describe: "Camera discovery IP address", default: "192.168.1.255" }) .option("discovery_ip", { describe: "Camera discovery IP address", default: "192.168.1.255" })
.option("attempt_to_fix_packet_loss", { default: false }) .option("attempt_to_fix_packet_loss", { default: false })
@@ -28,6 +30,9 @@ yargs(hideBin(process.argv))
(argv) => { (argv) => {
const opts: opt = argv as opt; const opts: opt = argv as opt;
buildLogger(argv.log_level, argv.color); buildLogger(argv.log_level, argv.color);
if (majorVersion < 16) {
logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
}
serveHttp(opts, argv.port, argv.audio || false); serveHttp(opts, argv.port, argv.audio || false);
}, },
) )
@@ -37,9 +42,8 @@ yargs(hideBin(process.argv))
(yargs) => { (yargs) => {
return yargs return yargs
.option("ansi", { default: false }) .option("ansi", { default: false })
.option("slow_startup", { default: false })
.option("color", { describe: "Use color in logs", default: undefined }) .option("color", { describe: "Use color in logs", default: undefined })
.boolean(["ansi", "slow_startup", "color"]) .boolean(["ansi", "color"])
.option("log_level", { describe: "Set log level", default: "info" }) .option("log_level", { describe: "Set log level", default: "info" })
.option("discovery_ip", { describe: "Camera discovery IP address", default: "192.168.1.255" }) .option("discovery_ip", { describe: "Camera discovery IP address", default: "192.168.1.255" })
.option("attempt_to_fix_packet_loss", { default: false }) .option("attempt_to_fix_packet_loss", { default: false })
@@ -51,6 +55,9 @@ yargs(hideBin(process.argv))
(argv) => { (argv) => {
const opts: opt = argv as unknown as opt; const opts: opt = argv as unknown as opt;
buildLogger(argv.log_level, argv.color); buildLogger(argv.log_level, argv.color);
if (majorVersion < 16) {
logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
}
pair({ opts, ssid: argv.ssid, password: argv.password }); pair({ opts, ssid: argv.ssid, password: argv.password });
}, },
) )
+2
View File
@@ -46,10 +46,12 @@ export const ControlCommands = {
ListWifi: 0x0360, // CMD_NET_WIFI_SCAN ListWifi: 0x0360, // CMD_NET_WIFI_SCAN
ListWifiAck: 0x0361, ListWifiAck: 0x0361,
StartVideo: 0x1030, // CMD_PEER_LIVEVIDEO_START StartVideo: 0x1030, // CMD_PEER_LIVEVIDEO_START
StartVideoAck: 0x1031,
StopVideo: 0x1130, // CMD_PEER_LIVEVIDEO_STOP StopVideo: 0x1130, // CMD_PEER_LIVEVIDEO_STOP
Shutdown: 0x1010, //CMD_SYSTEM_SHUTDOWN, Shutdown: 0x1010, //CMD_SYSTEM_SHUTDOWN,
Reboot: 0x1110, //CMD_SYSTEM_REBOOT, Reboot: 0x1110, //CMD_SYSTEM_REBOOT,
VideoParamSet: 0x1830, // CMD_PEER_VIDEOPARAM_SET VideoParamSet: 0x1830, // CMD_PEER_VIDEOPARAM_SET
VideoParamSetAck: 0x1831,
VideoParamGet: 0x1930, // CMD_PEER_VIDEOPARAM_GET VideoParamGet: 0x1930, // CMD_PEER_VIDEOPARAM_GET
IRToggle: 0x0a30, // CMD_PEER_IRCUT_ONOFF IRToggle: 0x0a30, // CMD_PEER_IRCUT_ONOFF
}; };
+9 -9
View File
@@ -1,7 +1,8 @@
import EventEmitter from "node:events";
import { create_LanSearchExt, create_LanSearch, parse_PunchPkt } from "./impl.js";
import { createSocket, RemoteInfo } from "node:dgram"; import { createSocket, RemoteInfo } from "node:dgram";
import EventEmitter from "node:events";
import { Commands } from "./datatypes.js"; import { Commands } from "./datatypes.js";
import { create_LanSearch, parse_PunchPkt } from "./impl.js";
import { logger } from "./logger.js"; import { logger } from "./logger.js";
const handleIncomingPunch = (msg: Buffer, ee: EventEmitter, rinfo: RemoteInfo) => { const handleIncomingPunch = (msg: Buffer, ee: EventEmitter, rinfo: RemoteInfo) => {
@@ -27,25 +28,24 @@ export const discoverDevices = (discovery_ip: string): EventEmitter => {
sock.on("message", (msg, rinfo) => handleIncomingPunch(msg, ee, rinfo)); sock.on("message", (msg, rinfo) => handleIncomingPunch(msg, ee, rinfo));
let timers = [];
sock.on("listening", () => { sock.on("listening", () => {
sock.setBroadcast(true); sock.setBroadcast(true);
logger.info(`Searching for devices on ${discovery_ip}`); logger.info(`Searching for devices on ${discovery_ip}`);
let ls_buf = create_LanSearch(); let ls_buf = create_LanSearch();
let lse_buf = create_LanSearchExt(); let int = setInterval(() => {
setInterval(() => {
logger.log("trace", `>> LanSearch`); logger.log("trace", `>> LanSearch`);
sock.send(new Uint8Array(ls_buf.buffer), SEND_PORT, discovery_ip); sock.send(new Uint8Array(ls_buf.buffer), SEND_PORT, discovery_ip);
logger.log("trace", `>> LanSearchExt`); }, 3000);
sock.send(new Uint8Array(lse_buf.buffer), SEND_PORT, discovery_ip); timers.push(int);
}, 2000);
logger.log("trace", `>> LanSearch`); logger.log("trace", `>> LanSearch`);
sock.send(new Uint8Array(ls_buf.buffer), SEND_PORT, discovery_ip); sock.send(new Uint8Array(ls_buf.buffer), SEND_PORT, discovery_ip);
logger.log("trace", `>> LanSearchExt`);
sock.send(new Uint8Array(lse_buf.buffer), SEND_PORT, discovery_ip);
}); });
sock.bind(); sock.bind();
sock.on("close", () => timers.forEach((timer) => clearInterval(timer)));
ee.on("close", () => sock.close());
return ee; return ee;
}; };
+57
View File
@@ -18,8 +18,22 @@ ilnk_proto.fields.start = ProtoField.uint16("iLnkP2P.start", "Start", base.HE
ilnk_proto.fields.cmd_dest = ProtoField.uint16("iLnkP2P.cmd_dest", "Dest", base.HEX) ilnk_proto.fields.cmd_dest = ProtoField.uint16("iLnkP2P.cmd_dest", "Dest", base.HEX)
ilnk_proto.fields.auth_token = ProtoField.bytes("iLnkP2P.auth_token", "CMD auth token", base.DASH) ilnk_proto.fields.auth_token = ProtoField.bytes("iLnkP2P.auth_token", "CMD auth token", base.DASH)
ilnk_proto.fields.cmd_payload = ProtoField.bytes("iLnkP2P.payload", "CMD Payload", base.DASH) ilnk_proto.fields.cmd_payload = ProtoField.bytes("iLnkP2P.payload", "CMD Payload", base.DASH)
ilnk_proto.fields.warning = ProtoField.string("iLnkP2P.warning", "Warning")
--
-- jpeg | audio | continuation type? -- jpeg | audio | continuation type?
ilnk_proto.fields.data_payload = ProtoField.bytes("iLnkP2P.data_payload", "Data Payload", base.DASH) ilnk_proto.fields.data_payload = ProtoField.bytes("iLnkP2P.data_payload", "Data Payload", base.DASH)
ilnk_proto.fields.payload_type = ProtoField.string("iLnkP2P.payload_type", "Payload type")
ilnk_proto.fields.payload_subtype = ProtoField.string("iLnkP2P.payload_type", "Payload type")
ilnk_proto.fields.payload_len = ProtoField.uint32("iLnkP2P.payload_len", "Payload len")
ilnk_proto.fields.frame_no = ProtoField.uint32("iLnkP2P.frame_no", "Frame no")
-- audio
ilnk_proto.fields.audio_header = ProtoField.bytes("iLnkP2P.audio_header", "Audio Header")
ilnk_proto.fields.hdr_type = ProtoField.uint16("iLnkP2P.hdr_type", "Header Type")
ilnk_proto.fields.hdr_streamid = ProtoField.uint16("iLnkP2P.hdr_streamid", "Header Stream ID")
ilnk_proto.fields.hdr_frameno = ProtoField.uint32("iLnkP2P.hdr_frameno", "Header Frame")
ilnk_proto.fields.hdr_len = ProtoField.uint16("iLnkP2P.hdr_len", "Header Len")
ilnk_proto.fields.hdr_ver = ProtoField.uint16("iLnkP2P.hdr_ver", "Header version")
ilnk_proto.fields.hdr_res = ProtoField.uint16("iLnkP2P.hdr_red", "Header resolution")
ilnk_proto.fields.encrypted = ProtoField.bool("iLnkP2P.encrypted", "Encrypted") ilnk_proto.fields.encrypted = ProtoField.bool("iLnkP2P.encrypted", "Encrypted")
ilnk_proto.fields.cmd_type = ProtoField.string("iLnkP2P.cmd_type", "Cmd Pkt Type") ilnk_proto.fields.cmd_type = ProtoField.string("iLnkP2P.cmd_type", "Cmd Pkt Type")
@@ -51,6 +65,12 @@ lut = {
[0xf171] = "RlyHelloAck2", [0xf171] = "RlyHelloAck2",
} }
control_lut = {
[0x2010] = "ConnectUser",
[0x2011] = "ConnectUserAck",
[0x0811] = "ConnectUserAck",
}
-- Define a function to dissect the packets -- Define a function to dissect the packets
function ilnk_proto.dissector(buffer, pinfo, tree) function ilnk_proto.dissector(buffer, pinfo, tree)
local packet_length = buffer:len() local packet_length = buffer:len()
@@ -88,7 +108,12 @@ function ilnk_proto.dissector(buffer, pinfo, tree)
subtree:add(ilnk_proto.fields.len, b_pkt_len) subtree:add(ilnk_proto.fields.len, b_pkt_len)
subtree:add(ilnk_proto.fields.m_type, buffer(4, 1)) subtree:add(ilnk_proto.fields.m_type, buffer(4, 1))
subtree:add(ilnk_proto.fields.m_stream_id, buffer(5, 1)) subtree:add(ilnk_proto.fields.m_stream_id, buffer(5, 1))
if pkt_len < 12 then
subtree:add(ilnk_proto.fields.warning, "Short read"):set_generated()
return
end
subtree:add(ilnk_proto.fields.pkt_seq, buffer(6, 2)) subtree:add(ilnk_proto.fields.pkt_seq, buffer(6, 2))
pinfo.cols.info:set(buffer(6, 2):uint())
local b_payload_len = buffer(0xc, 2) local b_payload_len = buffer(0xc, 2)
local payload_len = buffer(0xc, 2):le_uint() local payload_len = buffer(0xc, 2):le_uint()
@@ -137,7 +162,39 @@ function ilnk_proto.dissector(buffer, pinfo, tree)
subtree:add(ilnk_proto.fields.cmd_payload, payload_tvb:range(0, payload_len -4)) subtree:add(ilnk_proto.fields.cmd_payload, payload_tvb:range(0, payload_len -4))
end end
else else
local payload_type
local payload_subtype
local payload_tvb = ByteArray.tvb(buffer(8, packet_length-8):bytes(), "Data Payload") local payload_tvb = ByteArray.tvb(buffer(8, packet_length-8):bytes(), "Data Payload")
if payload_tvb:range(0, 4):uint() == 0xffd8ffdb then
payload_subtype = "new frame"
-- start of new frame
end
if payload_tvb:range(0, 4):uint() == 0x55aa15a8 then
payload_type = "audio"
subtree:add(ilnk_proto.fields.audio_header, buffer(8, 32))
subtree:add(ilnk_proto.fields.hdr_type, buffer(12, 1))
subtree:add(ilnk_proto.fields.hdr_streamid, buffer(13, 1))
subtree:add(ilnk_proto.fields.hdr_frameno, buffer(20, 4), buffer(20, 4):le_uint())
subtree:add(ilnk_proto.fields.hdr_len, buffer(24, 4), buffer(24, 4):le_uint())
subtree:add(ilnk_proto.fields.hdr_ver, buffer(28, 1), buffer(28, 1):le_uint())
subtree:add(ilnk_proto.fields.hdr_res, buffer(29, 1), buffer(29, 1):le_uint())
subtree:add(ilnk_proto.fields.payload_len, buffer(24, 4), buffer(24, 4):le_uint())
subtree:add(ilnk_proto.fields.frame_no, buffer(20, 4), buffer(20, 4):le_uint())
if payload_tvb:range(4, 1):uint() == 0x06 then
payload_subtype = "audio data"
elseif payload_tvb:range(4, 1):uint() == 0x03 then
payload_subtype = "maybe audio metadata"
else
payload_subtype = "REALLY not sure audio data"
end
else
payload_type = "jpeg"
payload_subtype = "jpeg continuation"
end
subtree:add(ilnk_proto.fields.payload_type, buffer(8, 4), payload_type)
subtree:add(ilnk_proto.fields.payload_subtype, buffer(12, 1), payload_subtype)
subtree:add(ilnk_proto.fields.data_payload, payload_tvb:range(0, packet_length-8)) subtree:add(ilnk_proto.fields.data_payload, payload_tvb:range(0, packet_length-8))
end end
end end
+28
View File
@@ -0,0 +1,28 @@
// Create a minimal EXIF segment with orientation
export const createExifOrientation = (orientation: number) => {
const tiffHeader = Buffer.from("49492A0008000000", "hex");
const ifdEntry = Buffer.concat([
Buffer.from("0100", "hex"), // Number of IFD entries
Buffer.from("1201030001000000", "hex"), // Tag, Type, Count
Buffer.from(orientation.toString(16).padStart(2, "0"), "hex"), // Orientation value
Buffer.from("0000", "hex"), // No more IFDs
Buffer.from("0000000000", "hex"), // padding??
]);
const exifData = Buffer.concat([Buffer.from("457869660000", "hex"), tiffHeader, ifdEntry]);
const segmentLength = Buffer.from([(exifData.length + 2) >> 8, (exifData.length + 2) & 0xff]);
const exifHeader = Buffer.concat([Buffer.from("FFE1", "hex"), segmentLength]);
return Buffer.concat([exifHeader, exifData]);
};
export const addExifToJpeg = (jpegData: Buffer, exifSegment: Buffer) => {
// Check for existing EXIF (simplified check)
if (jpegData.includes(Buffer.from("FFE1", "hex"))) {
throw new Error("JPEG already contains EXIF segment");
}
const soiEnd = 2; // After FFD8
const modifiedJpeg = Buffer.concat([jpegData.subarray(0, soiEnd), exifSegment, jpegData.subarray(soiEnd)]);
return modifiedJpeg;
};
+135 -86
View File
@@ -4,6 +4,7 @@ import { create_P2pRdy, SendListWifi, SendUsrChk, DevSerial } from "./impl.js";
import { Session } from "./session.js"; import { Session } from "./session.js";
import { u16_swap, u32_swap } from "./utils.js"; import { u16_swap, u32_swap } from "./utils.js";
import { logger } from "./logger.js"; import { logger } from "./logger.js";
import { hexdump } from "./hexdump.js";
export const notImpl = (session: Session, dv: DataView) => { export const notImpl = (session: Session, dv: DataView) => {
const raw = dv.readU16(); const raw = dv.readU16();
@@ -54,111 +55,143 @@ export const createResponseForControlCommand = (session: Session, dv: DataView):
XqBytesDec(dv.add(20), payload_len - 4, rotate_chr); XqBytesDec(dv.add(20), payload_len - 4, rotate_chr);
} }
if (cmd_id == ControlCommands.ConnectUserAck) { switch (cmd_id) {
let c = new Uint8Array(dv.add(0x18).readByteArray(4).buffer); case ControlCommands.ConnectUserAck:
session.ticket = [...c]; let c = new Uint8Array(dv.add(0x18).readByteArray(4).buffer);
session.eventEmitter.emit("login"); session.ticket = [...c];
return []; session.eventEmitter.emit("login");
} return [];
if (cmd_id == ControlCommands.DevStatusAck) { case ControlCommands.DevStatusAck:
// ParseDevStatus -> offset relevant? // ParseDevStatus -> offset relevant?
let charging = u32_swap(dv.add(0x28).readU32()) & 1 ? "" : "not "; // 0x14000101 v 0x14000100 let charging = u32_swap(dv.add(0x28).readU32()) & 1 ? "" : "not "; // 0x14000101 v 0x14000100
let power = u16_swap(dv.add(0x18).readU16()); // '3730' or '3765', milliVolts let power = u16_swap(dv.add(0x18).readU16()); // '3730' or '3765', milliVolts
let dbm = dv.add(0x24).readU8() - 0x100; // 0xbf - 0x100 = -65dbm .. constant?? let dbm = dv.add(0x24).readU8() - 0x100; // 0xbf - 0x100 = -65dbm .. constant??
// > -50 = excellent, -50 to -60 good, -60 to -70 fair, <-70 weak // > -50 = excellent, -50 to -60 good, -60 to -70 fair, <-70 weak
logger.info(`Camera ${session.devName}: ${charging}charging, battery at ${power / 1000}V, Wifi ${dbm} dBm`); logger.info(`Camera ${session.devName}: ${charging}charging, battery at ${power / 1000}V, Wifi ${dbm} dBm`);
} return [];
if (cmd_id == ControlCommands.WifiSettingsAck) { case ControlCommands.WifiSettingsAck:
const wifiSettings = { const wifiSettings = {
enable: dv.add(0x14).readU32(), enable: dv.add(0x14).readU32(),
status: dv.add(0x18).readU32(), status: dv.add(0x18).readU32(),
mode: dv.add(0x1c).readU32LE(), mode: dv.add(0x1c).readU32LE(),
channel: dv.add(0x20).readU32(), channel: dv.add(0x20).readU32(),
authtype: dv.add(0x24).readU32(), authtype: dv.add(0x24).readU32(),
dhcp: dv.add(0x28).readU32(), dhcp: dv.add(0x28).readU32(),
ssid: dv.add(0x2c).readString(0x20), ssid: dv.add(0x2c).readString(0x20),
psk: dv.add(0x4c).readString(0x80), psk: dv.add(0x4c).readString(0x80),
ip: dv.add(0xcc).readString(0x10), ip: dv.add(0xcc).readString(0x10),
mask: dv.add(0xdc).readString(0x10), mask: dv.add(0xdc).readString(0x10),
gw: dv.add(0xec).readString(0x10), gw: dv.add(0xec).readString(0x10),
dns1: dv.add(0xfc).readString(0x10), dns1: dv.add(0xfc).readString(0x10),
dns2: dv.add(0x10c).readString(0x10), dns2: dv.add(0x10c).readString(0x10),
};
const buf = SendListWifi(session);
logger.info(`Current Wifi settings: ${JSON.stringify(wifiSettings, null, 2)}`);
return [buf];
}
if (cmd_id == ControlCommands.ListWifiAck) {
let startat = 0x10;
let msg_len = 91;
logger.debug("payload len", payload_len);
let msg_count = (payload_len - 9) / msg_len;
let remote_msg_count = dv.add(startat).readU32LE();
logger.debug("should get messages:", msg_count, "in payload: ", remote_msg_count);
startat += 4;
let items = [];
for (let i = 0; i < msg_count; i++) {
const wifiListItem = {
// startat = msg_len * i + 0x14;
ssid: dv.add(startat).readString(0x40),
mac: dv.add(startat + 0x40).readByteArray(8),
security: dv.add(startat + 0x48).readU32LE(),
dbm0: dv.add(startat + 0x4c).readU32LE(),
dbm1: dv.add(startat + 0x50).readU32LE(),
mode: dv.add(startat + 0x54).readU32LE(),
channel: dv.add(startat + 0x58).readU32LE(),
}; };
logger.info(`Wifi Item: ${JSON.stringify(wifiListItem, null, 2)}`); const buf = SendListWifi(session);
startat += msg_len; logger.info(`Current Wifi settings: ${JSON.stringify(wifiSettings, null, 2)}`);
logger.debug("ended at", startat); return [buf];
items.push(wifiListItem);
} case ControlCommands.ListWifiAck:
if (payload_len == 4) {
logger.debug("ListWifi returned []");
return [];
}
let startat = 0x10;
let msg_len = 91;
let msg_count = (payload_len - 9) / msg_len;
let remote_msg_count = dv.add(startat).readU32LE();
logger.debug(`should get messages: ${msg_count} in payload: ${remote_msg_count}`);
startat += 4;
let items = [];
for (let i = 0; i < msg_count; i++) {
const wifiListItem = {
// startat = msg_len * i + 0x14;
ssid: dv.add(startat).readString(0x40),
mac: dv.add(startat + 0x40).readByteArray(8),
security: dv.add(startat + 0x48).readU32LE(),
dbm0: dv.add(startat + 0x4c).readU32LE(),
dbm1: dv.add(startat + 0x50).readU32LE(),
mode: dv.add(startat + 0x54).readU32LE(),
channel: dv.add(startat + 0x58).readU32LE(),
};
logger.info(`Wifi Item: ${JSON.stringify(wifiListItem, null, 2)}`);
startat += msg_len;
logger.debug("ended at", startat);
items.push(wifiListItem);
}
return [];
case ControlCommands.StartVideoAck:
logger.debug("Start video ack");
return [];
case ControlCommands.VideoParamSetAck:
logger.debug("Video param set ack");
return [];
default:
logger.info(`Unhandled control command: 0x${cmd_id.toString(16)}`);
} }
return []; return [];
}; };
const deal_with_data = (session: Session, dv: DataView) => { const deal_with_data = (session: Session, dv: DataView) => {
const pkt_len = dv.add(2).readU16(); const pkt_len = dv.add(2).readU16();
// data
const JPEG_HEADER = [0xff, 0xd8, 0xff, 0xdb]; // 12 equals start of header (0x8) + header length (0x4)
const AUDIO_HEADER = [0x55, 0xaa, 0x15, 0xa8]; if (pkt_len < 12) {
const m_hdr = dv.add(8).readByteArray(4); logger.debug("Got a short Drw packet, ignoring");
let is_new_image = true; return;
let audio = true;
const pkt_id = dv.add(6).readU16();
for (let i = 0; i < 4; i++) {
is_new_image = is_new_image && m_hdr.add(i).readU8() == JPEG_HEADER[i];
audio = audio && m_hdr.add(i).readU8() == AUDIO_HEADER[i];
} }
if (audio) { const FRAME_HEADER = [0x55, 0xaa, 0x15, 0xa8];
// "stream_head_t->type == 0x06" per pdf const m_hdr = dv.add(8).readByteArray(4);
if (dv.add(12).readU8() == 0x06) { const pkt_id = dv.add(6).readU16();
const STREAM_TYPE_AUDIO = 0x06;
const STREAM_TYPE_JPEG = 0x03;
const startNewFrame = (buf: ArrayBuffer) => {
if (session.curImage.length > 0 && !session.frame_is_bad) {
session.eventEmitter.emit("frame");
}
session.frame_was_fixed = false;
session.frame_is_bad = false;
session.curImage = [Buffer.from(buf)];
session.rcvSeqId = pkt_id;
};
let is_framed = m_hdr.startsWith(FRAME_HEADER);
if (is_framed) {
const stream_type = dv.add(12).readU8();
if (stream_type == STREAM_TYPE_AUDIO) {
const audio_len = u16_swap(dv.add(8 + 16).readU16()); const audio_len = u16_swap(dv.add(8 + 16).readU16());
// may have received the next 'data' packet id as an audio frame -- jpeg was fine
if (pkt_id == session.rcvSeqId + 1) {
session.rcvSeqId = session.rcvSeqId + 1;
}
const audio_buf = dv.add(32 + 8).readByteArray(audio_len).buffer; // 8 for pkt header, 32 for `stream_head_t` const audio_buf = dv.add(32 + 8).readByteArray(audio_len).buffer; // 8 for pkt header, 32 for `stream_head_t`
session.eventEmitter.emit("audio", { gap: false, data: Buffer.from(audio_buf) }); session.eventEmitter.emit("audio", { gap: false, data: Buffer.from(audio_buf) });
} else if (stream_type == STREAM_TYPE_JPEG) {
const to_read = pkt_len - 4 - 32;
if (to_read > 0) {
// some cameras do not send the data with the frame, but rather as a followup message
// skip 8 bytes (drw header) + 32 bytes (data frame)
const data = dv.add(32 + 8).readByteArray(to_read);
startNewFrame(data.buffer);
}
} else { } else {
logger.debug(`Ignoring data frame with stream type ${stream_type} - not implemented`);
// not sure what these are for, there's one per frame. maybe alignment? // not sure what these are for, there's one per frame. maybe alignment?
} }
} else { } else {
const JPEG_HEADER = [0xff, 0xd8, 0xff, 0xdb];
// a new JPEG image may begin either
// - as a frame with stream_type == 0x03
// - as unframed data, started by JPEG_HEADER
// but for both types of cameras, unframed data which does not start with JPEG_HEADER
// are segments of the (potentially already started) JPEG image
const data = dv.add(8).readByteArray(pkt_len - 4); const data = dv.add(8).readByteArray(pkt_len - 4);
if (is_new_image) { // this only happens on un-framed-cameras, which start the JPEG image directly
if (session.curImage.length > 0 && !session.frame_is_bad) { let is_new_image = m_hdr.startsWith(JPEG_HEADER);
session.eventEmitter.emit("frame");
}
session.frame_was_fixed = false; if (is_new_image) {
session.frame_is_bad = false; startNewFrame(data.buffer);
session.curImage = [Buffer.from(data.buffer)];
session.rcvSeqId = pkt_id;
} else { } else {
if (pkt_id <= session.rcvSeqId) { if (pkt_id <= session.rcvSeqId) {
// retransmit // retransmit
@@ -168,7 +201,10 @@ const deal_with_data = (session: Session, dv: DataView) => {
let b = Buffer.from(data.buffer); let b = Buffer.from(data.buffer);
if (pkt_id > session.rcvSeqId + 1) { if (pkt_id > session.rcvSeqId + 1) {
session.frame_is_bad = true; if (!session.frame_is_bad) {
session.frame_is_bad = true;
logger.debug(`Dropping corrupt frame ${pkt_id}, expected ${session.rcvSeqId + 1}`);
}
// this should always be enabled but currently it seems to cause more visual distortion // this should always be enabled but currently it seems to cause more visual distortion
// than just missing some frames // than just missing some frames
if (!session.options.attempt_to_fix_packet_loss) { if (!session.options.attempt_to_fix_packet_loss) {
@@ -234,6 +270,17 @@ const makeDrwAck = (dv: DataView): DataView => {
} }
return outbuf; return outbuf;
}; };
export const handle_DrwAck = (session: Session, dv: DataView) => {
const packetlen = dv.add(2).readU16();
const str_type = dv.add(4).readU8();
const str_id = dv.add(5).readU8();
const ack_count = dv.add(6).readU16();
for (let i = 0; i < ack_count; i++) {
const ack_id = dv.add(8 + i * 2).readU16();
session.ackDrw(ack_id);
}
};
export const handle_Drw = (session: Session, dv: DataView) => { export const handle_Drw = (session: Session, dv: DataView) => {
const ack = makeDrwAck(dv); const ack = makeDrwAck(dv);
session.send(ack); session.send(ack);
@@ -241,8 +288,10 @@ export const handle_Drw = (session: Session, dv: DataView) => {
const m_stream = dv.add(5).readU8(); // data = 1, control = 0 const m_stream = dv.add(5).readU8(); // data = 1, control = 0
if (m_stream == 1) { if (m_stream == 1) {
deal_with_data(session, dv); deal_with_data(session, dv);
} else { } else if (m_stream == 0) {
const b = createResponseForControlCommand(session, dv); const b = createResponseForControlCommand(session, dv);
b.forEach(session.send); b.forEach(session.send);
} else {
logger.warning(`Received a Drw packet with stream tag: ${m_stream}, which is not implemented`);
} }
}; };
+35 -4
View File
@@ -7,6 +7,7 @@ import { opt } from "./options.js";
import { discoverDevices } from "./discovery.js"; import { discoverDevices } from "./discovery.js";
import { DevSerial, SendDevStatus } from "./impl.js"; import { DevSerial, SendDevStatus } from "./impl.js";
import { Handlers, makeSession, Session, startVideoStream } from "./session.js"; import { Handlers, makeSession, Session, startVideoStream } from "./session.js";
import { addExifToJpeg, createExifOrientation } from "./exif.js";
// @ts-expect-error TS2307 // @ts-expect-error TS2307
import favicon from "./cam.ico.gz"; import favicon from "./cam.ico.gz";
@@ -21,6 +22,15 @@ const sessions: Record<string, Session> = {};
// Text file containing the mapping of camera names. // Text file containing the mapping of camera names.
const nameFile = "cameras.txt"; const nameFile = "cameras.txt";
// https://sirv.com/help/articles/rotate-photos-to-be-upright/
const oMap = [1, 8, 3, 6];
const oMapMirror = [2, 7, 4, 5];
const orientations = [1, 2, 3, 4, 5, 6, 7, 8].reduce((acc, cur) => {
return { [cur]: createExifOrientation(cur), ...acc };
}, {});
let camSettings: { [key: string]: { orientation: number; mirror: boolean } } = {};
// Reads the mapping of serial numbers to camera names from the text file. // Reads the mapping of serial numbers to camera names from the text file.
const cameraNames = Object.assign( const cameraNames = Object.assign(
{}, {},
@@ -86,11 +96,27 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
if (req.url.startsWith("/favicon.ico")) { if (req.url.startsWith("/favicon.ico")) {
res.setHeader("Content-Type", "image/x-icon"); res.setHeader("Content-Type", "image/x-icon");
res.setHeader("Content-Encoding", "gzip"); res.setHeader("Content-Encoding", "gzip");
res.end(favicon); res.end(Buffer.from(favicon));
return; return;
} }
if (req.url.startsWith("/camera/")) { if (req.url.startsWith("/rotate/")) {
let devId = req.url.split("/")[2];
let curPos = camSettings[devId]?.orientation || 0;
let nextPos = (curPos + 1) % 4;
logger.debug(`Rotating ${devId} to ${nextPos}`);
camSettings[devId].orientation = nextPos;
res.writeHead(204);
res.end();
return;
} else if (req.url.startsWith("/mirror/")) {
let devId = req.url.split("/")[2];
logger.debug(`Mirroring ${devId}`);
camSettings[devId].mirror = !camSettings[devId].mirror;
res.writeHead(204);
res.end();
return;
} else if (req.url.startsWith("/camera/")) {
let devId = req.url.split("/")[2]; let devId = req.url.split("/")[2];
logger.info(`Video stream requested for camera ${devId}`); logger.info(`Video stream requested for camera ${devId}`);
let s = sessions[devId]; let s = sessions[devId];
@@ -151,8 +177,12 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
const header = Buffer.from(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\n\r\n`); const header = Buffer.from(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\n\r\n`);
s.eventEmitter.on("frame", () => { s.eventEmitter.on("frame", () => {
const assembled = Buffer.concat(s.curImage); // Add an EXIF header to indicate if the image should be rotated or mirrored
let orientation = camSettings[dev.devId].orientation;
orientation = camSettings[dev.devId].mirror ? oMapMirror[orientation] : oMap[orientation];
const exifSegment = orientations[orientation];
const jpegHeader = addExifToJpeg(s.curImage[0], exifSegment);
const assembled = Buffer.concat([jpegHeader, ...s.curImage.slice(1)]);
responses[dev.devId].forEach((res) => { responses[dev.devId].forEach((res) => {
res.write(header); res.write(header);
res.write(assembled); res.write(assembled);
@@ -175,6 +205,7 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
}); });
} }
sessions[dev.devId] = s; sessions[dev.devId] = s;
camSettings[dev.devId] = { orientation: 0, mirror: false };
}); });
logger.info(`Starting HTTP server on port ${port}`); logger.info(`Starting HTTP server on port ${port}`);
+14
View File
@@ -0,0 +1,14 @@
import { createSocket, RemoteInfo } from "node:dgram";
export const mockServer = (onMessage: (msg: DataView) => Uint8Array[]) => {
const sock = createSocket("udp4");
const SEND_PORT = 32108;
sock.bind(SEND_PORT);
sock.on("message", (msg, rinfo: RemoteInfo) => {
const dv = new DataView(new Uint8Array(msg).buffer);
onMessage(dv).forEach((out) => {
sock.send(out, rinfo.port, rinfo.address);
});
});
return sock;
};
-1
View File
@@ -1,6 +1,5 @@
export type opt = { export type opt = {
ansi: boolean; ansi: boolean;
discovery_ip: string; discovery_ip: string;
slow_startup: boolean;
attempt_to_fix_packet_loss: boolean; attempt_to_fix_packet_loss: boolean;
}; };
+3
View File
@@ -15,5 +15,8 @@
"dependencies": { "dependencies": {
"winston": "^3.13.0", "winston": "^3.13.0",
"yargs": "^17.7.2" "yargs": "^17.7.2"
},
"engines": {
"node": ">=16.0"
} }
} }
+8 -1
View File
@@ -2,7 +2,7 @@ import { RemoteInfo } from "dgram";
import { opt } from "./options.js"; import { opt } from "./options.js";
import { discoverDevices } from "./discovery.js"; import { discoverDevices } from "./discovery.js";
import { DevSerial } from "./impl.js"; import { DevSerial, SendReboot, SendWifiSettings } from "./impl.js";
import { Handlers, makeSession, Session, configureWifi } from "./session.js"; import { Handlers, makeSession, Session, configureWifi } from "./session.js";
import { logger } from "./logger.js"; import { logger } from "./logger.js";
@@ -16,8 +16,15 @@ export const pair = ({ opts, ssid, password }: { opts: opt; ssid: string; passwo
} }
const onLogin = (s: Session) => { const onLogin = (s: Session) => {
logger.info(`Configuring camera ${s.devName}`);
configureWifi(ssid, password)(s); configureWifi(ssid, password)(s);
logger.info(`WiFi config for camera ${s.devName} is done`); logger.info(`WiFi config for camera ${s.devName} is done`);
logger.info(`Validating WiFi settings on ${s.devName}`);
s.send(SendWifiSettings(s));
logger.info(`Asking ${s.devName} to reboot`);
s.send(SendReboot(s));
}; };
devEv.on("discover", (rinfo: RemoteInfo, dev: DevSerial) => { devEv.on("discover", (rinfo: RemoteInfo, dev: DevSerial) => {
+43 -23
View File
@@ -2,13 +2,15 @@ import { createSocket, RemoteInfo } from "node:dgram";
import EventEmitter from "node:events"; import EventEmitter from "node:events";
import { Commands, CommandsByValue } from "./datatypes.js"; import { Commands, CommandsByValue } from "./datatypes.js";
import { handle_Drw, handle_P2PAlive, handle_P2PRdy, makeP2pRdy, noop, notImpl } from "./handlers.js"; import { handle_Drw, handle_DrwAck, handle_P2PAlive, handle_P2PRdy, makeP2pRdy, notImpl, noop } from "./handlers.js";
import { create_P2pAlive, DevSerial, SendStartVideo, SendVideoResolution, SendWifiDetails } from "./impl.js"; import { create_P2pAlive, DevSerial, SendStartVideo, SendVideoResolution, SendWifiDetails } from "./impl.js";
import { opt } from "./options.js"; import { opt } from "./options.js";
import { logger } from "./logger.js"; import { logger } from "./logger.js";
export type Session = { export type Session = {
send: (msg: DataView) => void; send: (msg: DataView) => void;
ackDrw: (id: number) => void;
unackedDrw: { [id: number]: { sent_ts: number; data: DataView } };
outgoingCommandId: number; outgoingCommandId: number;
ticket: number[]; ticket: number[];
eventEmitter: EventEmitter; eventEmitter: EventEmitter;
@@ -37,10 +39,13 @@ type msgCb = (
const handleIncoming: msgCb = (session, handlers, msg, rinfo) => { const handleIncoming: msgCb = (session, handlers, msg, rinfo) => {
const ab = new Uint8Array(msg).buffer; const ab = new Uint8Array(msg).buffer;
const dv = new DataView(ab); const dv = new DataView(ab);
const cmd = CommandsByValue[dv.readU16()]; const raw = dv.readU16();
const cmd = CommandsByValue[raw];
logger.log("trace", `<< ${cmd}`); logger.log("trace", `<< ${cmd}`);
handlers[cmd](session, dv, rinfo); handlers[cmd](session, dv, rinfo);
session.lastReceivedPacket = Date.now(); if (raw != Commands.P2PAlive && raw != Commands.P2PAliveAck) {
session.lastReceivedPacket = Date.now();
}
}; };
export const makeSession = ( export const makeSession = (
@@ -50,6 +55,7 @@ export const makeSession = (
onLogin: (s: Session) => void, onLogin: (s: Session) => void,
options: opt, options: opt,
): Session => { ): Session => {
let unackedDrw = {};
const sock = createSocket("udp4"); const sock = createSocket("udp4");
sock.on("error", (err) => { sock.on("error", (err) => {
@@ -60,23 +66,11 @@ export const makeSession = (
sock.on("message", (msg, rinfo) => handleIncoming(session, handlers, msg, rinfo)); sock.on("message", (msg, rinfo) => handleIncoming(session, handlers, msg, rinfo));
sock.on("listening", () => { sock.on("listening", () => {
const startup = () => { const buf = makeP2pRdy(dev);
const buf = makeP2pRdy(dev); session.send(buf);
session.send(buf); session.started = true;
// The YsxLite sends a P2PAlive message immediately after P2PRdy
const alive_buf = create_P2pAlive();
session.send(alive_buf);
session.started = true;
};
if (options.slow_startup) {
logger.info("Delaying session startup");
setTimeout(startup, 6500);
} else {
startup();
}
}); });
const SEND_PORT = 32108;
sock.bind(); sock.bind();
const sessTimer = setInterval(() => { const sessTimer = setInterval(() => {
const delta = Date.now() - session.lastReceivedPacket; const delta = Date.now() - session.lastReceivedPacket;
@@ -85,26 +79,51 @@ export const makeSession = (
let buf = create_P2pAlive(); let buf = create_P2pAlive();
session.send(buf); session.send(buf);
} }
if (delta > 8000) { if (delta > 5000) {
session.eventEmitter.emit("disconnect"); session.eventEmitter.emit("disconnect");
} }
} }
}, 400); }, 400);
const resendTimer = setInterval(() => {
const now = Date.now();
for (const [key, value] of Object.entries(session.unackedDrw)) {
const { sent_ts, data } = value;
if (now - sent_ts > 100) {
const pkt_id = data.add(6).readU16();
logger.debug(`Resending packet ${pkt_id} as ${session.outgoingCommandId}`);
data.add(6).writeU16(session.outgoingCommandId);
session.outgoingCommandId++;
delete session.unackedDrw[key];
session.send(data);
}
}
}, 500);
const session: Session = { const session: Session = {
outgoingCommandId: 0, outgoingCommandId: 0,
ticket: [0, 0, 0, 0], ticket: [0, 0, 0, 0],
lastReceivedPacket: 0, lastReceivedPacket: 0,
eventEmitter: new EventEmitter(), eventEmitter: new EventEmitter(),
connected: true, connected: true,
timers: [sessTimer], timers: [sessTimer, resendTimer],
devName: dev.devId, devName: dev.devId,
started: false, started: false,
send: (msg: DataView) => { send: (msg: DataView) => {
const raw = msg.readU16(); const raw = msg.readU16();
const cmd = CommandsByValue[raw]; const cmd = CommandsByValue[raw];
// send command
if (raw == 0xf1d0 && msg.add(4).readU8() == 0xd1) {
const packet_id = msg.add(6).readU16();
logger.debug(`Sending Drw Packet with id ${packet_id}`);
unackedDrw[packet_id] = { sent_ts: Date.now(), data: msg };
}
logger.log("trace", `>> ${cmd}`); logger.log("trace", `>> ${cmd}`);
sock.send(new Uint8Array(msg.buffer), SEND_PORT, session.dst_ip); sock.send(new Uint8Array(msg.buffer), ra.port, session.dst_ip);
},
ackDrw: (id: number) => {
logger.debug(`Removing ${id} from pending`);
delete unackedDrw[id];
}, },
dst_ip: ra.address, dst_ip: ra.address,
curImage: [], curImage: [],
@@ -112,6 +131,7 @@ export const makeSession = (
frame_is_bad: false, frame_is_bad: false,
frame_was_fixed: false, frame_was_fixed: false,
options: options, options: options,
unackedDrw,
}; };
session.eventEmitter.on("disconnect", () => { session.eventEmitter.on("disconnect", () => {
@@ -146,13 +166,13 @@ export const Handlers: Record<keyof typeof Commands, PacketHandler> = {
PunchPkt: notImpl, PunchPkt: notImpl,
P2PAlive: handle_P2PAlive, P2PAlive: handle_P2PAlive,
P2pRdy: handle_P2PRdy, P2pRdy: handle_P2PRdy,
DrwAck: noop, DrwAck: handle_DrwAck,
Drw: handle_Drw, Drw: handle_Drw,
P2PAliveAck: noop,
Close: notImpl, Close: notImpl,
LanSearchExt: notImpl, LanSearchExt: notImpl,
LanSearch: notImpl, LanSearch: notImpl,
P2PAliveAck: notImpl,
Hello: notImpl, Hello: notImpl,
P2pReq: notImpl, P2pReq: notImpl,
LstReq: notImpl, LstReq: notImpl,
+12
View File
@@ -59,6 +59,17 @@ DataView.prototype.writeString = function (str) {
const bytes = [...str].map((_, i) => str.charCodeAt(i)); const bytes = [...str].map((_, i) => str.charCodeAt(i));
return this.writeByteArray(bytes); return this.writeByteArray(bytes);
}; };
DataView.prototype.startsWith = function (arr) {
if (this.byteLength < arr.length) {
return false;
}
for (let i = 0; i < arr.length; i++) {
if (this.add(i).readU8() != arr[i]) {
return false;
}
}
return true;
};
declare global { declare global {
interface DataView { interface DataView {
@@ -77,6 +88,7 @@ declare global {
writeU32(n: number): void; writeU32(n: number): void;
writeU64(n: bigint): void; writeU64(n: bigint): void;
writeU8(n: number): void; writeU8(n: number): void;
startsWith(arr: number[]): boolean;
} }
} }
export default global; export default global;
+2 -7
View File
@@ -2,9 +2,9 @@ import "../shim.ts";
import assert from "assert"; import assert from "assert";
import { XqBytesDec, XqBytesEnc } from "../func_replacements.js";
import { hexdump } from "../hexdump.js"; import { hexdump } from "../hexdump.js";
import { SendStartVideo, SendDevStatus, SendUsrChk, SendWifiDetails, parse_PunchPkt } from "../impl.ts"; import { XqBytesDec, XqBytesEnc } from "../func_replacements.js";
import { parse_PunchPkt, SendDevStatus, SendStartVideo, SendUsrChk, SendWifiDetails } from "../impl.ts";
import { placeholderTypes, sprintf } from "../utils.js"; import { placeholderTypes, sprintf } from "../utils.js";
describe("debug_tools", () => { describe("debug_tools", () => {
@@ -122,11 +122,6 @@ describe("module", () => {
}); });
const hstrToBA = (hs) => new Uint8Array(hs.match(/../g).map((h) => parseInt(h, 16))).buffer; const hstrToBA = (hs) => new Uint8Array(hs.match(/../g).map((h) => parseInt(h, 16))).buffer;
describe("events", () => {
it("emits login event upon logging in", () => {
// TODO
});
});
describe("parse packet", () => { describe("parse packet", () => {
it("parses PunchPkt", () => { it("parses PunchPkt", () => {
const in_pkt_str = "f14100144241544400000000000262ca574f4e4a4d000000"; const in_pkt_str = "f14100144241544400000000000262ca574f4e4a4d000000";
+37
View File
@@ -0,0 +1,37 @@
import { mockServer } from "../mock_server.js";
import { discoverDevices } from "../discovery.js";
import { buildLogger } from "../logger.js";
import { Commands } from "../datatypes.js";
import assert from "assert";
const hstrToU8 = (hs) => new Uint8Array(hs.match(/../g).map((h) => parseInt(h, 16)));
describe("integration", () => {
it("discovers a device", () => {
// LanSearch (server) -> PunchPkt (camera) -> discovered serial
buildLogger("trace");
const EXPECTED_SERIAL = "BATD156362WONJM";
const punchPkt = "f14100144241544400000000000262ca574f4e4a4d000000";
const mockSock = mockServer((msg) => {
const cmd = msg.readU16();
if (cmd == Commands.LanSearch) {
const buf = hstrToU8(punchPkt);
return [buf];
}
return [];
});
const ev = discoverDevices("127.0.0.1");
ev.on("discover", (rinfo, dev) => {
assert.deepEqual(dev.devId, EXPECTED_SERIAL);
ev.emit("close");
mockSock.close();
});
});
// TODO
it("emits login event upon logging in", () => {
// LanSearch (server) -> PunchPkt (camera)
// vvv need to call makeSession vvv
// P2pRdy (server) -> P2pRdy (camera)
// Drw<Login> (server) -> [DrwAck, Drw<LoginAck>]
});
});