first commit

This commit is contained in:
jhartworks
2026-08-11 23:37:44 +02:00
commit c01b988714
17 changed files with 4705 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
npm-debug.log
.git
.gitignore
.env
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.env
coverage/
+13
View File
@@ -0,0 +1,13 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
COPY README.md ./
EXPOSE 3923
CMD ["npm", "start"]
+72
View File
@@ -0,0 +1,72 @@
# Bambu Web Helper
Multi-user web application for Bambu Cloud monitoring with Docker, Postgres,
user management, and a separate private MQTT broker configuration for every user.
## Features
- Web interface on port `3923`
- Postgres database through `docker compose`
- Login, self-service password changes, and admin password resets
- Admin-managed per-user printer limits, initially `1`
- Per-printer Bambu Cloud token storage
- Live printer status through Bambu Cloud MQTT
- Per-user MQTT broker with optional authentication, TLS, and certificate-error override
- Per-user Bambu push and MQTT sync intervals
## Start with Docker
1. Ensure Docker Desktop is running.
2. Run `docker compose up --build -d`.
3. Open `http://localhost:3923`.
The initial admin is set in `.env`:
- Username: `bambumin`
- Password: `ChangeMe123!`
## Defaults
The `.env` file supplies defaults for newly created users:
- `DEFAULT_USER_PRINTER_LIMIT=1`
- `DEFAULT_USER_PUSH_INTERVAL_MS=600000`
- `DEFAULT_USER_SYNC_INTERVAL_MS=30000`
- `BRIDGE_REFRESH_INTERVAL_MS=30000`
## Private MQTT
After login, every user configures their own MQTT broker in the web interface.
Enter only the hostname or IP address, such as `mqtt.example.net`; enter the port
separately. Enabling TLS uses `mqtts`. Enabling the certificate-error option sets
the MQTT client's `rejectUnauthorized` option to `false`.
Each printer publishes retained payloads to the configured user broker:
- `{baseTopic}/{serial}/availability`
- `{baseTopic}/{serial}/state`
- `{baseTopic}/{serial}/raw`
- `{baseTopic}/{serial}/{custom-suffix}` for configured individual scalar values
In the printer tab, users can map selected fields from `raw` or the normalized
`state` to their own topic suffix. Examples include `print.nozzle_temper` and
`nozzleTemp`. Scalar values are published directly as text, number, or boolean,
so subscribers do not need to parse the JSON status payload.
The push interval sends Bambu's `pushall` request. The MQTT sync interval republishes
the last known state; new live updates are always published immediately.
## Bambu Cloud
The integration follows the connection pattern used by
[Keralots/BambuHelper](https://github.com/Keralots/BambuHelper):
- `us.mqtt.bambulab.com` for `US` and `EU`, `cn.mqtt.bambulab.com` for `CN`
- TLS on port `8883`
- Username `u_{uid}` and the access token as password
- Status topic `device/{SERIAL}/report`
- Request topic `device/{SERIAL}/request`
The application requests complete status snapshots with `pushall`. A printer owner
can additionally switch the chamber light and pause, resume, or stop an active print
from the printer page. Stopping always requires a confirmation in the web interface.
+30
View File
@@ -0,0 +1,30 @@
services:
app:
build: .
restart: unless-stopped
env_file:
- .env
depends_on:
db:
condition: service_healthy
ports:
- "3923:3923"
db:
image: postgres:16-alpine
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 10
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
+675
View File
@@ -0,0 +1,675 @@
{
"name": "bambu-web-helper",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bambu-web-helper",
"version": "0.2.0",
"dependencies": {
"mqtt": "^5.13.3",
"pg": "^8.16.3"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@types/node": {
"version": "26.1.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.24",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz",
"integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bl": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.0",
"buffer": "^6.0.3",
"inherits": "^2.0.4",
"readable-stream": "^4.2.0"
}
},
"node_modules/broker-factory": {
"version": "3.1.15",
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz",
"integrity": "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1",
"worker-factory": "^7.0.50"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/commist": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fast-unique-numbers": {
"version": "9.0.27",
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz",
"integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.2.0"
}
},
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/js-sdsl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mqtt": {
"version": "5.15.2",
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.2.tgz",
"integrity": "sha512-VWZU2CSUY3U3oN0PSBRDE5SNsFi4zqqNeQ/uv3pZWqY3CrBXD/dhd0ZLjlsk5YnebGlrapi4lRVNJPUNQ5aZ3w==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.21",
"@types/ws": "^8.18.1",
"commist": "^3.2.0",
"concat-stream": "^2.0.0",
"debug": "^4.4.1",
"help-me": "^5.0.0",
"lru-cache": "^10.4.3",
"minimist": "^1.2.8",
"mqtt-packet": "^9.0.2",
"number-allocator": "^1.0.14",
"readable-stream": "^4.7.0",
"rfdc": "^1.4.1",
"socks": "^2.8.6",
"split2": "^4.2.0",
"worker-timers": "^8.0.23",
"ws": "^8.18.3"
},
"bin": {
"mqtt": "build/bin/mqtt.js",
"mqtt_pub": "build/bin/pub.js",
"mqtt_sub": "build/bin/sub.js"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/mqtt-packet": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
"license": "MIT",
"dependencies": {
"bl": "^6.0.8",
"debug": "^4.3.4",
"process-nextick-args": "^2.0.1"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/number-allocator": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.1",
"js-sdsl": "4.3.0"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/worker-factory": {
"version": "7.0.50",
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.50.tgz",
"integrity": "sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1"
}
},
"node_modules/worker-timers": {
"version": "8.0.34",
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.34.tgz",
"integrity": "sha512-WXL+Dqsm0G6dnC66rQsvM3tPT2adhbqSirWUCZGglkALOr8ocS0KlpU0iuKbjflJOlu3ydZikMEVrYnHOM9suw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"tslib": "^2.8.1",
"worker-timers-broker": "^8.0.18",
"worker-timers-worker": "^9.0.15"
}
},
"node_modules/worker-timers-broker": {
"version": "8.0.18",
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.18.tgz",
"integrity": "sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"broker-factory": "^3.1.15",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1",
"worker-timers-worker": "^9.0.15"
}
},
"node_modules/worker-timers-worker": {
"version": "9.0.15",
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.15.tgz",
"integrity": "sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"tslib": "^2.8.1",
"worker-factory": "^7.0.50"
}
},
"node_modules/ws": {
"version": "8.21.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "bambu-web-helper",
"version": "0.2.0",
"description": "Multi-user Bambu Cloud bridge with private MQTT publishing, Postgres storage, and a web admin UI.",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"mqtt": "^5.13.3",
"pg": "^8.16.3"
}
}
+127
View File
@@ -0,0 +1,127 @@
import crypto from "node:crypto";
const SCRYPT_KEYLEN = 64;
function base64UrlEncode(value) {
return Buffer.from(value).toString("base64url");
}
function base64UrlDecode(value) {
return Buffer.from(value, "base64url").toString("utf8");
}
function sign(value, secret) {
return crypto.createHmac("sha256", secret).update(value).digest("base64url");
}
function timingSafeEqualText(left, right) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function passwordTag(passwordHash) {
return crypto.createHash("sha256").update(passwordHash).digest("hex").slice(0, 16);
}
export async function hashPassword(password) {
const salt = crypto.randomBytes(16).toString("hex");
const derived = await new Promise((resolve, reject) => {
crypto.scrypt(password, salt, SCRYPT_KEYLEN, { maxmem: 64 * 1024 * 1024 }, (error, result) => {
if (error) {
reject(error);
return;
}
resolve(result);
});
});
return `scrypt:${salt}:${Buffer.from(derived).toString("hex")}`;
}
export async function verifyPassword(password, passwordHash) {
const parts = passwordHash.split(":");
if (parts.length !== 3 || parts[0] !== "scrypt") {
return false;
}
const [, salt, expectedHex] = parts;
const derived = await new Promise((resolve, reject) => {
crypto.scrypt(password, salt, SCRYPT_KEYLEN, { maxmem: 64 * 1024 * 1024 }, (error, result) => {
if (error) {
reject(error);
return;
}
resolve(result);
});
});
return timingSafeEqualText(Buffer.from(derived).toString("hex"), expectedHex);
}
export function createSessionToken(user, secret, ttlHours) {
const payload = JSON.stringify({
uid: user.id,
exp: Date.now() + ttlHours * 60 * 60 * 1000,
tag: passwordTag(user.password_hash),
});
const encodedPayload = base64UrlEncode(payload);
return `${encodedPayload}.${sign(encodedPayload, secret)}`;
}
export function verifySessionToken(token, secret) {
if (typeof token !== "string" || !token.includes(".")) {
return null;
}
const [encodedPayload, signature] = token.split(".");
const expectedSignature = sign(encodedPayload, secret);
if (!timingSafeEqualText(signature, expectedSignature)) {
return null;
}
try {
const payload = JSON.parse(base64UrlDecode(encodedPayload));
if (!payload?.uid || !payload?.exp || !payload?.tag) {
return null;
}
if (Date.now() > payload.exp) {
return null;
}
return payload;
} catch {
return null;
}
}
export function isSessionValidForUser(sessionPayload, user) {
return sessionPayload?.tag === passwordTag(user.password_hash);
}
export function parseCookies(cookieHeader) {
const cookies = {};
if (!cookieHeader) {
return cookies;
}
for (const chunk of cookieHeader.split(";")) {
const index = chunk.indexOf("=");
if (index === -1) {
continue;
}
const key = chunk.slice(0, index).trim();
const value = chunk.slice(index + 1).trim();
cookies[key] = value;
}
return cookies;
}
export function makeSessionCookie(token, maxAgeSeconds) {
return `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}`;
}
export function clearSessionCookie() {
return "session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
}
+74
View File
@@ -0,0 +1,74 @@
const BBL_HEADERS = {
"Content-Type": "application/json",
"User-Agent": "bambu_network_agent/01.09.05.01",
"X-BBL-Client-Name": "OrcaSlicer",
"X-BBL-Client-Type": "slicer",
"X-BBL-Client-Version": "01.09.05.51",
"X-BBL-Language": "en-US",
"X-BBL-OS-Type": "linux",
"X-BBL-OS-Version": "6.2.0",
"X-BBL-Agent-Version": "01.09.05.01",
Accept: "application/json",
};
export function getBrokerHost(region) {
return region === "CN" ? "cn.mqtt.bambulab.com" : "us.mqtt.bambulab.com";
}
export function getApiBase(region) {
return region === "CN" ? "https://api.bambulab.cn" : "https://api.bambulab.com";
}
function decodeJwtPayload(token) {
const segments = token.split(".");
if (segments.length < 2) {
return null;
}
const base64 = segments[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
try {
return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
} catch {
return null;
}
}
export function extractCloudUserIdFromToken(token) {
const payload = decodeJwtPayload(token);
const uid = payload?.uid ?? payload?.sub ?? payload?.user_id;
if (!uid) {
return null;
}
return `u_${uid}`;
}
export async function fetchCloudUserId(token, region) {
const response = await fetch(`${getApiBase(region)}/v1/user-service/my/profile`, {
method: "GET",
headers: {
...BBL_HEADERS,
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`Profile request failed with HTTP ${response.status}`);
}
const data = await response.json();
const uid = data?.uidStr ?? data?.uid;
if (!uid) {
throw new Error("Profile response did not contain uid");
}
return `u_${uid}`;
}
export async function resolveCloudUserId(token, region) {
const fromToken = extractCloudUserIdFromToken(token);
if (fromToken) {
return fromToken;
}
return fetchCloudUserId(token, region);
}
+408
View File
@@ -0,0 +1,408 @@
import crypto from "node:crypto";
import mqtt from "mqtt";
import { getBrokerHost, resolveCloudUserId } from "./bambu-api.js";
import { listBridgePrinters, updatePrinterCloudUserId } from "./db.js";
import { getPrivateMqttSettings } from "./private-broker.js";
import { createPrinterState, markPrinterDisconnected, markPrinterError, mergeReport } from "./state.js";
function makeSignature(printer) {
return JSON.stringify({
serial: printer.serial,
name: printer.name,
region: printer.region,
token: printer.access_token,
owner: printer.username,
pushIntervalMs: printer.push_interval_ms,
syncIntervalMs: printer.sync_interval_ms,
privateMqtt: getPrivateMqttSettings(printer),
});
}
function getMqttFields(printer) {
return Array.isArray(printer.mqtt_fields) ? printer.mqtt_fields.map((field) => ({
id: Number(field.id),
source: field.source,
jsonPath: field.jsonPath,
topicSuffix: field.topicSuffix,
})) : [];
}
export class BridgeManager {
constructor({ pool, privateBroker, refreshIntervalMs }) {
this.pool = pool;
this.privateBroker = privateBroker;
this.refreshIntervalMs = refreshIntervalMs;
this.connections = new Map();
this.states = new Map();
this.listeners = new Set();
this.syncTimer = null;
}
onChange(listener) {
this.listeners.add(listener);
}
emitChange() {
for (const listener of this.listeners) {
listener();
}
}
getSnapshotForUser(user) {
return Array.from(this.states.values())
.filter((state) => user.is_admin || state.ownerUserId === Number(user.id))
.sort((left, right) => left.name.localeCompare(right.name))
.map((state) => ({
printerId: state.printerId,
ownerUserId: state.ownerUserId,
ownerUsername: state.ownerUsername,
serial: state.serial,
name: state.name,
region: state.region,
connected: state.connected,
errorMessage: state.errorMessage,
lastSeenAt: state.lastSeenAt,
lastMessageAt: state.lastMessageAt,
mqttFields: state.mqttFields,
normalized: state.normalized,
raw: state.raw,
}));
}
async start() {
await this.refresh();
this.syncTimer = setInterval(() => {
this.refresh().catch((error) => {
console.error("[bridge] refresh failed:", error.message);
});
}, this.refreshIntervalMs);
}
async refresh() {
const printers = await listBridgePrinters(this.pool);
const desiredIds = new Set(printers.map((printer) => Number(printer.id)));
for (const [printerId, connection] of this.connections.entries()) {
if (!desiredIds.has(printerId)) {
this.stopConnection(printerId, connection);
this.connections.delete(printerId);
this.states.delete(printerId);
this.emitChange();
}
}
this.privateBroker.reconcile(printers);
for (const printer of printers) {
const printerId = Number(printer.id);
const signature = makeSignature(printer);
const current = this.connections.get(printerId);
if (!this.states.has(printerId)) {
this.states.set(printerId, createPrinterState(printer));
}
if (current && current.signature === signature) {
const currentState = this.states.get(printerId);
const mqttFields = getMqttFields(printer);
if (currentState && JSON.stringify(currentState.mqttFields) !== JSON.stringify(mqttFields)) {
this.updateState(printerId, {
...currentState,
mqttFields,
}, current.privateMqttSettings);
}
continue;
}
if (current) {
this.stopConnection(printerId, current, false);
}
await this.startConnection(printer, signature);
}
}
async startConnection(printer, signature) {
const printerId = Number(printer.id);
const privateMqttSettings = getPrivateMqttSettings(printer);
const state = createPrinterState(printer);
this.updateState(printerId, state, privateMqttSettings);
try {
const cloudUserId = printer.cloud_user_id || await resolveCloudUserId(printer.access_token, printer.region);
if (!printer.cloud_user_id) {
await updatePrinterCloudUserId(this.pool, printerId, cloudUserId);
}
const clientId = `bambu-web-helper-${printer.serial}-${crypto.randomUUID().slice(0, 8)}`;
const brokerHost = getBrokerHost(printer.region);
const client = mqtt.connect(`mqtts://${brokerHost}:8883`, {
username: cloudUserId,
password: printer.access_token,
clientId,
protocolVersion: 4,
reconnectPeriod: 5000,
rejectUnauthorized: true,
keepalive: 30,
connectTimeout: 15000,
});
const publishPushall = () => {
if (!client.connected) {
return;
}
const payload = {
pushing: {
sequence_id: String(Date.now()),
command: "pushall",
version: 1,
push_target: 1,
},
};
client.publish(`device/${printer.serial}/request`, JSON.stringify(payload));
};
const pushallTimer = setInterval(publishPushall, Number(printer.push_interval_ms));
const syncTimer = setInterval(() => {
const currentState = this.states.get(printerId);
if (currentState) {
this.privateBroker.publishPrinterState(currentState, privateMqttSettings);
}
}, Number(printer.sync_interval_ms));
client.on("connect", () => {
console.log(`[bridge] connected ${printer.username}/${printer.serial}`);
client.subscribe(`device/${printer.serial}/report`, { qos: 0 }, (error) => {
if (error) {
console.error(`[bridge] subscribe failed ${printer.serial}:`, error.message);
return;
}
publishPushall();
});
});
client.on("message", (_topic, payloadBuffer) => {
try {
const payload = JSON.parse(payloadBuffer.toString("utf8"));
const currentState = this.states.get(printerId) || createPrinterState(printer);
const nextState = mergeReport(currentState, payload);
this.updateState(printerId, nextState, privateMqttSettings);
} catch (error) {
console.error(`[bridge] invalid payload ${printer.serial}:`, error.message);
}
});
client.on("close", () => {
const currentState = this.states.get(printerId);
if (currentState) {
this.updateState(printerId, markPrinterDisconnected(currentState), privateMqttSettings);
}
});
client.on("offline", () => {
const currentState = this.states.get(printerId);
if (currentState) {
this.updateState(printerId, markPrinterDisconnected(currentState), privateMqttSettings);
}
});
client.on("error", (error) => {
const currentState = this.states.get(printerId);
if (currentState) {
this.updateState(printerId, markPrinterError(currentState, error.message), privateMqttSettings);
}
console.error(`[bridge] error ${printer.serial}:`, error.message);
});
this.connections.set(printerId, {
signature,
client,
pushallTimer,
syncTimer,
privateMqttSettings,
ownerUserId: Number(printer.user_id),
});
} catch (error) {
const currentState = this.states.get(printerId) || state;
this.updateState(printerId, markPrinterError(currentState, error.message), privateMqttSettings);
console.error(`[bridge] failed to start ${printer.username}/${printer.serial}:`, error.message);
}
}
stopConnection(printerId, connection, publishOfflineState = true) {
clearInterval(connection.pushallTimer);
clearInterval(connection.syncTimer);
connection.client.end(true);
const currentState = this.states.get(printerId);
if (currentState) {
const disconnected = markPrinterDisconnected(currentState);
if (publishOfflineState) {
this.updateState(printerId, disconnected, connection.privateMqttSettings);
} else {
this.states.set(printerId, disconnected);
this.emitChange();
}
}
}
updateState(printerId, nextState, privateMqttSettings = this.connections.get(printerId)?.privateMqttSettings) {
this.states.set(printerId, nextState);
this.privateBroker.publishPrinterState(nextState, privateMqttSettings);
this.emitChange();
}
clearPrivateMqttField(printerId, topicSuffix) {
const state = this.states.get(Number(printerId));
const settings = this.connections.get(Number(printerId))?.privateMqttSettings;
if (state && settings) {
this.privateBroker.clearPrinterField(state, settings, topicSuffix);
}
}
async setChamberLight(printerId, actor, enabled) {
const connection = this.connections.get(Number(printerId));
if (!connection) {
const error = new Error("Printer is not connected.");
error.status = 409;
throw error;
}
if (!actor.is_admin && connection.ownerUserId !== Number(actor.id)) {
const error = new Error("Forbidden.");
error.status = 403;
throw error;
}
if (!connection.client.connected) {
const error = new Error("Printer is currently offline.");
error.status = 409;
throw error;
}
const currentState = this.states.get(Number(printerId));
if (!currentState) {
const error = new Error("Printer state is unavailable.");
error.status = 409;
throw error;
}
const payload = {
system: {
sequence_id: String(Date.now()),
command: "ledctrl",
led_node: "chamber_light",
led_mode: enabled ? "on" : "off",
led_on_time: 500,
led_off_time: 500,
loop_times: 0,
interval_time: 0,
},
};
await new Promise((resolve, reject) => {
connection.client.publish(`device/${currentState.serial}/request`, JSON.stringify(payload), { qos: 0 }, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
const existingLights = Array.isArray(currentState.raw?.print?.lights_report)
? currentState.raw.print.lights_report
: [];
const hasChamberLight = existingLights.some((light) => light?.node === "chamber_light");
const lightsReport = existingLights.map((light) => (
light?.node === "chamber_light" ? { ...light, mode: enabled ? "on" : "off" } : light
));
if (!hasChamberLight) {
lightsReport.push({ node: "chamber_light", mode: enabled ? "on" : "off" });
}
this.updateState(Number(printerId), {
...currentState,
raw: {
...currentState.raw,
print: {
...(currentState.raw?.print || {}),
lights_report: lightsReport,
},
},
normalized: {
...currentState.normalized,
lightsReport,
chamberLight: enabled,
},
}, connection.privateMqttSettings);
}
async sendPrintCommand(printerId, actor, command) {
if (!["pause", "resume", "stop"].includes(command)) {
const error = new Error("Unsupported print command.");
error.status = 400;
throw error;
}
const connection = this.connections.get(Number(printerId));
if (!connection) {
const error = new Error("Printer is not connected.");
error.status = 409;
throw error;
}
if (!actor.is_admin && connection.ownerUserId !== Number(actor.id)) {
const error = new Error("Forbidden.");
error.status = 403;
throw error;
}
if (!connection.client.connected) {
const error = new Error("Printer is currently offline.");
error.status = 409;
throw error;
}
const currentState = this.states.get(Number(printerId));
if (!currentState) {
const error = new Error("Printer state is unavailable.");
error.status = 409;
throw error;
}
const printState = String(currentState.normalized?.gcodeState || "").toUpperCase();
if (command === "pause" && printState !== "RUNNING") {
const error = new Error("Only a running print can be paused.");
error.status = 409;
throw error;
}
if (command === "resume" && printState !== "PAUSE") {
const error = new Error("Only a paused print can be resumed.");
error.status = 409;
throw error;
}
if (command === "stop" && !["RUNNING", "PAUSE", "PREPARE"].includes(printState)) {
const error = new Error("There is no active print to stop.");
error.status = 409;
throw error;
}
const payload = {
print: {
sequence_id: String(Date.now()),
command,
param: "",
},
};
await new Promise((resolve, reject) => {
connection.client.publish(`device/${currentState.serial}/request`, JSON.stringify(payload), { qos: 1 }, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
close() {
clearInterval(this.syncTimer);
for (const [printerId, connection] of this.connections.entries()) {
this.stopConnection(printerId, connection);
}
this.connections.clear();
}
}
+655
View File
@@ -0,0 +1,655 @@
import { Pool } from "pg";
import { hashPassword, verifyPassword } from "./auth.js";
function fail(message, status = 400) {
const error = new Error(message);
error.status = status;
throw error;
}
function sanitizeUsername(username) {
const value = String(username || "").trim();
if (!/^[A-Za-z0-9_.-]{3,32}$/u.test(value)) {
fail("Username must be 3-32 chars and only contain letters, numbers, dot, underscore or dash.");
}
return value;
}
function sanitizePassword(password) {
const value = String(password || "");
if (value.length < 8) {
fail("Password must be at least 8 characters long.");
}
return value;
}
function sanitizeRegion(region) {
const value = String(region || "").trim().toUpperCase();
if (!["US", "EU", "CN"].includes(value)) {
fail("Region must be US, EU or CN.");
}
return value;
}
function sanitizeSerial(serial) {
const value = String(serial || "").trim();
if (value.length < 6 || value.length > 32) {
fail("Serial looks invalid.");
}
return value;
}
function sanitizePrinterName(name, serial) {
const value = String(name || "").trim();
return value || serial;
}
function sanitizePrinterLimit(limit) {
const value = Number(limit);
if (!Number.isInteger(value) || value < 1 || value > 500) {
fail("Printer limit must be an integer between 1 and 500.");
}
return value;
}
function sanitizeInterval(value, label) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 5000 || parsed > 86_400_000) {
fail(`${label} must be an integer between 5000 and 86400000 milliseconds.`);
}
return parsed;
}
function sanitizeMqttHost(host, required) {
const value = String(host || "").trim();
if (!value) {
if (required) {
fail("MQTT host is required when MQTT output is enabled.");
}
return "";
}
if (value.includes("://")) {
fail("MQTT host must not include a protocol. Configure TLS with the checkbox.");
}
let parsed;
try {
parsed = new URL(`mqtt://${value}`);
} catch {
fail("MQTT host is invalid.");
}
if (!parsed.hostname || parsed.port || parsed.username || parsed.password || (parsed.pathname && parsed.pathname !== "/") || parsed.search || parsed.hash) {
fail("MQTT host must only contain a hostname or IP address. Configure port separately.");
}
return parsed.hostname;
}
function sanitizeMqttPort(port) {
const parsed = Number(port);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
fail("MQTT port must be an integer between 1 and 65535.");
}
return parsed;
}
function sanitizeOptionalText(value, label, maxLength = 1024) {
const text = String(value || "").trim();
if (text.length > maxLength) {
fail(`${label} is too long.`);
}
return text;
}
function sanitizeMqttTopic(topic) {
const value = String(topic || "").trim().replace(/^\/+|\/+$/gu, "");
if (!value || value.length > 512 || value.includes("+") || value.includes("#")) {
fail("MQTT base topic is invalid.");
}
return value;
}
function sanitizeMqttFieldSource(source) {
const value = String(source || "raw").trim().toLowerCase();
if (!['raw', 'state'].includes(value)) {
fail("MQTT field source must be raw or state.");
}
return value;
}
function sanitizeMqttJsonPath(path) {
const value = String(path || "")
.trim()
.replace(/\[(\d+)\]/gu, ".$1");
const segment = "(?:[A-Za-z_$][A-Za-z0-9_$-]*|\\d+)";
if (!value || value.length > 512 || !new RegExp(`^${segment}(?:\\.${segment})*$`, "u").test(value)) {
fail("JSON path is invalid. Use dot notation such as print.nozzle_temper or ams.ams[0].tray[0].remain.");
}
return value;
}
function sanitizeMqttTopicSuffix(topic) {
const value = String(topic || "").trim().replace(/^\/+|\/+$/gu, "");
if (!value || value.length > 256 || value.includes("+") || value.includes("#") || !/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/u.test(value)) {
fail("MQTT topic suffix is invalid.");
}
if (["availability", "state", "raw"].includes(value)) {
fail("This MQTT topic suffix is reserved.");
}
return value;
}
function parseBoolean(value, fallback = false) {
if (typeof value === "boolean") {
return value;
}
if (value === undefined || value === null) {
return fallback;
}
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
fail("Boolean setting is invalid.");
}
export function createPool(connectionString) {
return new Pool({
connectionString,
});
}
export async function initDatabase(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
printer_limit INTEGER NOT NULL DEFAULT 1 CHECK (printer_limit >= 1),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS printers (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
serial TEXT NOT NULL,
region TEXT NOT NULL CHECK (region IN ('US', 'EU', 'CN')),
access_token TEXT NOT NULL,
cloud_user_id TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, serial)
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS printer_mqtt_fields (
id BIGSERIAL PRIMARY KEY,
printer_id BIGINT NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
source TEXT NOT NULL CHECK (source IN ('raw', 'state')),
json_path TEXT NOT NULL,
topic_suffix TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(printer_id, topic_suffix)
);
`);
// ALTER statements make this safe for installations created before per-user MQTT settings existed.
await pool.query(`
ALTER TABLE users
ADD COLUMN IF NOT EXISTS private_mqtt_enabled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS private_mqtt_host TEXT,
ADD COLUMN IF NOT EXISTS private_mqtt_port INTEGER NOT NULL DEFAULT 1883,
ADD COLUMN IF NOT EXISTS private_mqtt_tls BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS private_mqtt_ignore_certificate_errors BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS private_mqtt_username TEXT,
ADD COLUMN IF NOT EXISTS private_mqtt_password TEXT,
ADD COLUMN IF NOT EXISTS private_mqtt_base_topic TEXT NOT NULL DEFAULT 'bambu',
ADD COLUMN IF NOT EXISTS push_interval_ms INTEGER NOT NULL DEFAULT 600000,
ADD COLUMN IF NOT EXISTS sync_interval_ms INTEGER NOT NULL DEFAULT 30000;
`);
}
export async function ensureAdminUser(pool, { username, password, printerLimit, pushIntervalMs = 600000, syncIntervalMs = 30000 }) {
const normalizedUsername = sanitizeUsername(username);
const existing = await pool.query("SELECT id FROM users WHERE username = $1", [normalizedUsername]);
if (existing.rowCount > 0) {
await pool.query(
"UPDATE users SET is_admin = TRUE, printer_limit = GREATEST(printer_limit, $2), updated_at = NOW() WHERE username = $1",
[normalizedUsername, sanitizePrinterLimit(printerLimit)],
);
return;
}
const passwordHash = await hashPassword(sanitizePassword(password));
await pool.query(
`INSERT INTO users (username, password_hash, is_admin, printer_limit, push_interval_ms, sync_interval_ms)
VALUES ($1, $2, TRUE, $3, $4, $5)`,
[
normalizedUsername,
passwordHash,
sanitizePrinterLimit(printerLimit),
sanitizeInterval(pushIntervalMs, "Push interval"),
sanitizeInterval(syncIntervalMs, "Sync interval"),
],
);
}
export async function getUserById(pool, userId) {
const result = await pool.query("SELECT * FROM users WHERE id = $1", [userId]);
return result.rows[0] || null;
}
export async function getUserByUsername(pool, username) {
const result = await pool.query("SELECT * FROM users WHERE username = $1", [sanitizeUsername(username)]);
return result.rows[0] || null;
}
export function serializeUser(user) {
return {
id: Number(user.id),
username: user.username,
isAdmin: user.is_admin,
printerLimit: user.printer_limit,
mqttEnabled: user.private_mqtt_enabled,
mqttHost: user.private_mqtt_host || "",
mqttPort: user.private_mqtt_port,
mqttTls: user.private_mqtt_tls,
mqttIgnoreCertificateErrors: user.private_mqtt_ignore_certificate_errors,
mqttUsername: user.private_mqtt_username || "",
mqttPasswordConfigured: Boolean(user.private_mqtt_password),
mqttBaseTopic: user.private_mqtt_base_topic,
pushIntervalMs: user.push_interval_ms,
syncIntervalMs: user.sync_interval_ms,
createdAt: user.created_at,
updatedAt: user.updated_at,
};
}
export async function authenticateUser(pool, username, password) {
const user = await getUserByUsername(pool, username);
if (!user) {
fail("Invalid username or password.", 401);
}
const valid = await verifyPassword(password, user.password_hash);
if (!valid) {
fail("Invalid username or password.", 401);
}
return user;
}
export async function listUsers(pool) {
const result = await pool.query(`
SELECT
u.id,
u.username,
u.is_admin,
u.printer_limit,
u.created_at,
u.updated_at,
COUNT(p.id) AS printer_count
FROM users u
LEFT JOIN printers p ON p.user_id = u.id
GROUP BY u.id
ORDER BY u.username ASC
`);
return result.rows.map((row) => ({
id: Number(row.id),
username: row.username,
isAdmin: row.is_admin,
printerLimit: row.printer_limit,
printerCount: Number(row.printer_count),
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
export async function createUser(pool, {
username,
password,
printerLimit,
isAdmin = false,
pushIntervalMs = 600000,
syncIntervalMs = 30000,
}) {
const normalizedUsername = sanitizeUsername(username);
const normalizedPassword = sanitizePassword(password);
const normalizedLimit = sanitizePrinterLimit(printerLimit);
const passwordHash = await hashPassword(normalizedPassword);
try {
const result = await pool.query(
`INSERT INTO users (username, password_hash, is_admin, printer_limit, push_interval_ms, sync_interval_ms)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
normalizedUsername,
passwordHash,
Boolean(isAdmin),
normalizedLimit,
sanitizeInterval(pushIntervalMs, "Push interval"),
sanitizeInterval(syncIntervalMs, "Sync interval"),
],
);
return serializeUser({ ...result.rows[0], password_hash: passwordHash });
} catch (error) {
if (error.code === "23505") {
fail("Username already exists.");
}
throw error;
}
}
export async function updateUser(pool, userId, { printerLimit }) {
const normalizedLimit = sanitizePrinterLimit(printerLimit);
const countResult = await pool.query("SELECT COUNT(*) AS count FROM printers WHERE user_id = $1", [userId]);
const printerCount = Number(countResult.rows[0]?.count || 0);
if (normalizedLimit < printerCount) {
fail(`User already has ${printerCount} printers. Lower limit not allowed.`);
}
const result = await pool.query(
`UPDATE users
SET printer_limit = $2, updated_at = NOW()
WHERE id = $1
RETURNING *`,
[userId, normalizedLimit],
);
if (result.rowCount === 0) {
fail("User not found.", 404);
}
return serializeUser(result.rows[0]);
}
export async function resetUserPassword(pool, userId, newPassword) {
const passwordHash = await hashPassword(sanitizePassword(newPassword));
const result = await pool.query(
`UPDATE users
SET password_hash = $2, updated_at = NOW()
WHERE id = $1
RETURNING *`,
[userId, passwordHash],
);
if (result.rowCount === 0) {
fail("User not found.", 404);
}
return result.rows[0];
}
export async function updateOwnSettings(pool, userId, data) {
const existing = await getUserById(pool, userId);
if (!existing) {
fail("User not found.", 404);
}
const mqttEnabled = parseBoolean(data.mqttEnabled, existing.private_mqtt_enabled);
const mqttHost = sanitizeMqttHost(data.mqttHost ?? existing.private_mqtt_host, mqttEnabled);
const mqttPort = sanitizeMqttPort(data.mqttPort ?? existing.private_mqtt_port);
const mqttTls = parseBoolean(data.mqttTls, existing.private_mqtt_tls);
const mqttIgnoreCertificateErrors = parseBoolean(
data.mqttIgnoreCertificateErrors,
existing.private_mqtt_ignore_certificate_errors,
);
const mqttUsername = sanitizeOptionalText(data.mqttUsername ?? existing.private_mqtt_username, "MQTT username", 255);
const mqttBaseTopic = sanitizeMqttTopic(data.mqttBaseTopic ?? existing.private_mqtt_base_topic);
const pushIntervalMs = sanitizeInterval(data.pushIntervalMs ?? existing.push_interval_ms, "Push interval");
const syncIntervalMs = sanitizeInterval(data.syncIntervalMs ?? existing.sync_interval_ms, "Sync interval");
let mqttPassword = existing.private_mqtt_password;
if (parseBoolean(data.mqttClearPassword, false)) {
mqttPassword = null;
} else if (data.mqttPassword !== undefined && String(data.mqttPassword) !== "") {
mqttPassword = sanitizeOptionalText(data.mqttPassword, "MQTT password", 2048);
}
const result = await pool.query(
`UPDATE users
SET private_mqtt_enabled = $2,
private_mqtt_host = $3,
private_mqtt_port = $4,
private_mqtt_tls = $5,
private_mqtt_ignore_certificate_errors = $6,
private_mqtt_username = $7,
private_mqtt_password = $8,
private_mqtt_base_topic = $9,
push_interval_ms = $10,
sync_interval_ms = $11,
updated_at = NOW()
WHERE id = $1
RETURNING *`,
[
userId,
mqttEnabled,
mqttHost || null,
mqttPort,
mqttTls,
mqttIgnoreCertificateErrors,
mqttUsername || null,
mqttPassword,
mqttBaseTopic,
pushIntervalMs,
syncIntervalMs,
],
);
return result.rows[0];
}
export async function changeOwnPassword(pool, userId, currentPassword, newPassword) {
const user = await getUserById(pool, userId);
if (!user) {
fail("User not found.", 404);
}
const valid = await verifyPassword(currentPassword, user.password_hash);
if (!valid) {
fail("Current password is incorrect.", 401);
}
return resetUserPassword(pool, userId, newPassword);
}
export async function listBridgePrinters(pool) {
const result = await pool.query(`
SELECT
p.id,
p.user_id,
p.name,
p.serial,
p.region,
p.access_token,
p.cloud_user_id,
p.enabled,
p.created_at,
p.updated_at,
u.username,
u.private_mqtt_enabled,
u.private_mqtt_host,
u.private_mqtt_port,
u.private_mqtt_tls,
u.private_mqtt_ignore_certificate_errors,
u.private_mqtt_username,
u.private_mqtt_password,
u.private_mqtt_base_topic,
u.push_interval_ms,
u.sync_interval_ms,
mqtt_fields.mqtt_fields
FROM printers p
INNER JOIN users u ON u.id = p.user_id
LEFT JOIN LATERAL (
SELECT COALESCE(
json_agg(
json_build_object(
'id', fields.id,
'source', fields.source,
'jsonPath', fields.json_path,
'topicSuffix', fields.topic_suffix
)
ORDER BY fields.id ASC
),
'[]'::json
) AS mqtt_fields
FROM printer_mqtt_fields fields
WHERE fields.printer_id = p.id
) mqtt_fields ON TRUE
WHERE p.enabled = TRUE
ORDER BY p.id ASC
`);
return result.rows;
}
export async function updatePrinterCloudUserId(pool, printerId, cloudUserId) {
await pool.query(
"UPDATE printers SET cloud_user_id = $2, updated_at = NOW() WHERE id = $1",
[printerId, cloudUserId],
);
}
export async function countPrintersForUser(pool, userId) {
const result = await pool.query("SELECT COUNT(*) AS count FROM printers WHERE user_id = $1", [userId]);
return Number(result.rows[0]?.count || 0);
}
export async function createPrinter(pool, actor, data) {
const serial = sanitizeSerial(data.serial);
const region = sanitizeRegion(data.region);
const accessToken = String(data.accessToken || "").trim();
if (!accessToken) {
fail("Access token is required.");
}
const ownerId = Number(data.userId || actor.id);
const owner = actor.is_admin && ownerId !== Number(actor.id) ? await getUserById(pool, ownerId) : actor;
if (!owner) {
fail("Owner user not found.", 404);
}
const printerCount = await countPrintersForUser(pool, owner.id);
if (printerCount >= owner.printer_limit) {
fail(`Printer limit reached (${owner.printer_limit}).`);
}
try {
await pool.query(
`INSERT INTO printers (user_id, name, serial, region, access_token, cloud_user_id, enabled, updated_at)
VALUES ($1, $2, $3, $4, $5, NULL, TRUE, NOW())`,
[owner.id, sanitizePrinterName(data.name, serial), serial, region, accessToken],
);
} catch (error) {
if (error.code === "23505") {
fail("This serial already exists for that user.");
}
throw error;
}
}
export async function updatePrinter(pool, actor, printerId, data) {
const printer = await getPrinterById(pool, printerId);
if (!printer) {
fail("Printer not found.", 404);
}
if (!actor.is_admin && Number(printer.user_id) !== Number(actor.id)) {
fail("Forbidden.", 403);
}
const serial = sanitizeSerial(data.serial ?? printer.serial);
const region = sanitizeRegion(data.region ?? printer.region);
const accessToken = String(data.accessToken ?? printer.access_token).trim();
if (!accessToken) {
fail("Access token is required.");
}
try {
await pool.query(
`UPDATE printers
SET name = $2,
serial = $3,
region = $4,
access_token = $5,
cloud_user_id = NULL,
updated_at = NOW()
WHERE id = $1`,
[printerId, sanitizePrinterName(data.name ?? printer.name, serial), serial, region, accessToken],
);
} catch (error) {
if (error.code === "23505") {
fail("This serial already exists for that user.");
}
throw error;
}
}
export async function deletePrinter(pool, actor, printerId) {
const printer = await getPrinterById(pool, printerId);
if (!printer) {
fail("Printer not found.", 404);
}
if (!actor.is_admin && Number(printer.user_id) !== Number(actor.id)) {
fail("Forbidden.", 403);
}
await pool.query("DELETE FROM printers WHERE id = $1", [printerId]);
}
async function getOwnedPrinter(pool, actor, printerId) {
const printer = await getPrinterById(pool, printerId);
if (!printer) {
fail("Printer not found.", 404);
}
if (!actor.is_admin && Number(printer.user_id) !== Number(actor.id)) {
fail("Forbidden.", 403);
}
return printer;
}
export async function createPrinterMqttField(pool, actor, printerId, data) {
await getOwnedPrinter(pool, actor, printerId);
const source = sanitizeMqttFieldSource(data.source);
const jsonPath = sanitizeMqttJsonPath(data.jsonPath);
const topicSuffix = sanitizeMqttTopicSuffix(data.topicSuffix);
try {
const result = await pool.query(
`INSERT INTO printer_mqtt_fields (printer_id, source, json_path, topic_suffix)
VALUES ($1, $2, $3, $4)
RETURNING id, source, json_path, topic_suffix`,
[printerId, source, jsonPath, topicSuffix],
);
return result.rows[0];
} catch (error) {
if (error.code === "23505") {
fail("This MQTT topic suffix is already configured for the printer.");
}
throw error;
}
}
export async function deletePrinterMqttField(pool, actor, printerId, fieldId) {
await getOwnedPrinter(pool, actor, printerId);
const result = await pool.query(
`DELETE FROM printer_mqtt_fields
WHERE id = $1 AND printer_id = $2
RETURNING topic_suffix AS "topicSuffix"`,
[fieldId, printerId],
);
if (result.rowCount === 0) {
fail("MQTT field mapping not found.", 404);
}
return result.rows[0];
}
export async function getPrinterById(pool, printerId) {
const result = await pool.query("SELECT * FROM printers WHERE id = $1", [printerId]);
return result.rows[0] || null;
}
+78
View File
@@ -0,0 +1,78 @@
import fs from "node:fs";
import path from "node:path";
function loadDotEnvFile() {
const envPath = path.resolve(process.cwd(), ".env");
if (!fs.existsSync(envPath)) {
return;
}
const content = fs.readFileSync(envPath, "utf8");
for (const rawLine of content.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const separatorIndex = line.indexOf("=");
if (separatorIndex === -1) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
const value = line.slice(separatorIndex + 1).trim();
if (!(key in process.env)) {
process.env[key] = value;
}
}
}
function readString(name, fallback = undefined) {
const value = process.env[name];
if (typeof value === "string" && value.trim() !== "") {
return value.trim();
}
if (fallback !== undefined) {
return fallback;
}
throw new Error(`Missing environment variable ${name}`);
}
function readNumber(name, fallback) {
const value = process.env[name];
if (value === undefined || value === "") {
return fallback;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(`Environment variable ${name} must be a number`);
}
return parsed;
}
loadDotEnvFile();
export function loadEnvConfig() {
return {
app: {
host: readString("APP_HOST", "0.0.0.0"),
port: readNumber("APP_PORT", 3923),
sessionSecret: readString("SESSION_SECRET"),
sessionTtlHours: readNumber("SESSION_TTL_HOURS", 168),
},
database: {
connectionString: readString("DATABASE_URL"),
},
admin: {
username: readString("ADMIN_USERNAME", "bambumin"),
password: readString("ADMIN_PASSWORD", "ChangeMe123!"),
printerLimit: readNumber("ADMIN_PRINTER_LIMIT", 25),
},
defaults: {
printerLimit: readNumber("DEFAULT_USER_PRINTER_LIMIT", 1),
pushIntervalMs: readNumber("DEFAULT_USER_PUSH_INTERVAL_MS", readNumber("PUSHALL_INTERVAL_MS", 600000)),
syncIntervalMs: readNumber("DEFAULT_USER_SYNC_INTERVAL_MS", readNumber("SYNC_INTERVAL_MS", 30000)),
bridgeRefreshIntervalMs: readNumber("BRIDGE_REFRESH_INTERVAL_MS", 30000),
},
};
}
+40
View File
@@ -0,0 +1,40 @@
import { loadEnvConfig } from "./env.js";
import { createPool, ensureAdminUser, initDatabase } from "./db.js";
import { PrivateBrokerManager } from "./private-broker.js";
import { BridgeManager } from "./bridge-manager.js";
import { createWebServer } from "./web.js";
const config = loadEnvConfig();
const pool = createPool(config.database.connectionString);
await initDatabase(pool);
await ensureAdminUser(pool, { ...config.admin, ...config.defaults });
const privateBroker = new PrivateBrokerManager();
const bridgeManager = new BridgeManager({
pool,
privateBroker,
refreshIntervalMs: config.defaults.bridgeRefreshIntervalMs,
});
await bridgeManager.start();
const web = createWebServer({
app: config.app,
pool,
bridgeManager,
defaults: config.defaults,
});
await web.start();
console.log(`[http] listening on http://${config.app.host}:${config.app.port}`);
console.log(`[admin] seeded admin user ${config.admin.username}`);
function shutdown() {
bridgeManager.close();
privateBroker.close();
web.close();
pool.end();
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
+181
View File
@@ -0,0 +1,181 @@
import crypto from "node:crypto";
import mqtt from "mqtt";
export function getPrivateMqttSettings(printer) {
return {
userId: Number(printer.user_id),
username: printer.username,
enabled: printer.private_mqtt_enabled,
host: printer.private_mqtt_host,
port: Number(printer.private_mqtt_port),
tls: printer.private_mqtt_tls,
ignoreCertificateErrors: printer.private_mqtt_ignore_certificate_errors,
usernameForBroker: printer.private_mqtt_username,
password: printer.private_mqtt_password,
baseTopic: printer.private_mqtt_base_topic,
};
}
function getSignature(settings) {
return JSON.stringify(settings);
}
function getBrokerUrl(settings) {
const protocol = settings.tls ? "mqtts" : "mqtt";
return `${protocol}://${settings.host}:${settings.port}`;
}
function getJsonPathValue(source, jsonPath) {
let value = source;
for (const segment of String(jsonPath || "").split(".")) {
if (!value || typeof value !== "object" || !Object.hasOwn(value, segment)) {
return { found: false };
}
value = value[segment];
}
return { found: true, value };
}
function scalarPayload(value) {
if (value === null) {
return "";
}
if (typeof value === "string" || typeof value === "boolean") {
return String(value);
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return null;
}
export class PrivateBrokerManager {
constructor() {
this.clients = new Map();
}
reconcile(printers) {
const desired = new Map();
for (const printer of printers) {
const settings = getPrivateMqttSettings(printer);
desired.set(settings.userId, settings);
}
for (const [userId, entry] of this.clients.entries()) {
const settings = desired.get(userId);
if (!settings || !settings.enabled || entry.signature !== getSignature(settings)) {
this.stopClient(userId, entry);
}
}
}
getClient(settings) {
if (!settings?.enabled || !settings.host) {
return null;
}
const signature = getSignature(settings);
const current = this.clients.get(settings.userId);
if (current && current.signature === signature) {
return current;
}
if (current) {
this.stopClient(settings.userId, current);
}
const url = getBrokerUrl(settings);
const client = mqtt.connect(url, {
clientId: `bambu-web-helper-${settings.userId}-${crypto.randomUUID().slice(0, 8)}`,
username: settings.usernameForBroker || undefined,
password: settings.password || undefined,
reconnectPeriod: 5000,
connectTimeout: 15000,
protocolVersion: 4,
rejectUnauthorized: !settings.ignoreCertificateErrors,
});
const entry = { client, settings, signature, states: new Map() };
client.on("connect", () => {
console.log(`[private-mqtt] connected ${settings.username} -> ${url}`);
for (const state of entry.states.values()) {
this.publishState(entry, state);
}
});
client.on("error", (error) => {
console.error(`[private-mqtt] error for ${settings.username}:`, error.message);
});
this.clients.set(settings.userId, entry);
return entry;
}
publishPrinterState(printerState, settings) {
const entry = this.getClient(settings);
if (!entry) {
return;
}
entry.states.set(printerState.printerId, printerState);
this.publishState(entry, printerState);
}
clearPrinterField(printerState, settings, topicSuffix) {
const entry = this.getClient(settings);
if (!entry) {
return;
}
const topic = `${entry.settings.baseTopic}/${printerState.serial}/${topicSuffix}`;
entry.client.publish(topic, "", { qos: 0, retain: true });
}
publishState(entry, printerState) {
if (!entry.client.connected) {
return;
}
const prefix = `${entry.settings.baseTopic}/${printerState.serial}`;
this.publish(entry, `${prefix}/availability`, {
printerId: printerState.printerId,
ownerUsername: printerState.ownerUsername,
connected: printerState.connected,
lastSeenAt: printerState.lastSeenAt,
errorMessage: printerState.errorMessage,
});
this.publish(entry, `${prefix}/state`, printerState.normalized);
this.publish(entry, `${prefix}/raw`, printerState.raw);
for (const field of printerState.mqttFields || []) {
const source = field.source === "state" ? printerState.normalized : printerState.raw;
const result = getJsonPathValue(source, field.jsonPath);
if (!result.found) {
continue;
}
const payload = scalarPayload(result.value);
if (payload === null) {
continue;
}
this.publishScalar(entry, `${prefix}/${field.topicSuffix}`, payload);
}
}
publish(entry, topic, payload) {
entry.client.publish(topic, JSON.stringify(payload), { qos: 0, retain: true });
}
publishScalar(entry, topic, payload) {
entry.client.publish(topic, payload, { qos: 0, retain: true });
}
stopClient(userId, entry = this.clients.get(userId)) {
if (!entry) {
return;
}
entry.client.end(true);
if (this.clients.get(userId) === entry) {
this.clients.delete(userId);
}
}
close() {
for (const [userId, entry] of this.clients.entries()) {
this.stopClient(userId, entry);
}
}
}
+232
View File
@@ -0,0 +1,232 @@
function toNumber(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
const BAMBU_FAN_SPEED_PERCENT = {
0: 0,
1: 10,
2: 20,
4: 30,
5: 40,
7: 50,
9: 60,
10: 70,
12: 80,
13: 90,
15: 100,
};
function toFanSpeedPercent(value) {
const speed = toNumber(value);
if (speed === null || speed < 0) {
return null;
}
if (speed > 15) {
return Math.min(100, Math.round(speed));
}
return BAMBU_FAN_SPEED_PERCENT[speed] ?? Math.round((speed / 15) * 10) * 10;
}
function fanGearPercent(fanGear, byteOffset) {
const gear = toNumber(fanGear);
if (gear === null || gear < 0 || !Number.isSafeInteger(gear)) {
return null;
}
const pwm = Math.floor(gear / (256 ** byteOffset)) % 256;
return Math.round((pwm * 10) / 255) * 10;
}
function toBoolean(value) {
if (typeof value === "boolean") {
return value;
}
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
return null;
}
function deepMerge(target, source) {
if (Array.isArray(source)) {
return source.map((item) => deepMerge(undefined, item));
}
if (source && typeof source === "object") {
const base = target && typeof target === "object" && !Array.isArray(target) ? { ...target } : {};
for (const [key, value] of Object.entries(source)) {
base[key] = deepMerge(base[key], value);
}
return base;
}
return source;
}
function normalizeAms(print) {
const ams = print?.ams;
if (!ams || typeof ams !== "object") {
return null;
}
const units = Array.isArray(ams.ams) ? ams.ams : [];
return {
trayNow: toNumber(print?.tray_now),
version: ams?.version ?? null,
units: units.map((unit) => ({
id: toNumber(unit?.id),
humidity: toNumber(unit?.humidity),
temperature: toNumber(unit?.temp),
trays: Array.isArray(unit?.tray)
? unit.tray.map((tray) => ({
id: toNumber(tray?.id),
type: tray?.tray_type ?? null,
subtype: tray?.tray_sub_brands ?? null,
color: tray?.tray_color ?? null,
k: toNumber(tray?.k),
remain: toNumber(tray?.remain),
}))
: [],
})),
};
}
function normalizeChamberLight(lightsReport) {
const chamberLight = lightsReport.find((light) => light?.node === "chamber_light");
const mode = String(chamberLight?.mode ?? chamberLight?.led_mode ?? "").toLowerCase();
if (mode === "on") {
return true;
}
if (mode === "off") {
return false;
}
return null;
}
export function createPrinterState(printer) {
return {
printerId: Number(printer.id),
ownerUserId: Number(printer.user_id),
ownerUsername: printer.username,
serial: printer.serial,
name: printer.name,
region: printer.region,
connected: false,
errorMessage: null,
lastSeenAt: null,
lastMessageAt: null,
mqttFields: Array.isArray(printer.mqtt_fields) ? printer.mqtt_fields.map((field) => ({
id: Number(field.id),
source: field.source,
jsonPath: field.jsonPath,
topicSuffix: field.topicSuffix,
})) : [],
raw: {},
normalized: {
printerId: Number(printer.id),
ownerUserId: Number(printer.user_id),
ownerUsername: printer.username,
serial: printer.serial,
name: printer.name,
region: printer.region,
connected: false,
chamberLight: null,
},
};
}
export function mergeReport(existingState, report) {
const mergedRaw = deepMerge(existingState.raw, report);
const print = mergedRaw?.print ?? {};
const gcodeState = print?.gcode_state ?? null;
const stage = print?.stg ?? null;
const fanGear = print?.fan_gear;
const lightsReport = Array.isArray(print?.lights_report) ? print.lights_report : [];
const normalized = {
printerId: existingState.printerId,
ownerUserId: existingState.ownerUserId,
ownerUsername: existingState.ownerUsername,
serial: existingState.serial,
name: existingState.name,
region: existingState.region,
connected: true,
errorMessage: null,
lastSeenAt: new Date().toISOString(),
gcodeState,
stage,
printing: typeof gcodeState === "string" ? ["RUNNING", "PREPARE", "PAUSE"].includes(gcodeState) : false,
progress: toNumber(print?.mc_percent),
remainingMinutes: toNumber(print?.mc_remaining_time),
nozzleTemp: toNumber(print?.nozzle_temper),
nozzleTarget: toNumber(print?.nozzle_target_temper),
bedTemp: toNumber(print?.bed_temper),
bedTarget: toNumber(print?.bed_target_temper),
chamberTemp: toNumber(print?.chamber_temper),
layer: toNumber(print?.layer_num),
totalLayers: toNumber(print?.total_layer_num),
wifiSignal: toNumber(print?.wifi_signal),
speedLevel: toNumber(print?.spd_lvl),
partFanSpeed: fanGearPercent(fanGear, 0) ?? toFanSpeedPercent(print?.cooling_fan_speed),
auxFanSpeed: fanGearPercent(fanGear, 1) ?? toFanSpeedPercent(print?.big_fan1_speed),
chamberFanSpeed: fanGearPercent(fanGear, 2) ?? toFanSpeedPercent(print?.big_fan2_speed),
heatbreakFanSpeed: toFanSpeedPercent(print?.heatbreak_fan_speed),
subtaskName: print?.subtask_name ?? null,
projectId: print?.project_id ?? null,
taskId: print?.task_id ?? null,
profileId: print?.profile_id ?? null,
message: print?.msg ?? null,
homeFlag: print?.home_flag ?? null,
stat: print?.stat ?? null,
ams: normalizeAms(print),
lightsReport,
chamberLight: normalizeChamberLight(lightsReport),
rawFlags: {
online: toBoolean(print?.online),
upgradeState: print?.upgrade_state ?? null,
},
};
return {
...existingState,
connected: true,
errorMessage: null,
lastSeenAt: normalized.lastSeenAt,
lastMessageAt: normalized.lastSeenAt,
raw: mergedRaw,
normalized,
};
}
export function markPrinterDisconnected(existingState, errorMessage = null) {
return {
...existingState,
connected: false,
errorMessage,
normalized: {
...existingState.normalized,
connected: false,
errorMessage,
},
};
}
export function markPrinterError(existingState, errorMessage) {
return {
...existingState,
connected: false,
errorMessage,
normalized: {
...existingState.normalized,
connected: false,
errorMessage,
},
};
}
+357
View File
@@ -0,0 +1,357 @@
import fs from "node:fs";
import path from "node:path";
import http from "node:http";
import {
authenticateUser,
changeOwnPassword,
createPrinter,
createPrinterMqttField,
createUser,
deletePrinter,
deletePrinterMqttField,
getUserById,
listUsers,
resetUserPassword,
serializeUser,
updatePrinter,
updateOwnSettings,
updateUser,
} from "./db.js";
import {
clearSessionCookie,
createSessionToken,
isSessionValidForUser,
makeSessionCookie,
parseCookies,
verifySessionToken,
} from "./auth.js";
const INDEX_HTML = fs.readFileSync(path.resolve(process.cwd(), "src", "www", "index.html"), "utf8");
function sendJson(response, status, payload, headers = {}) {
response.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
...headers,
});
response.end(JSON.stringify(payload));
}
function sendError(response, error) {
const status = error?.status || 500;
sendJson(response, status, {
error: error?.message || "Internal server error",
});
}
async function readJson(request) {
const chunks = [];
for await (const chunk of request) {
chunks.push(chunk);
}
const raw = Buffer.concat(chunks).toString("utf8").trim();
if (!raw) {
return {};
}
try {
return JSON.parse(raw);
} catch {
const error = new Error("Invalid JSON body.");
error.status = 400;
throw error;
}
}
function parsePrinterId(pathname) {
const match = pathname.match(/^\/api\/printers\/(\d+)$/u);
return match ? Number(match[1]) : null;
}
function parsePrinterActionId(pathname, action) {
const escapedAction = action.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
const match = pathname.match(new RegExp(`^/api/printers/(\\d+)/${escapedAction}$`, "u"));
return match ? Number(match[1]) : null;
}
function parsePrinterMqttFieldIds(pathname) {
const match = pathname.match(/^\/api\/printers\/(\d+)\/mqtt-fields\/(\d+)$/u);
return match ? { printerId: Number(match[1]), fieldId: Number(match[2]) } : null;
}
function parseUserId(pathname, suffix) {
const escapedSuffix = suffix.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
const match = pathname.match(new RegExp(`^/api/admin/users/(\\d+)${escapedSuffix}$`, "u"));
return match ? Number(match[1]) : null;
}
export function createWebServer({ app, pool, bridgeManager, defaults }) {
const clients = new Set();
async function resolveUser(request) {
const cookies = parseCookies(request.headers.cookie || "");
const token = cookies.session;
const session = verifySessionToken(token, app.sessionSecret);
if (!session) {
return null;
}
const user = await getUserById(pool, session.uid);
if (!user || !isSessionValidForUser(session, user)) {
return null;
}
return user;
}
function buildBootstrap(user) {
const serializedUser = serializeUser(user);
const printers = bridgeManager.getSnapshotForUser(user);
return {
user: serializedUser,
printers,
printerCount: printers.length,
adminUsers: user.is_admin ? [] : undefined,
};
}
async function buildAdminBootstrap(user) {
const base = buildBootstrap(user);
if (user.is_admin) {
base.adminUsers = await listUsers(pool);
}
return base;
}
function broadcast() {
for (const client of clients) {
const payload = {
generatedAt: new Date().toISOString(),
printers: bridgeManager.getSnapshotForUser(client.user),
};
client.response.write(`data: ${JSON.stringify(payload)}\n\n`);
}
}
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, `http://${request.headers.host}`);
if (request.method === "GET" && url.pathname === "/") {
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(INDEX_HTML);
return;
}
if (request.method === "GET" && url.pathname === "/api/health") {
sendJson(response, 200, { ok: true, at: new Date().toISOString() });
return;
}
if (request.method === "POST" && url.pathname === "/api/login") {
const body = await readJson(request);
const user = await authenticateUser(pool, body.username, body.password);
const token = createSessionToken(user, app.sessionSecret, app.sessionTtlHours);
sendJson(
response,
200,
await buildAdminBootstrap(user),
{ "Set-Cookie": makeSessionCookie(token, app.sessionTtlHours * 60 * 60) },
);
return;
}
if (request.method === "POST" && url.pathname === "/api/logout") {
sendJson(response, 200, { ok: true }, { "Set-Cookie": clearSessionCookie() });
return;
}
const user = await resolveUser(request);
if (!user) {
if (url.pathname.startsWith("/api/")) {
sendJson(response, 401, { error: "Authentication required." }, { "Set-Cookie": clearSessionCookie() });
return;
}
}
if (request.method === "GET" && url.pathname === "/api/bootstrap") {
sendJson(response, 200, await buildAdminBootstrap(user));
return;
}
if (request.method === "GET" && url.pathname === "/api/events") {
response.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
response.write(`data: ${JSON.stringify({
generatedAt: new Date().toISOString(),
printers: bridgeManager.getSnapshotForUser(user),
})}\n\n`);
const entry = { response, user };
clients.add(entry);
request.on("close", () => clients.delete(entry));
return;
}
if (request.method === "POST" && url.pathname === "/api/account/password") {
const body = await readJson(request);
const updatedUser = await changeOwnPassword(pool, user.id, body.currentPassword, body.newPassword);
const token = createSessionToken(updatedUser, app.sessionSecret, app.sessionTtlHours);
sendJson(
response,
200,
{ ok: true },
{ "Set-Cookie": makeSessionCookie(token, app.sessionTtlHours * 60 * 60) },
);
return;
}
if (request.method === "POST" && url.pathname === "/api/account/settings") {
const body = await readJson(request);
const updatedUser = await updateOwnSettings(pool, user.id, body);
await bridgeManager.refresh();
sendJson(response, 200, {
user: serializeUser(updatedUser),
printers: bridgeManager.getSnapshotForUser(updatedUser),
});
return;
}
if (request.method === "POST" && url.pathname === "/api/printers") {
const body = await readJson(request);
await createPrinter(pool, user, body);
await bridgeManager.refresh();
sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) });
return;
}
const mqttFieldsPrinterId = parsePrinterActionId(url.pathname, "mqtt-fields");
if (mqttFieldsPrinterId && request.method === "POST") {
const body = await readJson(request);
await createPrinterMqttField(pool, user, mqttFieldsPrinterId, body);
await bridgeManager.refresh();
sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) });
return;
}
const mqttFieldIds = parsePrinterMqttFieldIds(url.pathname);
if (mqttFieldIds && request.method === "DELETE") {
const removedField = await deletePrinterMqttField(pool, user, mqttFieldIds.printerId, mqttFieldIds.fieldId);
bridgeManager.clearPrivateMqttField(mqttFieldIds.printerId, removedField.topicSuffix);
await bridgeManager.refresh();
sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) });
return;
}
const chamberLightPrinterId = parsePrinterActionId(url.pathname, "chamber-light");
if (chamberLightPrinterId && request.method === "POST") {
const body = await readJson(request);
if (typeof body.on !== "boolean") {
const error = new Error("Light state must be a boolean.");
error.status = 400;
throw error;
}
await bridgeManager.setChamberLight(chamberLightPrinterId, user, body.on);
sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) });
return;
}
const commandPrinterId = parsePrinterActionId(url.pathname, "commands");
if (commandPrinterId && request.method === "POST") {
const body = await readJson(request);
if (!["pause", "resume", "stop"].includes(body.command)) {
const error = new Error("Unsupported print command.");
error.status = 400;
throw error;
}
if (body.command === "stop" && body.confirm !== true) {
const error = new Error("Stopping a print requires confirmation.");
error.status = 400;
throw error;
}
await bridgeManager.sendPrintCommand(commandPrinterId, user, body.command);
sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) });
return;
}
const printerId = parsePrinterId(url.pathname);
if (printerId && request.method === "PUT") {
const body = await readJson(request);
await updatePrinter(pool, user, printerId, body);
await bridgeManager.refresh();
sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) });
return;
}
if (printerId && request.method === "DELETE") {
await deletePrinter(pool, user, printerId);
await bridgeManager.refresh();
sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) });
return;
}
if (url.pathname.startsWith("/api/admin/")) {
if (!user.is_admin) {
sendJson(response, 403, { error: "Admin only." });
return;
}
if (request.method === "GET" && url.pathname === "/api/admin/users") {
sendJson(response, 200, { users: await listUsers(pool) });
return;
}
if (request.method === "POST" && url.pathname === "/api/admin/users") {
const body = await readJson(request);
await createUser(pool, {
username: body.username,
password: body.password,
printerLimit: body.printerLimit,
isAdmin: false,
pushIntervalMs: defaults.pushIntervalMs,
syncIntervalMs: defaults.syncIntervalMs,
});
sendJson(response, 200, { users: await listUsers(pool) });
return;
}
const limitUserId = parseUserId(url.pathname, "");
if (limitUserId && request.method === "PATCH") {
const body = await readJson(request);
await updateUser(pool, limitUserId, { printerLimit: body.printerLimit });
sendJson(response, 200, { users: await listUsers(pool) });
return;
}
const resetUserId = parseUserId(url.pathname, "/reset-password");
if (resetUserId && request.method === "POST") {
const body = await readJson(request);
await resetUserPassword(pool, resetUserId, body.newPassword);
sendJson(response, 200, { users: await listUsers(pool) });
return;
}
}
sendJson(response, 404, { error: "Not found." });
} catch (error) {
sendError(response, error);
}
});
bridgeManager.onChange(() => broadcast());
return {
start() {
return new Promise((resolve) => {
server.listen(app.port, app.host, () => resolve());
});
},
close() {
for (const client of clients) {
client.response.end();
}
server.close();
},
};
}
+1740
View File
File diff suppressed because it is too large Load Diff