diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ea70d9b9..8ead88e0e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The main work (all changes without a GitHub username in brackets in the below li --> ## __WORK IN PROGRESS__ -- + - NOTE: This version is compatible with Node.js 20.x, 22.x and 24.x. Node.js 18.x is also supported with the following exceptions: - The matter.js tools for building and running test and applications (matter-*) which are mainly used by the npm scripts which use the "commander" dependency. @@ -31,6 +31,9 @@ The main work (all changes without a GitHub username in brackets in the below li - @matter/nodejs-ble - (@spudwebb) Fix: Increase BLE connect timeout fo 120seconds to optimize pairing +- @matter/nodejs-shell + - (@JimBuzbee) Feature: Adds a websocket mode inlcuding an example webpage to control the shell + - @matter/protocol - Breaking: `logEndpoint()` was removed. The Endpoints support logging directly via Diagnostics - Breaking: All legacy used *Server classes (AttributeServer, EventServer, CommandServer) are moved to the matter.js legacy package diff --git a/package-lock.json b/package-lock.json index 950fbb606b..462651dd57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10534,7 +10534,6 @@ "version": "8.18.2", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -10894,6 +10893,7 @@ "@matter/tools": "*", "@matter/types": "*", "@project-chip/matter.js": "*", + "ws": "^8.18.2", "yargs": "^17.7.2" }, "bin": { diff --git a/packages/nodejs-shell/README.md b/packages/nodejs-shell/README.md index 50dc290146..bf94dacd44 100644 --- a/packages/nodejs-shell/README.md +++ b/packages/nodejs-shell/README.md @@ -203,7 +203,13 @@ $ ls .matter-shell-1 $ more .matter-shell-1/Node.ip "fe80::148d:9bd8:5006:243%en0" ``` +# Running over websockets +If the matter shell is started with the parameter --webSocketInterface all interaction with the shell will be done over a websocket instead of the local terminal. The parameter --webSocketPort NNNN can be used to change from the default port of 3000 to a user-specified port. If the parameter --webServer is added, the matter shell will also start an http server that will serve files from the same directory as the application itself utilizying the same port as the websocket. The functionality of the shell will be identical to the above description with the exception that the "exit" command will only close the websocket and not exit the matter shell application. + +An example application that shows interaction from a web browser is included. The example shows how commands can be sent from html and javascript in the browser to the shell and how the results of the commands can be parsed to create a user interface. + +![Web Interface](src/shell/webassets/WebShell.png "Example Application") ``` █ █ diff --git a/packages/nodejs-shell/package.json b/packages/nodejs-shell/package.json index cd65aa3c91..29fbf439f0 100644 --- a/packages/nodejs-shell/package.json +++ b/packages/nodejs-shell/package.json @@ -26,7 +26,8 @@ "clean": "matter-build clean", "build": "matter-build", "build-clean": "matter-build --clean", - "shell": "matter-run src/app.ts" + "shell": "matter-run src/app.ts", + "bundle-shell": "esbuild src/app.ts --bundle --platform=node --conditions=esbuild --external:@stoprocent/noble --external:@stoprocent/bluetooth-hci-socket --sourcemap --minify --keep-names --outfile=build/bundle/app.cjs" }, "bin": { "matter-shell": "dist/cjs/app.js" @@ -50,6 +51,7 @@ "@matter/tools": "*", "@matter/types": "*", "@project-chip/matter.js": "*", + "ws": "^8.18.2", "yargs": "^17.7.2" }, "engines": { diff --git a/packages/nodejs-shell/src/app.ts b/packages/nodejs-shell/src/app.ts index c4a1e99af5..ceaeea5dc5 100644 --- a/packages/nodejs-shell/src/app.ts +++ b/packages/nodejs-shell/src/app.ts @@ -12,9 +12,13 @@ import { Ble } from "#protocol"; import yargs from "yargs/yargs"; import { MatterNode } from "./MatterNode.js"; import { Shell } from "./shell/Shell"; +import { initializeWebPlumbing } from "./web_plumbing.js"; const PROMPT = "matter> "; +const DEFAULT_WEBSOCKET_PORT = 3000; const logger = Logger.get("Shell"); +let theShell: Shell; + if (process.stdin?.isTTY) Logger.format = LogFormat.ANSI; let theNode: MatterNode; @@ -86,12 +90,38 @@ async function main() { type: "string", default: undefined, }, + webSocketInterface: { + description: "Enable WebSocket interface", + type: "boolean", + default: false, + }, + webSocketPort: { + description: "WebSocket and HTTP server port", + type: "number", + default: DEFAULT_WEBSOCKET_PORT, + }, + webServer: { + description: "Enable Web server when using WebSocket interface", + type: "boolean", + default: false, + }, }); }, async argv => { if (argv.help) return; - const { nodeNum, ble, bleHciId, nodeType, factoryReset, netInterface, logfile } = argv; + const { + nodeNum, + ble, + bleHciId, + nodeType, + factoryReset, + netInterface, + logfile, + webSocketInterface, + webSocketPort, + webServer, + } = argv; theNode = new MatterNode(nodeNum, netInterface); await theNode.initialize(factoryReset); @@ -111,8 +141,12 @@ async function main() { } setLogLevel("default", await theNode.Store.get("LogLevel", "info")); - const theShell = new Shell(theNode, nodeNum, PROMPT); - + if (webSocketInterface) { + Logger.format = LogFormat.PLAIN; + initializeWebPlumbing(theNode, nodeNum, webSocketPort, webServer); // set up but wait for connect to create Shell + } else { + theShell = new Shell(theNode, nodeNum, PROMPT, process.stdin, process.stdout); + } if (bleHciId !== undefined) { await theNode.Store.set("BleHciId", bleHciId); } @@ -130,11 +164,14 @@ async function main() { } console.log(`Started Node #${nodeNum} (Type: ${nodeType}) ${ble ? "with" : "without"} BLE`); - theShell.start(theNode.storageLocation); + if (!webSocketInterface) { + theShell.start(theNode.storageLocation); + } }, ) .version(false) - .scriptName("shell"); + .scriptName("shell") + .strict(); await yargsInstance.wrap(yargsInstance.terminalWidth()).parseAsync(); } diff --git a/packages/nodejs-shell/src/shell/Shell.ts b/packages/nodejs-shell/src/shell/Shell.ts index a2dfa33e89..0e356dc500 100644 --- a/packages/nodejs-shell/src/shell/Shell.ts +++ b/packages/nodejs-shell/src/shell/Shell.ts @@ -7,6 +7,7 @@ import { MatterError } from "#general"; import { createWriteStream, readFileSync } from "node:fs"; import readline from "node:readline"; +import { Readable, Writable } from "node:stream"; import yargs from "yargs/yargs"; import { MatterNode } from "../MatterNode.js"; import { exit } from "../app"; @@ -51,6 +52,8 @@ export class Shell { public theNode: MatterNode, public nodeNum: number, public prompt: string, + public input: Readable, + public output: Writable, ) {} start(storageBase?: string) { @@ -80,9 +83,9 @@ export class Shell { } } this.readline = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: true, + input: this.input, + output: this.output, + terminal: this.input === process.stdin && this.output === process.stdout, prompt: this.prompt, history: history.reverse(), historySize: MAX_HISTORY_SIZE, @@ -103,12 +106,15 @@ export class Shell { } catch (e) { process.stderr.write(`Error happened during history file write: ${e}\n`); } - exit() - .then(() => process.exit(0)) - .catch(e => { - process.stderr.write(`Close error: ${e}\n`); - process.exit(1); - }); + // only exit if we are running in a terminal + if (this.input === process.stdin && this.output === process.stdout) { + exit() + .then(() => process.exit(0)) + .catch(e => { + process.stderr.write(`Close error: ${e}\n`); + process.exit(1); + }); + } }); this.readline.prompt(); diff --git a/packages/nodejs-shell/src/shell/cmd_subscribe.ts b/packages/nodejs-shell/src/shell/cmd_subscribe.ts index e92dc74783..6979fc4c84 100644 --- a/packages/nodejs-shell/src/shell/cmd_subscribe.ts +++ b/packages/nodejs-shell/src/shell/cmd_subscribe.ts @@ -21,19 +21,19 @@ export default function commands(theNode: MatterNode) { }, handler: async (argv: any) => { - const { nodeId } = argv; - const node = (await theNode.connectAndGetNodes(nodeId))[0]; + const { nodeId: subscribeNodeId } = argv; + const node = (await theNode.connectAndGetNodes(subscribeNodeId))[0]; await node.subscribeAllAttributesAndEvents({ attributeChangedCallback: ({ path: { nodeId, clusterId, endpointId, attributeName }, value }) => console.log( - `${nodeId}: Attribute ${nodeId}/${endpointId}/${clusterId}/${attributeName} changed to ${Diagnostic.json( + `${subscribeNodeId}: Attribute ${nodeId}/${endpointId}/${clusterId}/${attributeName} changed to ${Diagnostic.json( value, )}`, ), eventTriggeredCallback: ({ path: { nodeId, clusterId, endpointId, eventName }, events }) => console.log( - `${nodeId} Event ${nodeId}/${endpointId}/${clusterId}/${eventName} triggered with ${Diagnostic.json( + `${subscribeNodeId} Event ${nodeId}/${endpointId}/${clusterId}/${eventName} triggered with ${Diagnostic.json( events, )}`, ), diff --git a/packages/nodejs-shell/src/shell/webassets/WebShell.png b/packages/nodejs-shell/src/shell/webassets/WebShell.png new file mode 100644 index 0000000000..612273ae79 Binary files /dev/null and b/packages/nodejs-shell/src/shell/webassets/WebShell.png differ diff --git a/packages/nodejs-shell/src/shell/webassets/favicon.png b/packages/nodejs-shell/src/shell/webassets/favicon.png new file mode 100644 index 0000000000..987eec2f25 Binary files /dev/null and b/packages/nodejs-shell/src/shell/webassets/favicon.png differ diff --git a/packages/nodejs-shell/src/shell/webassets/index.html b/packages/nodejs-shell/src/shell/webassets/index.html new file mode 100644 index 0000000000..966b8bd2f1 --- /dev/null +++ b/packages/nodejs-shell/src/shell/webassets/index.html @@ -0,0 +1,428 @@ + + + + + + + + + Matter Web Shell + + + + +
+ +
+

matter.js Web Shell Example

+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/packages/nodejs-shell/src/web_plumbing.ts b/packages/nodejs-shell/src/web_plumbing.ts new file mode 100644 index 0000000000..ce4d380f59 --- /dev/null +++ b/packages/nodejs-shell/src/web_plumbing.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Logger, LogLevel, NotImplementedError } from "@matter/general"; +import { Readable, Writable } from "node:stream"; +import WebSocket, { Data, WebSocketServer } from "ws"; +import { MatterNode } from "./MatterNode.js"; +import { Shell } from "./shell/Shell"; + +import fs from "fs"; +import http, { Server } from "node:http"; +import path from "path"; + +// Store active WebSocket +let client: WebSocket; +let server: Server; +let wss: WebSocketServer; +const socketLogger = "websocket"; + +export function initializeWebPlumbing( + theNode: MatterNode, + nodeNum: number, + webSocketPort: number, + webServer: boolean, +): void { + if (webServer) { + const root: string = path.resolve(__dirname) ?? "./"; + + server = http + .createServer((req, res) => { + const url = req.url ?? "/"; + const safePath: string = path.normalize( + path.join(root, decodeURIComponent(url === "/" ? "/index.html" : url)), + ); + + // Check that the resolved path is within the root directory + if (!safePath.startsWith(root)) { + res.writeHead(403).end("Forbidden"); + return; + } + + fs.readFile(safePath, (err, data) => { + if (err) return res.writeHead(404).end("Not Found"); + res.writeHead(200).end(data); + }); + }) + .listen(webSocketPort); + wss = new WebSocketServer({ server }); + } else wss = new WebSocketServer({ port: webSocketPort }); + + console.info(`WebSocket server running on ws://localhost:${webSocketPort}`); + + console.log = + // console.debug = // too much traffic - kills the websocket + console.info = + console.warn = + console.error = + (...args: any[]) => { + if (client && client.readyState === WebSocket.OPEN) { + client.send(args.map(arg => (typeof arg === "object" ? JSON.stringify(arg) : arg)).join(" ")); + } else + process.stdout.write( + args.map(arg => (typeof arg === "object" ? JSON.stringify(arg) : arg)).join(" ") + "\n", + ); + }; + + wss.on("connection", (ws: WebSocket) => { + if (client && client.readyState === WebSocket.OPEN) { + ws.send("ERROR: Shell in use by another client"); + ws.close(); + return; + } + + client = ws; // Track the client + + createWebSocketLogger(ws) + .then(logger => { + Logger.removeLogger("Shell"); + Logger.addLogger(socketLogger, logger); + }) + .catch(err => { + if (!(err instanceof NotImplementedError)) { + console.error("Failed to add WebSocket logger: " + err); + } + }); + + const shell = new Shell(theNode, nodeNum, "", createReadableStream(ws), createWritableStream(ws)); + shell.start(theNode.storageLocation); + + ws.on("close", () => { + process.stdout.write("Client disconnected\n"); + try { + if (Logger.getLoggerForIdentifier(socketLogger) !== undefined) { + Logger.removeLogger(socketLogger); + } + } catch (err) { + // Intentionally left empty + } + + client = ws; + }); + ws.on("error", err => { + process.stderr.write(`WebSocket error: ${err.message}\n`); + try { + if (Logger.getLoggerForIdentifier(socketLogger) !== undefined) { + Logger.removeLogger(socketLogger); + } + } catch (err) { + // Intentionally left empty + } + }); + }); + + async function createWebSocketLogger(socket: WebSocket): Promise<(level: LogLevel, formattedLog: string) => void> { + if (socket.readyState === WebSocket.CONNECTING) { + await new Promise((resolve, reject) => { + socket.onopen = () => resolve(); + socket.onerror = err => reject(new Error(`WebSocket error: ${err.type}`)); + }); + } + + return (__level: LogLevel, formattedLog: string) => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(formattedLog); + } else { + process.stderr.write(`WebSocket logger not open, log dropped: ${formattedLog}\n`); + } + }; + } +} +function createReadableStream(ws: WebSocket): Readable { + const readable = new Readable({ read() {} }); + + ws.on("message", (data: Data) => { + const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data.toString()); + + // add the data to our readable stream that the readLine instance is reading from + readable.push(chunk); + }); + + ws.on("close", () => { + readable.push(null); + }); + ws.on("error", err => { + readable.emit("error", err); + readable.push(null); + }); + + return readable; +} +function createWritableStream(ws: WebSocket): Writable { + const writable = new Writable({ + write(chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(chunk, callback); + } else { + if (chunk.length > 0) process.stderr.write(`ERROR: WebSocket is not open. Failed to send "${chunk}"\n`); + } + }, + final(callback: (error?: Error | null) => void) { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + callback(); + }, + }); + + ws.on("error", err => writable.emit("WebSocket Write Error: ", err)); + return writable; +}