Skip to content

Commit ab6dc14

Browse files
committed
feat: add standalone Discord webhooks and remove legacy per-printer config keys
1 parent c08c8cc commit ab6dc14

20 files changed

Lines changed: 1142 additions & 218 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Discord webhook notifications for the standalone WebUI with:
13+
- Global config keys in `config.json`: `DiscordSync`, `WebhookUrl`, `DiscordUpdateIntervalMinutes`
14+
- Multi-printer periodic status updates using a single shared timer
15+
- Event-driven notifications for print completion, printer cooled, and idle transitions
16+
- Status embeds using precise elapsed seconds and firmware ETA when available
17+
- Focused Discord notification service tests covering timer behavior, multi-context sends, and payload formatting
18+
19+
### Changed
20+
21+
- Legacy per-printer settings in `config.json` are now treated as stale keys only and stripped on save
22+
- Printer connection and backend selection now use per-printer `forceLegacyMode` instead of the removed global `ForceLegacyAPI`
23+
- Camera configuration resolution now uses only per-printer settings from saved printer details
24+
25+
### Removed
26+
27+
- Legacy global config ownership for `CustomCamera`, `CustomCameraUrl`, `CustomLeds`, `ForceLegacyAPI`, and `CameraProxyPort`
28+
1029
## [1.0.2] - 2026-01-31
1130

1231
### Added

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ class Service extends EventEmitter<EventMap> {
271271

272272
1. **Dual Build System**: Backend and frontend have separate `tsconfig.json` files with different module systems (CommonJS vs ES modules)
273273
2. **Data Directory**: Not in git but can be manually accessed for debugging. Default location is `<project>/data/`
274-
3. **Port Allocation**: Camera proxies dynamically allocate ports starting from config value (`CameraProxyPort`)
274+
3. **Camera Streams**: go2rtc manages camera streams per context; there is no user-facing global `CameraProxyPort` setting anymore
275275
4. **Polling Frequency**: All contexts poll at 3 seconds (changed from 30s for inactive contexts to prevent TCP keep-alive failures)
276276
5. **Context IDs**: UUID-based, generated during connection. Not tied to IP or serial number
277277
6. **Backend Lifecycle**: Backends are created per context, not shared. Each context has its own TCP connection

src/index.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { getMultiContextPollingCoordinator } from './services/MultiContextPollin
2323
import { getMultiContextPrintStateMonitor } from './services/MultiContextPrintStateMonitor';
2424
import { getMultiContextTemperatureMonitor } from './services/MultiContextTemperatureMonitor';
2525
import { getMultiContextSpoolmanTracker } from './services/MultiContextSpoolmanTracker';
26+
import { getDiscordNotificationService } from './services/discord';
2627
import { getGo2rtcService } from './services/Go2rtcService';
2728
import { initializeSpoolmanIntegrationService } from './services/SpoolmanIntegrationService';
2829
import { getSavedPrinterService } from './services/SavedPrinterService';
@@ -43,6 +44,7 @@ const pollingCoordinator = getMultiContextPollingCoordinator();
4344
const savedPrinterService = getSavedPrinterService();
4445
const webUIManager = getWebUIManager();
4546
const go2rtcService = getGo2rtcService();
47+
const discordService = getDiscordNotificationService();
4648

4749
let connectedContexts: string[] = [];
4850
let isInitialized = false;
@@ -230,6 +232,10 @@ function setupEventForwarding(): void {
230232
// For WebUI (single-printer or multi-printer), forward all context data
231233
// The WebUI/WebSocket layer will handle filtering if needed
232234
pollingCoordinator.on('polling-data', (contextId: string, data: any) => {
235+
if (data?.printerStatus) {
236+
discordService.updatePrinterStatus(contextId, data.printerStatus);
237+
}
238+
233239
// Forward all polling data regardless of active context
234240
// This ensures data reaches the WebUI even if active context isn't set yet
235241
webUIManager.handlePollingUpdate(data);
@@ -372,12 +378,17 @@ async function shutdown(): Promise<void> {
372378

373379
try {
374380
// Step 1: Stop polling (immediate)
375-
console.log('[Shutdown] Step 1/4: Stopping polling...');
381+
console.log('[Shutdown] Step 1/5: Stopping polling...');
376382
pollingCoordinator.stopAllPolling();
377383
console.log('[Shutdown] Polling stopped');
378384

379-
// Step 2: Parallel disconnects (all printers disconnect concurrently)
380-
console.log(`[Shutdown] Step 2/4: Disconnecting ${connectedContexts.length} context(s)...`);
385+
// Step 2: Stop Discord notifications
386+
console.log('[Shutdown] Step 2/5: Stopping Discord notifications...');
387+
discordService.dispose();
388+
console.log('[Shutdown] Discord notifications stopped');
389+
390+
// Step 3: Parallel disconnects (all printers disconnect concurrently)
391+
console.log(`[Shutdown] Step 3/5: Disconnecting ${connectedContexts.length} context(s)...`);
381392
if (connectedContexts.length > 0) {
382393
const results = await Promise.allSettled(connectedContexts.map((contextId) => connectionManager.disconnectContext(contextId)));
383394

@@ -396,13 +407,13 @@ async function shutdown(): Promise<void> {
396407
console.log('[Shutdown] No contexts to disconnect');
397408
}
398409

399-
// Step 3: Stop go2rtc camera streaming service
400-
console.log('[Shutdown] Step 3/4: Stopping camera streaming...');
410+
// Step 4: Stop go2rtc camera streaming service
411+
console.log('[Shutdown] Step 4/5: Stopping camera streaming...');
401412
await go2rtcService.shutdown();
402413
console.log('[Shutdown] Camera streaming stopped');
403414

404-
// Step 4: Stop WebUI (with timeout and force-close fallback)
405-
console.log('[Shutdown] Step 4/4: Stopping WebUI...');
415+
// Step 5: Stop WebUI (with timeout and force-close fallback)
416+
console.log('[Shutdown] Step 5/5: Stopping WebUI...');
406417
await webUIManager.stop(SHUTDOWN_CONFIG.WEBUI_STOP_TIMEOUT_MS);
407418
console.log('[Shutdown] WebUI stopped');
408419

@@ -473,6 +484,9 @@ async function main(): Promise<void> {
473484
getMultiContextPrintStateMonitor();
474485
console.log('[Init] Print state monitor initialized');
475486

487+
discordService.initialize();
488+
console.log('[Init] Discord notification service initialized');
489+
476490
// 9. Initialize Spoolman usage tracking
477491
const multiContextSpoolmanTracker = getMultiContextSpoolmanTracker();
478492
multiContextSpoolmanTracker.initialize();
@@ -524,11 +538,26 @@ async function main(): Promise<void> {
524538

525539
console.log(`[Events] Created PrintStateMonitor for context ${contextId}`);
526540

527-
// STEP 4: Create SpoolmanTracker for this context (depends on PrintStateMonitor)
541+
// STEP 4: Create TemperatureMonitor for this context
542+
const temperatureMonitor = getMultiContextTemperatureMonitor();
543+
temperatureMonitor.createMonitorForContext(contextId, pollingService, stateMonitor);
544+
const contextTemperatureMonitor = temperatureMonitor.getMonitor(contextId);
545+
546+
if (!contextTemperatureMonitor) {
547+
console.error('[Events] Failed to create temperature monitor');
548+
return;
549+
}
550+
551+
console.log(`[Events] Created TemperatureMonitor for context ${contextId}`);
552+
553+
// STEP 5: Create SpoolmanTracker for this context (depends on PrintStateMonitor)
528554
const spoolmanTracker = getMultiContextSpoolmanTracker();
529555
spoolmanTracker.createTrackerForContext(contextId, stateMonitor);
530556

531557
console.log(`[Events] Created SpoolmanTracker for context ${contextId}`);
558+
discordService.registerContext(contextId);
559+
discordService.attachContextMonitors(contextId, stateMonitor, contextTemperatureMonitor);
560+
console.log(`[Events] Registered Discord notifications for context ${contextId}`);
532561
void reconcileCameraStream(contextId);
533562
console.log(`[Events] All services initialized for context ${contextId}`);
534563
} catch (error) {

src/managers/ConfigManager.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ describe('ConfigManager', () => {
2727
WebUIPort: 3000,
2828
WebUIPassword: 'testpass',
2929
WebUIPasswordRequired: true,
30-
CameraProxyPort: 8181,
30+
DiscordSync: false,
31+
WebhookUrl: '',
32+
DiscordUpdateIntervalMinutes: 5,
3133
SpoolmanEnabled: false,
3234
SpoolmanServerUrl: ''
3335
}));
@@ -75,7 +77,9 @@ describe('ConfigManager', () => {
7577
expect(config).toHaveProperty('WebUIPort');
7678
expect(config).toHaveProperty('WebUIPassword');
7779
expect(config).toHaveProperty('WebUIPasswordRequired');
78-
expect(config).toHaveProperty('CameraProxyPort');
80+
expect(config).toHaveProperty('DiscordSync');
81+
expect(config).toHaveProperty('WebhookUrl');
82+
expect(config).toHaveProperty('DiscordUpdateIntervalMinutes');
7983
expect(config).toHaveProperty('SpoolmanEnabled');
8084
expect(config).toHaveProperty('SpoolmanServerUrl');
8185
});
@@ -87,7 +91,9 @@ describe('ConfigManager', () => {
8791
expect(typeof config.WebUIPort).toBe('number');
8892
expect(typeof config.WebUIPassword).toBe('string');
8993
expect(typeof config.WebUIPasswordRequired).toBe('boolean');
90-
expect(typeof config.CameraProxyPort).toBe('number');
94+
expect(typeof config.DiscordSync).toBe('boolean');
95+
expect(typeof config.WebhookUrl).toBe('string');
96+
expect(typeof config.DiscordUpdateIntervalMinutes).toBe('number');
9197
expect(typeof config.SpoolmanEnabled).toBe('boolean');
9298
expect(typeof config.SpoolmanServerUrl).toBe('string');
9399
});
@@ -163,8 +169,7 @@ describe('ConfigManager', () => {
163169

164170
expect(config.WebUIPort).toBeGreaterThanOrEqual(1);
165171
expect(config.WebUIPort).toBeLessThanOrEqual(65535);
166-
expect(config.CameraProxyPort).toBeGreaterThanOrEqual(1);
167-
expect(config.CameraProxyPort).toBeLessThanOrEqual(65535);
172+
expect(config.DiscordUpdateIntervalMinutes).toBeGreaterThanOrEqual(0);
168173
});
169174

170175
it('should have valid URL format for SpoolmanServerUrl', () => {

src/managers/ConnectionFlowManager.ts

Lines changed: 36 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ import {
5252
} from '../utils/PrinterUtils';
5353
import { TimeoutError, withTimeout } from '../utils/ShutdownTimeout';
5454
import { IPAddressSchema } from '../utils/validation.utils';
55-
import { getConfigManager } from './ConfigManager';
5655
import { getLoadingManager } from './LoadingManager';
5756
import { getPrinterBackendManager } from './PrinterBackendManager';
5857
import { getPrinterContextManager } from './PrinterContextManager';
@@ -80,7 +79,6 @@ interface ConnectionFlowState {
8079
}
8180

8281
export class ConnectionFlowManager extends EventEmitter {
83-
private readonly configManager = getConfigManager();
8482
private readonly loadingManager = getLoadingManager();
8583
private readonly backendManager = getPrinterBackendManager();
8684
private readonly contextManager = getPrinterContextManager();
@@ -107,11 +105,6 @@ export class ConnectionFlowManager extends EventEmitter {
107105

108106
/** Setup internal event handlers and service event forwarding */
109107
private setupEventHandlers(): void {
110-
// Forward configuration changes
111-
this.configManager.on('config:ForceLegacyAPI', (newValue: boolean) => {
112-
this.emit('force-legacy-changed', newValue);
113-
});
114-
115108
// Forward backend manager events
116109
this.forwardEvents(this.backendManager, [
117110
'backend-initialized',
@@ -585,7 +578,6 @@ export class ConnectionFlowManager extends EventEmitter {
585578
// Step 2: Detect printer family and requirements
586579
const familyInfo = detectPrinterFamily(tempResult.typeName);
587580
const clientType = determineClientType(familyInfo.is5MFamily);
588-
const ForceLegacyAPI = this.configManager.get('ForceLegacyAPI') || false;
589581

590582
this.emit('printer-type-detected', {
591583
typeName: tempResult.typeName,
@@ -599,34 +591,7 @@ export class ConnectionFlowManager extends EventEmitter {
599591
? tempResult.printerInfo.Name
600592
: discoveredPrinter.name;
601593

602-
// Step 3: Handle check code requirements
603-
let checkCode = getDefaultCheckCode();
604-
605-
// Check if this printer has a saved check code
606-
const savedCheckCode = this.savedPrinterService.getSavedCheckCode(
607-
discoveredPrinter.serialNumber
608-
);
609-
610-
if (savedCheckCode) {
611-
console.log('Using saved check code for known printer:', realPrinterName);
612-
checkCode = savedCheckCode;
613-
} else if (shouldPromptForCheckCode(familyInfo.is5MFamily, undefined, ForceLegacyAPI)) {
614-
this.loadingManager.hide();
615-
616-
const promptedCheckCode = await this.promptForCheckCode(realPrinterName);
617-
if (!promptedCheckCode) {
618-
this.loadingManager.showError('Printer pairing cancelled', 2000);
619-
return { success: false, error: 'Connection cancelled by user' };
620-
}
621-
checkCode = promptedCheckCode;
622-
623-
this.loadingManager.show({
624-
message: 'Establishing connection with pairing code...',
625-
canCancel: false,
626-
});
627-
}
628-
629-
// Step 4: Extract and validate printer information
594+
// Step 3: Extract and validate printer information
630595
this.loadingManager.updateMessage('Processing printer details...');
631596
const modelType = detectPrinterModelType(tempResult.typeName);
632597

@@ -649,6 +614,32 @@ export class ConnectionFlowManager extends EventEmitter {
649614
serialNumber = `Unknown-${Date.now()}`;
650615
}
651616

617+
const existingPrinter = this.savedPrinterService.getSavedPrinter(serialNumber);
618+
const forceLegacyMode = existingPrinter?.forceLegacyMode ?? false;
619+
620+
// Step 4: Handle check code requirements
621+
let checkCode = getDefaultCheckCode();
622+
const savedCheckCode = existingPrinter?.CheckCode;
623+
624+
if (savedCheckCode) {
625+
console.log('Using saved check code for known printer:', realPrinterName);
626+
checkCode = savedCheckCode;
627+
} else if (shouldPromptForCheckCode(familyInfo.is5MFamily, undefined, forceLegacyMode)) {
628+
this.loadingManager.hide();
629+
630+
const promptedCheckCode = await this.promptForCheckCode(realPrinterName);
631+
if (!promptedCheckCode) {
632+
this.loadingManager.showError('Printer pairing cancelled', 2000);
633+
return { success: false, error: 'Connection cancelled by user' };
634+
}
635+
checkCode = promptedCheckCode;
636+
637+
this.loadingManager.show({
638+
message: 'Establishing connection with pairing code...',
639+
canCancel: false,
640+
});
641+
}
642+
652643
// Update the discoveredPrinter object with the correct information for connection establishment
653644
const updatedDiscoveredPrinter: DiscoveredPrinter = {
654645
...discoveredPrinter,
@@ -671,7 +662,7 @@ export class ConnectionFlowManager extends EventEmitter {
671662
tempResult.typeName,
672663
familyInfo.is5MFamily,
673664
checkCode,
674-
ForceLegacyAPI
665+
forceLegacyMode
675666
);
676667

677668
if (!connectionResult) {
@@ -683,7 +674,6 @@ export class ConnectionFlowManager extends EventEmitter {
683674
this.loadingManager.updateMessage('Saving printer details...');
684675

685676
// Check if printer already exists to preserve per-printer settings
686-
const existingPrinter = this.savedPrinterService.getSavedPrinter(serialNumber);
687677
console.log(
688678
'[ConnectionFlow] Existing printer check for',
689679
serialNumber,
@@ -702,14 +692,14 @@ export class ConnectionFlowManager extends EventEmitter {
702692
IPAddress: discoveredPrinter.ipAddress,
703693
SerialNumber: serialNumber,
704694
CheckCode: checkCode,
705-
ClientType: ForceLegacyAPI ? 'legacy' : clientType,
695+
ClientType: forceLegacyMode ? 'legacy' : clientType,
706696
printerModel: tempResult.typeName,
707697
modelType,
708698
// Preserve existing per-printer settings or use defaults for new printers
709699
customCameraEnabled: existingPrinter?.customCameraEnabled ?? false,
710700
customCameraUrl: existingPrinter?.customCameraUrl ?? '',
711701
customLedsEnabled: existingPrinter?.customLedsEnabled ?? false,
712-
forceLegacyMode: existingPrinter?.forceLegacyMode ?? false,
702+
forceLegacyMode,
713703
activeSpoolData: existingPrinter?.activeSpoolData ?? null,
714704
};
715705

@@ -945,7 +935,6 @@ export class ConnectionFlowManager extends EventEmitter {
945935
console.log(`Initialized default per-printer settings for ${detailsWithDefaults.Name}`);
946936
}
947937

948-
const ForceLegacyAPI = this.configManager.get('ForceLegacyAPI') || false;
949938
const familyInfo = detectPrinterFamily(detailsWithDefaults.printerModel);
950939

951940
// Create a mock discovered printer for connection establishment
@@ -962,7 +951,7 @@ export class ConnectionFlowManager extends EventEmitter {
962951
detailsWithDefaults.printerModel,
963952
familyInfo.is5MFamily,
964953
detailsWithDefaults.CheckCode,
965-
ForceLegacyAPI
954+
detailsWithDefaults.forceLegacyMode ?? false
966955
);
967956

968957
if (!connectionResult) {
@@ -1251,14 +1240,15 @@ export class ConnectionFlowManager extends EventEmitter {
12511240
model: tempResult.typeName,
12521241
};
12531242

1243+
const forceLegacyMode = existingPrinter?.forceLegacyMode ?? false;
1244+
12541245
// Establish final connection
1255-
const ForceLegacyAPI = this.configManager.get('ForceLegacyAPI') || false;
12561246
const connectionResult = await this.connectionService.establishFinalConnection(
12571247
updatedDiscoveredPrinter,
12581248
tempResult.typeName,
12591249
is5MFamily,
12601250
checkCode,
1261-
ForceLegacyAPI
1251+
forceLegacyMode
12621252
);
12631253

12641254
if (!connectionResult) {
@@ -1273,14 +1263,14 @@ export class ConnectionFlowManager extends EventEmitter {
12731263
IPAddress: spec.ip,
12741264
SerialNumber: serialNumber,
12751265
CheckCode: checkCode,
1276-
ClientType: spec.type,
1266+
ClientType: forceLegacyMode ? 'legacy' : spec.type,
12771267
printerModel: tempResult.typeName,
12781268
modelType,
12791269
// Preserve previously configured per-printer overrides when present
12801270
customCameraEnabled: existingPrinter?.customCameraEnabled ?? false,
12811271
customCameraUrl: existingPrinter?.customCameraUrl ?? '',
12821272
customLedsEnabled: existingPrinter?.customLedsEnabled ?? false,
1283-
forceLegacyMode: existingPrinter?.forceLegacyMode ?? false,
1273+
forceLegacyMode,
12841274
};
12851275

12861276
await this.savedPrinterService.savePrinter(printerDetails);

0 commit comments

Comments
 (0)