33 Commits
Author SHA1 Message Date
David Ventura f59a2c1ad0 implement rotation via injecting exif headers 2024-05-01 23:36:48 +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
21 changed files with 661 additions and 175 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
+5 -7
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
+16 -36
View File
@@ -6,25 +6,20 @@ 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))
## 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 +27,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 +159,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
+18 -9
View File
@@ -5,17 +5,21 @@ import { serveHttp } from "../http_server.js";
import { opt } from "../options.js";
import { pair } from "../pair.js";
import { buildLogger } from "../logger.js";
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("slow_startup", { default: false })
.option("audio", { describe: "Also stream audio from camera", default: true })
.option("color", { describe: "Use color in logs", default: undefined })
.boolean(["ansi", "audio", "slow_startup", "color"])
.option("log_level", { describe: "Set log level", default: "info" })
.option("discovery_ip", { describe: "Camera discovery IP address", default: "192.168.1.255" })
.option("attempt_to_fix_packet_loss", { default: false })
.option("port", { describe: "HTTP Port to listen on", default: 5000 })
.number(["port"])
@@ -23,6 +27,7 @@ yargs(hideBin(process.argv))
},
(argv) => {
const opts: opt = argv as opt;
buildLogger(argv.log_level, argv.color);
serveHttp(opts, argv.port, argv.audio || false);
},
)
@@ -31,17 +36,21 @@ 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("slow_startup", { default: false })
.option("color", { describe: "Use color in logs", default: undefined })
.boolean(["ansi", "slow_startup", "color"])
.option("log_level", { describe: "Set log level", default: "info" })
.option("discovery_ip", { describe: "Camera discovery IP address", default: "192.168.1.255" })
.option("attempt_to_fix_packet_loss", { default: false })
.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);
pair({ opts, ssid: argv.ssid, password: argv.password });
},
)
+17 -12
View File
@@ -1,22 +1,21 @@
import EventEmitter from "node:events";
import { create_LanSearch, parse_PunchPkt } from "./impl.js";
import { create_LanSearchExt, create_LanSearch, parse_PunchPkt } from "./impl.js";
import { createSocket, RemoteInfo } from "node:dgram";
import { Commands } from "./datatypes.js";
import { logger } from "./logger.js";
const handleIncomingPunch = (msg: Buffer, ee: EventEmitter, rinfo: RemoteInfo, debug: boolean) => {
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,18 +25,24 @@ 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));
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();
let ls_buf = create_LanSearch();
let lse_buf = create_LanSearchExt();
setInterval(() => {
console.log(".");
sock.send(new Uint8Array(buf.buffer), SEND_PORT, discovery_ip);
logger.log("trace", `>> LanSearch`);
sock.send(new Uint8Array(ls_buf.buffer), SEND_PORT, discovery_ip);
logger.log("trace", `>> LanSearchExt`);
sock.send(new Uint8Array(lse_buf.buffer), SEND_PORT, discovery_ip);
}, 2000);
sock.send(new Uint8Array(buf.buffer), SEND_PORT, discovery_ip);
logger.log("trace", `>> LanSearch`);
sock.send(new Uint8Array(ls_buf.buffer), SEND_PORT, discovery_ip);
logger.log("trace", `>> LanSearchExt`);
sock.send(new Uint8Array(lse_buf.buffer), SEND_PORT, discovery_ip);
});
sock.bind();
+28 -7
View File
@@ -25,6 +25,9 @@ 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 +49,7 @@ lut = {
[0xf169] = "ListenReqAck",
[0xf170] = "RlyHelloAck",
[0xf171] = "RlyHelloAck2",
__index = function(tbl, key)
return "UNK " .. string.format("0x%X", key)
end
}
setmetatable(lut, lut)
-- Define a function to dissect the packets
function ilnk_proto.dissector(buffer, pinfo, tree)
@@ -59,11 +58,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))
@@ -140,4 +150,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;
};
+14 -15
View File
@@ -1,14 +1,14 @@
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";
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 +25,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,15 +45,13 @@ 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) {
@@ -64,12 +63,12 @@ export const createResponseForControlCommand = (session: Session, dv: DataView):
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 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`);
}
if (cmd_id == ControlCommands.WifiSettingsAck) {
@@ -89,17 +88,17 @@ 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) {
let startat = 0x10;
let msg_len = 91;
console.log("payload len", payload_len);
logger.debug("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,9 +112,9 @@ 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);
}
}
+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(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.info(`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.info(`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" });
};
+1 -1
View File
@@ -1,6 +1,6 @@
export type opt = {
debug: boolean;
ansi: boolean;
discovery_ip: string;
slow_startup: boolean;
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",
+3 -2
View File
@@ -3,16 +3,17 @@
"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"
}
}
+11 -6
View File
@@ -4,28 +4,33 @@ import { opt } from "./options.js";
import { discoverDevices } from "./discovery.js";
import { DevSerial } 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) => {
configureWifi(ssid, password)(s);
logger.info(`WiFi config for camera ${s.devName} is done`);
};
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;
+23 -15
View File
@@ -2,10 +2,10 @@ 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_P2PAlive, handle_P2PRdy, makeP2pRdy, noop, notImpl } 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;
@@ -21,6 +21,7 @@ export type Session = {
rcvSeqId: number;
frame_is_bad: boolean;
frame_was_fixed: boolean;
started: boolean;
options: opt;
};
@@ -37,10 +38,7 @@ 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 }));
}
logger.log("trace", `<< ${cmd}`);
handlers[cmd](session, dv, rinfo);
session.lastReceivedPacket = Date.now();
};
@@ -62,14 +60,27 @@ export const makeSession = (
sock.on("message", (msg, rinfo) => handleIncoming(session, handlers, msg, rinfo));
sock.on("listening", () => {
const buf = makePunchPkt(dev);
const startup = () => {
const buf = makeP2pRdy(dev);
session.send(buf);
// The YsxLite sends a P2PAlive message immediately after P2PRdy
const alive_buf = create_P2pAlive();
session.send(alive_buf);
session.started = true;
};
if (options.slow_startup) {
logger.info("Delaying session startup");
setTimeout(startup, 6500);
} else {
startup();
}
});
const SEND_PORT = 32108;
sock.bind();
const sessTimer = setInterval(() => {
const delta = Date.now() - session.lastReceivedPacket;
if (session.started) {
if (delta > 600) {
let buf = create_P2pAlive();
session.send(buf);
@@ -77,6 +88,7 @@ export const makeSession = (
if (delta > 8000) {
session.eventEmitter.emit("disconnect");
}
}
}, 400);
const session: Session = {
@@ -87,15 +99,11 @@ export const makeSession = (
connected: true,
timers: [sessTimer],
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 }));
}
}
logger.log("trace", `>> ${cmd}`);
sock.send(new Uint8Array(msg.buffer), SEND_PORT, session.dst_ip);
},
dst_ip: ra.address,
@@ -107,7 +115,7 @@ export const makeSession = (
};
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 +123,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;
+16 -2
View File
@@ -3,9 +3,8 @@ 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 { SendStartVideo, SendDevStatus, SendUsrChk, SendWifiDetails, parse_PunchPkt } from "../impl.ts";
import { placeholderTypes, sprintf } from "../utils.js";
describe("debug_tools", () => {
@@ -128,6 +127,21 @@ describe("events", () => {
// 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", () => {
it("builds a good SendUsrChk", () => {
const expected_str =
+1 -1
View File
@@ -7,7 +7,7 @@
"moduleResolution": "node",
"esModuleInterop": true,
"preserveConstEnums": true,
"outDir": "./build/",
"noEmit": true,
"rootDir": "./",
"strict": false,
"sourceMap": false