Initial Commit
This commit is contained in:
commit
92906e940d
134
.gitignore
vendored
Normal file
134
.gitignore
vendored
Normal 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
19
config.json
Normal 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
3
nodemon.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"ignore": ["node_modules/*", "*.log", "public/*", "config.json"]
|
||||
}
|
||||
2706
package-lock.json
generated
Normal file
2706
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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
83
readme.md
Normal 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
150
src/auth/auth.js
Normal 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
64
src/config.js
Normal 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
15
src/database/mongo.js
Normal 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 };
|
||||
45
src/database/printer.schema.js
Normal file
45
src/database/printer.schema.js
Normal 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
29
src/index.js
Normal 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
82
src/printer/jsonrpc.js
Normal 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;
|
||||
}
|
||||
}
|
||||
355
src/printer/printerclient.js
Normal file
355
src/printer/printerclient.js
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
90
src/printer/printermanager.js
Normal file
90
src/printer/printermanager.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/socket/socketclient.js
Normal file
63
src/socket/socketclient.js
Normal 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
207
src/socket/socketmanager.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user