Initial Commit

This commit is contained in:
Tom Butcher 2025-04-13 18:31:40 +00:00
commit 92906e940d
16 changed files with 4072 additions and 0 deletions

134
.gitignore vendored Normal file
View File

@ -0,0 +1,134 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
.DS_STORE
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.nova

19
config.json Normal file
View File

@ -0,0 +1,19 @@
{
"server": {
"port": 8080,
"logLevel": "debug"
},
"auth": {
"enabled": true,
"keycloak": {
"url": "https://auth.tombutcher.work",
"realm": "master",
"clientId": "farmcontrol-client",
"clientSecret": ""
},
"requiredRoles": []
},
"database": {
"url": "mongodb://192.168.68.53:27017/farmcontrol"
}
}

3
nodemon.json Normal file
View File

@ -0,0 +1,3 @@
{
"ignore": ["node_modules/*", "*.log", "public/*", "config.json"]
}

2706
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "farmcontrol-server",
"version": "1.0.0",
"description": "Connects to moonraker and also manages the socket connection to the printer.",
"main": "src/index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"author": "Tom Butcher",
"license": "ISC",
"dependencies": {
"axios": "^1.8.4",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"keycloak-connect": "^26.1.1",
"log4js": "^6.9.1",
"mongoose": "^8.13.2",
"socket.io": "^4.8.1",
"ws": "^8.18.1"
},
"devDependencies": {
"nodemon": "^3.1.9"
}
}

83
readme.md Normal file
View File

@ -0,0 +1,83 @@
# Farm Control Server
A Node.js application that bridges communication between external websocket clients and a Moonraker-controlled 3D printer.
## Features
- Connects to Moonraker API via websocket
- Provides a websocket server for external clients
- Relays JSON-RPC commands between clients and the printer
- Broadcasts printer status updates to all connected clients
- Handles reconnection if the Moonraker connection is lost
## Installation
1. Clone this repository
2. Install dependencies:
```
npm install
```
3. Edit the `config.json` file to match your Moonraker setup
4. Start the server:
```
npm start
```
## Configuration
The `config.json` file contains the following options:
```json
{
"moonraker": {
"host": "localhost",
"port": 7125,
"protocol": "ws",
"apiKey": null,
"identity": {
"name": "printer-bridge",
"version": "0.1.0",
"type": "external"
}
},
"server": {
"port": 8080
}
}
```
- `moonraker.host`: The hostname or IP address of your Moonraker instance
- `moonraker.port`: The port number of your Moonraker instance
- `moonraker.protocol`: The protocol to use (`ws` or `wss`)
- `moonraker.apiKey`: Your Moonraker API key (if required)
- `server.port`: The port number for the websocket server
## Usage
Connect to the websocket server at `ws://[host]:[port]` and send JSON-RPC formatted messages to control the printer.
Example client-side code:
```javascript
const ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => {
console.log('Connected to printer bridge');
// Send a command to get printer info
ws.send(JSON.stringify({
jsonrpc: "2.0",
method: "printer.info",
id: 1
}));
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('Received:', message);
};
```
## License
MIT

150
src/auth/auth.js Normal file
View File

@ -0,0 +1,150 @@
// auth.js - Keycloak authentication handler
import axios from "axios";
import jwt from "jsonwebtoken";
import log4js from "log4js";
// Load configuration
import { loadConfig } from "../config.js";
const config = loadConfig();
const logger = log4js.getLogger("MongoDB");
logger.level = config.server.logLevel;
export class KeycloakAuth {
constructor(config) {
this.config = config.auth;
this.tokenCache = new Map(); // Cache for verified tokens
}
// Verify a token with Keycloak server
async verifyToken(token) {
// Check cache first
if (this.tokenCache.has(token)) {
const cachedInfo = this.tokenCache.get(token);
if (cachedInfo.expiresAt > Date.now()) {
return { valid: true, user: cachedInfo.user };
} else {
// Token expired, remove from cache
this.tokenCache.delete(token);
}
}
try {
// Verify token with Keycloak introspection endpoint
const response = await axios.post(
`${this.config.keycloak.url}/realms/${this.config.keycloak.realm}/protocol/openid-connect/token/introspect`,
new URLSearchParams({
token: token,
client_id: this.config.keycloak.clientId,
client_secret: this.config.keycloak.clientSecret,
}),
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
},
);
const introspection = response.data;
if (!introspection.active) {
logger.info("Token is not active");
return { valid: false };
}
// Verify required roles if configured
if (
this.config.requiredRoles &&
this.config.requiredRoles.length > 0
) {
const hasRequiredRole = this.checkRoles(
introspection,
this.config.requiredRoles,
);
if (!hasRequiredRole) {
logger.info("User doesn't have required roles");
return { valid: false };
}
}
// Parse token to extract user info
const decodedToken = jwt.decode(token);
const user = {
id: decodedToken.sub,
username: decodedToken.preferred_username,
email: decodedToken.email,
name: decodedToken.name,
roles: this.extractRoles(decodedToken),
};
// Cache the verified token
const expiresAt = introspection.exp * 1000; // Convert to milliseconds
this.tokenCache.set(token, { expiresAt, user });
return { valid: true, user };
} catch (error) {
logger.error("Token verification error:", error.message);
return { valid: false };
}
}
// Extract roles from token
extractRoles(token) {
const roles = [];
// Extract realm roles
if (token.realm_access && token.realm_access.roles) {
roles.push(...token.realm_access.roles);
}
// Extract client roles
if (token.resource_access) {
for (const client in token.resource_access) {
if (token.resource_access[client].roles) {
roles.push(
...token.resource_access[client].roles.map(
(role) => `${client}:${role}`,
),
);
}
}
}
return roles;
}
// Check if user has required roles
checkRoles(tokenInfo, requiredRoles) {
// Extract roles from token
const userRoles = this.extractRoles(tokenInfo);
// Check if user has any of the required roles
return requiredRoles.some((role) => userRoles.includes(role));
}
}
// Socket.IO middleware for authentication
export function createAuthMiddleware(auth) {
return async (socket, next) => {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error("Authentication token is required"));
}
try {
const authResult = await auth.verifyToken(token);
if (!authResult.valid) {
return next(new Error("Invalid authentication token"));
}
// Attach user information to socket
socket.user = authResult.user;
next();
} catch (err) {
logger.error("Authentication error:", err);
next(new Error("Authentication failed"));
}
};
}

64
src/config.js Normal file
View File

@ -0,0 +1,64 @@
// config.js - Configuration handling
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
// Configure paths relative to this file
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONFIG_PATH = path.resolve(__dirname, "../config.json");
// Default configuration
const DEFAULT_CONFIG = {
server: {
port: 8080,
cors: {
origin: "*",
methods: ["GET", "POST"],
},
},
auth: {
enabled: true,
keycloak: {
url: "https://auth.tombutcher.work",
realm: "master",
clientId: "farmcontrol-client",
clientSecret: "MBtsENnnYRdJJrc1tLBJZrhnSQqNXqGk",
},
requiredRoles: ["printer-user"],
},
database: {
url: "mongodb://localhost:27017/farmcontrol",
},
};
// Load or create config file
export function loadConfig() {
try {
if (fs.existsSync(CONFIG_PATH)) {
const configData = fs.readFileSync(CONFIG_PATH, "utf8");
return JSON.parse(configData);
} else {
fs.writeFileSync(
CONFIG_PATH,
JSON.stringify(DEFAULT_CONFIG, null, 2),
);
console.log(`Created default configuration at ${CONFIG_PATH}`);
return DEFAULT_CONFIG;
}
} catch (err) {
console.error("Error loading config:", err);
return DEFAULT_CONFIG;
}
}
// Save configuration
export function saveConfig(config) {
try {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
return true;
} catch (err) {
console.error("Error saving config:", err);
return false;
}
}

15
src/database/mongo.js Normal file
View File

@ -0,0 +1,15 @@
import mongoose from "mongoose";
import { loadConfig } from "../config.js";
import log4js from "log4js";
// Load configuration
const config = loadConfig();
const logger = log4js.getLogger("Mongo DB");
logger.level = config.server.logLevel;
function dbConnect() {
mongoose.connection.once("open", () => logger.info("Database connected."));
return mongoose.connect(config.database.url, {});
}
export { dbConnect };

View File

@ -0,0 +1,45 @@
import mongoose from "mongoose";
const { Schema } = mongoose;
// Define the moonraker connection schema
const moonrakerSchema = new Schema(
{
host: { type: String, required: true },
port: { type: Number, required: true },
protocol: { type: String, required: true },
apiKey: { type: String, default: null },
},
{ _id: false },
);
// Define the main printer schema
const printerSchema = new Schema(
{
printerId: { type: String, required: true, unique: true },
printerName: { type: String, required: true },
online: { type: Boolean, required: true, default: false },
state: {
type: { type: String, required: true, default: "Offline" },
percent: { type: Number, required: false },
},
connectedAt: { type: Date, default: null },
loadedFillament: {
type: Schema.Types.ObjectId,
ref: "Fillament",
default: null,
},
moonraker: { type: moonrakerSchema, required: true },
},
{ timestamps: true },
);
// Add virtual id getter
printerSchema.virtual("id").get(function () {
return this._id.toHexString();
});
// Configure JSON serialization to include virtuals
printerSchema.set("toJSON", { virtuals: true });
// Create and export the model
export const printerModel = mongoose.model("Printer", printerSchema);

29
src/index.js Normal file
View File

@ -0,0 +1,29 @@
import { loadConfig } from "./config.js";
import { dbConnect } from "./database/mongo.js";
import { PrinterManager } from "./printer/printermanager.js";
import { SocketManager } from "./socket/socketmanager.js";
import { KeycloakAuth } from "./auth/auth.js";
import log4js from "log4js";
// Load configuration
const config = loadConfig();
const logger = log4js.getLogger("FarmControl Server");
logger.level = config.server.logLevel;
// Connect to database
dbConnect();
// Setup Keycloak Integration
const keycloakAuth = new KeycloakAuth(config);
// Create printer manager
const printerManager = new PrinterManager(config);
const socketManager = new SocketManager(config, printerManager);
process.on("SIGINT", () => {
logger.info("Shutting down...");
printerManager.closeAllConnections();
process.exit(0);
});

82
src/printer/jsonrpc.js Normal file
View File

@ -0,0 +1,82 @@
// jsonrpc.js - Implementation of JSON-RPC 2.0 protocol for Moonraker communication
export class JsonRPC {
constructor() {
this.id_counter = 0;
this.methods = {};
this.pending_requests = {};
}
// Generate a unique ID for RPC requests
generate_id() {
return this.id_counter++;
}
// Register a method to handle incoming notifications/responses
register_method(method_name, callback) {
this.methods[method_name] = callback;
}
// Process incoming messages
process_message(message) {
if (message.method && this.methods[message.method]) {
// Handle method call or notification
this.methods[message.method](message.params);
} else if (message.id !== undefined) {
// Handle response to a previous request
const rpc_promise = this.pending_requests[message.id];
if (rpc_promise) {
if (message.error) {
rpc_promise.reject(message.error);
} else {
rpc_promise.resolve(message.result);
}
delete this.pending_requests[message.id];
}
}
// If it's a notification without a registered method, ignore it
}
// Call a method without parameters
call_method(method) {
return this.call_method_with_kwargs(method, {});
}
// Call a method with parameters
call_method_with_kwargs(method, params) {
const id = this.generate_id();
const request = {
jsonrpc: "2.0",
method: method,
params: params,
id: id,
};
return new Promise((resolve, reject) => {
this.pending_requests[id] = { resolve, reject };
console.log(`Sending JSON-RPC request: ${JSON.stringify(request)}`);
// The actual sending of the message is done by the WebSocket connection
// This just prepares the message and returns a promise
if (this.socket) {
this.socket.send(JSON.stringify(request));
} else {
// If socket is not directly attached to this instance, the caller
// is responsible for sending the serialized request
this.last_request = JSON.stringify(request);
}
});
}
// For external socket handling
get_last_request() {
const req = this.last_request;
this.last_request = null;
return req;
}
// Associate a WebSocket with this RPC instance for direct communication
set_socket(socket) {
this.socket = socket;
}
}

View File

@ -0,0 +1,355 @@
// moonraker-connection.js - Handles connection to a single Moonraker instance
export class PrinterClient {
constructor(printerConfig) {
this.id = printerConfig.id;
this.printerName = printerConfig.printerName;
this.state = printerConfig.state;
this.online = printerConfig.online;
this.config = printerConfig.moonraker;
this.jsonRpc = new JsonRPC();
this.socket = null;
this.connectionId = null;
this.clients = new Set();
this.registerEventHandlers();
}
registerEventHandlers() {
// Register event handlers for Moonraker notifications
this.jsonRpc.register_method(
"notify_gcode_response",
this.handleGcodeResponse.bind(this),
);
this.jsonRpc.register_method(
"notify_status_update",
this.handleStatusUpdate.bind(this),
);
this.jsonRpc.register_method(
"notify_klippy_disconnected",
this.handleKlippyDisconnected.bind(this),
);
this.jsonRpc.register_method(
"notify_klippy_ready",
this.handleKlippyReady.bind(this),
);
this.jsonRpc.register_method(
"notify_filelist_changed",
this.handleFileListChanged.bind(this),
);
this.jsonRpc.register_method(
"notify_metadata_update",
this.handleMetadataUpdate.bind(this),
);
this.jsonRpc.register_method(
"notify_power_changed",
this.handlePowerChanged.bind(this),
);
}
connect() {
console.log(this.config);
const { protocol, host, port } = this.config;
const wsUrl = `${protocol}://${host}:${port}/websocket`;
logger.info(`Connecting to Moonraker at ${wsUrl} (${this.id})`);
this.socket = new WebSocket(wsUrl);
this.jsonRpc.set_socket(this.socket);
this.socket.on("open", () => {
logger.info(`Connected to Moonraker (${this.printerName})`);
this.online = true;
this.identifyConnection();
});
this.socket.on("message", (data) => {
const message = data.toString();
logger.trace(
`Received message from Moonraker (${this.printerName}): ${message}`,
);
try {
const parsed = JSON.parse(message);
this.jsonRpc.process_message(parsed);
if (parsed.method != undefined) {
//console.log(parsed.params);
this.broadcastToClients(message);
}
} catch (e) {
logger.error(
`Error processing message for ${this.printerName}:`,
e,
);
}
});
this.socket.on("close", () => {
logger.info(`Disconnected from Moonraker (${this.printerName})`);
this.online = false;
this.state = { type: "offline" };
this.connectionId = null;
// Attempt to reconnect after delay
setTimeout(() => this.connect(), 5000);
});
this.socket.on("error", (error) => {
logger.error(
`Moonraker connection error (${this.printerName}):`,
error,
);
});
}
identifyConnection() {
const args = {
client_name: "farmcontrol-server",
version: "0.1.0",
type: "web",
url: "https://github.com/printer-bridge",
};
if (this.config.apiKey) {
args.api_key = this.config.apiKey;
}
this.jsonRpc
.call_method_with_kwargs("server.connection.identify", args)
.then((result) => {
this.connectionId = result.connection_id;
logger.info(
`Connection identified with ID: ${this.connectionId} (${this.printerName})`,
);
this.getServerInfo();
})
.catch((error) => {
logger.error(
`Error identifying connection (${this.printerName}):`,
error,
);
});
}
getServerInfo() {
logger.trace("Getting server info");
this.jsonRpc
.call_method("server.info")
.then((result) => {
this.online = true;
this.state = { type: result.klippy_state };
logger.info(
"Server:",
`Moonraker ${result.moonraker_version} (${this.printerName})`,
);
if (result.klippy_state === "ready") {
this.getKlippyInfo();
this.subscribeToStateUpdates();
} else {
logger.info(
`Waiting for Klippy to be ready. Current state: ${result.klippy_state} (${this.printerName})`,
);
}
})
.catch((error) => {
logger.error(
`Error getting server info (${this.printerName}):`,
error,
);
});
}
getKlippyInfo() {
this.jsonRpc
.call_method("printer.info")
.then((result) => {
logger.info(
`Klippy info for ${this.printerName}: ${result.hostname}, ${result.software_version}`,
);
if (result.state === "error") {
logger.error(
`Klippy error for ${this.printerName}: ${result.state_message}`,
);
}
})
.catch((error) => {
logger.error(
`Error getting Klippy info (${this.printerName}):`,
error,
);
});
}
subscribeToAllUpdates() {
const subscriptions = {
objects: {
gcode_move: [
"gcode_position",
"speed",
"speed_factor",
"extrude_factor",
],
toolhead: ["position", "status"],
virtual_sdcard: null,
heater_bed: null,
extruder: null,
fan: null,
print_stats: null,
motion_report: null,
},
};
this.jsonRpc
.call_method_with_kwargs("printer.objects.subscribe", subscriptions)
.then((result) => {
logger.info(
`Subscribed to all printer updates (${this.printerName})`,
);
})
.catch((error) => {
logger.error(
`Error subscribing to all printer updates (${this.printerName}):`,
error,
);
});
}
subscribeToStateUpdates() {
const subscriptions = {
objects: {
toolhead: ["status"],
print_stats: null,
},
};
this.jsonRpc
.call_method_with_kwargs("printer.objects.subscribe", subscriptions)
.then((result) => {
logger.info(
`Subscribed to printer state updates (${this.printerName})`,
);
})
.catch((error) => {
logger.error(
`Error subscribing to printer state updates (${this.printerName}):`,
error,
);
});
}
sendMessage(message) {
if (!this.online) {
logger.error(
`Cannot send message: Not connected to Moonraker (${this.printerName})`,
);
return false;
}
this.jsonRpc
.call_method_with_kwargs(message.method, message.params)
.then((result) => {
logger.info(`Message sent to (${this.printerName})`);
if (result.status != undefined) {
const resultStatusMessage = {
method: "notify_status_update",
params: [result.status],
};
this.broadcastToClients(
JSON.stringify(resultStatusMessage),
);
}
})
.catch((error) => {
logger.error(
`Error sending message to (${this.printerName}):`,
error,
);
});
return true;
}
addClient(client) {
this.clients.add(client);
logger.info(
`Client subscribed to ${this.printerName}. Total subscribers: ${this.clients.size}`,
);
}
removeClient(client) {
this.clients.delete(client);
logger.info(
`Client unsubscribed from ${this.printerName}. Total subscribers: ${this.clients.size}`,
);
}
broadcastToClients(message) {
for (const client of this.clients) {
if (client.conn._readyState === "open") {
var jsonMessage = JSON.parse(message);
jsonMessage.params = {
printerId: this.id,
...jsonMessage.params,
};
client.emit(jsonMessage.method, jsonMessage.params);
}
}
}
getStatus() {
return {
id: this.printerId,
name: this.printerName,
connected: this.online,
connectionId: this.connectionId,
host: this.config.host,
port: this.config.port,
};
}
// Event handlers
handleGcodeResponse(response) {
logger.info(`GCode response (${this.printerName}): ${response}`);
}
handleStatusUpdate(status) {
// Process printer status updates
if (status.print_stats && status.print_stats.state) {
logger.info(
`Print state for ${this.printerName}: ${status.print_stats.state}`,
);
this.state.type = status.print_stats.state;
}
}
handleKlippyDisconnected() {
this.online = false;
logger.info(`Klippy disconnected (${this.printerName})`);
}
handleKlippyReady() {
logger.info(`Klippy ready (${this.printerName})`);
this.getKlippyInfo();
this.subscribeToStateUpdates();
}
handleFileListChanged(fileInfo) {
logger.info(
`File list changed for ${this.printerName}:`,
fileInfo.action,
);
}
handleMetadataUpdate(metadata) {
logger.info(
`Metadata updated for ${this.printerName}:`,
metadata.filename,
);
}
handlePowerChanged(powerStatus) {
logger.info(
`Power status changed for ${this.printerName}:`,
powerStatus,
);
}
}

View File

@ -0,0 +1,90 @@
// printer-manager.js - Manages multiple printer connections through MongoDB
import { PrinterClient } from "./printerclient.js";
import { saveConfig, loadConfig } from "../config.js";
import { printerModel } from "../database/printer.schema.js"; // Import your printer model
import log4js from "log4js";
// Load configuration
const config = loadConfig();
const logger = log4js.getLogger("Printer Manager");
logger.level = config.server.logLevel;
export class PrinterManager {
constructor(config) {
this.config = config;
this.printerClientConnections = new Map();
this.statusCheckInterval = null;
this.initializePrinterConnections();
}
async initializePrinterConnections() {
try {
// Get all printers from the database
const printers = await printerModel.find({});
for (const printer of printers) {
await this.connectToPrinter(printer);
}
logger.info(
`Initialized connections to ${printers.length} printers`,
);
// Set up periodic status checking
this.startStatusChecking();
} catch (error) {
logger.error(
`Error initializing printer connections: ${error.message}`,
);
}
}
async connectToPrinter(printer) {
// Create and store the connection
const printerClientConnection = new PrinterClient(printer);
this.moonrakerConnections.set(printer.id, printerClientConnection);
// Connect to the printer
printerClientConnection.connect();
logger.info(
`Connected to printer: ${printer.printerName} (${printer.id})`,
);
return true;
}
getPrinterClient(printerId) {
return this.printerClientConnections.get(printerId);
}
getAllPrinterClients() {
return this.printerClientConnections.values();
}
// Process command for a specific printer
processCommand(printerId, command) {
const printerClientConnection =
this.printerClientConnections.get(printerId);
if (!printerClientConnection) {
return {
success: false,
error: `Printer with ID ${printerId} not found`,
};
}
const success = connection.sendCommand(message);
return {
success,
error: success ? null : "Printer not connected",
};
}
// Close all printer connections
closeAllConnections() {
for (const printerClientConnection of this.printerClientConnections.values()) {
if (printerClientConnection.socket) {
printerClientConnection.socket.close();
}
}
}
}

View File

@ -0,0 +1,63 @@
import { WebSocket } from "ws";
import { JsonRPC } from "./jsonrpc.js";
import log4js from "log4js";
// Load configuration
import { loadConfig } from "./config.js";
const config = loadConfig();
const logger = log4js.getLogger("Moonraker");
logger.level = config.server.logLevel;
export class SocketClient {
constructor(socket, printerManager) {
this.socket = socket;
this.user = socket?.user;
this.printerManager = printerManager;
this.socket.on("bridge.list_printers", (data) => {
});
this.socket.on("bridge.add_printer", (data, callback) => {
});
this.socket.on("bridge.remove_printer", (data, callback) => {
});
this.socket.on("bridge.update_printer", (data, callback) => {
});
// Handle printer commands
this.socket.on("printer_command", (data, callback) => {
try {
if (data && data.params.printerId) {
const printerId = data.params.printerId;
// Remove the printer_id before forwarding
const cleanCommand = { ...data };
delete cleanCommand.params.printerId;
const result = printerManager.processCommand(
printerId,
cleanCommand,
);
} else {
logger.error("Missing Printer ID");
}
} catch (e) {
logger.error("Error processing client command:", e);
}
});
this.socket.on("disconnect", () => {
// Unsubscribe from all printers when client disconnects
printerManager.unsubscribeClientFromAll(socket);
logger.info("External client disconnected:", socket.user?.username);
});
}
}

207
src/socket/socketmanager.js Normal file
View File

@ -0,0 +1,207 @@
// server.js - HTTP and Socket.IO server setup
import { Server } from "socket.io";
import http from "http";
import { createAuthMiddleware } from "./auth.js";
import log4js from "log4js";
// Load configuration
import { loadConfig } from "./config.js";
const config = loadConfig();
const logger = log4js.getLogger("Server");
logger.level = config.server.logLevel;
export class SocketServer {
constructor(config, printerManager, auth) {
this.socketClientConnections = new Map();
// Create HTTP server
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Multi-Printer Bridge Server");
});
// Create Socket.IO server
const io = new Server(server, {
cors: {
origin: config.server.corsOrigins || "*",
methods: ["GET", "POST"],
},
});
// Apply authentication middleware
io.use(createAuthMiddleware(auth));
// Handle client connections
io.on("connection", (socket) => {
logger.info("External client connected:", socket.user?.username);
this.socketClientConnections.set(socket.id, socket);
});
// Start the server
server.listen(config.server.port, () => {
logger.info(
`Multi-Printer Bridge server listening on port ${config.server.port}`,
);
});
return { server, io };
}
}
export function setupServer(config, printerManager, auth) {}
// Command handlers
function handleSubscribe(
socket,
data,
clientSubscriptions,
printerManager,
callback,
) {
logger.info("Handling subscribe command...");
const printerId = data.printerId;
if (printerManager.subscribeClient(printerId, socket)) {
clientSubscriptions.add(printerId);
logger.info(`Client subscribed to printer ${printerId}`);
if (callback) {
callback({
success: true,
printer_id: printerId,
});
}
} else {
logger.warn(`Client failed to subscribe to printer ${printerId}`);
if (callback) {
callback({
success: false,
error: {
code: -32001,
message: `Printer ${printerId} not found`,
},
});
}
}
}
function handleUnsubscribe(
socket,
data,
clientSubscriptions,
printerManager,
callback,
) {
const printerId = data.printer_id;
if (printerManager.unsubscribeClient(printerId, socket)) {
clientSubscriptions.delete(printerId);
if (callback)
callback({
success: true,
printer_id: printerId,
});
} else {
if (callback)
callback({
success: false,
error: {
code: -32001,
message: `Printer ${printerId} not found`,
},
});
}
}
function handleListPrinters(socket, data, printerManager, callback) {
if (callback)
callback({
printers: printerManager.getAllPrinters(),
});
}
function handleListPrintersSubscribe(socket, data, printerManager, callback) {
if (callback)
callback({
printers: printerManager.getAllPrinters(),
});
}
function handleAddPrinter(socket, data, printerManager, callback) {
if (printerManager.addPrinter(data.printer_config)) {
if (callback)
callback({
success: true,
printer: printerManager
.getPrinter(data.printer_config.id)
.getStatus(),
});
// Notify all clients about the new printer
broadcastPrinterList(printerManager, socket);
} else {
if (callback)
callback({
success: false,
error: {
code: -32003,
message: `Failed to add printer with ID ${data.printer_config.id}`,
},
});
}
}
function handleRemovePrinter(socket, data, printerManager, callback) {
if (printerManager.removePrinter(data.printer_id)) {
if (callback)
callback({
success: true,
printer_id: data.printer_id,
});
// Notify all clients about the updated printer list
broadcastPrinterList(printerManager, socket);
} else {
if (callback)
callback({
success: false,
error: {
code: -32001,
message: `Printer ${data.printer_id} not found`,
},
});
}
}
function handleUpdatePrinter(socket, data, printerManager, callback) {
if (printerManager.updatePrinter(data.printer_config)) {
if (callback)
callback({
success: true,
printer: printerManager
.getPrinter(data.printer_config.id)
.getStatus(),
});
// Notify all clients about the updated printer
broadcastPrinterList(printerManager, socket);
} else {
if (callback)
callback({
success: false,
error: {
code: -32001,
message: `Failed to update printer ${data.printer_config.id}`,
},
});
}
}
function broadcastPrinterList(printerManager, excludeSocket = null) {
const printerList = printerManager.getAllPrinters();
const message = {
printers: printerList,
};
for (const client of printerManager.getAllClients()) {
if (client !== excludeSocket && client.connected) {
client.emit("notify_printers_updated", message);
}
}
}