From 690c1b41465866c874af4e2fcfa098776d7e541d Mon Sep 17 00:00:00 2001 From: Adriano Cunha <35786489+adrcunha@users.noreply.github.com> Date: Tue, 30 Apr 2024 07:18:10 -0700 Subject: [PATCH] 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. --- README.md | 24 ++++++----- asd.html | 106 ++++++++++++++++++++++++++++++++++++------------- cam.ico.gz | Bin 0 -> 634 bytes cameras.txt | 10 +++++ cmd/bin.ts | 15 +++---- handlers.ts | 8 ++-- http_server.ts | 82 +++++++++++++++++++++++++++++--------- session.ts | 4 +- 8 files changed, 181 insertions(+), 68 deletions(-) create mode 100644 cam.ico.gz create mode 100644 cameras.txt diff --git a/README.md b/README.md index 4a13955..68ea498 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,14 @@ configure a camera Options: --help Show help [boolean] --version Show version number [boolean] - --debug [boolean] [default: false] + --debug Log debug messages [boolean] [default: false] --ansi [boolean] [default: false] - --discovery_ip [default: "192.168.1.255"] + --discovery_ip Camera discovery IP address + [default: "192.168.1.255"] --attempt_to_fix_packet_loss [default: false] - --ssid [string] [required] - --password [string] [required] + --ssid Wifi network for the camera to connect to + [string] [required] + --password Wifi network password [string] [required] ``` ## Running @@ -44,10 +46,12 @@ start http server Options: --help Show help [boolean] --version Show version number [boolean] - --debug [boolean] [default: false] + --debug Log debug messages [boolean] [default: false] --ansi [boolean] [default: false] - --audio [boolean] [default: false] - --discovery_ip [default: "192.168.1.255"] + --audio Also stream audio from camera + [boolean] [default: true] + --discovery_ip Camera discovery IP address + [default: "192.168.1.255"] --attempt_to_fix_packet_loss [default: false] --port HTTP Port to listen on [number] [default: 5000] ``` @@ -58,9 +62,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. + +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 diff --git a/asd.html b/asd.html index f523ff4..50e8e53 100644 --- a/asd.html +++ b/asd.html @@ -1,40 +1,72 @@ -

${id}


+ + + ${name} + +

${name}


+ diff --git a/cam.ico.gz b/cam.ico.gz new file mode 100644 index 0000000000000000000000000000000000000000..733fad77813a7054d47dabde0b6a61250f03baac GIT binary patch literal 634 zcmV-=0)_n_iwFqfVJ&6=17l%rE@@+L0PUGiOB+EL#@~&>lpr+_u}5>MNDtx%DCF+N zFW|u*gdXz&gb3|ND1~0zOWR9}_fqf+NDw@EP(&#aGzBUB7pKqbyaU5_c3qR%uq*R~ zCoemhKfjrs4ZH6WMfj|&5WZXV{xQ)!5iP+eq7BmJK|w+HUR&@t_!?H8&3&H3uXrG( z)JTq^Xc5g1;otB(;huv}a$rvK1vnnvl^uHRSXy`bD4B5*7N2E-<&-rj-Oa_dk+6{a2=!GlGG^_KfKKU zMTq+jo9_?kNmmhPoSNI{lDY$`t^{%a^??3JcquV-@Y7=%r5=<&L5`cZ)Gnv$JsNnHSwLl zEqnf)Rp$2>tg^p9GAh&iJMQx<1qB6_R_aE#$0MSe8sG+lC{jl^k<_;ILHwf{@J+@n zVn^aeLr3aJ9OyVDu_kdrVs`@Z;jE4`H9amz=6Eorxj2%E9sN7cgWK`EY72UvX5MBU U%(|F$%DS%q0OA%?C%z>B0J|elC;$Ke literal 0 HcmV?d00001 diff --git a/cameras.txt b/cameras.txt new file mode 100644 index 0000000..55655ac --- /dev/null +++ b/cameras.txt @@ -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 diff --git a/cmd/bin.ts b/cmd/bin.ts index cdd28b9..ca6a1d2 100644 --- a/cmd/bin.ts +++ b/cmd/bin.ts @@ -11,11 +11,11 @@ yargs(hideBin(process.argv)) "start http server", (yargs) => { return yargs - .option("debug", { default: false }) + .option("debug", { describe: "Log debug messages", default: false }) .option("ansi", { default: false }) - .option("audio", { default: false }) + .option("audio", { describe: "Also stream audio from camera", default: true }) .boolean(["debug", "ansi", "audio"]) - .option("discovery_ip", { default: "192.168.1.255" }) + .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"]) @@ -31,13 +31,14 @@ yargs(hideBin(process.argv)) "configure a camera", (yargs) => { return yargs - .option("debug", { default: false }) + .option("debug", { describe: "Log debug messages", default: false }) .option("ansi", { default: false }) .boolean(["debug", "ansi"]) - .option("discovery_ip", { default: "192.168.1.255" }) + .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) => { diff --git a/handlers.ts b/handlers.ts index 44523be..fb97ade 100644 --- a/handlers.ts +++ b/handlers.ts @@ -68,12 +68,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}`); + console.log(`Camera ${session.devName}: ${charging}charging, battery at ${power/1000}V, Wifi ${dbm} dBm`); } if (cmd_id == ControlCommands.WifiSettingsAck) { @@ -93,7 +93,7 @@ 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)}`); + console.log(`Current Wifi settings: ${JSON.stringify(wifiSettings, null, 2)}`); return [buf]; } diff --git a/http_server.ts b/http_server.ts index de4ef72..262e913 100644 --- a/http_server.ts +++ b/http_server.ts @@ -1,17 +1,35 @@ import { RemoteInfo } from "dgram"; -import { readFileSync } from "node:fs"; +import { readFileSync, existsSync } from "node:fs"; import http from "node:http"; 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"; -let BOUNDARY = "a very good boundary line"; -let responses: Record = {}; -let audioResponses: Record = {}; -let sessions: Record = {}; +const BOUNDARY = "a very good boundary line"; +const responses: Record = {}; +const audioResponses: Record = {}; +const sessions: Record = {}; +// Text file containing the mapping of camera names. +const nameFile = "cameras.txt"; + +// Reads the simple mapping of 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]]: kv[1] }); })); + +// Returns the camera name (custom name, if it exists, otherwise its ID). +const cameraName = (id: string): string => cameraNames[id] || id; + +// Page's favicon. +const favicon = readFileSync("cam.ico.gz"); + +// The HTTP server. export const serveHttp = (opts: opt, port: number, with_audio: boolean) => { const server = http.createServer((req, res) => { if (req.url.startsWith("/ui/")) { @@ -27,8 +45,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 = readFileSync("asd.html").toString() + .replace(/\${id}/g, devId) + .replace(/\${name}/g, cameraName(devId)) + .replace(/\${audio}/g, with_audio.toString()) + .replace(/\${debug}/g, opts.debug.toString()); + res.end(ui); return; } if (req.url.startsWith("/audio/")) { @@ -46,22 +68,30 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => { } res.setHeader("Content-Type", `text/event-stream`); audioResponses[devId].push(res); + console.log(`Audio stream requested for camera ${devId}`); + return; + } + + 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("/camera/")) { let devId = req.url.split("/")[2]; - console.log("requested for", devId); + console.log(`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,29 +99,43 @@ 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"); + console.log(`Video stream closed for camera ${devId}`); }); } else { - res.write(``); + res.write(""); + res.write(""); + res.write(``); + res.write("All cameras"); + res.write(""); + res.write(""); + res.write("

All cameras


"); Object.keys(sessions).forEach((id) => - res.write(`

${id}


`), + res.write(`

${cameraName(id)}


`), ); - res.write(``); + res.write(""); + res.write(""); res.end(); } }); let devEv = discoverDevices(opts.debug, opts.discovery_ip); + + const startSession = (s: Session) => { + s.send(SendDevStatus(s)); + startVideoStream(s); + console.log(`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}`); + console.log(`Camera ${dev.devId} at ${rinfo.address} already discovered, ignoring`); return; } - console.log(`discovered ${dev.devId} - ${rinfo.address}`); + console.log(`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`); @@ -105,7 +149,7 @@ export const serveHttp = (opts: opt, port: number, with_audio: boolean) => { }); s.eventEmitter.on("disconnect", () => { - console.log("deleting from sessions"); + console.log(`Camera ${dev.devId} disconnected`); delete sessions[dev.devId]; }); if (with_audio) { diff --git a/session.ts b/session.ts index b172eaf..c9b43c6 100644 --- a/session.ts +++ b/session.ts @@ -107,7 +107,7 @@ export const makeSession = ( }; session.eventEmitter.on("disconnect", () => { - console.log(`Disconnected from ${session.devName} - ${session.dst_ip}`); + console.log(`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 +115,7 @@ export const makeSession = ( }); session.eventEmitter.on("login", () => { - console.log(`Logged in - ${session.devName}`); + console.log(`Logging in to camera ${session.devName}`); onLogin(session); }); return session;