62 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
David Ventura 58c58e38ac increase delay & send LanSearchExt 2024-05-01 16:57:43 +02:00
David Ventura b5d4ab6024 set noEmit flag on tsc 2024-05-01 13:26:56 +02:00
David Ventura 2ce568c019 trim serial/name mapping; add a log message 2024-05-01 13:17:37 +02:00
David Ventura 5a0b36f42b add links to artifacts 2024-05-01 13:17:15 +02:00
David Ventura 74863f675e remove example output from readme 2024-05-01 13:07:11 +02:00
David Ventura 9654dc2841 implement logging properly 2024-05-01 13:06:27 +02:00
David Ventura 9c19c05274 include files at build time 2024-05-01 12:44:52 +02:00
David Ventura a547546055 add slow-startup flag 2024-05-01 11:56:17 +02:00
David Ventura c4007961c7 fix makefile target 2024-05-01 11:23:32 +02:00
David Ventura 15f70b55af run tsc in ci 2024-05-01 11:23:26 +02:00
David Ventura 8f4c5f09a4 tidy up dissector 2024-05-01 11:23:16 +02:00
David Ventura 06cafff105 return nil from packet lut 2024-05-01 11:23:05 +02:00
David Ventura e2a4352659 add heuristic packet matcher 2024-05-01 10:47:00 +02:00
David Ventura 5bb0038d9b send P2PAlive immediately after P2pRdy 2024-04-30 23:16:24 +02:00
David Ventura b653368d89 fix test 2024-04-30 23:11:27 +02:00
Adriano Cunha 690c1b4146 More features in the HTTP server (#16)
* Added more features to HTTP server

* Added a title to the multi-camera view page.
* Support naming cameras through a simple mapping using a text file.
* Updated some logging messages to be more verbose (and match those in pair.ts).
* Confirmed the charging status and battery level values reported back by DevStatusAck.
* Log camera status (charging, battery, Wifi strength) when connected.

* More updates to HTTP server

* Deal with Web Audio autoplay policy by starting camera on mute and adding a button to toggle the audio stream.
* Add a favicon so the server tan can be easily spotted.

The favicon file is a 48x48 32bpp ICO file, gzipped.

* Update README.md

* Describe flags and enable camera audio by default.

* Update audio button based on state change

* Update description of discovery_ip in bin.ts

* Update session start message in http_server.ts

* Update README.md

Update flags and note about --discovery_ip.
2024-04-30 16:18:10 +02:00
David Ventura 66796267f2 add serial to punchpkt/p2prdy dissector 2024-04-30 12:00:37 +02:00
David Ventura 11ab1597f6 test punchpkt parsing 2024-04-30 12:00:37 +02:00
Adriano CunhaandDavid Ventura 3edccdc4ab Update "Spyware" section in README (#15)
* Update "Spyware" section in README

Added a paragraph mentioning the possible reason and clarifying that blocking outgoing traffic doesn't affect the HTTP server.

* Update README.md

---------

Co-authored-by: David Ventura <davidventura27@gmail.com>
2024-04-28 00:53:04 +02:00
David Ventura c308307fea Merge pull request #11 from adrcunha/patch-3
Trigger debug output in handlers.ts according to --debug
2024-04-28 00:51:31 +02:00
Adriano Cunha ca6928aa7c Merge branch 'master' into patch-3 2024-04-27 13:01:17 -07:00
David Ventura 632308a50e Merge pull request #13 from adrcunha/patch-1
Add a few user-friendly messages to pair.ts for first-timers
2024-04-27 13:32:18 +02:00
Adriano Cunha 40bb81f22e Add a few user-friendly messages to pair.ts for first-timers 2024-04-26 17:29:53 -07:00
Adriano Cunha e9487f9603 Add a few user-friendly messages to pair.ts for first-timers
Also fix the session handling, allowing a camera to be reconnected and recognized again.
2024-04-26 08:51:06 -07:00
Adriano Cunha 82d9115ae6 Merge branch 'DavidVentura:master' into master 2024-04-26 08:47:36 -07:00
Adriano Cunha 31baea2415 Trigger debug output in handlers.ts according to --debug 2024-04-26 08:42:42 -07:00
David Ventura da867078b1 Merge pull request #10 from adrcunha/patch-3
Remove the `pair` target
2024-04-26 17:41:39 +02:00
David Ventura dc75519f3d Merge pull request #9 from adrcunha/patch-2
Drop newline in the activity indicator
2024-04-26 17:41:14 +02:00
David Ventura 8246ea52ef Merge pull request #8 from adrcunha/patch-1
Update instructions in README
2024-04-26 17:40:19 +02:00
Adriano Cunha 72de55b26f Remove the pair target
Now that `pair` requires parameters and SSID/password have no defaults, this target doesn't work.
2024-04-26 08:30:33 -07:00
Adriano Cunha 6b5026dcf1 Drop newline in the activity indicator
The "." character is printed on a new line every 2s to indicate that the discovery process is running. However, using a new line unnecessary floods the output log; switch to printing "." without a newline, keeping the activity indicator while also drastically reducing the number of lines in the output log.
2024-04-26 08:24:55 -07:00
Adriano Cunha a3a5a9404c Update instructions in README
Update instructions a bit for first-time users, fix a few typos.
2024-04-26 08:18:20 -07:00
26 changed files with 965 additions and 268 deletions
+1
View File
@@ -28,6 +28,7 @@ jobs:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run tsc
- run: npm run build
- run: npm test
- uses: actions/upload-artifact@v4
+1
View File
@@ -3,3 +3,4 @@ venv
node_modules
build
dist/
cameras.txt
+6 -8
View File
@@ -1,4 +1,4 @@
.PHONY: run hook install-wireshark-dissector test build
.PHONY: run hook install-wireshark-dissector test build typecheck
bundle.js: frida-hooks.js func_replacements.js
~/node_modules/.bin/frida-compile -o $@ frida-hooks.js
@@ -8,15 +8,13 @@ venv: requirements.txt
./venv/bin/pip install -r requirements.txt
touch venv
build: node_modules
typecheck: node_modules
npm run tsc
build: node_modules
npm run build
run: node_modules
./node_modules/.bin/ts-node --esm cmd/bin http_server --port=1234
pair: node_modules
./node_modules/.bin/ts-node --esm cmd/bin pair
run: build
node dist/bin.cjs http_server --port=1234
hook: bundle.js venv
./venv/bin/python3 -u loader3.py
@@ -25,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
+24 -36
View File
@@ -6,25 +6,28 @@ 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/)
## Pairing a new camera
Ensure your device in access point mode (the blue LED blinks slowly to indicate that); optionally, press the MODE button for 5s to switch to access point mode.
Connect to the device's access point (e.g., FTYC811847AGFDZ) and run `node dist/bin.cjs pair --ssid <SSID> --password <PASSWORD>`.
## Running
To execute the HTTP server, run `node bin.cjs`; you can access the JPEG stream at http://localhost:5000/ and (optionally) a file `audio.pcm` will be created.
```bash
$ node dist/bin.cjs http_server --help
start http server
Options:
--help Show help [boolean]
--version Show version number [boolean]
--debug [boolean] [default: false]
--ansi [boolean] [default: false]
--audio [boolean] [default: false]
--discovery_ip [default: "192.168.1.255"]
--attempt_to_fix_packet_loss [default: false]
--port HTTP Port to listen on [number] [default: 5000]
```
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).
@@ -32,28 +35,11 @@ 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.
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 enabled.
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.
## Pairing a new camera
Connect to the device's access point and run `node bin.cjs pair --ssid <SSID> --password <PASSWORD>`.
```bash
$ node dist/bin.cjs pair --help
configure a camera
Options:
--help Show help [boolean]
--version Show version number [boolean]
--debug [boolean] [default: false]
--ansi [boolean] [default: false]
--discovery_ip [default: "192.168.1.255"]
--attempt_to_fix_packet_loss [default: false]
--ssid [string] [required]
--password [string] [required]
```
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).
## Protocol
@@ -181,6 +167,8 @@ Every 8-10s
There are some other strings in the APK ending in `-$$` which decode to other ips/hostnames.
Most of the IPs point to AWS compute instances, and this connection is probably used to see live streams over the Internet using the app. It's fine (and recommended!) to block outgoing traffic from the cameras, as it won't affect the HTTP server.
## Other stuff
+46 -5
View File
@@ -1,5 +1,12 @@
<html>
<h2>${id}</h2><a href="/camera/${id}"><img src="/camera/${id}"/></a><hr/>
<head>
<link rel="shortcut icon" href="/favicon.ico">
<title>${name}</title>
</head><body>
<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="fetch('/rotate/${id}')">Rotate</button>
<button onclick="fetch('/mirror/${id}')">Mirror</button>
<script>
const alaw_to_s16_table = [
-5504, -5248, -6016, -5760, -4480, -4224, -4992, -4736, -7552, -7296, -8064, -7808, -6528, -6272, -7040, -6784, -2752,
@@ -22,20 +29,34 @@
return alaw_to_s16_table[a_val];
};
const audio_context = new AudioContext();
var audio_context;
const audio_button = document.getElementById('audio');
audio_button.disabled = !${audio};
update_audio_button();
function setup_audio() {
audio_context = new AudioContext();
const gain_node = audio_context.createGain(); // Declare gain node
const channels =1;
const sample_rate = 8000;
const audioBuffer = audio_context.createBuffer(channels, 960, sample_rate); // 960??
//const audioBuffer = audio_context.createBuffer(channels, decoded.length, sample_rate);
audio_context.onstatechange = () => {
console.log("Audio state is now ", audio_context.state);
update_audio_button(audio_context.state == "running");
};
gain_node.connect(audio_context.destination); // Connect gain node to speakers
audio_context.resume();
const evtSource = new EventSource('/audio/${id}');
const evtSource = new EventSource("/audio/${id}");
evtSource.onopen = (e) => {
console.log("evtsource open");
}
evtSource.onerror = (e) => {
console.log("evtsource error", e);
}
let endsAt = 0;
let startAt = 0;
evtSource.onmessage = (e) => {
@@ -55,8 +76,28 @@
source_node.start(startAt);
endsAt = now + audioBuffer.duration * 1000;
};
evtSource.onerror = (e) => {
console.log("evtsource error", e);
}
function update_audio_button(on) {
if (${audio}) {
audio_button.innerText = "Audio: " + (on ? "on \u{1F508}" : "off \u{1F507}");
}
}
function toggle_audio() {
if (audio_context == undefined) {
setup_audio();
return;
}
if (audio_context.state == "running") {
audio_context.suspend();
return;
}
if (audio_context.state == "suspended") {
audio_context.resume();
return;
}
console.log("Unknown audio stream status");
}
</script>
</html>
BIN
View File
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
# 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
+25 -9
View File
@@ -1,3 +1,4 @@
import process from "node:process";
import { hideBin } from "yargs/helpers";
import yargs from "yargs/yargs";
@@ -5,17 +6,22 @@ import { serveHttp } from "../http_server.js";
import { opt } from "../options.js";
import { pair } from "../pair.js";
import { buildLogger, logger } from "../logger.js";
const majorVersion = process.versions.node.split(".").map(Number)[0];
yargs(hideBin(process.argv))
.command(
"http_server",
"start http server",
(yargs) => {
return yargs
.option("debug", { default: false })
.option("ansi", { default: false })
.option("audio", { default: false })
.boolean(["debug", "ansi", "audio"])
.option("discovery_ip", { default: "192.168.1.255" })
.option("audio", { describe: "Also stream audio from camera", default: true })
.option("color", { describe: "Use color in logs", default: undefined })
.boolean(["ansi", "audio", "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 })
.number(["port"])
@@ -23,6 +29,10 @@ yargs(hideBin(process.argv))
},
(argv) => {
const opts: opt = argv as opt;
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);
},
)
@@ -31,17 +41,23 @@ yargs(hideBin(process.argv))
"configure a camera",
(yargs) => {
return yargs
.option("debug", { default: false })
.option("ansi", { default: false })
.boolean(["debug", "ansi"])
.option("discovery_ip", { default: "192.168.1.255" })
.option("color", { describe: "Use color in logs", default: undefined })
.boolean(["ansi", "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 })
.demandOption("ssid")
.demandOption("password")
.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);
if (majorVersion < 16) {
logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
}
pair({ opts, ssid: argv.ssid, password: argv.password });
},
)
+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
};
+21 -16
View File
@@ -1,22 +1,22 @@
import EventEmitter from "node:events";
import { create_LanSearch, parse_PunchPkt } from "./impl.js";
import { createSocket, RemoteInfo } from "node:dgram";
import { Commands } from "./datatypes.js";
import EventEmitter from "node:events";
const handleIncomingPunch = (msg: Buffer, ee: EventEmitter, rinfo: RemoteInfo, debug: boolean) => {
import { Commands } from "./datatypes.js";
import { create_LanSearch, parse_PunchPkt } from "./impl.js";
import { logger } from "./logger.js";
const handleIncomingPunch = (msg: Buffer, ee: EventEmitter, rinfo: RemoteInfo) => {
const ab = new Uint8Array(msg).buffer;
const dv = new DataView(ab);
const cmd_id = dv.readU16();
if (cmd_id != Commands.PunchPkt) {
return;
}
if (debug) {
console.log("Discovery got a PunchPkt");
}
logger.debug("Received a PunchPkt message");
ee.emit("discover", rinfo, parse_PunchPkt(dv));
};
export const discoverDevices = (debug: boolean, discovery_ip: string): EventEmitter => {
export const discoverDevices = (discovery_ip: string): EventEmitter => {
const sock = createSocket("udp4");
const SEND_PORT = 32108;
const ee = new EventEmitter();
@@ -26,21 +26,26 @@ export const discoverDevices = (debug: boolean, discovery_ip: string): EventEmit
sock.close();
});
sock.on("message", (msg, rinfo) => handleIncomingPunch(msg, ee, rinfo, debug));
sock.on("message", (msg, rinfo) => handleIncomingPunch(msg, ee, rinfo));
let timers = [];
sock.on("listening", () => {
sock.setBroadcast(true);
console.log(`Searching for devices on ${discovery_ip}`);
logger.info(`Searching for devices on ${discovery_ip}`);
let buf = create_LanSearch();
setInterval(() => {
console.log(".");
sock.send(new Uint8Array(buf.buffer), SEND_PORT, discovery_ip);
}, 2000);
sock.send(new Uint8Array(buf.buffer), SEND_PORT, discovery_ip);
let ls_buf = create_LanSearch();
let int = setInterval(() => {
logger.log("trace", `>> LanSearch`);
sock.send(new Uint8Array(ls_buf.buffer), SEND_PORT, discovery_ip);
}, 3000);
timers.push(int);
logger.log("trace", `>> LanSearch`);
sock.send(new Uint8Array(ls_buf.buffer), SEND_PORT, discovery_ip);
});
sock.bind();
sock.on("close", () => timers.forEach((timer) => clearInterval(timer)));
ee.on("close", () => sock.close());
return ee;
};
+85 -7
View File
@@ -18,13 +18,30 @@ 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.decrypted_data = ProtoField.bytes("iLnkP2P.decrypted_data", "Decrypted data")
-- PunchPkt
ilnk_proto.fields.serial = ProtoField.string("iLnkP2P.serial", "Serial")
lut = {
[0xf1f0] = "Close",
[0xf132] = "LanSearchExt",
@@ -46,11 +63,13 @@ lut = {
[0xf169] = "ListenReqAck",
[0xf170] = "RlyHelloAck",
[0xf171] = "RlyHelloAck2",
__index = function(tbl, key)
return "UNK " .. string.format("0x%X", key)
end
}
setmetatable(lut, lut)
control_lut = {
[0x2010] = "ConnectUser",
[0x2011] = "ConnectUserAck",
[0x0811] = "ConnectUserAck",
}
-- Define a function to dissect the packets
function ilnk_proto.dissector(buffer, pinfo, tree)
@@ -59,11 +78,22 @@ function ilnk_proto.dissector(buffer, pinfo, tree)
local subtree = tree:add(ilnk_proto, buffer(), "iLnkP2P")
-- Add the entire packet as a field
local packetname = lut[buffer(0, 2):uint()]
subtree:add(ilnk_proto.fields.type, packetname)
local packettype = buffer(0, 2):uint()
local packetname = lut[packettype]
packetname = packetname or "UNK " .. string.format("0x%X", packettype)
subtree:add(ilnk_proto.fields.type, buffer(0, 2), packetname)
-- Set the protocol description in the packet list
pinfo.cols.protocol:set("iLnkP2P")
pinfo.cols.info:set(packetname)
if packetname == "PunchPkt" or packetname == "P2pRdy" then
local len = buffer(2, 2)
subtree:add(ilnk_proto.fields.len, len)
local serial_prefix = buffer(4, 4):string()
local serial_no = UInt64(buffer(12, 4):uint(), buffer(8, 4):uint())
local serial_suffix = buffer(16, 5):string()
subtree:add(ilnk_proto.fields.serial, buffer(4, len:uint()-3), serial_prefix..serial_no..serial_suffix)
end
if packetname == "DrwAck" then
subtree:add(ilnk_proto.fields.len, buffer(2, 2))
subtree:add(ilnk_proto.fields.m_type, buffer(4, 1))
@@ -78,7 +108,12 @@ 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))
pinfo.cols.info:set(buffer(6, 2):uint())
local b_payload_len = buffer(0xc, 2)
local payload_len = buffer(0xc, 2):le_uint()
@@ -127,7 +162,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
@@ -140,4 +207,15 @@ function ilnk_proto.dissector(buffer, pinfo, tree)
-- subtree:add(ilnk_proto.fields.payload, buffer(2, packet_length-2))
end
udp_table = DissectorTable.get("udp.port"):add(49512, ilnk_proto)
local function heuristic_checker(buffer, pinfo, tree)
length = buffer:len()
if length < 2 then return false end
local packetname = lut[buffer(0, 2):uint()]
if packetname ~= nil then
ilnk_proto.dissector(buffer, pinfo, tree)
return true
end
return false
end
udp_table2 = DissectorTable.get("udp.port"):add(32108, ilnk_proto)
h = ilnk_proto:register_heuristic("udp", heuristic_checker)
+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;
};
+98 -50
View File
@@ -1,14 +1,15 @@
import { Commands, CommandsByValue, ControlCommands } from "./datatypes.js";
import { XqBytesDec } from "./func_replacements.js";
import { hexdump } from "./hexdump.js";
import { create_P2pRdy, SendListWifi, SendUsrChk, DevSerial } from "./impl.js";
import { Session } from "./session.js";
import { u16_swap, u32_swap } from "./utils.js";
import { logger } from "./logger.js";
import { hexdump } from "./hexdump.js";
export const notImpl = (_: Session, dv: DataView) => {
export const notImpl = (session: Session, dv: DataView) => {
const raw = dv.readU16();
const cmd = CommandsByValue[raw];
console.log(`^^ ${cmd} (${raw.toString(16)}) and it's not implemented yet`);
logger.debug(`^^ ${cmd} (${raw.toString(16)}) and it's not implemented yet`);
};
export const noop = (_: Session, __: DataView) => {};
@@ -25,11 +26,12 @@ export const handle_P2PAlive = (session: Session, _: DataView) => {
};
export const handle_P2PRdy = (session: Session, _: DataView) => {
// TODO - config
const b = SendUsrChk(session, "admin", "admin");
session.send(b);
};
export const makePunchPkt = (dev: DevSerial): 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
outbuf.add(0).writeString(dev.prefix);
@@ -44,35 +46,33 @@ export const createResponseForControlCommand = (session: Session, dv: DataView):
const payload_len = u16_swap(dv.add(0xc).readU16());
if (start_type != 0x110a) {
console.error(`Expected start_type to be 0xa11, got 0x${start_type.toString(16)}`);
logger.error(`Expected start_type to be 0xa11, got 0x${start_type.toString(16)}`);
return [];
}
const rotate_chr = 4;
if (payload_len > rotate_chr) {
// 20 = 16 (header) + 4 (??)
XqBytesDec(dv.add(20), payload_len - 4, rotate_chr);
console.log("Decrypted");
console.log(hexdump(dv));
}
if (cmd_id == ControlCommands.ConnectUserAck) {
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; // 0x14000101 v 0x14000100
let power = u16_swap(dv.add(0x18).readU16()); // '3730' or '3765', milliVolts?
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??
// > -50 = excellent, -50 to -60 good, -60 to -70 fair, <-70 weak
console.log(`charging? ${charging}, batlevel? ${power}, 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 = {
enable: dv.add(0x14).readU32(),
status: dv.add(0x18).readU32(),
@@ -89,17 +89,19 @@ export const createResponseForControlCommand = (session: Session, dv: DataView):
dns2: dv.add(0x10c).readString(0x10),
};
const buf = SendListWifi(session);
console.log(`Current wifi settings: ${JSON.stringify(wifiSettings, null, 2)}`);
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 [];
}
let startat = 0x10;
let msg_len = 91;
console.log("payload len", payload_len);
let msg_count = (payload_len - 9) / msg_len;
let remote_msg_count = dv.add(startat).readU32LE();
console.log("should get messages:", msg_count, "in payload: ", remote_msg_count);
logger.debug(`should get messages: ${msg_count} in payload: ${remote_msg_count}`);
startat += 4;
let items = [];
for (let i = 0; i < msg_count; i++) {
@@ -113,53 +115,83 @@ export const createResponseForControlCommand = (session: Session, dv: DataView):
mode: dv.add(startat + 0x54).readU32LE(),
channel: dv.add(startat + 0x58).readU32LE(),
};
console.log(`Wifi Item: ${JSON.stringify(wifiListItem, null, 2)}`);
logger.info(`Wifi Item: ${JSON.stringify(wifiListItem, null, 2)}`);
startat += msg_len;
console.log("ended at", startat);
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 [];
};
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.debug("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 = u16_swap(dv.add(8 + 16).readU16());
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
@@ -169,7 +201,10 @@ 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;
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 (!session.options.attempt_to_fix_packet_loss) {
@@ -235,6 +270,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);
@@ -242,8 +288,10 @@ 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`);
}
};
+110 -24
View File
@@ -1,18 +1,57 @@
import { RemoteInfo } from "dgram";
import { readFileSync } from "node:fs";
import { readFileSync, existsSync } from "node:fs";
import http from "node:http";
import { logger } from "./logger.js";
import { opt } from "./options.js";
import { discoverDevices } from "./discovery.js";
import { DevSerial } from "./impl.js";
import { DevSerial, SendDevStatus } from "./impl.js";
import { Handlers, makeSession, Session, startVideoStream } from "./session.js";
import { addExifToJpeg, createExifOrientation } from "./exif.js";
let BOUNDARY = "a very good boundary line";
let responses: Record<string, http.ServerResponse[]> = {};
let audioResponses: Record<string, http.ServerResponse[]> = {};
let sessions: Record<string, Session> = {};
// @ts-expect-error TS2307
import favicon from "./cam.ico.gz";
// @ts-expect-error TS2307
import html_template from "./asd.html";
const BOUNDARY = "a very good boundary line";
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];
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;
// The HTTP server.
export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
logger.info(`Mapping camera names: ${JSON.stringify(cameraNames)}`);
const server = http.createServer((req, res) => {
if (req.url.startsWith("/ui/")) {
let devId = req.url.split("/")[2];
@@ -27,8 +66,12 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
res.end("Nothing online");
return;
}
const ui = readFileSync("asd.html").toString();
res.end(ui.replace(/\${id}/g, devId));
const ui = html_template
.toString()
.replace(/\${id}/g, devId)
.replace(/\${name}/g, cameraName(devId))
.replace(/\${audio}/g, with_audio.toString());
res.end(ui);
return;
}
if (req.url.startsWith("/audio/")) {
@@ -46,22 +89,46 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
}
res.setHeader("Content-Type", `text/event-stream`);
audioResponses[devId].push(res);
logger.info(`Audio stream requested for camera ${devId}`);
return;
}
if (req.url.startsWith("/camera/")) {
if (req.url.startsWith("/favicon.ico")) {
res.setHeader("Content-Type", "image/x-icon");
res.setHeader("Content-Encoding", "gzip");
res.end(Buffer.from(favicon));
return;
}
if (req.url.startsWith("/rotate/")) {
let devId = req.url.split("/")[2];
console.log("requested for", devId);
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];
logger.info(`Video stream requested for camera ${devId}`);
let s = sessions[devId];
if (s === undefined) {
res.writeHead(400);
res.end("invalid ID");
res.end(`Camera ${devId} not discovered`);
return;
}
if (!s.connected) {
res.writeHead(400);
res.end("Nothing online");
res.end(`Camera ${devId} offline`);
return;
}
@@ -69,35 +136,53 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
responses[devId].push(res);
res.on("close", () => {
responses[devId] = responses[devId].filter((r) => r !== res);
console.log("Conn closed, kicked");
logger.info(`Video stream closed for camera ${devId}`);
});
} else {
res.write(`<html>`);
res.write("<html>");
res.write("<head>");
res.write(`<link rel="shortcut icon" href="/favicon.ico">`);
res.write("<title>All cameras</title>");
res.write("</head>");
res.write("<body>");
res.write("<h1>All cameras</h1><hr/>");
Object.keys(sessions).forEach((id) =>
res.write(`<h2>${id}</h2><a href="/ui/${id}"><img src="/camera/${id}"/></a><hr/>`),
res.write(`<h2>${cameraName(id)}</h2><a href="/ui/${id}"><img src="/camera/${id}"/></a><hr/>`),
);
res.write(`</html>`);
res.write("</body>");
res.write("</html>");
res.end();
}
});
let devEv = discoverDevices(opts.debug, opts.discovery_ip);
let devEv = discoverDevices(opts.discovery_ip);
const startSession = (s: Session) => {
s.send(SendDevStatus(s));
startVideoStream(s);
logger.info(`Camera ${s.devName} is now ready to stream`);
};
devEv.on("discover", (rinfo: RemoteInfo, dev: DevSerial) => {
if (dev.devId in sessions) {
console.log(`ignoring ${dev.devId} - ${rinfo.address}`);
logger.info(`Camera ${dev.devId} at ${rinfo.address} already discovered, ignoring`);
return;
}
console.log(`discovered ${dev.devId} - ${rinfo.address}`);
logger.info(`Discovered camera ${dev.devId} at ${rinfo.address}`);
responses[dev.devId] = [];
audioResponses[dev.devId] = [];
const s = makeSession(Handlers, dev, rinfo, startVideoStream, opts);
const s = makeSession(Handlers, dev, rinfo, startSession, opts);
const header = Buffer.from(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\n\r\n`);
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) => {
res.write(header);
res.write(assembled);
@@ -105,7 +190,7 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
});
s.eventEmitter.on("disconnect", () => {
console.log("deleting from sessions");
logger.info(`Camera ${dev.devId} disconnected`);
delete sessions[dev.devId];
});
if (with_audio) {
@@ -120,8 +205,9 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
});
}
sessions[dev.devId] = s;
camSettings[dev.devId] = { orientation: 0, mirror: false };
});
console.log(`Starting HTTP server on port ${port}`);
logger.info(`Starting HTTP server on port ${port}`);
server.listen(port);
};
+7
View File
@@ -155,6 +155,13 @@ export const SendUsrChk = (session: Session, username: string, password: string)
return makeDataReadWrite(session, ControlCommands.ConnectUser, cmd_payload);
};
export const create_LanSearchExt = (): DataView => {
const outbuf = new DataView(new Uint8Array(4).buffer);
outbuf.writeU16(Commands.LanSearchExt);
outbuf.add(2).writeU16(0x0);
return outbuf;
};
export const create_LanSearch = (): DataView => {
const outbuf = new DataView(new Uint8Array(4).buffer);
outbuf.writeU16(Commands.LanSearch);
+26
View File
@@ -0,0 +1,26 @@
import { isatty } from "node:tty";
import { addColors, config, createLogger, format, transports as wtransports } from "winston";
const myFormat = format.printf(({ level, message, timestamp }) => {
return `${timestamp} [${level}] ${message}`;
});
const transports = {
console: new wtransports.Console(),
};
export let logger = undefined;
export const buildLogger = (level: string, colorize: boolean | undefined) => {
let use_color = colorize === undefined ? isatty(1) : colorize;
const fmt = use_color
? format.combine(format.colorize(), format.timestamp(), myFormat)
: format.combine(format.timestamp(), myFormat);
logger = createLogger({
levels: { ...config.syslog.levels, trace: 10 },
level,
format: fmt,
transports: [transports.console],
});
addColors({ trace: "white" });
};
+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,5 +1,4 @@
export type opt = {
debug: boolean;
ansi: boolean;
discovery_ip: string;
attempt_to_fix_packet_loss: boolean;
+253 -6
View File
@@ -5,16 +5,25 @@
"packages": {
"": {
"dependencies": {
"esbuild": "^0.20.2",
"winston": "^3.13.0",
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/yargs": "^17.0.32",
"esbuild": "^0.20.2",
"mocha": "^10.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
},
"node_modules/@colors/colors": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
"integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
"engines": {
"node": ">=0.1.90"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -27,6 +36,16 @@
"node": ">=12"
}
},
"node_modules/@dabh/diagnostics": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz",
"integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==",
"dependencies": {
"colorspace": "1.1.x",
"enabled": "2.0.x",
"kuler": "^2.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
@@ -34,6 +53,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
@@ -49,6 +69,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
@@ -64,6 +85,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
@@ -79,6 +101,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
@@ -94,6 +117,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@@ -109,6 +133,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@@ -124,6 +149,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@@ -139,6 +165,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@@ -154,6 +181,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -169,6 +197,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -184,6 +213,7 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -199,6 +229,7 @@
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -214,6 +245,7 @@
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -229,6 +261,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -244,6 +277,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -259,6 +293,7 @@
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -274,6 +309,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -289,6 +325,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
@@ -304,6 +341,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
@@ -319,6 +357,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
@@ -334,6 +373,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -349,6 +389,7 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -364,6 +405,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -431,6 +473,11 @@
"undici-types": "~5.26.4"
}
},
"node_modules/@types/triple-beam": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="
},
"node_modules/@types/yargs": {
"version": "17.0.32",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz",
@@ -523,6 +570,11 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"node_modules/async": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz",
"integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg=="
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -645,6 +697,15 @@
"node": ">=12"
}
},
"node_modules/color": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
"integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
"dependencies": {
"color-convert": "^1.9.3",
"color-string": "^1.6.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -661,6 +722,37 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/color/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/color/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
},
"node_modules/colorspace": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz",
"integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==",
"dependencies": {
"color": "^3.1.3",
"text-hex": "1.0.x"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -722,10 +814,16 @@
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/enabled": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
"integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="
},
"node_modules/esbuild": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
@@ -779,6 +877,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/fecha": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -816,6 +919,11 @@
"flat": "cli.js"
}
},
"node_modules/fn.name": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -929,8 +1037,12 @@
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
},
"node_modules/is-binary-path": {
"version": "2.1.0",
@@ -991,6 +1103,17 @@
"node": ">=8"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unicode-supported": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
@@ -1015,6 +1138,11 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/kuler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -1046,6 +1174,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/logform": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.6.0.tgz",
"integrity": "sha512-1ulHeNPp6k/LD8H91o7VYFBng5i1BDE7HoKxVbZiGFidS1Rj65qcywLxX+pVfAPoQJEjRdvKcusKwOupHCVOVQ==",
"dependencies": {
"@colors/colors": "1.6.0",
"@types/triple-beam": "^1.3.2",
"fecha": "^4.2.0",
"ms": "^2.1.1",
"safe-stable-stringify": "^2.3.1",
"triple-beam": "^1.3.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -1136,8 +1280,7 @@
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/nanoid": {
"version": "3.3.3",
@@ -1169,6 +1312,14 @@
"wrappy": "1"
}
},
"node_modules/one-time": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
"integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
"dependencies": {
"fn.name": "1.x.x"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -1238,6 +1389,19 @@
"safe-buffer": "^5.1.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -1262,7 +1426,6 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -1278,6 +1441,14 @@
}
]
},
"node_modules/safe-stable-stringify": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz",
"integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==",
"engines": {
"node": ">=10"
}
},
"node_modules/serialize-javascript": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
@@ -1287,6 +1458,30 @@
"randombytes": "^2.1.0"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/stack-trace": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
"integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
"engines": {
"node": "*"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -1338,6 +1533,11 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1350,6 +1550,14 @@
"node": ">=8.0"
}
},
"node_modules/triple-beam": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
"integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==",
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@@ -1422,12 +1630,51 @@
"dev": true,
"peer": true
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true
},
"node_modules/winston": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.13.0.tgz",
"integrity": "sha512-rwidmA1w3SE4j0E5MuIufFhyJPBDG7Nu71RkZor1p2+qHvJSZ9GYDA81AyleQcZbh/+V6HjeBdfnTZJm9rSeQQ==",
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.2",
"async": "^3.2.3",
"is-stream": "^2.0.0",
"logform": "^2.4.0",
"one-time": "^1.0.0",
"readable-stream": "^3.4.0",
"safe-stable-stringify": "^2.3.1",
"stack-trace": "0.0.x",
"triple-beam": "^1.3.0",
"winston-transport": "^4.7.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/winston-transport": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.7.0.tgz",
"integrity": "sha512-ajBj65K5I7denzer2IYW6+2bNIVqLGDHqDw3Ow8Ohh+vdW+rv4MZ6eiDvHoKhfJFZ2auyN8byXieDDJ96ViONg==",
"dependencies": {
"logform": "^2.3.2",
"readable-stream": "^3.6.0",
"triple-beam": "^1.3.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/workerpool": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz",
+6 -2
View File
@@ -3,16 +3,20 @@
"scripts": {
"test": "mocha tests",
"tsc": "tsc",
"build": "esbuild cmd/bin.ts --bundle --platform=node --outfile=dist/bin.cjs --target=node12"
"build": "esbuild cmd/bin.ts --bundle --platform=node --outfile=dist/bin.cjs --target=node12 --loader:.gz=binary --loader:.html=text"
},
"devDependencies": {
"@types/yargs": "^17.0.32",
"esbuild": "^0.20.2",
"mocha": "^10.2.0",
"ts-node": "^10.9.2",
"esbuild": "^0.20.2",
"typescript": "^5.3.3"
},
"dependencies": {
"winston": "^3.13.0",
"yargs": "^17.7.2"
},
"engines": {
"node": ">=16.0"
}
}
+19 -7
View File
@@ -2,30 +2,42 @@ import { RemoteInfo } from "dgram";
import { opt } from "./options.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 { logger } from "./logger.js";
export const pair = ({ opts, ssid, password }: { opts: opt; ssid: string; password: string }) => {
console.log(`Will configure any devices found to join ${ssid}`);
logger.info(`Will configure any devices found to join ${ssid}`);
let sessions: Record<string, Session> = {};
let devEv = discoverDevices(opts.debug, opts.discovery_ip);
let devEv = discoverDevices(opts.discovery_ip);
if (password == "") {
throw new Error("You must set a non-zero-length password");
}
const onLogin = configureWifi(ssid, password);
const onLogin = (s: Session) => {
logger.info(`Configuring camera ${s.devName}`);
configureWifi(ssid, password)(s);
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) => {
if (dev.devId in sessions) {
console.log(`ignoring ${dev.devId} - ${rinfo.address}`);
logger.info(`Camera ${dev.devId} at ${rinfo.address} already discovered, ignoring`);
return;
}
console.log(`discovered ${dev.devId} - ${rinfo.address}`);
logger.info(`Discovered camera ${dev.devId} at ${rinfo.address}`);
const s = makeSession(Handlers, dev, rinfo, onLogin, opts);
s.eventEmitter.on("disconnect", () => {
console.log("deleting from sessions");
logger.info(`Camera ${dev.devId} disconnected`);
logger.info("Press CONTROL+C if you're done setting up your cameras");
delete sessions[dev.devId];
});
sessions[dev.devId] = s;
+49 -21
View File
@@ -2,13 +2,15 @@ import { createSocket, RemoteInfo } from "node:dgram";
import EventEmitter from "node:events";
import { Commands, CommandsByValue } from "./datatypes.js";
import { handle_Drw, handle_P2PAlive, handle_P2PRdy, makePunchPkt, noop, notImpl } from "./handlers.js";
import { hexdump } from "./hexdump.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 { 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;
@@ -21,6 +23,7 @@ export type Session = {
rcvSeqId: number;
frame_is_bad: boolean;
frame_was_fixed: boolean;
started: boolean;
options: opt;
};
@@ -36,13 +39,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()];
if (session.options.debug) {
console.log(`<< ${cmd}`);
console.log(hexdump(msg.buffer, { ansi: session.options.ansi, ansiColor: 1 }));
}
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 = (
@@ -52,6 +55,7 @@ export const makeSession = (
onLogin: (s: Session) => void,
options: opt,
): Session => {
let unackedDrw = {};
const sock = createSocket("udp4");
sock.on("error", (err) => {
@@ -62,41 +66,64 @@ export const makeSession = (
sock.on("message", (msg, rinfo) => handleIncoming(session, handlers, msg, rinfo));
sock.on("listening", () => {
const buf = makePunchPkt(dev);
const buf = makeP2pRdy(dev);
session.send(buf);
session.started = true;
});
const SEND_PORT = 32108;
sock.bind();
const sessTimer = setInterval(() => {
const delta = Date.now() - session.lastReceivedPacket;
if (session.started) {
if (delta > 600) {
let buf = create_P2pAlive();
session.send(buf);
}
if (delta > 8000) {
if (delta > 5000) {
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];
if (options.debug) {
console.log(`>> ${cmd}`);
if (raw != Commands.P2PAlive) {
console.log(hexdump(msg.buffer, { ansi: options.ansi, ansiColor: 0 }));
// 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 };
}
}
sock.send(new Uint8Array(msg.buffer), SEND_PORT, session.dst_ip);
logger.log("trace", `>> ${cmd}`);
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: [],
@@ -104,10 +131,11 @@ export const makeSession = (
frame_is_bad: false,
frame_was_fixed: false,
options: options,
unackedDrw,
};
session.eventEmitter.on("disconnect", () => {
console.log(`Disconnected from ${session.devName} - ${session.dst_ip}`);
logger.info(`Disconnected from camera ${session.devName} at ${session.dst_ip}`);
session.dst_ip = "0.0.0.0";
session.connected = false;
session.timers.forEach((x) => clearInterval(x));
@@ -115,7 +143,7 @@ export const makeSession = (
});
session.eventEmitter.on("login", () => {
console.log(`Logged in - ${session.devName}`);
logger.info(`Logging in to camera ${session.devName}`);
onLogin(session);
});
return session;
@@ -138,13 +166,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,
P2PAliveAck: noop,
Close: notImpl,
LanSearchExt: notImpl,
LanSearch: notImpl,
P2PAliveAck: notImpl,
Hello: notImpl,
P2pReq: notImpl,
LstReq: notImpl,
+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;
+15 -6
View File
@@ -2,10 +2,9 @@ import "../shim.ts";
import assert from "assert";
import { XqBytesDec, XqBytesEnc } from "../func_replacements.js";
import { createResponseForControlCommand } from "../handlers.js";
import { hexdump } from "../hexdump.js";
import { SendStartVideo, SendDevStatus, SendUsrChk, SendWifiDetails } 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";
describe("debug_tools", () => {
@@ -123,9 +122,19 @@ 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
describe("parse packet", () => {
it("parses PunchPkt", () => {
const in_pkt_str = "f14100144241544400000000000262ca574f4e4a4d000000";
const pkt = new DataView(hstrToBA(in_pkt_str));
const expected = {
prefix: "BATD",
serial: "156362",
suffix: "WONJM",
serialU64: BigInt(156362),
devId: "BATD156362WONJM",
};
assert.deepEqual(parse_PunchPkt(pkt), expected);
});
});
describe("make packet", () => {
+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>]
});
});
+1 -1
View File
@@ -7,7 +7,7 @@
"moduleResolution": "node",
"esModuleInterop": true,
"preserveConstEnums": true,
"outDir": "./build/",
"noEmit": true,
"rootDir": "./",
"strict": false,
"sourceMap": false