58 Commits
Author SHA1 Message Date
David Ventura c80b6b0a67 Add content-length & newline before boundary 2024-10-02 22:43:53 +02:00
Tin LaiandTin Lai 9f6f0ca8fa fix crashes when pkt_id 1 is dropped (#33)
Signed-off-by: Tin Lai <tin@tinyiu.com>
Co-authored-by: Tin Lai <tin@tinyiu.com>
2024-07-04 11:27:06 +02:00
David Ventura 8f19d215ba split out parseDevStatusAck & test it 2024-07-01 16:03:53 +02:00
David Ventura f85248bc6a support single-frame capture 2024-07-01 16:03:21 +02:00
David Ventura 80b1e4643b add test wifidetails + fix implementation offset 2024-05-07 13:35:22 +02:00
David Ventura 2e68a64e92 update example 2024-05-07 12:06:52 +02:00
David Ventura 04fb1a8e4c configure timeout 2024-05-07 12:06:46 +02:00
David Ventura 2facd91fb5 call configureWifi after scanning to include the channel 2024-05-07 12:06:35 +02:00
David Ventura 75401621cf write channel byte 2024-05-07 12:05:57 +02:00
David Ventura 17d00eaba9 emit ListWifi on scan 2024-05-07 12:05:12 +02:00
David Ventura db98e6685c notes 2024-05-07 11:30:04 +02:00
David Ventura 40dab0c017 bump timeout for wifiscanning - should only be in pair mode.. 2024-05-07 11:30:04 +02:00
David Ventura 6b28e91b9a parse swver & remove uN_swap 2024-05-07 11:30:04 +02:00
David Ventura cdc5be9948 add wifi parsing test 2024-05-07 11:30:04 +02:00
Adriano Cunha 4a6a58bd7b Fix crash in http_server (#25)
Set camera config before referencing it. Also set audio default to true.
2024-05-06 22:09:03 +02:00
David Ventura 0db9cfd198 tolerate cut off packets & fix wifilist parsing 2024-05-06 16:52:46 +02:00
David Ventura 7b9d8fb5b7 tolerate cutoff packets 2024-05-06 16:52:30 +02:00
David Ventura 0ef5581b3b add missing channel as well 2024-05-06 15:36:08 +02:00
David Ventura 5060a4d044 change loglevel 2024-05-06 15:23:59 +02:00
David Ventura 27ab7d9af2 split long string 2024-05-06 11:53:34 +02:00
David Ventura ea533d786d add warning on timeout 2024-05-06 11:48:23 +02:00
David Ventura e2751c65bc flip ip & mask 2024-05-06 11:48:23 +02:00
David Ventura ceec51b22d add command dump 2024-05-06 11:35:50 +02:00
David Ventura a45b706ad0 add wifiparams type 2024-05-06 11:35:33 +02:00
David Ventura f065fbca9a handle close message 2024-05-06 09:25:58 +02:00
David Ventura 760dbc5820 Fix #17. Support cameras with short prefixes 2024-05-05 18:07:34 +02:00
David Ventura d5d4161dd8 implement settings support 2024-05-05 17:39:52 +02:00
David Ventura f4b7b1a463 remove ansi from options 2024-05-05 16:43:31 +02:00
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
25 changed files with 1185 additions and 241 deletions
+1
View File
@@ -3,3 +3,4 @@ venv
node_modules
build
dist/
cameras.txt
+1 -1
View File
@@ -23,7 +23,7 @@ node_modules:
npm install
test: node_modules
./node_modules/.bin/mocha --require ts-node/register tests/fn.test.js
npm run test
install-wireshark-dissector:
mkdir -p ~/.local/lib/wireshark/plugins
+61 -3
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))
## 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
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/)
@@ -19,6 +27,7 @@ Connect to the device's access point (e.g., FTYC811847AGFDZ) and run `node dist/
## Running
### HTTP Server
To execute the HTTP server, run `node dist/bin.cjs http_server`; you can access the JPEG stream at http://localhost:5000/.
The roundtrip delay when using MJPEG is [~350ms](pics/delay.jpg?raw=true).
@@ -27,11 +36,60 @@ There's a basic UI which can display multiple cameras:
![](pics/web-ui.jpg?raw=true)
The server will send a broadcast packet every few seconds to discover all the cameras available; this means that it *must* run in the same broadcast domain (VLAN) as your cameras. For debugging purposes, you can send the packets to a specific camera by setting `--discovery_ip` to its IP address.
Clicking on the image will take you to a page that has audio streaming. Click the button below the image to mute/unmute the audio.
To customize the camera names, edit the file `cameras.txt` and add the names of your cameras, one per line, in the format `CAMERA_ID=descriptive name` (you must restart the HTTP server for the changes to take effect).
#### Settings
You can provide a config file in `yml` format, then pass it as an argument: `node bin.cjs http_server --config_file <your_config.yml>`
```yml
http_server:
port: 5000
logging:
level: debug
use_color: true
cameras:
FTYC477360FAWUK:
alias: "A9"
rotate: 1
mirror: false
fix_packet_loss: yes
audio: true
BATC609531EXLVS:
alias: "X5"
# If you are crossing broadcast domains (VLANs) then
# you need to specify all IPs as unicast targets
discovery_ips:
- 192.168.40.101
- 192.168.40.102
- 192.168.40.103
- 192.168.40.104
- 192.168.40.105
# If you are in the same broadcast domain, then
# it's easier to just use the broadcast address of your network
# discovery_ips:
# - 192.168.1.255
blacklisted_ips:
- 192.168.40.102
```
All keys are optional
You must restart the HTTP server for changes to the settings file to take effect.
### Single capture mode
```bash
node bin.cjs frame --discovery_ip 192.168.40.104 --out out.jpg
```
----
## Protocol
-10
View File
@@ -1,10 +0,0 @@
# This is a simple name mapping for cameras, in the form
#
# CAMERA_ID=descriptive name
#
# Blank lines and lines starting with "#" are ignored.
#
# Any updates to this file require restarting the HTTP server.
# This file is meant to be updated manually, it is NOT updated dynamically.
FTYC811847AGFDZ=Office camera
+39
View File
@@ -0,0 +1,39 @@
import fs from "node:fs";
import { DevSerial } from "./impl.js";
import { RemoteInfo } from "dgram";
import { logger } from "./logger.js";
import { startVideoStream } from "./session.js";
import { discoverDevices } from "./discovery.js";
import { Session } from "./session.js";
import { Handlers, makeSession } from "./session.js";
import { config } from "./settings.js";
const sessions: Record<string, Session> = {};
export const captureSingle = ({ discovery_ip, out_file }: { discovery_ip: string; out_file: string }) => {
let devEv = discoverDevices([discovery_ip]);
const startSession = (s: Session) => {
startVideoStream(s);
logger.info(`Camera ${s.devName} is now ready to stream`);
};
devEv.on("discover", (rinfo: RemoteInfo, dev: DevSerial) => {
if (dev.devId in sessions) {
logger.info(`Camera ${dev.devId} at ${rinfo.address} already discovered, ignoring`);
return;
}
logger.info(`Discovered camera ${dev.devId} at ${rinfo.address}`);
const s = makeSession(Handlers, dev, rinfo, startSession, 5000);
sessions[dev.devId] = s;
config.cameras[dev.devId] = { fix_packet_loss: false };
s.eventEmitter.on("frame", () => {
const assembled = Buffer.concat(s.curImage);
fs.writeFileSync(out_file, assembled);
logger.info(`Got frame. Exiting`);
devEv.emit("close");
s.close();
});
});
};
+58 -22
View File
@@ -1,11 +1,15 @@
import process from "node:process";
import { hideBin } from "yargs/helpers";
import yargs from "yargs/yargs";
import { captureSingle } from "../capture_single.js";
import { serveHttp } from "../http_server.js";
import { opt } from "../options.js";
import { pair } from "../pair.js";
import { loadConfig, config } from "../settings.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))
.command(
@@ -13,22 +17,38 @@ yargs(hideBin(process.argv))
"start http server",
(yargs) => {
return yargs
.option("ansi", { default: false })
.option("slow_startup", { default: false })
.option("audio", { describe: "Also stream audio from camera", default: true })
.option("color", { describe: "Use color in logs", default: undefined })
.boolean(["ansi", "audio", "slow_startup", "color"])
.option("log_level", { describe: "Set log level", default: "info" })
.option("discovery_ip", { describe: "Camera discovery IP address", default: "192.168.1.255" })
.option("attempt_to_fix_packet_loss", { default: false })
.option("port", { describe: "HTTP Port to listen on", default: 5000 })
.option("color", { describe: "Use color in logs" })
.boolean(["audio", "color"])
.option("config_file", { describe: "Specify config file" })
.option("log_level", { describe: "Set log level" })
.option("discovery_ip", { describe: "Camera discovery IP address" })
.option("port", { describe: "HTTP Port to listen on" })
.string(["log_level", "discovery_ip", "config_file"])
.number(["port"])
.strict();
},
(argv) => {
const opts: opt = argv as opt;
buildLogger(argv.log_level, argv.color);
serveHttp(opts, argv.port, argv.audio || false);
if (argv.config_file !== undefined) {
loadConfig(argv.config_file);
}
if (argv.port) {
config.http_server.port = argv.port;
}
if (argv.color !== undefined) {
config.logging.use_color = argv.color;
}
if (argv.log_level !== undefined) {
config.logging.level = argv.log_level;
}
if (argv.discovery_ip !== undefined) {
config.discovery_ips = [argv.discovery_ip];
}
buildLogger(config.logging.level, config.logging.use_color);
if (majorVersion < 16) {
logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
}
serveHttp(config.http_server.port);
},
)
.command(
@@ -36,22 +56,38 @@ yargs(hideBin(process.argv))
"configure a camera",
(yargs) => {
return yargs
.option("ansi", { default: false })
.option("slow_startup", { default: false })
.option("color", { describe: "Use color in logs", default: undefined })
.boolean(["ansi", "slow_startup", "color"])
.option("log_level", { describe: "Set log level", default: "info" })
.option("discovery_ip", { describe: "Camera discovery IP address", default: "192.168.1.255" })
.option("attempt_to_fix_packet_loss", { default: false })
.option("ssid", { describe: "Wifi network for the camera to connect to" })
.option("password", { describe: "Wifi network password" })
.demandOption(["ssid", "password"])
.string(["ssid", "password"]);
},
(argv) => {
const opts: opt = argv as unknown as opt;
buildLogger(argv.log_level, argv.color);
pair({ opts, ssid: argv.ssid, password: argv.password });
buildLogger(argv.log_level, undefined);
if (majorVersion < 16) {
logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
}
pair({ ssid: argv.ssid, password: argv.password });
},
)
.command(
"frame",
"capture a single frame from the first discovered camera",
(yargs) => {
return yargs
.option("log_level", { describe: "Set log level", default: "info" })
.option("discovery_ip", { describe: "Camera discovery IP address", default: "192.168.1.255" })
.option("out", { describe: "Path for output file" })
.demandOption(["out"])
.string(["out", "discovery_ip"]);
},
(argv) => {
buildLogger(argv.log_level, undefined);
if (majorVersion < 16) {
logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
}
captureSingle({ discovery_ip: argv.discovery_ip, out_file: argv.out });
},
)
.demandCommand()
+34
View File
@@ -0,0 +1,34 @@
http_server:
port: 5000
logging:
level: debug
use_color: true
cameras:
FTYC477360FAWUK:
alias: "A9"
rotate: 1
mirror: false
fix_packet_loss: yes
audio: true
BATC609531EXLVS:
alias: "X5"
# If you are crossing broadcast domains (VLANs) then
# you need to specify all IPs as unicast targets
discovery_ips:
- 192.168.40.101
- 192.168.40.102
- 192.168.40.103
- 192.168.40.104
- 192.168.40.105
- 192.168.1.1
# If you are in the same broadcast domain, then
# it's easier to just use the broadcast address of your network
# discovery_ips:
# - 192.168.1.255
blacklisted_ips:
- 192.168.40.102
+2
View File
@@ -46,10 +46,12 @@ export const ControlCommands = {
ListWifi: 0x0360, // CMD_NET_WIFI_SCAN
ListWifiAck: 0x0361,
StartVideo: 0x1030, // CMD_PEER_LIVEVIDEO_START
StartVideoAck: 0x1031,
StopVideo: 0x1130, // CMD_PEER_LIVEVIDEO_STOP
Shutdown: 0x1010, //CMD_SYSTEM_SHUTDOWN,
Reboot: 0x1110, //CMD_SYSTEM_REBOOT,
VideoParamSet: 0x1830, // CMD_PEER_VIDEOPARAM_SET
VideoParamSetAck: 0x1831,
VideoParamGet: 0x1930, // CMD_PEER_VIDEOPARAM_GET
IRToggle: 0x0a30, // CMD_PEER_IRCUT_ONOFF
};
+20 -13
View File
@@ -1,8 +1,10 @@
import EventEmitter from "node:events";
import { create_LanSearchExt, create_LanSearch, parse_PunchPkt } from "./impl.js";
import { createSocket, RemoteInfo } from "node:dgram";
import EventEmitter from "node:events";
import { Commands } from "./datatypes.js";
import { create_LanSearch, parse_PunchPkt } from "./impl.js";
import { logger } from "./logger.js";
import { config } from "./settings.js";
const handleIncomingPunch = (msg: Buffer, ee: EventEmitter, rinfo: RemoteInfo) => {
const ab = new Uint8Array(msg).buffer;
@@ -11,11 +13,15 @@ const handleIncomingPunch = (msg: Buffer, ee: EventEmitter, rinfo: RemoteInfo) =
if (cmd_id != Commands.PunchPkt) {
return;
}
if (config.blacklisted_ips.indexOf(rinfo.address) !== -1) {
logger.debug(`Dropping packet of blacklisted IP: ${rinfo.address}`);
return;
}
logger.debug("Received a PunchPkt message");
ee.emit("discover", rinfo, parse_PunchPkt(dv));
};
export const discoverDevices = (discovery_ip: string): EventEmitter => {
export const discoverDevices = (discovery_ips: string[]): EventEmitter => {
const sock = createSocket("udp4");
const SEND_PORT = 32108;
const ee = new EventEmitter();
@@ -27,25 +33,26 @@ export const discoverDevices = (discovery_ip: string): EventEmitter => {
sock.on("message", (msg, rinfo) => handleIncomingPunch(msg, ee, rinfo));
let timers = [];
sock.on("listening", () => {
let ls_buf = create_LanSearch();
sock.setBroadcast(true);
discovery_ips.forEach((discovery_ip) => {
logger.info(`Searching for devices on ${discovery_ip}`);
let ls_buf = create_LanSearch();
let lse_buf = create_LanSearchExt();
setInterval(() => {
logger.log("trace", `>> LanSearch`);
let int = setInterval(() => {
logger.log("trace", `>> LanSearch [${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);
}, 2000);
logger.log("trace", `>> LanSearch`);
}, 3000);
timers.push(int);
logger.log("trace", `>> LanSearch [${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.on("close", () => timers.forEach((timer) => clearInterval(timer)));
ee.on("close", () => sock.close());
return ee;
};
+358
View File
@@ -18,11 +18,26 @@ 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.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.warning = ProtoField.string("iLnkP2P.warning", "Warning")
--
-- jpeg | audio | continuation type?
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.cmd_type = ProtoField.string("iLnkP2P.cmd_type", "Cmd Pkt Type")
ilnk_proto.fields.cmd_name = ProtoField.string("iLnkP2P.cmd_name", "Cmd Pkt")
ilnk_proto.fields.decrypted_data = ProtoField.bytes("iLnkP2P.decrypted_data", "Decrypted data")
-- PunchPkt
@@ -51,6 +66,307 @@ lut = {
[0xf171] = "RlyHelloAck2",
}
control_lut = {
[0x0000] = "CFGID_VERSION",
[0x0000] = "CMD_ACK_OK",
[0x0001] = "CFGID_LANGUAGE",
[0x0001] = "CMD_ACK_UNAUTH",
[0x0002] = "CFGID_PRODUCTE",
[0x0002] = "CMD_ACK_NO_PRIVILEGE",
[0x0003] = "CFGID_UPGRADE",
[0x0003] = "CMD_ACK_INVALID_PARAM",
[0x0004] = "CFGID_P2P",
[0x0004] = "CMD_ACK_CMDEXCUTE_FAILED",
[0x0005] = "CFGID_TZ",
[0x0005] = "CMD_ACK_NONE_RESULT",
[0x0006] = "CFGID_USER",
[0x0006] = "CMD_ACK_UNKNOWN",
[0x0007] = "CFGID_OPR",
[0x0008] = "CFGID_SERIAL",
[0x0009] = "CFGID_WIRED",
[0x000a] = "CFGID_WLAN",
[0x000b] = "CFGID_OSD",
[0x000c] = "CFGID_IMG",
[0x000d] = "CFGID_CMOS",
[0x000e] = "CFGID_PTZ",
[0x000f] = "CFGID_AUDIO",
[0x0010] = "CFGID_VIDEO",
[0x0011] = "CFGID_RECPOLICY",
[0x0012] = "CFGID_RESCH",
[0x0013] = "CFGID_MDALARM",
[0x0014] = "CFGID_ADCALARM",
[0x0015] = "CFGID_INPUTALARM",
[0x0016] = "CFGID_SMTP",
[0x0017] = "CFGID_FTP",
[0x0018] = "CFGID_PUSH",
[0x0019] = "CFGID_WLANPMK",
[0x00fe] = "CGICMD",
[0x00ff] = "BINCMD",
[0x03e8] = "CMD_ACK_ILLIGAL",
[0x0eff] = "CMD_DEV_BROADCAST",
[0x1000] = "CMD_SYSTEM_DFTCFG_IMPORT",
[0x1001] = "CMD_SYSTEM_DFTCFG_EXPORT",
[0x1002] = "CMD_SYSTEM_DFTCFG_RECOVERY",
[0x1003] = "CMD_SYSTEM_ITEMDFTCFG_RECOVERY",
[0x1004] = "CMD_SYSTEM_CRNCFG_EXPORT",
[0x1005] = "CMD_SYSTEM_CRNCFG_IMPORT",
[0x1006] = "CMD_SYSTEM_DFTCFG_CREATE",
[0x1007] = "CMD_SYSTEM_UPGRAD_SET",
[0x1008] = "CMD_SYSTEM_STATUS_GET",
[0x1009] = "CMD_SYSTEM_UPGRAD_GET",
[0x1010] = "CMD_SYSTEM_SHUTDOWN",
[0x1011] = "CMD_SYSTEM_REBOOT",
[0x1012] = "CMD_SYSTEM_INF_GET",
[0x1013] = "CMD_SYSTEM_ALIAS_SET",
[0x1020] = "CMD_SYSTEM_USER_CHK",
[0x1021] = "CMD_SYSTEM_USER_SET",
[0x1022] = "CMD_SYSTEM_USER_GET",
[0x1023] = "CMD_SYSTEM_USER_CHG",
[0x1030] = "CMD_SYSTEM_P2PPARAM_SET",
[0x1031] = "CMD_SYSTEM_OPRPOLICY_SET",
[0x1032] = "CMD_SYSTEM_OPRPOLICY_GET",
[0x1033] = "CMD_SYSTEM_P2PPARAM_GET",
[0x1040] = "CMD_SYSTEM_DATETIME_SET",
[0x1041] = "CMD_SYSTEM_DATETIME_GET",
[0x1051] = "CMD_NOTIFICATION",
[0x1100] = "ACK_SYSTEM_DFTCFG_IMPORT",
[0x1101] = "ACK_SYSTEM_DFTCFG_EXPORT",
[0x1102] = "ACK_SYSTEM_DFTCFG_RECOVERY",
[0x1103] = "ACK_SYSTEM_ITEMDFTCFG_RECOVERY",
[0x1104] = "ACK_SYSTEM_CRNCFG_EXPORT",
[0x1105] = "ACK_SYSTEM_CRNCFG_IMPORT",
[0x1106] = "ACK_SYSTEM_DFTCFG_CREATE",
[0x1107] = "ACK_SYSTEM_UPGRAD_SET",
[0x1108] = "ACK_SYSTEM_STATUS_GET",
[0x1109] = "ACK_SYSTEM_UPGRAD_GET",
[0x1110] = "ACK_SYSTEM_SHUTDOWN",
[0x1111] = "ACK_SYSTEM_REBOOT",
[0x1112] = "ACK_SYSTEM_INF_GET",
[0x1113] = "ACK_SYSTEM_ALIAS_SET",
[0x1120] = "ACK_SYSTEM_USER_CHK",
[0x1121] = "ACK_SYSTEM_USER_SET",
[0x1122] = "ACK_SYSTEM_USER_GET",
[0x1123] = "ACK_SYSTEM_USER_CHG",
[0x1130] = "ACK_SYSTEM_P2PPARAM_SET",
[0x1131] = "ACK_SYSTEM_OPRPOLICY_SET",
[0x1132] = "ACK_SYSTEM_OPRPOLICY_GET",
[0x1133] = "ACK_SYSTEM_P2PPARAM_GET",
[0x1140] = "ACK_SYSTEM_DATETIME_SET",
[0x1141] = "ACK_SYSTEM_DATETIME_GET",
[0x1151] = "ACK_NOTIFICATION",
[0x2000] = "CMD_SD_FORMAT",
[0x2001] = "CMD_SD_RECPOLICY_SET",
[0x2002] = "CMD_SD_RECPOLICY_GET",
[0x2003] = "CMD_SD_RECORDING_NOW",
[0x2004] = "CMD_SD_INFO_GET",
[0x2005] = "CMD_SD_RECORDFILE_GET",
[0x2006] = "CMD_SD_RECORDSCH_GET",
[0x2007] = "CMD_SD_RECORDSCH_SET",
[0x2008] = "CMD_SD_RETRIVEL",
[0x2009] = "CMD_SD_PICFILE_GET",
[0x200a] = "CMD_SD_PIC_CAPTURE",
[0x200b] = "CMD_SD_REC_DEL",
[0x200c] = "CMD_SD_PIC_DEL",
[0x200d] = "CMD_SD_SPL_DEL",
[0x2100] = "ACK_SD_FORMAT",
[0x2101] = "ACK_SD_RECPOLICY_SET",
[0x2102] = "ACK_SD_RECPOLICY_GET",
[0x2103] = "ACK_SD_RECORDING_NOW",
[0x2104] = "ACK_SD_INFO_GET",
[0x2105] = "ACK_SD_RECORDFILE_GET",
[0x2106] = "ACK_SD_RECORDSCH_GET",
[0x2107] = "ACK_SD_RECORDSCH_SET",
[0x2108] = "ACK_SD_RETRIVEL",
[0x2109] = "ACK_SD_PICFILE_GET",
[0x210a] = "ACK_SD_PIC_CAPTURE",
[0x210b] = "ACK_SD_REC_DEL",
[0x210c] = "ACK_SD_PIC_DEL",
[0x210d] = "ACK_SD_SPL_DEL",
[0x3000] = "CMD_PEER_LIVEAUDIO_START",
[0x3001] = "CMD_PEER_LIVEAUDIO_STOP",
[0x3002] = "CMD_LOCAL_LIVEAUDIO_START",
[0x3003] = "CMD_LOCAL_LIVEAUDIO_STOP",
[0x3004] = "CMD_PEER_AUDIOPARAM_SET",
[0x3005] = "CMD_PEER_AUDIOPARAM_GET",
[0x3006] = "CMD_PEER_AUDIOFILE_STARTPLAY",
[0x3007] = "CMD_PEER_AUDIOFILE_STOPPLAY",
[0x3008] = "CMD_PEER_AUDIOFILELIST_GET",
[0x300a] = "CMD_PEER_IRCUT_ONOFF",
[0x300b] = "CMD_PEER_LIGHTFILL_ONOFF",
[0x3010] = "CMD_PEER_LIVEVIDEO_START",
[0x3011] = "CMD_PEER_LIVEVIDEO_STOP",
[0x3012] = "CMD_PEER_PLAYBACK_START",
[0x3013] = "CMD_PEER_PLAYBACK_STOP",
[0x3014] = "CMD_PEER_PLAYBACK_SEEK",
[0x3015] = "CMD_PEER_PLAYBACK_SPEED",
[0x3016] = "CMD_PEER_PLAYBACK_PAUSE",
[0x3017] = "CMD_PEER_PLAYBACK_RESUME",
[0x3018] = "CMD_PEER_VIDEOPARAM_SET",
[0x3019] = "CMD_PEER_VIDEOPARAM_GET",
[0x301a] = "CMD_SNAPSHOT_GET",
[0x301b] = "CMD_PEER_PLAYBACK_END",
[0x301c] = "CMD_PEER_PLAYBACK_STEP",
[0x3020] = "CMD_DOORBELL_CALL_OPEN",
[0x3021] = "CMD_DOORBELL_CALL_CLOSE",
[0x3022] = "CMD_DOORBELL_CALL_ACCEPT",
[0x3023] = "CMD_DOORBELL_CALL_REJECT",
[0x3024] = "CMD_LOCAL_LIVEVIDIO_SEND_ON",
[0x3025] = "CMD_LOCAL_LIVEVIDIO_SEND_OFF",
[0x3026] = "CMD_LOCAL_AUDIO_STATUS_SET",
[0x3027] = "CMD_LOCAL_AUDIO_STATUS_GET",
[0x3028] = "CMD_LOCAL_AVREC_START",
[0x3029] = "CMD_LOCAL_AVREC_STOP",
[0x3030] = "CMD_LOCAL_PLAYBACK_START",
[0x3031] = "CMD_LOCAL_PLAYBACK_STOP",
[0x3032] = "CMD_LOCAL_PLAYBACK_SEEK",
[0x3033] = "CMD_LOCAL_PLAYBACK_PAUSE",
[0x3034] = "CMD_LOCAL_PLAYBACK_RESUME",
[0x3035] = "CMD_LOCAL_PLAYBACK_START1",
[0x3036] = "CMD_LOCAL_PLAYBACK_STEP",
[0x3037] = "CMD_LOCAL_PLAYBACK_START2",
[0x3040] = "CMD_LOCAL_MJREC_START",
[0x3041] = "CMD_LOCAL_MJREC_STOP",
[0x3100] = "ACK_PEER_LIVEAUDIO_START",
[0x3101] = "ACK_PEER_LIVEAUDIO_STOP",
[0x3102] = "ACK_LOCAL_LIVEAUDIO_START",
[0x3103] = "ACK_LOCAL_LIVEAUDIO_STOP",
[0x3104] = "ACK_PEER_AUDIOPARAM_SET",
[0x3105] = "ACK_PEER_AUDIOPARAM_GET",
[0x3106] = "ACK_PEER_AUDIOFILE_STARTPLAY",
[0x3107] = "ACK_PEER_AUDIOFILE_STOPPLAY",
[0x3108] = "ACK_PEER_AUDIOFILELIST_GET",
[0x310a] = "ACK_PEER_IRCUT_ONOFF",
[0x310b] = "ACK_PEER_LIGHTFILL_ONOFF",
[0x3110] = "ACK_PEER_LIVEVIDEO_START",
[0x3111] = "ACK_PEER_LIVEVIDEO_STOP",
[0x3112] = "ACK_PEER_PLAYBACK_START",
[0x3113] = "ACK_PEER_PLAYBACK_STOP",
[0x3114] = "ACK_PEER_PLAYBACK_SEEK",
[0x3115] = "ACK_PEER_PLAYBACK_SPEED",
[0x3116] = "ACK_PEER_PLAYBACK_PAUSE",
[0x3117] = "ACK_PEER_PLAYBACK_RESUME",
[0x3118] = "ACK_PEER_VIDEOPARAM_SET",
[0x3119] = "ACK_PEER_VIDEOPARAM_GET",
[0x311a] = "ACK_SNAPSHOT_GET",
[0x311b] = "ACK_PEER_PLAYBACK_END",
[0x311c] = "ACK_PEER_PLAYBACK_STEP",
[0x4000] = "CMD_FILE_CTRL",
[0x4005] = "CMD_FILETRANSFER_FILELIST_GET",
[0x4010] = "CMD_LOCALPATH",
[0x4101] = "ACK_FILE_CREATE",
[0x4102] = "ACK_FILE_RENAME",
[0x4103] = "ACK_FILE_DELETE",
[0x4104] = "ACK_FILE_MOVE",
[0x4105] = "ACK_FILE_LIST",
[0x4110] = "ACK_FILE_DOWNLOAD",
[0x4111] = "ACK_FILE_DOWNLOAD_PAUSE",
[0x4112] = "ACK_FILE_DOWNLOAD_RESUME",
[0x4113] = "ACK_FILE_DOWNLOAD_CANCEL",
[0x4120] = "ACK_FILE_UPLOAD",
[0x4121] = "ACK_FILE_UPLOAD_PAUSE",
[0x4122] = "ACK_FILE_UPLOAD_RESUME",
[0x4123] = "ACK_FILE_UPLOAD_CANCEL",
[0x50ff] = "CMD_PASSTHROUGH_STRING_PUT",
[0x51ff] = "ACK_PASSTHROUGH_STRING_PUT",
[0x55fe] = "CMD_SESSION_CHECK",
[0x6001] = "CB_IEGET_STATUS",
[0x6001] = "CMD_NET_WIFISETTING_SET",
[0x6002] = "CB_IEGET_PARAM",
[0x6002] = "CMD_NET_WIFISETTING_GET",
[0x6003] = "CB_IEGET_CAM_PARAMS",
[0x6003] = "CMD_NET_WIFI_SCAN",
[0x6004] = "CB_IEGET_LOG",
[0x6004] = "CMD_NET_WIREDSETTING_SET",
[0x6005] = "CB_IEGET_MISC",
[0x6005] = "CMD_NET_WIREDSETTING_GET",
[0x6006] = "CB_IEGET_RECORD",
[0x6007] = "CB_IEGET_RECORD_FILE",
[0x6008] = "CB_IEGET_WIFI_SCAN",
[0x6009] = "CB_IEGET_FACTORY",
[0x600a] = "CB_IESET_IR",
[0x600b] = "CB_IESET_UPNP",
[0x600c] = "CB_IESET_ALARM",
[0x600d] = "CB_IESET_LOG",
[0x600e] = "CB_IESET_USER",
[0x600f] = "CB_IESET_ALIAS",
[0x6010] = "CB_IESET_MAIL",
[0x6011] = "CB_IESET_WIFI",
[0x6012] = "CB_CAM_CONTROL",
[0x6013] = "CB_IESET_DATE",
[0x6014] = "CB_IESET_MEDIA",
[0x6015] = "CB_IESET_SNAPSHOT",
[0x6016] = "CB_IESET_DDNS",
[0x6017] = "CB_IESET_MISC",
[0x6018] = "CB_IEGET_FTPTEST",
[0x6019] = "CB_DECODER_CONTROL",
[0x601a] = "CB_IESET_DEFAULT",
[0x601b] = "CB_IESET_MOTO",
[0x601c] = "CB_IEGET_MAILTEST",
[0x601d] = "CB_IESET_MAILTEST",
[0x601e] = "CB_IEDEL_FILE",
[0x601f] = "CB_IELOGIN",
[0x6020] = "CB_IESET_DEVICE",
[0x6021] = "CB_IESET_NETWORK",
[0x6022] = "CB_IESET_FTPTEST",
[0x6023] = "CB_IESET_DNS",
[0x6024] = "CB_IESET_OSD",
[0x6025] = "CB_IESET_FACTORY",
[0x6026] = "CB_IESET_PPPOE",
[0x6027] = "CB_IEREBOOT",
[0x6028] = "CB_IEFORMATSD",
[0x6029] = "CB_IESET_RECORDSCH",
[0x602a] = "CB_IESET_WIFISCAN",
[0x602b] = "CB_IERESTORE",
[0x602c] = "CB_IESET_FTP",
[0x602d] = "CB_IESET_RTSP",
[0x602e] = "CB_IEGET_VIDEOSTREAM",
[0x602f] = "CB_UPGRADE_APP",
[0x6030] = "CB_UPGRADE_SYS",
[0x6031] = "CB_SET_IIC",
[0x6032] = "CB_GET_IIC",
[0x6033] = "CB_IEGET_ALARMLOG",
[0x6034] = "CB_IESET_ALARMLOGCLR",
[0x6035] = "CB_IEGET_SYSWIFI",
[0x6036] = "CB_IESET_SYSWIFI",
[0x6037] = "CB_IEGET_LIVESTREAM",
[0x6040] = "CB_NOTIFICATION",
[0x6053] = "CB_IEGET_BILL",
[0x6054] = "CB_APP_VERSION",
[0x60a0] = "CB_CHECK_USER",
[0x60a1] = "CB_IESET_BILL",
[0x6101] = "ACK_NET_WIFISETTING_SET",
[0x6102] = "ACK_NET_WIFISETTING_GET",
[0x6103] = "ACK_NET_WIFI_SCAN",
[0x6104] = "ACK_NET_WIREDSETTING_SET",
[0x6105] = "ACK_NET_WIREDSETTING_GET",
[0x7000] = "CMD_FRIEND_MSG",
[0x99f0] = "CB_SET_P2PPARAM",
[0x99fe] = "CB_GET_SYSOPR",
[0x99ff] = "CB_SET_SYSOPR",
[0xf000] = "CMD_LOCAL_SESSION_INF",
[0xf001] = "CMD_LOCAL_SESSION_CHECK",
[0xf002] = "CMD_LOCAL_SESSION_GET",
[0xf003] = "CMD_LOCAL_SESSION_CTRL",
[0xf004] = "CMD_LOCAL_REC_START",
[0xf005] = "CMD_LOCAL_REC_STOP",
[0xf006] = "CMD_LOCAL_REC_MERGECTRL",
[0xf007] = "CMD_LOCAL_P2P_START",
[0xf008] = "CMD_LOCAL_P2P_STOP",
[0xf00f] = "CMD_SESSION_CLOSE",
[0xf010] = "CMD_LOCAL_PUSH_STRING",
[0xf011] = "CMD_LOCAL_PUSH_CFG",
[0xf012] = "CMD_LOCAL_RCVVID_DEC",
[0xf021] = "CMD_LOCAL_LAPSED",
[0xff01] = "CB_SET_SINGLE_SETTING_DEFAULT",
[0xff10] = "CB_GET_FILE",
[0xff11] = "CB_PUT_FILE",
[0xff12] = "CB_SET_FILE",
[0xff13] = "CB_GET_FILELIST",
[0xff14] = "CB_SET_GPIO",
[0xff15] = "CB_GET_GPIO",
[0xff16] = "CB_GET_ADC",
}
-- Define a function to dissect the packets
function ilnk_proto.dissector(buffer, pinfo, tree)
local packet_length = buffer:len()
@@ -88,13 +404,23 @@ function ilnk_proto.dissector(buffer, pinfo, tree)
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_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))
local b_payload_len = buffer(0xc, 2)
local payload_len = buffer(0xc, 2):le_uint()
if payload_len > pkt_len then
payload_len = pkt_len - 12
end
if not is_data_packet then
subtree:add_le(ilnk_proto.fields.start, buffer(8, 2))
subtree:add_le(ilnk_proto.fields.cmd, buffer(0xa, 2))
local cmdname = control_lut[buffer(0xa, 2):le_uint()] or "UNK"
pinfo.cols.info:set(cmdname)
subtree:add(ilnk_proto.fields.cmd_name, cmdname):set_generated()
subtree:add_le(ilnk_proto.fields.cmd_payload_len, b_payload_len)
subtree:add_le(ilnk_proto.fields.cmd_dest, buffer(0xe, 2))
-- inline value for short-payload bytes
@@ -137,7 +463,39 @@ function ilnk_proto.dissector(buffer, pinfo, tree)
subtree:add(ilnk_proto.fields.cmd_payload, payload_tvb:range(0, payload_len -4))
end
else
local payload_type
local payload_subtype
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))
end
end
+173 -60
View File
@@ -1,9 +1,9 @@
import { Commands, CommandsByValue, ControlCommands } from "./datatypes.js";
import { XqBytesDec } from "./func_replacements.js";
import { create_P2pRdy, SendListWifi, SendUsrChk, DevSerial } from "./impl.js";
import { Session } from "./session.js";
import { u16_swap, u32_swap } from "./utils.js";
import { create_P2pRdy, DevSerial, SendListWifi, SendUsrChk } from "./impl.js";
import { logger } from "./logger.js";
import { Session } from "./session.js";
import { config } from "./settings.js";
export const notImpl = (session: Session, dv: DataView) => {
const raw = dv.readU16();
@@ -31,18 +31,55 @@ export const handle_P2PRdy = (session: Session, _: DataView) => {
};
export const makeP2pRdy = (dev: DevSerial): DataView => {
const len = dev.prefix.length + dev.suffix.length + 8;
const outbuf = new DataView(new Uint8Array(0x14).buffer); // 8 = serial u64
// The protocol seems to expect 4 bytes -- check the regression test
// `replies properly to PunchPkt with 3-letters-long prefix` for a case with a
// real device
const devPrefixLength = 4;
outbuf.add(0).writeString(dev.prefix);
outbuf.add(4).writeU64(dev.serialU64);
outbuf.add(8 + dev.prefix.length).writeString(dev.suffix);
outbuf.add(8 + devPrefixLength).writeString(dev.suffix);
return create_P2pRdy(outbuf);
};
export const swVerToString = (swver: number): string => {
return (
((swver >> 24) & 255).toString() +
"." +
((swver >> 16) & 255).toString() +
"." +
((swver >> 8) & 255).toString() +
"." +
(swver & 255).toString()
);
};
export type DevStatus = {
charging: boolean;
battery_mV: number;
dbm: number;
swver: string;
};
export const parseDevStatusAck = (dv: DataView): DevStatus => {
const charging = dv.add(0x28).readU32LE() & 1; // 0x14000101 v 0x14000100
const power = dv.add(0x18).readU16LE(); // '3730' or '3765', milliVolts
const dbm = dv.add(0x24).readU8() - 0x100; // 0xbf - 0x100 = -65dbm .. constant??
const n_swver = dv.add(0x14).readU32LE();
const swver = swVerToString(n_swver);
return {
charging: charging > 0,
battery_mV: power,
dbm,
swver,
};
};
export const createResponseForControlCommand = (session: Session, dv: DataView): DataView[] => {
const start_type = dv.add(8).readU16(); // 0xa11 on control; data starts here on DATA pkt
const cmd_id = dv.add(10).readU16(); // 0x1120
const payload_len = u16_swap(dv.add(0xc).readU16());
let payload_len = dv.add(0xc).readU16LE();
if (dv.byteLength > 20 && payload_len > dv.byteLength) {
logger.warning(`Received a cropped payload: ${payload_len} when packet is ${dv.byteLength}`);
payload_len = dv.byteLength - 20;
}
if (start_type != 0x110a) {
logger.error(`Expected start_type to be 0xa11, got 0x${start_type.toString(16)}`);
@@ -54,24 +91,28 @@ export const createResponseForControlCommand = (session: Session, dv: DataView):
XqBytesDec(dv.add(20), payload_len - 4, rotate_chr);
}
if (cmd_id == ControlCommands.ConnectUserAck) {
// the first 20 bytes are header
switch (cmd_id) {
case ControlCommands.ConnectUserAck:
let c = new Uint8Array(dv.add(0x18).readByteArray(4).buffer);
session.ticket = [...c];
session.eventEmitter.emit("login");
return [];
}
if (cmd_id == ControlCommands.DevStatusAck) {
case ControlCommands.DevStatusAck:
// ParseDevStatus -> offset relevant?
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 dbm = dv.add(0x24).readU8() - 0x100; // 0xbf - 0x100 = -65dbm .. constant??
const status = parseDevStatusAck(dv);
// > -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}: sw: ${status.swver}, ${status.charging ? "" : "not "}charging, battery at ${
status.battery_mV
}mV, Wifi ${status.dbm} dBm`,
);
return [];
if (cmd_id == ControlCommands.WifiSettingsAck) {
case ControlCommands.WifiSettingsAck:
const wifiSettings = {
enable: dv.add(0x14).readU32(),
status: dv.add(0x18).readU32(),
@@ -90,75 +131,125 @@ export const createResponseForControlCommand = (session: Session, dv: DataView):
const buf = SendListWifi(session);
logger.info(`Current Wifi settings: ${JSON.stringify(wifiSettings, null, 2)}`);
return [buf];
}
if (cmd_id == ControlCommands.ListWifiAck) {
case ControlCommands.ListWifiAck:
if (payload_len == 4) {
logger.debug("ListWifi returned []");
return [];
}
const items = parseListWifi(dv);
session.eventEmitter.emit("ListWifi", items);
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 [];
};
export type WifiListItem = {
ssid: string;
mac: string;
security: number;
dbm0: number;
dbm1: number;
mode: number;
channel: number;
};
export const parseListWifi = (dv: DataView): WifiListItem[] => {
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);
const msg_len = 0x5c; // 0x58 + 0x4 of the last u32
const msg_count = dv.add(startat).readU32LE();
startat += 4;
let items = [];
for (let i = 0; i < msg_count; i++) {
if (startat + msg_len > dv.byteLength) {
logger.warning("Wifi listing got cropped");
break;
}
const macBytes = dv.add(startat + 0x40).readByteArray(6).buffer;
const mb = new Uint8Array(macBytes);
const wifiListItem = {
// startat = msg_len * i + 0x14;
ssid: dv.add(startat).readString(0x40),
mac: dv.add(startat + 0x40).readByteArray(8),
mac: [...mb].map((b) => b.toString(16).padStart(2, "0")).join(":"),
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 [];
return items;
};
const deal_with_data = (session: Session, dv: DataView) => {
const pkt_len = dv.add(2).readU16();
// data
const JPEG_HEADER = [0xff, 0xd8, 0xff, 0xdb];
const AUDIO_HEADER = [0x55, 0xaa, 0x15, 0xa8];
const m_hdr = dv.add(8).readByteArray(4);
let is_new_image = true;
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];
// 12 equals start of header (0x8) + header length (0x4)
if (pkt_len < 12) {
logger.log("trace", "Got a short Drw packet, ignoring");
return;
}
if (audio) {
// "stream_head_t->type == 0x06" per pdf
if (dv.add(12).readU8() == 0x06) {
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`
session.eventEmitter.emit("audio", { gap: false, data: Buffer.from(audio_buf) });
} else {
// not sure what these are for, there's one per frame. maybe alignment?
}
} else {
const data = dv.add(8).readByteArray(pkt_len - 4);
if (is_new_image) {
const FRAME_HEADER = [0x55, 0xaa, 0x15, 0xa8];
const m_hdr = dv.add(8).readByteArray(4);
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(data.buffer)];
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 = dv.add(8 + 16).readU16LE();
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) });
} 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 {
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?
}
} 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);
// this only happens on un-framed-cameras, which start the JPEG image
// directly
let is_new_image = m_hdr.startsWith(JPEG_HEADER);
if (is_new_image) {
startNewFrame(data.buffer);
} else {
if (pkt_id <= session.rcvSeqId) {
// retransmit
@@ -168,14 +259,17 @@ const deal_with_data = (session: Session, dv: DataView) => {
let b = Buffer.from(data.buffer);
if (pkt_id > session.rcvSeqId + 1) {
if (!session.frame_is_bad) {
session.frame_is_bad = true;
// this should always be enabled but currently it seems to cause more visual distortion
// than just missing some frames
if (!session.options.attempt_to_fix_packet_loss) {
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 than just missing some frames
if (!config.cameras[session.devName]?.fix_packet_loss) {
return;
}
if (session.curImage.length == 1) return; // header does not have markers
if (session.curImage.length <= 1) return; // header does not have markers
let lastFrameSlice = session.curImage[session.curImage.length - 1];
const lastResetMarker = findAllResetMarkers(lastFrameSlice).pop();
@@ -234,6 +328,17 @@ const makeDrwAck = (dv: DataView): DataView => {
}
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) => {
const ack = makeDrwAck(dv);
session.send(ack);
@@ -241,8 +346,16 @@ export const handle_Drw = (session: Session, dv: DataView) => {
const m_stream = dv.add(5).readU8(); // data = 1, control = 0
if (m_stream == 1) {
deal_with_data(session, dv);
} else {
} else if (m_stream == 0) {
const b = createResponseForControlCommand(session, dv);
b.forEach(session.send);
} else {
logger.warning(`Received a Drw packet with stream tag: ${m_stream}, which is not implemented`);
}
};
export const handle_Close = (session: Session, dv: DataView) => {
const ack = makeDrwAck(dv);
session.send(ack);
logger.info("Requested to close connection");
session.close();
};
+19 -41
View File
@@ -1,11 +1,10 @@
import { RemoteInfo } from "dgram";
import { readFileSync, existsSync } from "node:fs";
import http from "node:http";
import { logger } from "./logger.js";
import { opt } from "./options.js";
import { config } from "./settings.js";
import { discoverDevices } from "./discovery.js";
import { DevSerial, SendDevStatus } from "./impl.js";
import { DevSerial } from "./impl.js";
import { Handlers, makeSession, Session, startVideoStream } from "./session.js";
import { addExifToJpeg, createExifOrientation } from "./exif.js";
@@ -19,9 +18,6 @@ const responses: Record<string, http.ServerResponse[]> = {};
const audioResponses: Record<string, http.ServerResponse[]> = {};
const sessions: Record<string, Session> = {};
// Text file containing the mapping of camera names.
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];
@@ -29,29 +25,13 @@ 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.
const cameraNames = Object.assign(
{},
...(existsSync(nameFile) ? readFileSync(nameFile, "utf8") : "")
.toString()
.replace(/\r\n/g, "\n")
.split("\n")
.filter((l) => !l.startsWith("#"))
.filter((l) => l.trim() != "")
.map((l) => {
let kv = l.split("=");
return { [kv[0].trim()]: kv[1].trim() };
}),
);
// Returns the camera name (custom name, if it exists, otherwise its ID).
const cameraName = (id: string): string => cameraNames[id] || id;
const cameraName = (id: string): string => config.cameras[id].alias || id;
// The HTTP server.
export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
logger.info(`Mapping camera names: ${JSON.stringify(cameraNames)}`);
export const serveHttp = (port: number) => {
const server = http.createServer((req, res) => {
if (req.url.startsWith("/ui/")) {
let devId = req.url.split("/")[2];
@@ -70,7 +50,7 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
.toString()
.replace(/\${id}/g, devId)
.replace(/\${name}/g, cameraName(devId))
.replace(/\${audio}/g, with_audio.toString());
.replace(/\${audio}/g, config.cameras[devId].audio ? "true" : "false");
res.end(ui);
return;
}
@@ -96,23 +76,23 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
if (req.url.startsWith("/favicon.ico")) {
res.setHeader("Content-Type", "image/x-icon");
res.setHeader("Content-Encoding", "gzip");
res.end(favicon);
res.end(Buffer.from(favicon));
return;
}
if (req.url.startsWith("/rotate/")) {
let devId = req.url.split("/")[2];
let curPos = camSettings[devId]?.orientation || 0;
let curPos = config.cameras[devId]?.rotate || 0;
let nextPos = (curPos + 1) % 4;
logger.info(`Rotating ${devId} to ${nextPos}`);
camSettings[devId].orientation = nextPos;
logger.debug(`Rotating ${devId} to ${nextPos}`);
config.cameras[devId].rotate = nextPos;
res.writeHead(204);
res.end();
return;
} else if (req.url.startsWith("/mirror/")) {
let devId = req.url.split("/")[2];
logger.info(`Mirroring ${devId}`);
camSettings[devId].mirror = !camSettings[devId].mirror;
logger.debug(`Mirroring ${devId}`);
config.cameras[devId].mirror = !config.cameras[devId].mirror;
res.writeHead(204);
res.end();
return;
@@ -155,10 +135,9 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
}
});
let devEv = discoverDevices(opts.discovery_ip);
let devEv = discoverDevices(config.discovery_ips);
const startSession = (s: Session) => {
s.send(SendDevStatus(s));
startVideoStream(s);
logger.info(`Camera ${s.devName} is now ready to stream`);
};
@@ -172,17 +151,18 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
logger.info(`Discovered camera ${dev.devId} at ${rinfo.address}`);
responses[dev.devId] = [];
audioResponses[dev.devId] = [];
const s = makeSession(Handlers, dev, rinfo, startSession, opts);
const header = Buffer.from(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\n\r\n`);
const s = makeSession(Handlers, dev, rinfo, startSession, 5000);
sessions[dev.devId] = s;
config.cameras[dev.devId] = { rotate: 0, mirror: false, audio: true, ...(config.cameras[dev.devId] || {}) };
s.eventEmitter.on("frame", () => {
// 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];
let orientation = config.cameras[dev.devId].rotate;
orientation = config.cameras[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)]);
const header = Buffer.from(`\r\n--${BOUNDARY}\r\nContent-Length: ${assembled.length}\r\nContent-Type: image/jpeg\r\n\r\n`);
responses[dev.devId].forEach((res) => {
res.write(header);
res.write(assembled);
@@ -193,7 +173,7 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
logger.info(`Camera ${dev.devId} disconnected`);
delete sessions[dev.devId];
});
if (with_audio) {
if (config.cameras[dev.devId].audio) {
s.eventEmitter.on("audio", ({ gap, data }) => {
// ew, maybe WS?
var b64encoded = Buffer.from(data).toString("base64");
@@ -204,8 +184,6 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
});
});
}
sessions[dev.devId] = s;
camSettings[dev.devId] = { orientation: 0, mirror: false };
});
logger.info(`Starting HTTP server on port ${port}`);
+13 -4
View File
@@ -119,7 +119,13 @@ export const SendReboot = (session: Session): DataView => {
return makeDataReadWrite(session, ControlCommands.Reboot, dv);
};
export const SendWifiDetails = (session: Session, ssid: string, password: string, dhcp: boolean): DataView => {
export const SendWifiDetails = (
session: Session,
ssid: string,
password: string,
channel: number,
dhcp: boolean,
): DataView => {
if (!dhcp) {
throw new Error("only DHCP is supported");
}
@@ -132,11 +138,14 @@ export const SendWifiDetails = (session: Session, ssid: string, password: string
let m_dns1 = "0.0.0.0";
let m_dns2 = "0.0.0.0";
cmd_payload.add(0x14).writeU8(1); // DHCP ?
// tag_wifiParams in types/all.h
cmd_payload.add(0x0c).writeU8(channel);
cmd_payload.add(0x10).writeU8(0); // TODO: AUTH
cmd_payload.add(0x14).writeU8(1); // DHCP
cmd_payload.add(0x18).writeByteArray(str2byte(ssid));
cmd_payload.add(0x38).writeByteArray(str2byte(password));
cmd_payload.add(0xb8).writeByteArray(str2byte(mask_reversed));
cmd_payload.add(0xc8).writeByteArray(str2byte(m_ip));
cmd_payload.add(0xb8).writeByteArray(str2byte(m_ip));
cmd_payload.add(0xc8).writeByteArray(str2byte(mask_reversed));
cmd_payload.add(0xd8).writeByteArray(str2byte(m_gw));
cmd_payload.add(0xe8).writeByteArray(str2byte(m_dns1));
cmd_payload.add(0xf8).writeByteArray(str2byte(m_dns2));
+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;
};
-2
View File
@@ -1,6 +1,4 @@
export type opt = {
ansi: boolean;
discovery_ip: string;
slow_startup: boolean;
attempt_to_fix_packet_loss: boolean;
};
+15
View File
@@ -6,6 +6,7 @@
"": {
"dependencies": {
"winston": "^3.13.0",
"yaml": "^2.4.2",
"yargs": "^17.7.2"
},
"devDependencies": {
@@ -14,6 +15,9 @@
"mocha": "^10.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=16.0"
}
},
"node_modules/@colors/colors": {
@@ -1711,6 +1715,17 @@
"node": ">=10"
}
},
"node_modules/yaml": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",
"integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+4
View File
@@ -14,6 +14,10 @@
},
"dependencies": {
"winston": "^3.13.0",
"yaml": "^2.4.2",
"yargs": "^17.7.2"
},
"engines": {
"node": ">=16.0"
}
}
+32 -7
View File
@@ -1,23 +1,26 @@
import { RemoteInfo } from "dgram";
import { opt } from "./options.js";
import { config } from "./settings.js";
import { discoverDevices } from "./discovery.js";
import { DevSerial } from "./impl.js";
import { WifiListItem } from "./handlers.js";
import { DevSerial, SendListWifi } from "./impl.js";
import { Handlers, makeSession, Session, configureWifi } from "./session.js";
import { logger } from "./logger.js";
export const pair = ({ opts, ssid, password }: { opts: opt; ssid: string; password: string }) => {
export const pair = ({ ssid, password }: { ssid: string; password: string }) => {
logger.info(`Will configure any devices found to join ${ssid}`);
let sessions: Record<string, Session> = {};
let devEv = discoverDevices(opts.discovery_ip);
let devEv = discoverDevices(config.discovery_ips);
if (password == "") {
throw new Error("You must set a non-zero-length password");
}
const onLogin = (s: Session) => {
configureWifi(ssid, password)(s);
logger.info(`WiFi config for camera ${s.devName} is done`);
logger.info(`Scanning for Wifi networks on ${s.devName} -- this may time out`);
// configureWifi(ssid, password, 0)(s);
s.send(SendListWifi(s));
};
devEv.on("discover", (rinfo: RemoteInfo, dev: DevSerial) => {
@@ -26,13 +29,35 @@ export const pair = ({ opts, ssid, password }: { opts: opt; ssid: string; passwo
return;
}
logger.info(`Discovered camera ${dev.devId} at ${rinfo.address}`);
const s = makeSession(Handlers, dev, rinfo, onLogin, opts);
const s = makeSession(Handlers, dev, rinfo, onLogin, 10000);
let configured = {};
s.eventEmitter.on("disconnect", () => {
logger.info(`Camera ${dev.devId} disconnected`);
if (configured[dev.devId]) {
logger.info("Press CONTROL+C if you're done setting up your cameras");
}
delete sessions[dev.devId];
delete configured[dev.devId];
});
sessions[dev.devId] = s;
s.eventEmitter.on("ListWifi", (items: WifiListItem[]) => {
const matches = items.filter((i) => i.ssid == ssid);
if (matches.length == 0) {
logger.error(`Camera could not find SSID '${ssid}'`);
return;
}
if (configured[dev.devId]) {
logger.info(`Got two answers from camera, ignoring second`);
return;
}
const match = matches[0];
logger.info(`Configuring camera ${s.devName} on ${JSON.stringify(match)}`);
configureWifi(ssid, password, match.channel)(s);
configured[dev.devId] = true;
logger.info(`WiFi config for camera ${s.devName} is done`);
logger.info(`Camera should reboot now`);
});
});
};
+3
View File
@@ -336,3 +336,6 @@ what is this??
0220 00 00 00 00 00 00 00 00 ........
-- this looks like google keys: AIzaSyB
most useful parsing at IpcByte2ObjectParser and IlnkUtils
+58 -26
View File
@@ -2,13 +2,23 @@ import { createSocket, RemoteInfo } from "node:dgram";
import EventEmitter from "node:events";
import { Commands, CommandsByValue } from "./datatypes.js";
import { handle_Drw, handle_P2PAlive, handle_P2PRdy, makeP2pRdy, noop, notImpl } from "./handlers.js";
import {
handle_Close,
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 { opt } from "./options.js";
import { logger } from "./logger.js";
export type Session = {
send: (msg: DataView) => void;
ackDrw: (id: number) => void;
unackedDrw: { [id: number]: { sent_ts: number; data: DataView } };
outgoingCommandId: number;
ticket: number[];
eventEmitter: EventEmitter;
@@ -22,7 +32,7 @@ export type Session = {
frame_is_bad: boolean;
frame_was_fixed: boolean;
started: boolean;
options: opt;
close: () => void;
};
export type PacketHandler = (session: Session, dv: DataView, rinfo: RemoteInfo) => void;
@@ -37,10 +47,13 @@ type msgCb = (
const handleIncoming: msgCb = (session, handlers, msg, rinfo) => {
const ab = new Uint8Array(msg).buffer;
const dv = new DataView(ab);
const cmd = CommandsByValue[dv.readU16()];
const raw = dv.readU16();
const cmd = CommandsByValue[raw];
logger.log("trace", `<< ${cmd}`);
handlers[cmd](session, dv, rinfo);
if (raw != Commands.P2PAlive && raw != Commands.P2PAliveAck) {
session.lastReceivedPacket = Date.now();
}
};
export const makeSession = (
@@ -48,8 +61,9 @@ export const makeSession = (
dev: DevSerial,
ra: RemoteInfo,
onLogin: (s: Session) => void,
options: opt,
timeoutMs: number,
): Session => {
let unackedDrw = {};
const sock = createSocket("udp4");
sock.on("error", (err) => {
@@ -60,23 +74,11 @@ export const makeSession = (
sock.on("message", (msg, rinfo) => handleIncoming(session, handlers, msg, rinfo));
sock.on("listening", () => {
const startup = () => {
const buf = makeP2pRdy(dev);
session.send(buf);
// 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();
const sessTimer = setInterval(() => {
const delta = Date.now() - session.lastReceivedPacket;
@@ -85,33 +87,62 @@ export const makeSession = (
let buf = create_P2pAlive();
session.send(buf);
}
if (delta > 8000) {
if (delta > timeoutMs) {
logger.warning(`Camera ${session.devName} timed out`);
session.eventEmitter.emit("disconnect");
}
}
}, 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 = {
outgoingCommandId: 0,
ticket: [0, 0, 0, 0],
lastReceivedPacket: 0,
eventEmitter: new EventEmitter(),
connected: true,
timers: [sessTimer],
timers: [sessTimer, resendTimer],
devName: dev.devId,
started: false,
send: (msg: DataView) => {
const raw = msg.readU16();
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}`);
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,
curImage: [],
rcvSeqId: 0,
frame_is_bad: false,
frame_was_fixed: false,
options: options,
unackedDrw,
close: () => {
session.eventEmitter.emit("disconnect");
},
};
session.eventEmitter.on("disconnect", () => {
@@ -120,6 +151,7 @@ export const makeSession = (
session.connected = false;
session.timers.forEach((x) => clearInterval(x));
session.timers = [];
sock.close();
});
session.eventEmitter.on("login", () => {
@@ -129,9 +161,9 @@ export const makeSession = (
return session;
};
export const configureWifi = (ssid: string, password: string) => {
export const configureWifi = (ssid: string, password: string, channel: number) => {
return (s: Session) => {
[SendWifiDetails(s, ssid, password, true)].forEach(s.send);
[SendWifiDetails(s, ssid, password, channel, true)].forEach(s.send);
};
};
@@ -146,13 +178,13 @@ export const Handlers: Record<keyof typeof Commands, PacketHandler> = {
PunchPkt: notImpl,
P2PAlive: handle_P2PAlive,
P2pRdy: handle_P2PRdy,
DrwAck: noop,
DrwAck: handle_DrwAck,
Drw: handle_Drw,
Close: handle_Close,
Close: notImpl,
P2PAliveAck: noop,
LanSearchExt: notImpl,
LanSearch: notImpl,
P2PAliveAck: notImpl,
Hello: notImpl,
P2pReq: notImpl,
LstReq: notImpl,
+45
View File
@@ -0,0 +1,45 @@
import fs from "node:fs";
import { parse } from "yaml";
interface HttpServerConfig {
port: number;
}
interface LoggingConfig {
level: string;
use_color?: boolean;
}
interface CameraConfig {
alias?: string;
rotate?: number;
mirror?: boolean;
audio?: boolean;
fix_packet_loss?: boolean;
}
interface AppConfig {
http_server: HttpServerConfig;
logging: LoggingConfig;
cameras: Record<string, CameraConfig>;
discovery_ips: string[];
blacklisted_ips: string[];
}
const DefaultConfig: AppConfig = {
http_server: { port: 5000 },
logging: { level: "info" },
cameras: {},
discovery_ips: ["192.168.1.255"],
blacklisted_ips: [],
};
let config = DefaultConfig;
export const loadConfig = (path: string) => {
const data = fs.readFileSync(path, { encoding: "utf-8" });
config = parse(data) as AppConfig;
config = { ...DefaultConfig, ...config };
};
export { config };
+12
View File
@@ -59,6 +59,17 @@ DataView.prototype.writeString = function (str) {
const bytes = [...str].map((_, i) => str.charCodeAt(i));
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 {
interface DataView {
@@ -77,6 +88,7 @@ declare global {
writeU32(n: number): void;
writeU64(n: bigint): void;
writeU8(n: number): void;
startsWith(arr: number[]): boolean;
}
}
export default global;
+123 -8
View File
@@ -1,10 +1,13 @@
// vim: nowrap
import "../shim.ts";
import assert from "assert";
import { XqBytesDec, XqBytesEnc } from "../func_replacements.js";
import { makeP2pRdy, parseDevStatusAck, parseListWifi } from "../handlers.js";
import { hexdump } from "../hexdump.js";
import { SendStartVideo, SendDevStatus, SendUsrChk, SendWifiDetails, parse_PunchPkt } from "../impl.ts";
import { parse_PunchPkt, SendDevStatus, SendStartVideo, SendUsrChk, SendWifiDetails } from "../impl.ts";
import { buildLogger } from "../logger.js";
import { placeholderTypes, sprintf } from "../utils.js";
describe("debug_tools", () => {
@@ -122,11 +125,7 @@ describe("module", () => {
});
const hstrToBA = (hs) => new Uint8Array(hs.match(/../g).map((h) => parseInt(h, 16))).buffer;
describe("events", () => {
it("emits login event upon logging in", () => {
// TODO
});
});
const BATohstr = (ba) => [...new Uint8Array(ba.buffer)].map((b) => b.toString(16).padStart(2, "0")).join("");
describe("parse packet", () => {
it("parses PunchPkt", () => {
const in_pkt_str = "f14100144241544400000000000262ca574f4e4a4d000000";
@@ -141,6 +140,85 @@ describe("parse packet", () => {
assert.deepEqual(parse_PunchPkt(pkt), expected);
});
{
const in_pkt_str = "f14100145848410000000000000003e24b4d4d4542000000";
const pkt = new DataView(hstrToBA(in_pkt_str));
it("parses PunchPkt when prefix is 3 letters long", () => {
const expected = {
prefix: "XHA",
serial: "994",
suffix: "KMMEB",
serialU64: BigInt(994),
devId: "XHA994KMMEB",
};
assert.deepEqual(parse_PunchPkt(pkt), expected);
});
// https://github.com/DavidVentura/cam-reverse/issues/17#issuecomment-2094819873
it("replies properly to PunchPkt with 3-letters-long prefix", () => {
const dev = parse_PunchPkt(pkt);
const p2prdy = makeP2pRdy(dev);
let p2pstr = BATohstr(p2prdy);
assert.deepEqual(in_pkt_str.slice(8), p2pstr.slice(8));
});
}
it("parses wifiscan chan0", () => {
buildLogger("warning");
const in_pkt_str =
"f1d00238d1000009110a03612c020100060000002f4f44550101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101a741a15822a1010101010101b2fefefe650101010101010101010101" +
"404253422f4674647275010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101ab41a158605c010101010101b6fefefe650101010101010101010101" +
"404253422f4f44550101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101a741a158605c010101010101c3fefefe650101010101010101010101" +
"404253422f4674647275010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101af41a15822a1010101010101c8fefefe650101010101010101010101" +
"404253422f4f44550101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101933aac0b5330010101010101b4fefefe650101010101010101010101" +
"404253422f4674647275010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101973aac0b5330010101010101b4fefefe650101010101010101010101" +
"40425342"; // incomplete entry
let pkt = new DataView(hstrToBA(in_pkt_str));
let payload_len = pkt.add(0xc).readU16LE();
XqBytesDec(pkt.add(20), payload_len - 4, 4); // this mutates pkt
const expected = [
{ channel: 0, dbm0: 4294967219, dbm1: 100, mac: "a6:40:a0:59:23:a0", mode: 0, security: 0, ssid: "ACRC.NET" },
{ channel: 0, dbm0: 4294967223, dbm1: 100, mac: "aa:40:a0:59:61:5d", mode: 0, security: 0, ssid: "ACRC.Guest" },
{ channel: 0, dbm0: 4294967234, dbm1: 100, mac: "a6:40:a0:59:61:5d", mode: 0, security: 0, ssid: "ACRC.NET" },
{ channel: 0, dbm0: 4294967241, dbm1: 100, mac: "ae:40:a0:59:23:a0", mode: 0, security: 0, ssid: "ACRC.Guest" },
{ channel: 0, dbm0: 4294967221, dbm1: 100, mac: "92:3b:ad:0a:52:31", mode: 0, security: 0, ssid: "ACRC.NET" },
{ channel: 0, dbm0: 4294967221, dbm1: 100, mac: "96:3b:ad:0a:52:31", mode: 0, security: 0, ssid: "ACRC.Guest" },
];
assert.deepEqual(parseListWifi(pkt), expected);
});
it("parses wifiscan chan2", () => {
buildLogger("warning");
const in_pkt_str =
"f1d00404d100000d110a0361f80300000b0000002f4f44550101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101603735316031340101010101c701010165010101010101010301010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101606035316031340101010101c7010101650101010101010103010101" + // frame
"404253422f4f44550101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101603735316031340101010101c2010101650101010101010103010101" +
"01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101676460373736320101010101b1010101650101010101010103010101" +
"404253422f4f44550101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101383332636065310101010101af010101650101010101010103010101" +
"01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101393764606465350101010101af010101650101010101010103010101" +
"01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101326460603964670101010101ac010101650101010101010103010101" +
"404253422f4674647275010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101606435316031340101010101c7010101650101010101010103010101" +
"404253422f4674647275010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101606035316031340101010101c2010101650101010101010103010101" +
"01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101643562323360620101010101b3010101650101010101010103010101" +
"404253422f4674647275010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101383732636065310101010101af010101650101010101010103010101" +
"40425342"; // incomplete
let pkt = new DataView(hstrToBA(in_pkt_str));
let payload_len = pkt.add(0xc).readU16LE();
XqBytesDec(pkt.add(20), payload_len - 4, 4); // this mutates pkt
const expected = [
{ ssid: "ACRC.NET", mac: "61:36:34:30:61:30", security: 0, dbm0: 198, dbm1: 100, mode: 0, channel: 2 },
{ ssid: "", mac: "61:61:34:30:61:30", security: 0, dbm0: 198, dbm1: 100, mode: 0, channel: 2 },
{ ssid: "ACRC.NET", mac: "61:36:34:30:61:30", security: 0, dbm0: 195, dbm1: 100, mode: 0, channel: 2 },
{ ssid: "", mac: "66:65:61:36:36:37", security: 0, dbm0: 176, dbm1: 100, mode: 0, channel: 2 },
{ ssid: "ACRC.NET", mac: "39:32:33:62:61:64", security: 0, dbm0: 174, dbm1: 100, mode: 0, channel: 2 },
{ ssid: "", mac: "38:36:65:61:65:64", security: 0, dbm0: 174, dbm1: 100, mode: 0, channel: 2 },
{ ssid: "", mac: "33:65:61:61:38:65", security: 0, dbm0: 173, dbm1: 100, mode: 0, channel: 2 },
{ ssid: "ACRC.Guest", mac: "61:65:34:30:61:30", security: 0, dbm0: 198, dbm1: 100, mode: 0, channel: 2 },
{ ssid: "ACRC.Guest", mac: "61:61:34:30:61:30", security: 0, dbm0: 195, dbm1: 100, mode: 0, channel: 2 },
{ ssid: "", mac: "65:34:63:33:32:61", security: 0, dbm0: 178, dbm1: 100, mode: 0, channel: 2 },
{ ssid: "ACRC.Guest", mac: "39:36:33:62:61:64", security: 0, dbm0: 174, dbm1: 100, mode: 0, channel: 2 },
];
assert.deepEqual(parseListWifi(pkt), expected);
});
});
describe("make packet", () => {
it("builds a good SendUsrChk", () => {
@@ -169,10 +247,47 @@ describe("make packet", () => {
it("builds a good WifiSettingsSet", () => {
const sess = { outgoingCommandId: 2, ticket: [1, 2, 3, 4] };
const _expected_str =
"f1d00118d1000002110a01600c010000010203040101010101010101010101010101010100010101726a786f647501010101010101010101010101010101010101010101010101017274716473627360710101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101312f3334342f3334342f333434010101312f312f312f31010101010101010101312f312f312f31010101010101010101312f312f312f31010101010101010101312f312f312f3101010101010101010101010101";
"f1d00118d1000002110a01600c01000001020304" + // drw header + ticket
"0101010101010101010101010101010100010101" + // all zeroes, but DHCP u32
// set to 1
"726a786f64750101010101010101010101010101010101010101010101010101" + // ssid
"7274716473627360710101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" + // pass
"312f312f312f31010101010101010101" + // 0.0.0.0
"312f3334342f3334342f333434010101" + // 0.255.255.255
"312f312f312f31010101010101010101" + // 0.0.0.0
"312f312f312f31010101010101010101" + // 0.0.0.0
"312f312f312f31010101010101010101" + // 0.0.0.0
"01010101"; // 0000
const expected = hstrToBA(_expected_str);
const got = SendWifiDetails(sess, "skynet", "supercrap", true);
const got = SendWifiDetails(sess, "skynet", "supercrap", 0, true);
assert.deepEqual(got.buffer, expected);
});
it("builds a good WifiSettingsSet - with channel", () => {
const sess = { outgoingCommandId: 0xc, ticket: [0x30, 0x35, 0x74, 0x72] };
const _expected_str =
"f1d00118d100000c110a01600c010000303574720101010101010101030101010101010100010101404253422f4f445501010101010101010101010101010101010101010101010167606a6471607272010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101312f312f312f31010101010101010101312f3334342f3334342f333434010101312f312f312f31010101010101010101312f312f312f31010101010101010101312f312f312f3101010101010101010101010101";
const expected = hstrToBA(_expected_str);
const got = SendWifiDetails(sess, "ACRC.NET", "fakepass", 2, true);
assert.deepEqual(got.buffer, expected);
});
it("parses devstatusack", () => {
const pkt = new DataView(
hstrToBA(
"f1d0008cd1000009110a08118000000000000000190e010101010101fefefefebefefefe01000001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010100010101010001030101010101010101010101010101fefefefefe010101010101010134030300",
),
);
let payload_len = pkt.add(0xc).readU16LE();
XqBytesDec(pkt.add(20), payload_len - 4, 0); // this mutates pkt
const got = parseDevStatusAck(pkt);
const expected = {
battery_mV: 0,
charging: false,
dbm: -256,
swver: "0.0.15.24",
};
assert.deepEqual(expected, got);
});
});
+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>]
});
});
+19
View File
@@ -2,6 +2,7 @@ struct avparamset_t {
uint32_t paramType;
uint32_t paramValue;
};
struct datetime_t {
uint32_t now; // the code to _write_ these uses long ))
uint32_t tz;
@@ -99,3 +100,21 @@ struct stream_head_t {
short sample;
short index;
};
struct tag_wifiParams { /* PlaceHolder Structure */
int enable;
int noidea; // wifi status?
int mode;
int chan;
int auth;
int dhcp;
char ssid[32];
char psk[128];
char ip[16];
char mask[16];
char gw[16];
char dns1[16];
char dns2[16];
};