diff --git a/README.md b/README.md index 200e296..4f59ee2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cameras-example.txt b/cameras-example.txt deleted file mode 100644 index 55655ac..0000000 --- a/cameras-example.txt +++ /dev/null @@ -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 diff --git a/cmd/bin.ts b/cmd/bin.ts index d93fea3..2837757 100644 --- a/cmd/bin.ts +++ b/cmd/bin.ts @@ -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() diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..cc9c3e9 --- /dev/null +++ b/config.example.yml @@ -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 diff --git a/discovery.ts b/discovery.ts index 336c270..2ec5a70 100644 --- a/discovery.ts +++ b/discovery.ts @@ -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(); diff --git a/handlers.ts b/handlers.ts index 7a9e6a4..b6d586c 100644 --- a/handlers.ts +++ b/handlers.ts @@ -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; } diff --git a/http_server.ts b/http_server.ts index 663c41d..501e8a1 100644 --- a/http_server.ts +++ b/http_server.ts @@ -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 = {}; const audioResponses: Record = {}; const sessions: Record = {}; -// 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}`); diff --git a/package-lock.json b/package-lock.json index 16f596f..5b8c413 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b319f9e..8340e77 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,10 @@ }, "dependencies": { "winston": "^3.13.0", + "yaml": "^2.4.2", "yargs": "^17.7.2" }, "engines": { - "node": ">=16.0" + "node": ">=16.0" } } diff --git a/pair.ts b/pair.ts index af61641..33d390a 100644 --- a/pair.ts +++ b/pair.ts @@ -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 = {}; - 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`); diff --git a/session.ts b/session.ts index b845b86..eb48c10 100644 --- a/session.ts +++ b/session.ts @@ -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, }; diff --git a/settings.ts b/settings.ts new file mode 100644 index 0000000..989e3b0 --- /dev/null +++ b/settings.ts @@ -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; + 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 }; diff --git a/tests/integration.test.js b/tests/integration.test.js index 5fb4a70..f8cc61f 100644 --- a/tests/integration.test.js +++ b/tests/integration.test.js @@ -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");