Skip to content

Commit bc1098a

Browse files
committed
feat: add camera stream detection fallback
1 parent fa31e7b commit bc1098a

5 files changed

Lines changed: 161 additions & 23 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [1.3.0] - 2026-03-21
11+
1012
### Added
1113

12-
- `FlashForgeA4Client` — dedicated Adventurer 4 Lite / Pro TCP client aligned with the documented M601 and M115 behavior
14+
- `Endpoints.CAMERA_STREAM_PORT` - exported constant for the known FlashForge OEM MJPEG stream port
15+
- `FiveMClient.detectCameraStream()` - probes `http://<printer-ip>:8080/?action=stream` and falls back from `HEAD` to `GET` when firmware does not report `cameraStreamUrl`
16+
- Vitest coverage for camera stream probing success, `HEAD` timeout fallback, and no-camera behavior
17+
- `FlashForgeA4Client` - dedicated Adventurer 4 Lite / Pro TCP client aligned with the documented M601 and M115 behavior
1318
- `A4BuildVolume`, `A4FileEntry`, `A4PrinterInfo`, and `A4PrinterVariant` types for typed Adventurer 4 responses
1419

1520
### Changed
@@ -21,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2126

2227
### Added
2328

24-
- `FiveMClient.cameraStreamUrl` caches the OEM camera stream URL reported by the printer in machine-info responses, cleared on dispose
29+
- `FiveMClient.cameraStreamUrl` — caches the OEM camera stream URL reported by the printer in machine-info responses, cleared on dispose
2530

2631
### Changed
2732

@@ -31,17 +36,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3136

3237
### Added
3338

34-
- `FlashForgeA3Client` full Adventurer 3 TCP client aligned with the documented G-code protocol, exported from the package root
35-
- `A3GCodeController` A3-specific G-code command controller with a dedicated instruction set
39+
- `FlashForgeA3Client` — full Adventurer 3 TCP client aligned with the documented G-code protocol, exported from the package root
40+
- `A3GCodeController` — A3-specific G-code command controller with a dedicated instruction set
3641
- `A3BuildVolume`, `A3FileEntry`, `A3PrinterInfo`, `A3Thumbnail` types for typed Adventurer 3 responses
3742
- `GCodeClientCapabilities` interface for capability-based client selection across printer generations
3843
- `PrinterModel`, `DiscoveryProtocol`, `PrinterStatus` enums providing fully-typed discovery results
3944
- `DiscoveredPrinter` and `DiscoveryOptions` TypeScript interfaces replacing loosely-typed discovery objects
40-
- `DiscoveryErrors` custom error class hierarchy for structured discovery error handling
45+
- `DiscoveryErrors` — custom error class hierarchy for structured discovery error handling
4146
- PID-based legacy model fallback in `PrinterDiscovery`: known USB product IDs (`0x0008` Adventurer 3, `0x001e` Adventurer 4 Pro) are used as a secondary hint when name heuristics are inconclusive
42-
- `FlashForgeTcpClient.uploadFile()` M28 / raw-binary / M29 file upload flow for legacy printers, with automatic filename normalization
43-
- `FiveMClientConnectionOptions` optional HTTP port and TCP port overrides for `FiveMClient` construction
44-
- `FlashForgeTcpClientOptions` optional TCP port override for `FlashForgeTcpClient` construction
47+
- `FlashForgeTcpClient.uploadFile()` — M28 / raw-binary / M29 file upload flow for legacy printers, with automatic filename normalization
48+
- `FiveMClientConnectionOptions` — optional HTTP port and TCP port overrides for `FiveMClient` construction
49+
- `FlashForgeTcpClientOptions` — optional TCP port override for `FlashForgeTcpClient` construction
4550
- Vitest test suite with unit coverage for discovery, client lifecycle, and response parsers
4651
- Biome linter and formatter configuration
4752

@@ -66,17 +71,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6671

6772
### Added
6873

69-
- `FiveMClient` modern HTTP/JSON API client for Adventurer 5M and Adventurer 5M Pro
70-
- `FlashForgeClient` / `FlashForgeTcpClient` legacy TCP G-code client base
71-
- `FlashForgePrinterDiscovery` UDP multicast and broadcast discovery covering all FlashForge models
72-
- `Control`, `Files`, `Info`, `JobControl`, `TempControl` modern API action classes for printer control
73-
- `Filament` filament data accessor for modern printers
74-
- `GCodeController` G-code command controller for legacy TCP printers
75-
- `GCodes`, `Commands`, `Endpoints` G-code and HTTP API constant tables
76-
- `EndstopStatus`, `LocationInfo`, `PrintStatus`, `TempInfo`, `ThumbnailInfo` TCP response parsers and models
77-
- `MachineInfo` unified machine state model with AD5X material station support
78-
- `FNetCode`, `NetworkUtils` network response code constants and HTTP utility helpers
79-
- `FFMachineInfo`, `FFPrinterDetail`, `MatlStationInfo`, `SlotInfo` typed models for printer detail responses
74+
- `FiveMClient` — modern HTTP/JSON API client for Adventurer 5M and Adventurer 5M Pro
75+
- `FlashForgeClient` / `FlashForgeTcpClient` — legacy TCP G-code client base
76+
- `FlashForgePrinterDiscovery` — UDP multicast and broadcast discovery covering all FlashForge models
77+
- `Control`, `Files`, `Info`, `JobControl`, `TempControl` — modern API action classes for printer control
78+
- `Filament` — filament data accessor for modern printers
79+
- `GCodeController` — G-code command controller for legacy TCP printers
80+
- `GCodes`, `Commands`, `Endpoints` — G-code and HTTP API constant tables
81+
- `EndstopStatus`, `LocationInfo`, `PrintStatus`, `TempInfo`, `ThumbnailInfo` — TCP response parsers and models
82+
- `MachineInfo` — unified machine state model with AD5X material station support
83+
- `FNetCode`, `NetworkUtils` — network response code constants and HTTP utility helpers
84+
- `FFMachineInfo`, `FFPrinterDetail`, `MatlStationInfo`, `SlotInfo` — typed models for printer detail responses
8085
- AD5X material station support: `AD5XLocalJobParams`, `AD5XSingleColorJobParams`, `AD5XUploadParams`, `AD5XMaterialMapping`
8186
- `Product` enum for modern printer model identification
8287
- LED control for legacy TCP clients
@@ -85,14 +90,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8590

8691
### Fixed
8792

88-
- Homing command (`G28`) incorrectly triggering a short timeout extended to 15 s
93+
- Homing command (`G28`) incorrectly triggering a short timeout — extended to 15 s
8994
- M661 local file list response parsing rewritten to handle varied firmware delimiter patterns
9095
- `Commands` / `Endpoints` constant lookup inconsistencies on initial port
9196
- `FlashForgeTcpClient` shutdown race condition
9297
- AD5X job info parsing returning incomplete data
9398
- `NetworkUtils.isOk` usage corrected across response handlers
9499

95-
[Unreleased]: https://github.com/GhostTypes/ff-5mp-api-ts/compare/v1.2.0...HEAD
100+
[Unreleased]: https://github.com/GhostTypes/ff-5mp-api-ts/compare/v1.3.0...HEAD
101+
[1.3.0]: https://github.com/GhostTypes/ff-5mp-api-ts/compare/v1.2.0...v1.3.0
96102
[1.2.0]: https://github.com/GhostTypes/ff-5mp-api-ts/compare/v1.1.0...v1.2.0
97103
[1.1.0]: https://github.com/GhostTypes/ff-5mp-api-ts/compare/v1.0.0...v1.1.0
98104
[1.0.0]: https://github.com/GhostTypes/ff-5mp-api-ts/releases/tag/v1.0.0

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ghosttypes/ff-api",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"description": "FlashForge 3D Printer API for Node.js",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/FiveMClient.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ const { flashForgeClientConstructor, axiosCreate } = vi.hoisted(() => ({
1111
axiosCreate: vi.fn(),
1212
}));
1313

14+
const httpPost = vi.fn();
15+
const httpHead = vi.fn();
16+
const httpGet = vi.fn();
17+
1418
vi.mock('./tcpapi/FlashForgeClient', () => ({
1519
FlashForgeClient: flashForgeClientConstructor,
1620
}));
@@ -32,7 +36,9 @@ describe('FiveMClient', () => {
3236
};
3337
});
3438
axiosCreate.mockReturnValue({
35-
post: vi.fn(),
39+
post: httpPost,
40+
head: httpHead,
41+
get: httpGet,
3642
});
3743
});
3844

@@ -71,4 +77,58 @@ describe('FiveMClient', () => {
7177
expect(client.cacheDetails(machineInfo)).toBe(true);
7278
expect(client.cameraStreamUrl).toBe('http://192.168.1.10:8080/?action=stream');
7379
});
80+
81+
it('detects the OEM camera stream from a successful HEAD probe', async () => {
82+
const client = new FiveMClient('192.168.1.10', 'SN-1', 'CHK-1');
83+
84+
httpHead.mockResolvedValue({
85+
status: 200,
86+
headers: {
87+
'content-type': 'multipart/x-mixed-replace; boundary=frame',
88+
},
89+
});
90+
91+
await expect(client.detectCameraStream()).resolves.toBe('http://192.168.1.10:8080/?action=stream');
92+
expect(httpHead).toHaveBeenCalledTimes(1);
93+
expect(httpGet).not.toHaveBeenCalled();
94+
});
95+
96+
it('falls back to GET when HEAD does not detect a stream', async () => {
97+
const client = new FiveMClient('192.168.1.10', 'SN-1', 'CHK-1');
98+
const destroy = vi.fn();
99+
100+
httpHead.mockResolvedValue({
101+
status: 405,
102+
headers: {},
103+
});
104+
httpGet.mockResolvedValue({
105+
status: 200,
106+
headers: {},
107+
data: { destroy },
108+
});
109+
110+
await expect(client.detectCameraStream()).resolves.toBe('http://192.168.1.10:8080/?action=stream');
111+
expect(httpHead).toHaveBeenCalledTimes(1);
112+
expect(httpGet).toHaveBeenCalledTimes(1);
113+
expect(destroy).toHaveBeenCalledTimes(1);
114+
});
115+
116+
it('returns an empty string when neither probe method finds a camera stream', async () => {
117+
const client = new FiveMClient('192.168.1.10', 'SN-1', 'CHK-1');
118+
const destroy = vi.fn();
119+
120+
httpHead.mockRejectedValue(new Error('timeout'));
121+
httpGet.mockResolvedValue({
122+
status: 404,
123+
headers: {
124+
'content-type': 'text/html',
125+
},
126+
data: { destroy },
127+
});
128+
129+
await expect(client.detectCameraStream()).resolves.toBe('');
130+
expect(httpHead).toHaveBeenCalledTimes(1);
131+
expect(httpGet).toHaveBeenCalledTimes(1);
132+
expect(destroy).toHaveBeenCalledTimes(1);
133+
});
74134
});

src/FiveMClient.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,27 @@ export class FiveMClient {
166166
await this.tcpClient.dispose();
167167
}
168168

169+
/**
170+
* Probes the printer's known OEM camera endpoint.
171+
* Falls back to a short GET when HEAD is unsupported so MJPEG servers
172+
* that reject HEAD requests are still detected.
173+
* @param timeoutMs Timeout for each probe attempt in milliseconds.
174+
* @returns The working camera stream URL, or an empty string if not detected.
175+
*/
176+
public async detectCameraStream(timeoutMs: number = 3000): Promise<string> {
177+
const probeUrl = `http://${this.ipAddress}:${Endpoints.CAMERA_STREAM_PORT}/?action=stream`;
178+
179+
if (await this.probeCameraStreamWithHead(probeUrl, timeoutMs)) {
180+
return probeUrl;
181+
}
182+
183+
if (await this.probeCameraStreamWithGet(probeUrl, timeoutMs)) {
184+
return probeUrl;
185+
}
186+
187+
return '';
188+
}
189+
169190
/**
170191
* Caches machine details from the provided FFMachineInfo object.
171192
* @param info The FFMachineInfo object containing printer details.
@@ -200,6 +221,55 @@ export class FiveMClient {
200221
return `http://${this.ipAddress}:${this.PORT}${endpoint}`;
201222
}
202223

224+
private async probeCameraStreamWithHead(probeUrl: string, timeoutMs: number): Promise<boolean> {
225+
try {
226+
const response = await this.httpClient.head(probeUrl, {
227+
timeout: timeoutMs,
228+
validateStatus: () => true,
229+
});
230+
return this.isValidCameraProbeResponse(response.status, response.headers);
231+
} catch {
232+
return false;
233+
}
234+
}
235+
236+
private async probeCameraStreamWithGet(probeUrl: string, timeoutMs: number): Promise<boolean> {
237+
let responseData: { destroy?: () => void } | undefined;
238+
239+
try {
240+
const response = await this.httpClient.get(probeUrl, {
241+
timeout: timeoutMs,
242+
responseType: 'stream',
243+
validateStatus: () => true,
244+
});
245+
responseData =
246+
response.data && typeof response.data === 'object' ? (response.data as { destroy?: () => void }) : undefined;
247+
return this.isValidCameraProbeResponse(response.status, response.headers);
248+
} catch {
249+
return false;
250+
} finally {
251+
responseData?.destroy?.();
252+
}
253+
}
254+
255+
private isValidCameraProbeResponse(
256+
status: number,
257+
headers: Record<string, string | string[] | undefined>
258+
): boolean {
259+
if (status !== 200) {
260+
return false;
261+
}
262+
263+
const rawContentType = headers['content-type'];
264+
const contentType = (
265+
Array.isArray(rawContentType) ? rawContentType.join(',') : rawContentType || ''
266+
).toLowerCase();
267+
268+
return (
269+
contentType === '' || contentType.includes('multipart') || contentType.includes('video/x-mjpeg')
270+
);
271+
}
272+
203273
/**
204274
* Verifies the connection to the printer by retrieving machine details and TCP information.
205275
* @returns A Promise that resolves to true if the connection is verified, false otherwise.

src/api/server/Endpoints.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
* for various API requests.
1212
*/
1313
export class Endpoints {
14+
/** Known OEM camera stream port exposed by FlashForge firmware. */
15+
static readonly CAMERA_STREAM_PORT = 8080;
1416
/** Endpoint for sending control commands to the printer (e.g., light control, job control, temperature control). */
1517
static readonly Control = '/control';
1618
/** Endpoint for retrieving detailed information and status about the printer. */

0 commit comments

Comments
 (0)