Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b59c77729c | ||
|
|
21189a8ae9 | ||
|
|
8c71e84875 | ||
|
|
bac2b4fd4f | ||
|
|
c81d7d4182 | ||
|
|
a93564cd11 | ||
|
|
d7d3b91528 | ||
|
|
204677ceae | ||
|
|
03893f8d81 | ||
|
|
dc43a66209 | ||
|
|
6bed63b61f | ||
|
|
2a3d047f48 | ||
|
|
1edb7370cf | ||
|
|
6c7856054e | ||
|
|
f29206a1e1 | ||
|
|
449210ec23 | ||
|
|
3d003be371 | ||
|
|
7b153d6ff8 | ||
|
|
f5da7a34bd | ||
|
|
e496b33ff5 | ||
|
|
f9e2dc63dc | ||
|
|
80e50560e6 | ||
|
|
2ec4d603c6 | ||
|
|
45b8b159d1 | ||
|
|
c51e4f8a7a | ||
|
|
68fe0fb798 | ||
|
|
7ca795c917 | ||
|
|
d14e16c53d | ||
|
|
6ca18b057b | ||
|
|
1439d5f632 | ||
|
|
58c58e38ac | ||
|
|
b5d4ab6024 | ||
|
|
2ce568c019 | ||
|
|
5a0b36f42b | ||
|
|
74863f675e | ||
|
|
9654dc2841 | ||
|
|
9c19c05274 | ||
|
|
a547546055 | ||
|
|
c4007961c7 | ||
|
|
15f70b55af | ||
|
|
8f4c5f09a4 | ||
|
|
06cafff105 | ||
|
|
e2a4352659 | ||
|
|
5bb0038d9b | ||
|
|
b653368d89 | ||
|
|
690c1b4146 | ||
|
|
66796267f2 | ||
|
|
11ab1597f6 | ||
|
|
3edccdc4ab | ||
|
|
c308307fea | ||
|
|
ca6928aa7c | ||
|
|
632308a50e | ||
|
|
40bb81f22e | ||
|
|
e9487f9603 | ||
|
|
82d9115ae6 | ||
|
|
31baea2415 | ||
|
|
da867078b1 | ||
|
|
dc75519f3d | ||
|
|
8246ea52ef | ||
|
|
72de55b26f | ||
|
|
6b5026dcf1 | ||
|
|
a3a5a9404c |
@@ -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
|
||||
|
||||
@@ -3,3 +3,4 @@ venv
|
||||
node_modules
|
||||
build
|
||||
dist/
|
||||
cameras.txt
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,41 +1,62 @@
|
||||
<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,
|
||||
-2624, -3008, -2880, -2240, -2112, -2496, -2368, -3776, -3648, -4032, -3904, -3264, -3136, -3520, -3392, -22016,
|
||||
-20992, -24064, -23040, -17920, -16896, -19968, -18944, -30208, -29184, -32256, -31232, -26112, -25088, -28160,
|
||||
-27136, -11008, -10496, -12032, -11520, -8960, -8448, -9984, -9472, -15104, -14592, -16128, -15616, -13056, -12544,
|
||||
-14080, -13568, -344, -328, -376, -360, -280, -264, -312, -296, -472, -456, -504, -488, -408, -392, -440, -424, -88,
|
||||
-72, -120, -104, -24, -8, -56, -40, -216, -200, -248, -232, -152, -136, -184, -168, -1376, -1312, -1504, -1440, -1120,
|
||||
-1056, -1248, -1184, -1888, -1824, -2016, -1952, -1632, -1568, -1760, -1696, -688, -656, -752, -720, -560, -528, -624,
|
||||
-592, -944, -912, -1008, -976, -816, -784, -880, -848, 5504, 5248, 6016, 5760, 4480, 4224, 4992, 4736, 7552, 7296,
|
||||
8064, 7808, 6528, 6272, 7040, 6784, 2752, 2624, 3008, 2880, 2240, 2112, 2496, 2368, 3776, 3648, 4032, 3904, 3264,
|
||||
3136, 3520, 3392, 22016, 20992, 24064, 23040, 17920, 16896, 19968, 18944, 30208, 29184, 32256, 31232, 26112, 25088,
|
||||
28160, 27136, 11008, 10496, 12032, 11520, 8960, 8448, 9984, 9472, 15104, 14592, 16128, 15616, 13056, 12544, 14080,
|
||||
13568, 344, 328, 376, 360, 280, 264, 312, 296, 472, 456, 504, 488, 408, 392, 440, 424, 88, 72, 120, 104, 24, 8, 56,
|
||||
40, 216, 200, 248, 232, 152, 136, 184, 168, 1376, 1312, 1504, 1440, 1120, 1056, 1248, 1184, 1888, 1824, 2016, 1952,
|
||||
1632, 1568, 1760, 1696, 688, 656, 752, 720, 560, 528, 624, 592, 944, 912, 1008, 976, 816, 784, 880, 848,
|
||||
];
|
||||
const alaw_to_s16_table = [
|
||||
-5504, -5248, -6016, -5760, -4480, -4224, -4992, -4736, -7552, -7296, -8064, -7808, -6528, -6272, -7040, -6784, -2752,
|
||||
-2624, -3008, -2880, -2240, -2112, -2496, -2368, -3776, -3648, -4032, -3904, -3264, -3136, -3520, -3392, -22016,
|
||||
-20992, -24064, -23040, -17920, -16896, -19968, -18944, -30208, -29184, -32256, -31232, -26112, -25088, -28160,
|
||||
-27136, -11008, -10496, -12032, -11520, -8960, -8448, -9984, -9472, -15104, -14592, -16128, -15616, -13056, -12544,
|
||||
-14080, -13568, -344, -328, -376, -360, -280, -264, -312, -296, -472, -456, -504, -488, -408, -392, -440, -424, -88,
|
||||
-72, -120, -104, -24, -8, -56, -40, -216, -200, -248, -232, -152, -136, -184, -168, -1376, -1312, -1504, -1440, -1120,
|
||||
-1056, -1248, -1184, -1888, -1824, -2016, -1952, -1632, -1568, -1760, -1696, -688, -656, -752, -720, -560, -528, -624,
|
||||
-592, -944, -912, -1008, -976, -816, -784, -880, -848, 5504, 5248, 6016, 5760, 4480, 4224, 4992, 4736, 7552, 7296,
|
||||
8064, 7808, 6528, 6272, 7040, 6784, 2752, 2624, 3008, 2880, 2240, 2112, 2496, 2368, 3776, 3648, 4032, 3904, 3264,
|
||||
3136, 3520, 3392, 22016, 20992, 24064, 23040, 17920, 16896, 19968, 18944, 30208, 29184, 32256, 31232, 26112, 25088,
|
||||
28160, 27136, 11008, 10496, 12032, 11520, 8960, 8448, 9984, 9472, 15104, 14592, 16128, 15616, 13056, 12544, 14080,
|
||||
13568, 344, 328, 376, 360, 280, 264, 312, 296, 472, 456, 504, 488, 408, 392, 440, 424, 88, 72, 120, 104, 24, 8, 56,
|
||||
40, 216, 200, 248, 232, 152, 136, 184, 168, 1376, 1312, 1504, 1440, 1120, 1056, 1248, 1184, 1888, 1824, 2016, 1952,
|
||||
1632, 1568, 1760, 1696, 688, 656, 752, 720, 560, 528, 624, 592, 944, 912, 1008, 976, 816, 784, 880, 848,
|
||||
];
|
||||
|
||||
const alaw_to_s16 = (a_val) => {
|
||||
return alaw_to_s16_table[a_val];
|
||||
};
|
||||
const alaw_to_s16 = (a_val) => {
|
||||
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);
|
||||
|
||||
gain_node.connect(audio_context.destination); // Connect gain node to speakers
|
||||
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
Binary file not shown.
@@ -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
@@ -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 });
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
+141
-93
@@ -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,122 +46,152 @@ 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) {
|
||||
let c = new Uint8Array(dv.add(0x18).readByteArray(4).buffer);
|
||||
session.ticket = [...c];
|
||||
session.eventEmitter.emit("login");
|
||||
return [];
|
||||
}
|
||||
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) {
|
||||
// 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 dbm = dv.add(0x24).readU8() - 0x100; // 0xbf - 0x100 = -65dbm .. constant??
|
||||
// > -50 = excellent, -50 to -60 good, -60 to -70 fair, <-70 weak
|
||||
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??
|
||||
// > -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) {
|
||||
const wifiSettings = {
|
||||
enable: dv.add(0x14).readU32(),
|
||||
status: dv.add(0x18).readU32(),
|
||||
mode: dv.add(0x1c).readU32LE(),
|
||||
channel: dv.add(0x20).readU32(),
|
||||
authtype: dv.add(0x24).readU32(),
|
||||
dhcp: dv.add(0x28).readU32(),
|
||||
ssid: dv.add(0x2c).readString(0x20),
|
||||
psk: dv.add(0x4c).readString(0x80),
|
||||
ip: dv.add(0xcc).readString(0x10),
|
||||
mask: dv.add(0xdc).readString(0x10),
|
||||
gw: dv.add(0xec).readString(0x10),
|
||||
dns1: dv.add(0xfc).readString(0x10),
|
||||
dns2: dv.add(0x10c).readString(0x10),
|
||||
};
|
||||
const buf = SendListWifi(session);
|
||||
console.log(`Current wifi settings: ${JSON.stringify(wifiSettings, null, 2)}`);
|
||||
return [buf];
|
||||
}
|
||||
|
||||
if (cmd_id == ControlCommands.ListWifiAck) {
|
||||
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);
|
||||
startat += 4;
|
||||
let items = [];
|
||||
for (let i = 0; i < msg_count; i++) {
|
||||
const wifiListItem = {
|
||||
// startat = msg_len * i + 0x14;
|
||||
ssid: dv.add(startat).readString(0x40),
|
||||
mac: dv.add(startat + 0x40).readByteArray(8),
|
||||
security: dv.add(startat + 0x48).readU32LE(),
|
||||
dbm0: dv.add(startat + 0x4c).readU32LE(),
|
||||
dbm1: dv.add(startat + 0x50).readU32LE(),
|
||||
mode: dv.add(startat + 0x54).readU32LE(),
|
||||
channel: dv.add(startat + 0x58).readU32LE(),
|
||||
case ControlCommands.WifiSettingsAck:
|
||||
const wifiSettings = {
|
||||
enable: dv.add(0x14).readU32(),
|
||||
status: dv.add(0x18).readU32(),
|
||||
mode: dv.add(0x1c).readU32LE(),
|
||||
channel: dv.add(0x20).readU32(),
|
||||
authtype: dv.add(0x24).readU32(),
|
||||
dhcp: dv.add(0x28).readU32(),
|
||||
ssid: dv.add(0x2c).readString(0x20),
|
||||
psk: dv.add(0x4c).readString(0x80),
|
||||
ip: dv.add(0xcc).readString(0x10),
|
||||
mask: dv.add(0xdc).readString(0x10),
|
||||
gw: dv.add(0xec).readString(0x10),
|
||||
dns1: dv.add(0xfc).readString(0x10),
|
||||
dns2: dv.add(0x10c).readString(0x10),
|
||||
};
|
||||
console.log(`Wifi Item: ${JSON.stringify(wifiListItem, null, 2)}`);
|
||||
startat += msg_len;
|
||||
console.log("ended at", startat);
|
||||
items.push(wifiListItem);
|
||||
}
|
||||
const buf = SendListWifi(session);
|
||||
logger.info(`Current Wifi settings: ${JSON.stringify(wifiSettings, null, 2)}`);
|
||||
return [buf];
|
||||
|
||||
case ControlCommands.ListWifiAck:
|
||||
if (payload_len == 4) {
|
||||
logger.debug("ListWifi returned []");
|
||||
return [];
|
||||
}
|
||||
let startat = 0x10;
|
||||
let msg_len = 91;
|
||||
let msg_count = (payload_len - 9) / msg_len;
|
||||
let remote_msg_count = dv.add(startat).readU32LE();
|
||||
logger.debug(`should get messages: ${msg_count} in payload: ${remote_msg_count}`);
|
||||
startat += 4;
|
||||
let items = [];
|
||||
for (let i = 0; i < msg_count; i++) {
|
||||
const wifiListItem = {
|
||||
// startat = msg_len * i + 0x14;
|
||||
ssid: dv.add(startat).readString(0x40),
|
||||
mac: dv.add(startat + 0x40).readByteArray(8),
|
||||
security: dv.add(startat + 0x48).readU32LE(),
|
||||
dbm0: dv.add(startat + 0x4c).readU32LE(),
|
||||
dbm1: dv.add(startat + 0x50).readU32LE(),
|
||||
mode: dv.add(startat + 0x54).readU32LE(),
|
||||
channel: dv.add(startat + 0x58).readU32LE(),
|
||||
};
|
||||
logger.info(`Wifi Item: ${JSON.stringify(wifiListItem, null, 2)}`);
|
||||
startat += msg_len;
|
||||
logger.debug("ended at", startat);
|
||||
items.push(wifiListItem);
|
||||
}
|
||||
return [];
|
||||
case ControlCommands.StartVideoAck:
|
||||
logger.debug("Start video ack");
|
||||
return [];
|
||||
case ControlCommands.VideoParamSetAck:
|
||||
logger.debug("Video param set ack");
|
||||
return [];
|
||||
default:
|
||||
logger.info(`Unhandled control command: 0x${cmd_id.toString(16)}`);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
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 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(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());
|
||||
// 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 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);
|
||||
if (is_new_image) {
|
||||
if (session.curImage.length > 0 && !session.frame_is_bad) {
|
||||
session.eventEmitter.emit("frame");
|
||||
}
|
||||
// this only happens on un-framed-cameras, which start the JPEG image directly
|
||||
let is_new_image = m_hdr.startsWith(JPEG_HEADER);
|
||||
|
||||
session.frame_was_fixed = false;
|
||||
session.frame_is_bad = false;
|
||||
session.curImage = [Buffer.from(data.buffer)];
|
||||
session.rcvSeqId = pkt_id;
|
||||
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) {
|
||||
session.frame_is_bad = true;
|
||||
if (!session.frame_is_bad) {
|
||||
session.frame_is_bad = true;
|
||||
logger.debug(`Dropping corrupt frame ${pkt_id}, expected ${session.rcvSeqId + 1}`);
|
||||
}
|
||||
// this should always be enabled but currently it seems to cause more visual distortion
|
||||
// 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
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
};
|
||||
@@ -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,5 +1,4 @@
|
||||
export type opt = {
|
||||
debug: boolean;
|
||||
ansi: boolean;
|
||||
discovery_ip: string;
|
||||
attempt_to_fix_packet_loss: boolean;
|
||||
|
||||
Generated
+253
-6
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+55
-27
@@ -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);
|
||||
session.lastReceivedPacket = Date.now();
|
||||
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 (delta > 600) {
|
||||
let buf = create_P2pAlive();
|
||||
session.send(buf);
|
||||
}
|
||||
if (delta > 8000) {
|
||||
session.eventEmitter.emit("disconnect");
|
||||
if (session.started) {
|
||||
if (delta > 600) {
|
||||
let buf = create_P2pAlive();
|
||||
session.send(buf);
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -7,7 +7,7 @@
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"preserveConstEnums": true,
|
||||
"outDir": "./build/",
|
||||
"noEmit": true,
|
||||
"rootDir": "./",
|
||||
"strict": false,
|
||||
"sourceMap": false
|
||||
|
||||
Reference in New Issue
Block a user