Skip to content

Commit 3eb7f35

Browse files
committed
Add Creality printer support via ha_creality_ws integration
Support Creality printers (K1, K2, Ender 3 V3, Hi) alongside Bambu Lab printers with seamless multi-brand dashboard experience. - Add printer brand selection (Bambu Lab / Creality) to Add Printer dialog - Discover Creality printers and CFS boxes/slots from ha_creality_ws entities - Generate Creality-specific automation YAML (selected attr, used_material_length) - Convert filament length (cm) to weight (g) via material density in webhook handler - Pre-install ha_creality_ws in embedded HA Docker image - Publish creality-beta Docker tag from feature branch for testers - Better error message when printer integration is not installed in HA
1 parent d0a1fde commit 3eb7f35

File tree

7 files changed

+735
-125
lines changed

7 files changed

+735
-125
lines changed

.github/workflows/docker-publish.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
push:
55
branches:
66
- main
7+
- feature/creality-support
78
tags:
89
- 'v*'
910

@@ -26,6 +27,7 @@ jobs:
2627
uses: actions/checkout@v4
2728

2829
- name: Verify addon version matches package.json
30+
if: startsWith(github.ref, 'refs/tags/v')
2931
run: |
3032
APP_VERSION=$(node -p "require('./app/package.json').version")
3133
ADDON_VERSION=$(grep '^version:' spoolmansync-ha-addon/config.yaml | sed 's/version: *"\(.*\)"/\1/')
@@ -68,6 +70,8 @@ jobs:
6870
type=semver,pattern={{version}}
6971
# Set major.minor tag (v1.0.0 -> 1.0)
7072
type=semver,pattern={{major}}.{{minor}}
73+
# Beta tag for feature branches
74+
type=raw,value=creality-beta,enable=${{ github.ref == 'refs/heads/feature/creality-support' }}
7175
7276
- name: Generate Docker tags for HA
7377
id: meta-ha
@@ -81,6 +85,7 @@ jobs:
8185
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
8286
type=semver,pattern={{version}}
8387
type=semver,pattern={{major}}.{{minor}}
88+
type=raw,value=creality-beta,enable=${{ github.ref == 'refs/heads/feature/creality-support' }}
8489
8590
- name: Generate Docker tags for addon
8691
id: meta-addon
@@ -94,6 +99,7 @@ jobs:
9499
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
95100
type=semver,pattern={{version}}
96101
type=semver,pattern={{major}}.{{minor}}
102+
type=raw,value=creality-beta,enable=${{ github.ref == 'refs/heads/feature/creality-support' }}
97103
98104
# Build and push main app image (multi-arch: amd64 + arm64)
99105
- name: Build and push SpoolmanSync app

app/src/app/api/printers/setup/route.ts

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
22
import { HomeAssistantClient } from '@/lib/api/homeassistant';
33
import prisma from '@/lib/db';
44

5-
const BAMBU_LAB_DOMAIN = 'bambu_lab';
5+
const SUPPORTED_DOMAINS = ['bambu_lab', 'ha_creality_ws'] as const;
6+
type PrinterDomain = typeof SUPPORTED_DOMAINS[number];
67
const HIDDEN_PRINTERS_KEY = 'hidden_printers';
78

89
/**
@@ -34,7 +35,7 @@ async function saveHiddenPrinters(hidden: { entryId: string; title: string }[]):
3435

3536
/**
3637
* GET /api/printers/setup
37-
* Get current Bambu Lab config entries (configured printers),
38+
* Get config entries for all supported printer integrations (Bambu Lab, Creality),
3839
* filtered to exclude printers the user has removed from SpoolmanSync.
3940
* Also returns hidden entries so the UI can offer to re-add them.
4041
*/
@@ -46,15 +47,25 @@ export async function GET() {
4647
return NextResponse.json({ error: 'Home Assistant not connected' }, { status: 400 });
4748
}
4849

49-
console.log('[Printers] Fetching config entries for domain:', BAMBU_LAB_DOMAIN);
50-
const entries = await client.getConfigEntries(BAMBU_LAB_DOMAIN);
50+
// Fetch config entries for all supported printer domains
51+
const allEntries = [];
52+
for (const domain of SUPPORTED_DOMAINS) {
53+
try {
54+
const entries = await client.getConfigEntries(domain);
55+
// Tag each entry with its domain for the frontend
56+
allEntries.push(...entries.map(e => ({ ...e, domain })));
57+
} catch (err) {
58+
// Domain not installed — skip silently
59+
console.log(`[Printers] Domain ${domain} not found or error:`, err);
60+
}
61+
}
5162

5263
// Filter out printers the user has hidden from SpoolmanSync
5364
const hidden = await getHiddenPrinters();
5465
const hiddenIds = new Set(hidden.map(h => h.entryId));
55-
const currentEntryIds = new Set(entries.map(e => e.entry_id));
56-
const visibleEntries = entries.filter(e => !hiddenIds.has(e.entry_id));
57-
const hiddenEntries = entries.filter(e => hiddenIds.has(e.entry_id));
66+
const currentEntryIds = new Set(allEntries.map(e => e.entry_id));
67+
const visibleEntries = allEntries.filter(e => !hiddenIds.has(e.entry_id));
68+
const hiddenEntries = allEntries.filter(e => hiddenIds.has(e.entry_id));
5869

5970
// Clean up stale hidden entries for printers no longer in HA
6071
const staleHidden = hidden.filter(h => !currentEntryIds.has(h.entryId));
@@ -64,19 +75,19 @@ export async function GET() {
6475
console.log(`[Printers] Cleaned up ${staleHidden.length} stale hidden entry(ies)`);
6576
}
6677

67-
console.log('[Printers] Found', entries.length, 'entries,', visibleEntries.length, 'visible,', hiddenEntries.length, 'hidden');
78+
console.log('[Printers] Found', allEntries.length, 'entries,', visibleEntries.length, 'visible,', hiddenEntries.length, 'hidden');
6879
return NextResponse.json({ entries: visibleEntries, hiddenEntries });
6980
} catch (error) {
70-
console.error('[Printers] Error getting Bambu Lab entries:', error);
81+
console.error('[Printers] Error getting printer entries:', error);
7182
return NextResponse.json({ error: 'Failed to get printer configurations' }, { status: 500 });
7283
}
7384
}
7485

7586
/**
7687
* POST /api/printers/setup
77-
* Start or continue a Bambu Lab config flow, or unhide a printer.
88+
* Start or continue a printer config flow, or unhide a printer.
7889
*
79-
* Body for starting flow: { action: 'start' }
90+
* Body for starting flow: { action: 'start', domain?: 'bambu_lab' | 'ha_creality_ws' }
8091
* Body for continuing flow: { action: 'continue', flowId: string, userInput: object }
8192
* Body for aborting flow: { action: 'abort', flowId: string }
8293
* Body for re-adding hidden printer: { action: 'unhide', entryId: string }
@@ -89,12 +100,25 @@ export async function POST(request: NextRequest) {
89100
}
90101

91102
const body = await request.json();
92-
const { action, flowId, userInput, entryId } = body;
103+
const { action, flowId, userInput, entryId, domain } = body;
93104

94105
switch (action) {
95106
case 'start': {
96-
const result = await client.startConfigFlow(BAMBU_LAB_DOMAIN);
97-
return NextResponse.json(result);
107+
const targetDomain: PrinterDomain = SUPPORTED_DOMAINS.includes(domain) ? domain : 'bambu_lab';
108+
try {
109+
const result = await client.startConfigFlow(targetDomain);
110+
return NextResponse.json(result);
111+
} catch (err) {
112+
const msg = err instanceof Error ? err.message : '';
113+
// HA returns 404 "Invalid handler" when integration is not installed
114+
if (msg.includes('404') || msg.includes('Invalid handler')) {
115+
const integrationName = targetDomain === 'ha_creality_ws' ? 'ha_creality_ws' : 'ha-bambulab';
116+
return NextResponse.json({
117+
error: `The ${integrationName} integration is not installed in Home Assistant. Please install it via HACS first, then try again.`,
118+
}, { status: 400 });
119+
}
120+
throw err;
121+
}
98122
}
99123

100124
case 'continue': {
@@ -156,8 +180,15 @@ export async function DELETE(request: NextRequest) {
156180
try {
157181
const client = await HomeAssistantClient.fromConnection();
158182
if (client) {
159-
const entries = await client.getConfigEntries('bambu_lab');
160-
const entry = entries.find(e => e.entry_id === entryId);
183+
// Search across all supported domains
184+
let entry = null;
185+
for (const d of SUPPORTED_DOMAINS) {
186+
try {
187+
const entries = await client.getConfigEntries(d);
188+
entry = entries.find(e => e.entry_id === entryId);
189+
if (entry) break;
190+
} catch { /* domain not installed */ }
191+
}
161192
if (entry) {
162193
entryTitle = entry.title;
163194

app/src/app/api/webhook/route.ts

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,45 @@ import { checkAndUpdateAlerts } from '@/lib/alerts';
2222
* }
2323
*/
2424

25-
/** Returns true if tray_uuid is a real Bambu spool serial (not empty, unknown, or all zeros) */
25+
/** Returns true if tray_uuid/rfid is a real spool identifier (not empty, unknown, or all zeros) */
2626
function isValidTrayUuid(tray_uuid: string | undefined | null): boolean {
2727
if (!tray_uuid || tray_uuid === 'unknown' || tray_uuid === '') return false;
2828
// ha-bambulab reports all zeros for non-Bambu spools without RFID tags
2929
if (tray_uuid.replace(/0/g, '') === '') return false;
3030
return true;
3131
}
3232

33+
/**
34+
* Material density lookup (g/cm³) for converting filament length to weight.
35+
* Used when Creality printers report usage in cm instead of grams.
36+
* Standard filament diameter: 1.75mm
37+
*/
38+
const MATERIAL_DENSITY: Record<string, number> = {
39+
PLA: 1.24,
40+
'PLA+': 1.24,
41+
PETG: 1.27,
42+
ABS: 1.04,
43+
ASA: 1.07,
44+
TPU: 1.21,
45+
PC: 1.20,
46+
PA: 1.14, // Nylon
47+
'PA-CF': 1.35,
48+
'PA-GF': 1.36,
49+
PVA: 1.23,
50+
HIPS: 1.04,
51+
};
52+
53+
/**
54+
* Convert filament length (cm) to weight (grams).
55+
* Uses filament diameter of 1.75mm and material-specific density.
56+
*/
57+
function lengthToWeight(lengthCm: number, material?: string): number {
58+
const radiusCm = 0.0875; // 1.75mm / 2, converted to cm
59+
const volumeCm3 = Math.PI * radiusCm * radiusCm * lengthCm;
60+
const density = (material && MATERIAL_DENSITY[material.toUpperCase()]) || MATERIAL_DENSITY.PLA;
61+
return volumeCm3 * density;
62+
}
63+
3364
export async function POST(request: NextRequest) {
3465
try {
3566
const body = await request.json();
@@ -71,9 +102,18 @@ export async function POST(request: NextRequest) {
71102

72103
// Handle spool_usage event - deduct filament weight from spool
73104
if (event === 'spool_usage') {
74-
const { used_weight, active_tray_id, tray_uuid } = body;
105+
const { used_weight, used_length, active_tray_id, tray_uuid, material } = body;
106+
107+
// Determine weight to deduct: either directly provided (Bambu) or converted from length (Creality)
108+
let weightToDeduct = used_weight;
109+
let lengthConverted = false;
110+
if ((!weightToDeduct || weightToDeduct <= 0) && used_length && used_length > 0) {
111+
weightToDeduct = lengthToWeight(used_length, material);
112+
lengthConverted = true;
113+
console.log(`Converted ${used_length}cm to ${weightToDeduct.toFixed(2)}g (material: ${material || 'PLA default'})`);
114+
}
75115

76-
if (!used_weight || used_weight <= 0) {
116+
if (!weightToDeduct || weightToDeduct <= 0) {
77117
return NextResponse.json({ status: 'ignored', reason: 'no weight to deduct' });
78118
}
79119

@@ -102,18 +142,29 @@ export async function POST(request: NextRequest) {
102142
});
103143
}
104144

145+
// If we have a matched spool and converted from length, try to use the spool's
146+
// actual filament density for a more accurate conversion
147+
if (lengthConverted && matchedSpool.filament?.material) {
148+
const betterWeight = lengthToWeight(used_length, matchedSpool.filament.material);
149+
if (betterWeight !== weightToDeduct) {
150+
console.log(`Refined conversion using spool material ${matchedSpool.filament.material}: ${weightToDeduct.toFixed(2)}g -> ${betterWeight.toFixed(2)}g`);
151+
weightToDeduct = betterWeight;
152+
}
153+
}
154+
105155
// Deduct the used weight from the spool
106-
await client.useWeight(matchedSpool.id, used_weight);
156+
await client.useWeight(matchedSpool.id, weightToDeduct);
107157

108158
// Check low filament alerts (fire-and-forget)
109159
checkAndUpdateAlerts().catch(err => console.error('Alert check failed:', err));
110160

111-
console.log(`Deducted ${used_weight}g from spool #${matchedSpool.id} (${matchedSpool.filament.name})`);
161+
const deductionNote = lengthConverted ? ` (converted from ${used_length}cm)` : '';
162+
console.log(`Deducted ${weightToDeduct.toFixed(2)}g${deductionNote} from spool #${matchedSpool.id} (${matchedSpool.filament.name})`);
112163

113-
// Store the spool serial number (tray_uuid) if we have a valid one
164+
// Store the spool serial/RFID if we have a valid one
114165
// This enables future auto-matching when the same spool is reinserted
115-
// tray_uuid is the Bambu spool serial number, unique per physical spool
116-
// (unlike tag_uid which differs per RFID tag - each spool has 2 tags)
166+
// For Bambu: tray_uuid is the spool serial (unique per physical spool)
167+
// For Creality: rfid is a numeric RFID tag ID
117168
let tagStored = false;
118169
if (isValidTrayUuid(tray_uuid)) {
119170
// Check if this spool already has this serial number stored
@@ -149,19 +200,20 @@ export async function POST(request: NextRequest) {
149200
type: 'usage',
150201
spoolId: matchedSpool.id,
151202
spoolName: matchedSpool.filament.name,
152-
deducted: used_weight,
153-
newWeight: matchedSpool.remaining_weight - used_weight,
203+
deducted: weightToDeduct,
204+
newWeight: matchedSpool.remaining_weight - weightToDeduct,
154205
trayId: active_tray_id,
155206
timestamp: Date.now(),
156207
};
157208
spoolEvents.emit(SPOOL_UPDATED, updateEvent);
158209

159210
await createActivityLog({
160211
type: 'spool_usage',
161-
message: `Deducted ${used_weight}g from spool #${matchedSpool.id} (${matchedSpool.filament.name})`,
212+
message: `Deducted ${weightToDeduct.toFixed(2)}g${deductionNote} from spool #${matchedSpool.id} (${matchedSpool.filament.name})`,
162213
details: {
163214
spoolId: matchedSpool.id,
164-
usedWeight: used_weight,
215+
usedWeight: weightToDeduct,
216+
...(lengthConverted && { usedLengthCm: used_length }),
165217
trayId: active_tray_id,
166218
tagStored,
167219
},
@@ -170,8 +222,8 @@ export async function POST(request: NextRequest) {
170222
return NextResponse.json({
171223
status: 'success',
172224
spoolId: matchedSpool.id,
173-
deducted: used_weight,
174-
newRemainingWeight: matchedSpool.remaining_weight - used_weight,
225+
deducted: weightToDeduct,
226+
newRemainingWeight: matchedSpool.remaining_weight - weightToDeduct,
175227
tagStored,
176228
});
177229
}
@@ -186,7 +238,8 @@ export async function POST(request: NextRequest) {
186238

187239
// Check if tray is now empty (no filament, or explicitly "Empty")
188240
// ha-bambulab reports name="Empty" when tray has no filament
189-
const trayIsEmpty = !name || name.toLowerCase() === 'empty' || name === '';
241+
// ha_creality_ws reports empty string or no name when slot is empty
242+
const trayIsEmpty = !name || name.toLowerCase() === 'empty' || name === '' || name === 'unavailable';
190243

191244
if (trayIsEmpty) {
192245
// Auto-unassign any spool currently assigned to this tray

0 commit comments

Comments
 (0)