implement settings support

This commit is contained in:
David Ventura
2024-05-05 17:39:52 +02:00
parent f4b7b1a463
commit d5d4161dd8
13 changed files with 205 additions and 83 deletions
+46 -3
View File
@@ -35,11 +35,54 @@ There's a basic UI which can display multiple cameras:
![](pics/web-ui.jpg?raw=true)
The server will send a broadcast packet every few seconds to discover all the cameras available; this means that it *must* run in the same broadcast domain (VLAN) as your cameras. For debugging purposes, you can send the packets to a specific camera by setting `--discovery_ip` to its IP address.
Clicking on the image will take you to a page that has audio streaming. Click the button below the image to mute/unmute the audio.
To customize the camera names, edit the file `cameras.txt` and add the names of your cameras, one per line, in the format `CAMERA_ID=descriptive name` (you must restart the HTTP server for the changes to take effect).
## Settings
You can provide a config file in `yml` format, check `config.example.yml` for an updated example:
```yml
http_server:
port: 5000
logging:
level: debug
use_color: true
cameras:
FTYC477360FAWUK:
alias: "A9"
rotate: 1
mirror: false
fix_packet_loss: yes
audio: true
BATC609531EXLVS:
alias: "X5"
# If you are crossing broadcast domains (VLANs) then
# you need to specify all IPs as unicast targets
discovery_ips:
- 192.168.40.101
- 192.168.40.102
- 192.168.40.103
- 192.168.40.104
- 192.168.40.105
# If you are in the same broadcast domain, then
# it's easier to just use the broadcast address of your network
# discovery_ips:
# - 192.168.1.255
blacklisted_ips:
- 192.168.40.102
```
All keys are optional
You must restart the HTTP server for changes to the settings file to take effect.
----
## Protocol
-10
View File
@@ -1,10 +0,0 @@
# This is a simple name mapping for cameras, in the form
#
# CAMERA_ID=descriptive name
#
# Blank lines and lines starting with "#" are ignored.
#
# Any updates to this file require restarting the HTTP server.
# This file is meant to be updated manually, it is NOT updated dynamically.
FTYC811847AGFDZ=Office camera
+27 -16
View File
@@ -3,8 +3,8 @@ import { hideBin } from "yargs/helpers";
import yargs from "yargs/yargs";
import { serveHttp } from "../http_server.js";
import { opt } from "../options.js";
import { pair } from "../pair.js";
import { loadConfig, config } from "../settings.js";
import { buildLogger, logger } from "../logger.js";
@@ -16,23 +16,38 @@ yargs(hideBin(process.argv))
"start http server",
(yargs) => {
return yargs
.option("audio", { describe: "Also stream audio from camera", default: true })
.option("color", { describe: "Use color in logs", default: undefined })
.option("color", { describe: "Use color in logs" })
.boolean(["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 })
.option("config_file", { describe: "Specify config file" })
.option("log_level", { describe: "Set log level" })
.option("discovery_ip", { describe: "Camera discovery IP address" })
.option("port", { describe: "HTTP Port to listen on" })
.string(["log_level", "discovery_ip", "config_file"])
.number(["port"])
.strict();
},
(argv) => {
const opts: opt = argv as opt;
buildLogger(argv.log_level, argv.color);
if (argv.config_file !== undefined) {
loadConfig(argv.config_file);
}
if (argv.port) {
config.http_server.port = argv.port;
}
if (argv.color !== undefined) {
config.logging.use_color = argv.color;
}
if (argv.log_level !== undefined) {
config.logging.level = argv.log_level;
}
if (argv.discovery_ip !== undefined) {
config.discovery_ips = [argv.discovery_ip];
}
buildLogger(config.logging.level, config.logging.use_color);
if (majorVersion < 16) {
logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
}
serveHttp(opts, argv.port, argv.audio || false);
serveHttp(config.http_server.port);
},
)
.command(
@@ -40,23 +55,19 @@ yargs(hideBin(process.argv))
"configure a camera",
(yargs) => {
return yargs
.option("color", { describe: "Use color in logs", default: undefined })
.boolean(["color"])
.option("log_level", { describe: "Set log level", default: "info" })
.option("discovery_ip", { describe: "Camera discovery IP address", default: "192.168.1.255" })
.option("attempt_to_fix_packet_loss", { default: false })
.option("ssid", { describe: "Wifi network for the camera to connect to" })
.option("password", { describe: "Wifi network password" })
.demandOption(["ssid", "password"])
.string(["ssid", "password"]);
},
(argv) => {
const opts: opt = argv as unknown as opt;
buildLogger(argv.log_level, argv.color);
buildLogger(argv.log_level, undefined);
if (majorVersion < 16) {
logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
}
pair({ opts, ssid: argv.ssid, password: argv.password });
pair({ ssid: argv.ssid, password: argv.password });
},
)
.demandCommand()
+33
View File
@@ -0,0 +1,33 @@
http_server:
port: 5000
logging:
level: debug
use_color: true
cameras:
FTYC477360FAWUK:
alias: "A9"
rotate: 1
mirror: false
fix_packet_loss: yes
audio: true
BATC609531EXLVS:
alias: "X5"
# If you are crossing broadcast domains (VLANs) then
# you need to specify all IPs as unicast targets
discovery_ips:
- 192.168.40.101
- 192.168.40.102
- 192.168.40.103
- 192.168.40.104
- 192.168.40.105
# If you are in the same broadcast domain, then
# it's easier to just use the broadcast address of your network
# discovery_ips:
# - 192.168.1.255
blacklisted_ips:
- 192.168.40.102
+17 -10
View File
@@ -4,6 +4,7 @@ import EventEmitter from "node:events";
import { Commands } from "./datatypes.js";
import { create_LanSearch, parse_PunchPkt } from "./impl.js";
import { logger } from "./logger.js";
import { config } from "./settings.js";
const handleIncomingPunch = (msg: Buffer, ee: EventEmitter, rinfo: RemoteInfo) => {
const ab = new Uint8Array(msg).buffer;
@@ -12,11 +13,15 @@ const handleIncomingPunch = (msg: Buffer, ee: EventEmitter, rinfo: RemoteInfo) =
if (cmd_id != Commands.PunchPkt) {
return;
}
if (config.blacklisted_ips.indexOf(rinfo.address) !== -1) {
logger.debug(`Dropping packet of blacklisted IP: ${rinfo.address}`);
return;
}
logger.debug("Received a PunchPkt message");
ee.emit("discover", rinfo, parse_PunchPkt(dv));
};
export const discoverDevices = (discovery_ip: string): EventEmitter => {
export const discoverDevices = (discovery_ips: string[]): EventEmitter => {
const sock = createSocket("udp4");
const SEND_PORT = 32108;
const ee = new EventEmitter();
@@ -30,17 +35,19 @@ export const discoverDevices = (discovery_ip: string): EventEmitter => {
let timers = [];
sock.on("listening", () => {
sock.setBroadcast(true);
logger.info(`Searching for devices on ${discovery_ip}`);
let ls_buf = create_LanSearch();
let int = setInterval(() => {
logger.log("trace", `>> LanSearch`);
sock.setBroadcast(true);
discovery_ips.forEach((discovery_ip) => {
logger.info(`Searching for devices on ${discovery_ip}`);
let int = setInterval(() => {
logger.log("trace", `>> LanSearch [${discovery_ip}]`);
sock.send(new Uint8Array(ls_buf.buffer), SEND_PORT, discovery_ip);
}, 3000);
timers.push(int);
logger.log("trace", `>> LanSearch [${discovery_ip}]`);
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();
+2 -1
View File
@@ -5,6 +5,7 @@ import { Session } from "./session.js";
import { u16_swap, u32_swap } from "./utils.js";
import { logger } from "./logger.js";
import { hexdump } from "./hexdump.js";
import { config } from "./settings.js";
export const notImpl = (session: Session, dv: DataView) => {
const raw = dv.readU16();
@@ -207,7 +208,7 @@ const deal_with_data = (session: Session, dv: DataView) => {
}
// 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) {
if (!config.cameras[session.devName].fix_packet_loss) {
return;
}
+13 -33
View File
@@ -1,9 +1,8 @@
import { RemoteInfo } from "dgram";
import { readFileSync, existsSync } from "node:fs";
import http from "node:http";
import { logger } from "./logger.js";
import { opt } from "./options.js";
import { config } from "./settings.js";
import { discoverDevices } from "./discovery.js";
import { DevSerial, SendDevStatus } from "./impl.js";
import { Handlers, makeSession, Session, startVideoStream } from "./session.js";
@@ -19,9 +18,6 @@ const responses: Record<string, http.ServerResponse[]> = {};
const audioResponses: Record<string, http.ServerResponse[]> = {};
const sessions: Record<string, Session> = {};
// Text file containing the mapping of camera names.
const nameFile = "cameras.txt";
// https://sirv.com/help/articles/rotate-photos-to-be-upright/
const oMap = [1, 8, 3, 6];
const oMapMirror = [2, 7, 4, 5];
@@ -29,29 +25,13 @@ const orientations = [1, 2, 3, 4, 5, 6, 7, 8].reduce((acc, cur) => {
return { [cur]: createExifOrientation(cur), ...acc };
}, {});
let camSettings: { [key: string]: { orientation: number; mirror: boolean } } = {};
// Reads the mapping of serial numbers to camera names from the text file.
const cameraNames = Object.assign(
{},
...(existsSync(nameFile) ? readFileSync(nameFile, "utf8") : "")
.toString()
.replace(/\r\n/g, "\n")
.split("\n")
.filter((l) => !l.startsWith("#"))
.filter((l) => l.trim() != "")
.map((l) => {
let kv = l.split("=");
return { [kv[0].trim()]: kv[1].trim() };
}),
);
// Returns the camera name (custom name, if it exists, otherwise its ID).
const cameraName = (id: string): string => cameraNames[id] || id;
const cameraName = (id: string): string => config.cameras[id].alias || id;
// The HTTP server.
export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
logger.info(`Mapping camera names: ${JSON.stringify(cameraNames)}`);
export const serveHttp = (port: number) => {
const server = http.createServer((req, res) => {
if (req.url.startsWith("/ui/")) {
let devId = req.url.split("/")[2];
@@ -70,7 +50,7 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
.toString()
.replace(/\${id}/g, devId)
.replace(/\${name}/g, cameraName(devId))
.replace(/\${audio}/g, with_audio.toString());
.replace(/\${audio}/g, config.cameras[devId].audio ? "true" : "false");
res.end(ui);
return;
}
@@ -102,17 +82,17 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
if (req.url.startsWith("/rotate/")) {
let devId = req.url.split("/")[2];
let curPos = camSettings[devId]?.orientation || 0;
let curPos = config.cameras[devId]?.rotate || 0;
let nextPos = (curPos + 1) % 4;
logger.debug(`Rotating ${devId} to ${nextPos}`);
camSettings[devId].orientation = nextPos;
config.cameras[devId].rotate = nextPos;
res.writeHead(204);
res.end();
return;
} else if (req.url.startsWith("/mirror/")) {
let devId = req.url.split("/")[2];
logger.debug(`Mirroring ${devId}`);
camSettings[devId].mirror = !camSettings[devId].mirror;
config.cameras[devId].mirror = !config.cameras[devId].mirror;
res.writeHead(204);
res.end();
return;
@@ -155,7 +135,7 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
}
});
let devEv = discoverDevices(opts.discovery_ip);
let devEv = discoverDevices(config.discovery_ips);
const startSession = (s: Session) => {
s.send(SendDevStatus(s));
@@ -172,14 +152,14 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
logger.info(`Discovered camera ${dev.devId} at ${rinfo.address}`);
responses[dev.devId] = [];
audioResponses[dev.devId] = [];
const s = makeSession(Handlers, dev, rinfo, startSession, opts);
const s = makeSession(Handlers, dev, rinfo, startSession);
const header = Buffer.from(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\n\r\n`);
s.eventEmitter.on("frame", () => {
// Add an EXIF header to indicate if the image should be rotated or mirrored
let orientation = camSettings[dev.devId].orientation;
orientation = camSettings[dev.devId].mirror ? oMapMirror[orientation] : oMap[orientation];
let orientation = config.cameras[dev.devId].rotate;
orientation = config.cameras[dev.devId].mirror ? oMapMirror[orientation] : oMap[orientation];
const exifSegment = orientations[orientation];
const jpegHeader = addExifToJpeg(s.curImage[0], exifSegment);
const assembled = Buffer.concat([jpegHeader, ...s.curImage.slice(1)]);
@@ -193,7 +173,7 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
logger.info(`Camera ${dev.devId} disconnected`);
delete sessions[dev.devId];
});
if (with_audio) {
if (config.cameras[dev.devId].audio) {
s.eventEmitter.on("audio", ({ gap, data }) => {
// ew, maybe WS?
var b64encoded = Buffer.from(data).toString("base64");
@@ -205,7 +185,7 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => {
});
}
sessions[dev.devId] = s;
camSettings[dev.devId] = { orientation: 0, mirror: false };
config.cameras[dev.devId] = { rotate: 0, mirror: false, ...(config.cameras[dev.devId] || {}) };
});
logger.info(`Starting HTTP server on port ${port}`);
+15
View File
@@ -6,6 +6,7 @@
"": {
"dependencies": {
"winston": "^3.13.0",
"yaml": "^2.4.2",
"yargs": "^17.7.2"
},
"devDependencies": {
@@ -14,6 +15,9 @@
"mocha": "^10.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=16.0"
}
},
"node_modules/@colors/colors": {
@@ -1711,6 +1715,17 @@
"node": ">=10"
}
},
"node_modules/yaml": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",
"integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+2 -1
View File
@@ -14,9 +14,10 @@
},
"dependencies": {
"winston": "^3.13.0",
"yaml": "^2.4.2",
"yargs": "^17.7.2"
},
"engines": {
"node": ">=16.0"
"node": ">=16.0"
}
}
+4 -4
View File
@@ -1,16 +1,16 @@
import { RemoteInfo } from "dgram";
import { opt } from "./options.js";
import { config } from "./settings.js";
import { discoverDevices } from "./discovery.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 }) => {
export const pair = ({ ssid, password }: { ssid: string; password: string }) => {
logger.info(`Will configure any devices found to join ${ssid}`);
let sessions: Record<string, Session> = {};
let devEv = discoverDevices(opts.discovery_ip);
let devEv = discoverDevices(config.discovery_ips);
if (password == "") {
throw new Error("You must set a non-zero-length password");
}
@@ -33,7 +33,7 @@ export const pair = ({ opts, ssid, password }: { opts: opt; ssid: string; passwo
return;
}
logger.info(`Discovered camera ${dev.devId} at ${rinfo.address}`);
const s = makeSession(Handlers, dev, rinfo, onLogin, opts);
const s = makeSession(Handlers, dev, rinfo, onLogin);
s.eventEmitter.on("disconnect", () => {
logger.info(`Camera ${dev.devId} disconnected`);
-4
View File
@@ -4,7 +4,6 @@ import EventEmitter from "node:events";
import { Commands, CommandsByValue } from "./datatypes.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 = {
@@ -24,7 +23,6 @@ export type Session = {
frame_is_bad: boolean;
frame_was_fixed: boolean;
started: boolean;
options: opt;
};
export type PacketHandler = (session: Session, dv: DataView, rinfo: RemoteInfo) => void;
@@ -53,7 +51,6 @@ export const makeSession = (
dev: DevSerial,
ra: RemoteInfo,
onLogin: (s: Session) => void,
options: opt,
): Session => {
let unackedDrw = {};
const sock = createSocket("udp4");
@@ -130,7 +127,6 @@ export const makeSession = (
rcvSeqId: 0,
frame_is_bad: false,
frame_was_fixed: false,
options: options,
unackedDrw,
};
+45
View File
@@ -0,0 +1,45 @@
import fs from "node:fs";
import { parse } from "yaml";
interface HttpServerConfig {
port: number;
}
interface LoggingConfig {
level: string;
use_color?: boolean;
}
interface CameraConfig {
alias?: string;
rotate?: number;
mirror?: boolean;
audio?: boolean;
fix_packet_loss?: boolean;
}
interface AppConfig {
http_server: HttpServerConfig;
logging: LoggingConfig;
cameras: Record<string, CameraConfig>;
discovery_ips: string[];
blacklisted_ips: string[];
}
const DefaultConfig: AppConfig = {
http_server: { port: 5000 },
logging: { level: "info" },
cameras: {},
discovery_ips: ["192.168.1.255"],
blacklisted_ips: [],
};
let config = DefaultConfig;
export const loadConfig = (path: string) => {
const data = fs.readFileSync(path, { encoding: "utf-8" });
config = parse(data) as AppConfig;
config = { ...DefaultConfig, ...config };
};
export { config };
+1 -1
View File
@@ -20,7 +20,7 @@ describe("integration", () => {
}
return [];
});
const ev = discoverDevices("127.0.0.1");
const ev = discoverDevices(["127.0.0.1"]);
ev.on("discover", (rinfo, dev) => {
assert.deepEqual(dev.devId, EXPECTED_SERIAL);
ev.emit("close");