Skip to content

Commit 3a3f709

Browse files
committed
feat: auto-detect OEM cameras from printer-reported stream URLs
Introduce CameraStreamCoordinator to detect and register OEM camera streams from printer-reported URLs, alongside the broader go2rtc migration, Discord webhook notifications, and E2E test framework. - Add CameraStreamCoordinator service for OEM stream URL detection - Add printerSettingsDefaults utility for consistent per-printer init - Update all backends to expose printer-reported OEM stream URL - Refactor camera-utils and camera routes to use coordinator-driven stream resolution - Add test coverage for coordinator, camera-utils, and defaults - go2rtc-based camera streaming replacing legacy proxy/RTSP stack - Discord webhook notifications with multi-printer status support - Playwright E2E testing framework (fixture + emulator-backed) - Backend bundling via build-backend.ts for pkg-compatible output - Bump version to 1.1.0
1 parent 21c6de7 commit 3a3f709

24 files changed

+517
-187
lines changed

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.1.0] - 2026-03-08
11+
1012
### Added
1113

14+
- `CameraStreamCoordinator` service to detect and register OEM camera streams from printer-reported stream URLs without manual configuration
15+
- `printerSettingsDefaults` utility for consistent per-printer settings initialization across backends
16+
- Test coverage for `camera-utils`, `printerSettingsDefaults`, and OEM stream coordinator behavior
1217
- Playwright E2E testing framework with dual configuration:
1318
- Fixture-based E2E tests (`e2e/`) for fast WebUI validation with a stub HTTP+WebSocket server
1419
- Emulator-backed E2E tests (`e2e-emulator/`) for full lifecycle testing with `flashforge-emulator-v2` printer emulator
@@ -40,6 +45,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4045

4146
### Changed
4247

48+
- Camera configuration resolution now uses `CameraStreamCoordinator` for OEM stream URL detection before falling back to per-printer overrides
49+
- All printer backends updated to expose the printer-reported OEM stream URL for coordinator use
4350
- `type-check` script now runs both `type-check:app` and `type-check:e2e` for full TypeScript validation
4451
- Camera streaming migrated from the legacy proxy/RTSP stack to go2rtc-managed per-context streams
4552
- Frontend camera playback now uses the bundled `video-rtc` player instead of the previous streaming path
@@ -152,7 +159,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
152159
- Optional password authentication
153160
- Configuration persistence in `data/config.json`
154161

155-
[Unreleased]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.0.2...HEAD
162+
[Unreleased]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.1.0...HEAD
163+
[1.1.0]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.0.2...v1.1.0
156164
[1.0.2]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.0.1...v1.0.2
157165
[1.0.1]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.0.0...v1.0.1
158166
[1.0.0]: https://github.com/Parallel-7/FlashForgeWebUI/releases/tag/v1.0.0

data/printer_details.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
"ClientType": "new",
1010
"printerModel": "Flashforge AD5X",
1111
"modelType": "ad5x",
12-
"customCameraEnabled": true,
12+
"customCameraEnabled": false,
1313
"customCameraUrl": "",
1414
"customLedsEnabled": false,
1515
"forceLegacyMode": false,
1616
"lastConnected": "2025-11-23T02:52:58.265Z"
1717
}
1818
}
19-
}
19+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "flashforge-webui",
3-
"version": "1.0.2",
3+
"version": "1.1.0",
44
"description": "Standalone WebUI for FlashForge 3D Printers",
55
"main": "dist/index.js",
66
"bin": "dist/index.js",

src/index.ts

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@ import { getMultiContextPrintStateMonitor } from './services/MultiContextPrintSt
2626
import { getMultiContextSpoolmanTracker } from './services/MultiContextSpoolmanTracker';
2727
import { getMultiContextTemperatureMonitor } from './services/MultiContextTemperatureMonitor';
2828
import { getSavedPrinterService } from './services/SavedPrinterService';
29+
import { resolveAndEnsureCameraStream } from './services/CameraStreamCoordinator';
2930
import { initializeSpoolmanIntegrationService } from './services/SpoolmanIntegrationService';
3031
import type { PollingData } from './types/polling';
3132
import type { PrinterClientType, PrinterDetails } from './types/printer';
32-
import { getCameraUserConfig, resolveCameraConfig } from './utils/camera-utils';
33+
import { getCameraUserConfig } from './utils/camera-utils';
3334
import type { HeadlessConfig, PrinterSpec } from './utils/HeadlessArguments';
3435
import { parseHeadlessArguments, validateHeadlessConfig } from './utils/HeadlessArguments';
36+
import { applyPerPrinterDefaults } from './utils/printerSettingsDefaults';
3537
import { createHardDeadline } from './utils/ShutdownTimeout';
3638
import { initializeDataDirectory } from './utils/setup';
3739
import { getWebUIManager } from './webui/server/WebUIManager';
@@ -104,7 +106,7 @@ async function connectLastUsed(): Promise<string[]> {
104106
);
105107

106108
// Convert StoredPrinterDetails to PrinterDetails
107-
const printerDetails: PrinterDetails = {
109+
const printerDetails: PrinterDetails = applyPerPrinterDefaults({
108110
Name: lastUsedPrinter.Name,
109111
IPAddress: lastUsedPrinter.IPAddress,
110112
SerialNumber: lastUsedPrinter.SerialNumber,
@@ -116,7 +118,7 @@ async function connectLastUsed(): Promise<string[]> {
116118
customCameraUrl: lastUsedPrinter.customCameraUrl,
117119
customLedsEnabled: lastUsedPrinter.customLedsEnabled,
118120
forceLegacyMode: lastUsedPrinter.forceLegacyMode,
119-
};
121+
});
120122

121123
const results = await connectionManager.connectHeadlessFromSaved([printerDetails]);
122124

@@ -137,7 +139,7 @@ async function connectAllSaved(): Promise<string[]> {
137139
console.log(`[Connection] Connecting to ${savedPrinters.length} saved printer(s)...`);
138140

139141
// Convert StoredPrinterDetails to PrinterDetails
140-
const printerDetailsList: PrinterDetails[] = savedPrinters.map((saved) => ({
142+
const printerDetailsList: PrinterDetails[] = savedPrinters.map((saved) => applyPerPrinterDefaults({
141143
Name: saved.Name,
142144
IPAddress: saved.IPAddress,
143145
SerialNumber: saved.SerialNumber,
@@ -261,45 +263,24 @@ async function reconcileCameraStream(contextId: string): Promise<void> {
261263
return;
262264
}
263265

264-
const features = backendManager.getFeatures(contextId);
265-
if (!features) {
266+
const backend = backendManager.getBackendForContext(contextId);
267+
if (!backend) {
266268
await go2rtcService.removeStream(contextId);
267269
return;
268270
}
269271

270-
const cameraConfig = resolveCameraConfig({
272+
const ensuredStream = await resolveAndEnsureCameraStream({
273+
contextId,
271274
printerIpAddress: context.printerDetails.IPAddress,
272-
printerFeatures: features,
275+
printerFeatures: backend.getBackendStatus().features,
273276
userConfig: getCameraUserConfig(contextId),
277+
go2rtcService,
274278
});
275279

276-
if (
277-
!cameraConfig.isAvailable ||
278-
!cameraConfig.streamUrl ||
279-
!cameraConfig.streamType ||
280-
(cameraConfig.sourceType !== 'builtin' && cameraConfig.sourceType !== 'custom')
281-
) {
282-
await go2rtcService.removeStream(contextId);
280+
if (!ensuredStream) {
283281
return;
284282
}
285283

286-
if (
287-
go2rtcService.hasMatchingStream(
288-
contextId,
289-
cameraConfig.streamUrl,
290-
cameraConfig.sourceType,
291-
cameraConfig.streamType
292-
)
293-
) {
294-
return;
295-
}
296-
297-
await go2rtcService.addStream(
298-
contextId,
299-
cameraConfig.streamUrl,
300-
cameraConfig.sourceType,
301-
cameraConfig.streamType
302-
);
303284
console.log(`[Camera] Stream ready for context: ${contextId}`);
304285
} catch (error) {
305286
console.error(`[Camera] Failed to reconcile stream for context ${contextId}:`, error);
@@ -581,6 +562,21 @@ async function main(): Promise<void> {
581562
});
582563
console.log('[Events] Context-updated hook configured');
583564

565+
connectionManager.on('feature-updated', (event: { contextId?: string; changedKeys?: readonly string[] }) => {
566+
const contextId = event.contextId;
567+
if (!contextId) {
568+
return;
569+
}
570+
571+
const changedKeys = event.changedKeys || [];
572+
if (!changedKeys.includes('oemCameraStreamUrl')) {
573+
return;
574+
}
575+
576+
void reconcileCameraStream(contextId);
577+
});
578+
console.log('[Events] Feature-updated hook configured');
579+
584580
connectionManager.on('pre-disconnect', (contextId: string) => {
585581
void go2rtcService.removeStream(contextId);
586582
});

src/managers/ConnectionFlowManager.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
getDefaultCheckCode,
5151
shouldPromptForCheckCode,
5252
} from '../utils/PrinterUtils';
53+
import { applyPerPrinterDefaults } from '../utils/printerSettingsDefaults';
5354
import { TimeoutError, withTimeout } from '../utils/ShutdownTimeout';
5455
import { IPAddressSchema } from '../utils/validation.utils';
5556
import { getLoadingManager } from './LoadingManager';
@@ -687,7 +688,7 @@ export class ConnectionFlowManager extends EventEmitter {
687688
forceLegacyMode: existingPrinter?.forceLegacyMode,
688689
});
689690

690-
const printerDetails: PrinterDetails = {
691+
const printerDetails: PrinterDetails = applyPerPrinterDefaults({
691692
Name: formatPrinterName(printerName, serialNumber),
692693
IPAddress: discoveredPrinter.ipAddress,
693694
SerialNumber: serialNumber,
@@ -703,7 +704,7 @@ export class ConnectionFlowManager extends EventEmitter {
703704
customLedsEnabled: existingPrinter?.customLedsEnabled ?? false,
704705
forceLegacyMode,
705706
activeSpoolData: existingPrinter?.activeSpoolData ?? null,
706-
};
707+
});
707708

708709
console.log('[ConnectionFlow] Final printer details to save:', printerDetails);
709710

@@ -918,21 +919,10 @@ export class ConnectionFlowManager extends EventEmitter {
918919

919920
try {
920921
// Ensure per-printer settings have defaults if not set
921-
const detailsWithDefaults: PrinterDetails = {
922-
...details,
923-
customCameraEnabled: details.customCameraEnabled ?? false,
924-
customCameraUrl: details.customCameraUrl ?? '',
925-
customLedsEnabled: details.customLedsEnabled ?? false,
926-
forceLegacyMode: details.forceLegacyMode ?? false,
927-
};
922+
const detailsWithDefaults: PrinterDetails = applyPerPrinterDefaults(details);
928923

929924
// If we added defaults, save them back to printer_details.json
930-
if (
931-
details.customCameraEnabled === undefined ||
932-
details.customCameraUrl === undefined ||
933-
details.customLedsEnabled === undefined ||
934-
details.forceLegacyMode === undefined
935-
) {
925+
if (JSON.stringify(detailsWithDefaults) !== JSON.stringify(details)) {
936926
await this.savedPrinterService.savePrinter(detailsWithDefaults);
937927
console.log(`Initialized default per-printer settings for ${detailsWithDefaults.Name}`);
938928
}
@@ -1293,7 +1283,7 @@ export class ConnectionFlowManager extends EventEmitter {
12931283
}
12941284

12951285
// Save printer details
1296-
const printerDetails: PrinterDetails = {
1286+
const printerDetails: PrinterDetails = applyPerPrinterDefaults({
12971287
Name: formatPrinterName(printerName, serialNumber),
12981288
IPAddress: spec.ip,
12991289
SerialNumber: serialNumber,
@@ -1308,7 +1298,7 @@ export class ConnectionFlowManager extends EventEmitter {
13081298
customCameraUrl: existingPrinter?.customCameraUrl ?? '',
13091299
customLedsEnabled: existingPrinter?.customLedsEnabled ?? false,
13101300
forceLegacyMode,
1311-
};
1301+
});
13121302

13131303
await this.savedPrinterService.savePrinter(printerDetails);
13141304
await this.savedPrinterService.updateLastConnected(serialNumber);

src/managers/PrinterDetailsManager.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
ValidatedPrinterDetails,
2626
} from '../types/printer';
2727
import { detectPrinterModelType } from '../utils/PrinterUtils';
28+
import { normalizeCustomCameraSettings } from '../utils/printerSettingsDefaults';
2829
import { getDataPath } from '../utils/setup';
2930

3031
/**
@@ -74,6 +75,11 @@ export class PrinterDetailsManager {
7475
* Remove unsupported per-printer keys while preserving current behavior.
7576
*/
7677
private sanitizePrinterDetails(details: PrinterDetails): PrinterDetails {
78+
const normalizedCameraSettings = normalizeCustomCameraSettings({
79+
customCameraEnabled: details.customCameraEnabled,
80+
customCameraUrl: details.customCameraUrl,
81+
});
82+
7783
return {
7884
Name: details.Name,
7985
IPAddress: details.IPAddress,
@@ -84,11 +90,11 @@ export class PrinterDetailsManager {
8490
...(details.modelType ? { modelType: details.modelType } : {}),
8591
...(details.commandPort !== undefined ? { commandPort: details.commandPort } : {}),
8692
...(details.httpPort !== undefined ? { httpPort: details.httpPort } : {}),
87-
...(details.customCameraEnabled !== undefined
88-
? { customCameraEnabled: details.customCameraEnabled }
93+
...(normalizedCameraSettings.customCameraEnabled !== undefined
94+
? { customCameraEnabled: normalizedCameraSettings.customCameraEnabled }
8995
: {}),
90-
...(details.customCameraUrl !== undefined
91-
? { customCameraUrl: details.customCameraUrl }
96+
...(normalizedCameraSettings.customCameraUrl !== undefined
97+
? { customCameraUrl: normalizedCameraSettings.customCameraUrl }
9298
: {}),
9399
...(details.customLedsEnabled !== undefined
94100
? { customLedsEnabled: details.customLedsEnabled }
@@ -307,8 +313,9 @@ export class PrinterDetailsManager {
307313
// Ensure modelType is set if missing
308314
const modelType = oldData.modelType || detectPrinterModelType(oldData.printerModel);
309315

316+
const normalizedOldData = this.sanitizePrinterDetails(oldData);
310317
const storedDetails: StoredPrinterDetails = {
311-
...oldData,
318+
...normalizedOldData,
312319
modelType,
313320
lastConnected: new Date().toISOString(),
314321
};

src/printer-backends/AD5XBackend.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* - Multi-color printing support with material mapping
88
* - AD5X-specific job operations (upload 3MF with material mappings)
99
* - Material station status monitoring (slot contents, active slot, heating status)
10-
* - No built-in camera (custom camera URL supported)
10+
* - OEM camera auto-detection when the printer reports a stream URL
1111
* - Custom LED control via G-code (when enabled)
1212
* - No built-in filtration control
1313
*
@@ -47,7 +47,7 @@ export class AD5XBackend extends DualAPIBackend {
4747
protected getChildBaseFeatures(): PrinterFeatureSet {
4848
return {
4949
camera: {
50-
builtin: false, // AD5X doesn't have built-in camera
50+
oemStreamUrl: '',
5151
customUrl: null,
5252
customEnabled: false,
5353
},
@@ -124,6 +124,8 @@ export class AD5XBackend extends DualAPIBackend {
124124
* Override from DualAPIBackend
125125
*/
126126
protected async processMachineInfo(_machineInfo: unknown): Promise<void> {
127+
await super.processMachineInfo(_machineInfo);
128+
127129
// Store machine info for material station data extraction with type validation
128130
if (isAD5XMachineInfo(_machineInfo)) {
129131
this.lastMachineInfo = _machineInfo;

src/printer-backends/Adventurer5MBackend.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*
44
* Provides backend functionality specific to the Adventurer 5M standard model:
55
* - Dual API support (FiveMClient + FlashForgeClient)
6-
* - No built-in camera (custom camera URL supported)
6+
* - OEM camera auto-detection when the printer reports a stream URL
77
* - LED control via G-code (auto-detected from product endpoint)
88
* - No filtration control (5M standard lacks this feature)
99
* - Full job management capabilities (local/recent jobs, upload, start/pause/resume/cancel)
@@ -15,7 +15,7 @@
1515
*
1616
* This backend extends DualAPIBackend to leverage common dual-API functionality while
1717
* defining model-specific features. The main difference from the Pro model is the lack
18-
* of built-in camera and filtration control features.
18+
* of factory camera and filtration control features.
1919
*/
2020

2121
import type { MaterialStationStatus, PrinterFeatureSet } from '../types/printer-backend';
@@ -33,7 +33,7 @@ export class Adventurer5MBackend extends DualAPIBackend {
3333
protected getChildBaseFeatures(): PrinterFeatureSet {
3434
return {
3535
camera: {
36-
builtin: false,
36+
oemStreamUrl: '',
3737
customUrl: null,
3838
customEnabled: false,
3939
},

src/printer-backends/Adventurer5MProBackend.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*
44
* Provides backend functionality specific to the Adventurer 5M Pro model:
55
* - Dual API support (FiveMClient + FlashForgeClient)
6-
* - Built-in RTSP camera support (rtsp://printer-ip:8554/stream)
6+
* - OEM camera auto-detection when the printer reports a stream URL
77
* - Built-in LED control via new API
88
* - Filtration control (off/internal/external modes)
99
* - Full job management capabilities (local/recent jobs, upload, start/pause/resume/cancel)
@@ -14,8 +14,8 @@
1414
* - Adventurer5MProBackend class: Backend for Adventurer 5M Pro printers
1515
*
1616
* This backend extends DualAPIBackend to leverage common dual-API functionality while
17-
* defining Pro-specific features. Key differences from standard 5M include built-in
18-
* RTSP camera and filtration control capabilities.
17+
* defining Pro-specific features. Key differences from standard 5M include factory
18+
* camera support and filtration control capabilities.
1919
*/
2020

2121
import type { MaterialStationStatus, PrinterFeatureSet } from '../types/printer-backend';
@@ -33,7 +33,7 @@ export class Adventurer5MProBackend extends DualAPIBackend {
3333
protected getChildBaseFeatures(): PrinterFeatureSet {
3434
return {
3535
camera: {
36-
builtin: true,
36+
oemStreamUrl: '',
3737
customUrl: null,
3838
customEnabled: false,
3939
},

0 commit comments

Comments
 (0)