Compare commits

...
Author SHA1 Message Date
Lam Wei Lun ba68e215d6 Merge impl branch to fix conflicts 2026-10-02 11:27:51 +08:00
Ian Chua f6ccc7a9f9 Merge branch 'main' into feat/printer-agent-impl 2026-10-01 15:09:51 +08:00
Ian Chua 6194c4f8bb chore: port add_platform_root_certificates from 16027 2026-10-01 14:21:26 +08:00
peachismomo 5af7140cb0 Merge remote-tracking branch 'origin/main' into feat/printer-agent-impl 2026-10-01 00:51:05 +08:00
peachismomo 1cac04eae8 refactor: separate out networking infra 2026-10-01 00:28:57 +08:00
Ian Chua b935a652b9 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-30 19:31:28 +08:00
Ian Chua a1fd66362b Merge branch 'main' into feat/printer-agent-infra 2026-09-30 19:29:07 +08:00
peachismomo a6cad8a6e5 fix: printer agent and camera shutdown races 2026-09-30 19:27:50 +08:00
Lam Wei Lun ad8c6e741e Removal of specific ORCA_PRINTER_AGENT_ID usage in DeviceManager/SelectMachine/SendMultiMachinePage. Generalized the capabilities under IPrinterAgent 2026-09-30 18:01:17 +08:00
Lam Wei Lun e45d52efd1 Merge impl branch 2026-09-30 12:42:51 +08:00
Lam Wei Lun 543ca95059 fix: make the AMS device UI work on non-Bambu printer agents
Resolve the filament list's printer model from the agent-reported model id or the selected printer profile, so the AMS Materials Setting dropdown and its Confirm button work on OrcaSonar. Third-party trays pre-fill and allow editing their temps, and the popup's initial selection no longer overwrites them.

Render explicitly-empty trays as "Empty" instead of "?" using a wire-derived DevAmsTray::is_empty, while keeping the slot editable.

Hide the flow-dynamics K/N controls for non-Bambu agents — tile, dialog, validation and sends — with Bambu behavior unchanged.

Tests cover model resolution, the K/N gate and empty-tray classification.
2026-09-29 17:47:24 +08:00
Lam Wei Lun 799fbee86c feat: enable filament mapping and resolve printer-set AMS trays by material type
Drop the index-correlation gate so mapped prints are gated only on the filament_mapping capability
Resolve AMS trays set on the printer UI to the matching Generic preset using their material type and color
Skip the forced external-spool selection on printers without an AMS and add coverage for the mapping gates
2026-09-29 14:02:40 +08:00
Ian Chua 9bac75dda5 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-28 21:15:32 +08:00
Ian Chua 50c5ec2862 fix: remove redundant earlier expires_never call 2026-09-28 21:14:22 +08:00
Ian Chua b36a72e16a fix: orca mqtt session timeout after connack 2026-09-28 21:14:21 +08:00
Ian Chua 64371c191e Merge branch 'main' into feat/printer-agent-infra 2026-09-28 20:50:03 +08:00
Ian Chua 94702a5f8d Merge branch 'feat/printer-agent-infra' of https://github.com/OrcaSlicer/OrcaSlicer into feat/printer-agent-infra 2026-09-28 20:48:51 +08:00
Ian Chua 68cdd31852 Merge branch 'main' into feat/printer-agent-infra 2026-09-28 20:48:37 +08:00
Lam Wei Lun adcc470cff feat: add filament mapping and AMS capability gates for the orca printer agent
Parse connector-scope capabilities from get_capabilities: supported_features, capabilities.protocol.features and supported_commands are read for orca devices even when the reply has no flags block, reset before each parse, and left untouched for Bambu.

Gate macro-backed AMS commands on fms plus the advertised command (change filament, select tray, control, user settings, RFID, drying stop); gate slot metadata writes on filament_slots. Unknown capability is not support.

Serialize PrintParams::ams_mapping2 into print.gcode_file.filament_mapping with the array position as filament_index and {255,255} dropped. The field is omitted when empty, so an unmapped print stays byte-identical. A mapped print is refused visibly while ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED is false or the connector did not advertise filament_mapping; the GUI predicate shares the serializer's definition so both gates agree. Refuse a partially mapped print that leaves a used filament without a target.

Return a non-success result from start_local_print_with_record so PrintJob falls back to start_print instead of treating an unsent print as success.

Tests cover serializer keying and omission, GUI/agent gate agreement, payload shape, the with_record result, capability parsing, and the per-command AMS gate.
2026-09-26 22:50:58 +08:00
Ian Chua 87eb8de001 fix: serialize MQTT ws I/O and dewcouple keepalive 2026-09-24 20:28:13 +08:00
Ian Chua 220508534a fix: cancel inflight cloud signaling requests 2026-09-24 19:03:58 +08:00
Ian Chua 5a0810365b fix: clarify OrcaCloud signaling endpoint contract 2026-09-24 18:18:48 +08:00
Ian Chua fa98ec5afe feat: add ssl and ca_cert for OrcaPrinterAgent 2026-09-24 17:53:46 +08:00
Ian Chua 3c1d8ac8b4 fix: allow input ip address dialog to validate if is valid endpoint 2026-09-24 17:53:12 +08:00
Ian Chua 936b0bbc17 feat: add https compatibility for moonraker based printer agents 2026-09-24 14:53:32 +08:00
Ian Chua 5222a37eff fix: command_ams_refresh_rfid function signature in test_printer_agent.cpp 2026-09-24 12:35:03 +08:00
peachismomo 6da938b660 fix: compilation after merge 2026-09-24 01:15:55 +08:00
peachismomo 2dcdf49185 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-24 01:02:59 +08:00
peachismomo 52e845a821 fix: add include for non BBL_RELEASE_TO_PUBLIC path in BBLPrinterAgent 2026-09-24 00:23:47 +08:00
Ian Chua ffb4813510 fix: preserve moonraker device names 2026-09-23 20:17:06 +08:00
Ian Chua 7436f68e09 fix: make moonraker gcode commands asynchronous 2026-09-23 20:15:52 +08:00
Ian Chua ad956591ce fix: use correct device id for Moonraker connections 2026-09-23 20:14:21 +08:00
Ian Chua 8da8e9bee1 fix: persist input printer host and port 2026-09-23 20:12:18 +08:00
Ian Chua 0acc8608b1 refactor: make printer connection SSL agent-specific 2026-09-23 20:11:47 +08:00
Ian Chua a2a9b2ce94 refactor: collapse command_ams_refresh_rfid and command_ams_refresh_rfid2 2026-09-23 18:30:05 +08:00
Ian Chua 07bee6c7a4 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-23 15:35:22 +08:00
Ian Chua 1615390182 Merge branch 'main' into feat/printer-agent-infra 2026-09-23 14:54:38 +08:00
Ian Chua cf20994b58 refactor: connect_printer api and dialog (#15827)
# Description

<!--
> Please provide a summary of the changes made in this PR. Include
details such as:
  > * What issue does this PR address or fix?
  > * What new features or enhancements does this PR introduce?
> * Are there any breaking changes or dependencies that need to be
considered?
-->

Changing the IP address input dialog and `connect_printer` API in
preparation for configurable Moonraker/Orca printer agent connections.

# Screenshots/Recordings/Graphs

<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->
<img width="460" height="699" alt="image"
src="https://github.com/user-attachments/assets/35e3ce17-7594-4401-b140-100dea082221"
/>


## Tests

<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-09-23 14:54:26 +08:00
Ian Chua 309bb3c6e2 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-23 14:53:49 +08:00
Ian Chua 79f20c3996 Merge branch 'feat/printer-agent-infra' into refactor/connect-printer-api 2026-09-23 14:48:33 +08:00
Ian Chua 33179c1902 fix: make integration tests headless 2026-09-23 14:29:57 +08:00
peachismomo ccaafb3003 tests: cover WebMediaController lifecycle with wxWebView stub 2026-09-23 03:50:35 +08:00
peachismomo 7d4d8276a2 tests: add device manager integration coverage 2026-09-23 03:25:08 +08:00
peachismomo c492436228 refactor: centralize printer compatibility checks 2026-09-23 03:06:15 +08:00
peachismomo 99a1cd1b74 chore: reduce diagnostic logging level to trace 2026-09-23 02:58:30 +08:00
peachismomo 4b41bf7f4f fix: clear webview document on stop 2026-09-23 02:57:56 +08:00
peachismomo d5962052ca fix: handle malformed printer progress values safely 2026-09-23 02:53:10 +08:00
peachismomo f1410c8cac feat: extend optional printer model warnings to calibration & ams workflows 2026-09-23 02:49:27 +08:00
peachismomo 7283a05040 fix: validate windows FFmpeg avformat library 2026-09-23 02:33:47 +08:00
peachismomo c32c87863b test: cover printer agent default command dispatch 2026-09-23 01:54:23 +08:00
peachismomo 06f7016d6a fix: preserve printer agent defaults in PrinterAgentPluginCapabilityTrampoline 2026-09-23 01:53:56 +08:00
peachismomo be9ae6ffbf fix(tests): make omitted printer agent operations answer like a missing agent 2026-09-23 00:35:33 +08:00
Ian Chua cda25c3715 refactor: connect_printer api and dialog 2026-09-22 21:54:10 +08:00
Ian Chua 2dcdfe2d32 fix: refresh token before configuring printer MQTT 2026-09-22 21:46:09 +08:00
Ian Chua 9e34ca536b fix: port BBL implementations from #15711 2026-09-22 21:11:03 +08:00
Ian Chua 201617a6c0 fix: guard DeviceManager command dispatch when no printer agent is bound 2026-09-22 21:09:58 +08:00
Ian Chua 2dcbee3d76 Merge branch 'main' into feat/printer-agent-infra 2026-09-22 20:55:29 +08:00
Ian Chua 6c353d9596 feat: add ssl compatibility with moonrakerprinteragent 2026-09-22 18:30:30 +08:00
Ian Chua ce0c759ded fix: snapmakerprinteragent use moonraker's lifecycle-owned command worker 2026-09-22 16:06:18 +08:00
Ian Chua 9a7e925790 fix: re-implement missing commands on BBLPrinterAgent 2026-09-22 15:49:22 +08:00
Ian Chua f27b0a1f9d Merge branch main into feat/printer-agent-infra 2026-09-22 12:11:40 +08:00
Ian Chua cf1c164546 feat: add printer-agent.md doc to HLSD 2026-09-21 18:40:24 +08:00
Ian Chua d9f1f8b207 fix: camera signaling API 2026-09-21 16:19:31 +08:00
Ian Chua a096e1ab8c Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-21 16:02:11 +08:00
Ian Chua bca8dd00dd Merge branch 'feat/printer-agent-infra' of https://github.com/OrcaSlicer/OrcaSlicer into feat/printer-agent-infra 2026-09-21 15:45:49 +08:00
Ian Chua 8b7f8cac82 fix: follow external-packages for flatpak libdatachannel deps & add flatpak path to use source_dir 2026-09-21 15:45:39 +08:00
Ian Chua aae03257ed Merge branch 'main' into feat/printer-agent-infra 2026-09-21 11:54:23 +08:00
Ian Chua 3fba237c9a fix: shift camera signaling channel to network agent 2026-09-21 11:48:16 +08:00
Ian Chua cfb6349695 fix: update printer agent plugin API 2026-09-21 11:46:36 +08:00
Ian Chua f53ad6993a fix: update windows ffmpeg prebuild 2026-09-21 11:02:20 +08:00
peachismomo 91c6d1d3d5 Merge branch 'feat/printer-agent-impl' of https://github.com/OrcaSlicer/OrcaSlicer into feat/printer-agent-impl 2026-09-18 22:31:37 +08:00
peachismomo b91f48141d fix: load windows roots for cloud MQTT and camera signaling 2026-09-18 22:31:25 +08:00
Ian Chua e168d900f2 fix: stop flatpak DataChannel build from re-cloning over the sandboxed network 2026-09-18 22:10:30 +08:00
Ian Chua 467e249b3d Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-18 20:46:18 +08:00
Ian Chua bc03e3be2f fix: bump libdatachannel ver & update flatpak to use tar instead 2026-09-18 20:45:29 +08:00
Ian Chua 5287fd0160 fix: swtich start print command from http to mqtt command 2026-09-18 15:59:57 +08:00
Ian Chua c441fd2fa4 Merge branch feat/printer-agent-infra into feat/printer-agent-impl 2026-09-18 15:59:25 +08:00
Ian Chua f224b8e4a6 refactor: media controller playback routing and ownership 2026-09-18 13:36:46 +08:00
Ian Chua 8b479c9af3 fix: default impl for vendor agnostic gcode commansd 2026-09-17 18:46:56 +08:00
Ian Chua d8299c388e fix: regression bug, connecting to bambu needs bblp username 2026-09-17 18:44:54 +08:00
Ian Chua 771d455d12 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-17 16:33:38 +08:00
Ian Chua a7ab01b815 fix: remove unimplemented pure virtual functions 2026-09-17 16:33:11 +08:00
Ian Chua 4d85b6627e fix: wrap command_* with small wrapper 2026-09-17 12:51:17 +08:00
Ian Chua 38e35dbff4 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-16 22:59:22 +08:00
Ian Chua dcddb9283c fix: updated stale docs 2026-09-16 22:54:54 +08:00
Ian Chua ba9cf6e897 Revert "fix: latent ams in use bug"
This reverts commit 910dbcd2e6.
2026-09-16 22:54:54 +08:00
Ian Chua d1f2ecb903 feat: restore printer agent implementations 2026-09-16 22:54:46 +08:00
Ian Chua ee2b4ccf70 Merge branch 'main' into feat/printer-agent-infra 2026-09-16 22:32:32 +08:00
Ian Chua 753c793ea0 revert: filament sync work 2026-09-16 22:20:35 +08:00
Ian Chua 9c5494b19f fix: disable unused DataChannel media support 2026-09-16 22:08:53 +08:00
Ian Chua 93a119427a Merge branch main into feat/printer-agent-infra 2026-09-16 22:06:09 +08:00
Ian Chua d264fcaef4 fix: always build bundled DataChannel dep 2026-09-16 22:04:27 +08:00
Ian Chua b4ab6dedfd fix: move printer agent plugin tests into test_plugin_lifecycle.cpp 2026-09-16 21:33:04 +08:00
Ian Chua ed59f93456 fix: scope get_my_machine_list to printers listed under the current printer agent 2026-09-16 19:48:59 +08:00
Ian Chua cf52c81dcd fix: stop the correct media controller 2026-09-16 18:44:06 +08:00
Ian Chua 803a2a3239 fix: dedupe compatible printer type check 2026-09-16 17:18:59 +08:00
Ian Chua 985092bb46 fix: move non-mandatory printer agent function stubs to IPrinterAgent 2026-09-16 16:56:43 +08:00
Ian Chua cb16a2e547 feat: enable https camera stream mode 2026-09-16 16:24:29 +08:00
Ian Chua 2c2e91b629 fix: remove hardcoded ICE servers 2026-09-16 14:36:49 +08:00
Ian Chua c3faf903ca fix: use ORCA_CLOUD_PROVIDER instead of hardcoded string 2026-09-16 14:24:40 +08:00
Ian Chua 5f3c957597 fix: remove stale comment 2026-09-16 14:23:00 +08:00
Ian Chua 8557285389 fix: revert moonraker specific behavior 2026-09-16 14:21:09 +08:00
Ian Chua a9edab78a8 fix: inject provider, agent id and generation to get_user_print_info to ensure correct metadata 2026-09-16 14:15:24 +08:00
Ian Chua 79dede7d59 fix: change rtc log level 2026-09-16 14:14:21 +08:00
Ian Chua 2ac45b2f78 fix: invoke js clearInterval on WebMediaController::stop 2026-09-16 14:13:37 +08:00
Ian Chua b4f33e71e9 fix: add internal_developer_mode chekc back to MediaPlayCtrl::load() 2026-09-16 14:12:48 +08:00
Ian Chua 8c91cab971 fix(ci): add libdatachannel to flatpak manifest 2026-09-15 20:01:48 +08:00
Ian Chua 79077518e9 fix: re-include apply header guarded by ifdef __APPLE__ 2026-09-15 19:45:14 +08:00
Ian Chua 56aa348c89 fix(ci): set depends openssl 2026-09-15 18:16:50 +08:00
Lam Wei Lun ae1752fbf2 Resolve printer agent first before getting cloud printer agent 2026-09-15 18:02:02 +08:00
Ian Chua cf3f36a1c6 Merge branch 'feat/printer-agent-infra' of https://github.com/OrcaSlicer/OrcaSlicer into feat/printer-agent-infra 2026-09-15 17:37:57 +08:00
Ian Chua cca7d8adb1 fix(ci): deps build order for datachannel 2026-09-15 17:35:25 +08:00
Ian Chua 396d9ff51a Merge branch 'main' into feat/printer-agent-infra 2026-09-15 16:47:15 +08:00
Ian Chua 81540c81e0 Merge branch 'feat/printer-agent-infra' of https://github.com/OrcaSlicer/OrcaSlicer into feat/printer-agent-infra 2026-09-15 16:46:38 +08:00
Ian Chua e7ace6cc99 fix: unit tests & unused variables 2026-09-15 16:46:28 +08:00
Lam Wei Lun 79c20a1fad Guard libdatachannel. Remove unused code 2026-09-15 16:33:12 +08:00
Ian Chua e48f9f47d2 Revert "fix: parameterize orcaslicer_copy_test_dlls() for printer_agent_plugin_tests"
This reverts commit 2f566e3779.
2026-09-15 16:12:33 +08:00
Ian Chua 2f566e3779 fix: parameterize orcaslicer_copy_test_dlls() for printer_agent_plugin_tests 2026-09-15 16:08:10 +08:00
Ian Chua 37d1edf69a fix: printer agent virutal optional functions 2026-09-15 15:54:55 +08:00
Lam Wei Lun f1cf9c69b1 Log first before std::move 2026-09-15 15:49:15 +08:00
Lam Wei Lun 1df362888e Fixes nullptr deref 2026-09-15 15:47:48 +08:00
Ian Chua 4b2466b179 fix: uninitialized ams state blocking print 2026-09-15 14:40:04 +08:00
Ian Chua 3adcb3e953 fix: split infra from impl 2026-09-15 13:27:17 +08:00
Ian Chua 104dbb2140 fix: camera auto-play on startup 2026-09-14 16:17:17 +08:00
Ian Chua e482cbbdd7 fix: warnings 2026-09-14 14:56:34 +08:00
Ian Chua 2a5ddd9edb fix: warnings 2026-09-14 14:16:02 +08:00
Ian Chua c10f83cf61 Merge branch 'main' into feat/printer-agent-impl 2026-09-14 13:01:03 +08:00
Ian Chua 8eeec6935e Merge branch 'main' of https://github.com/OrcaSlicer/OrcaSlicer_priv into feat/printer-agent-impl 2026-09-14 12:53:04 +08:00
Ian Chua 131a2abf99 Merge pull request #138 from OrcaSlicer/feat/orca-printer-agent
feat: orca printer agent
2026-09-14 12:14:53 +08:00
peachismomo 9daecc59b4 temp: doc for intended change 2026-09-12 02:32:01 +08:00
peachismomo 58e000d842 feat: cloud download via HTTP 2026-09-12 02:31:27 +08:00
Ian Chua af6be5858e fix: cloud printers were using the wrong MQTT endpoint 2026-09-11 17:38:56 +08:00
Ian Chua 3fc7fd99d6 fix: shim layer for any compatibiliity changes 2026-09-10 21:55:31 +08:00
Ian Chua 3fe043651b fix: moonraker printer agent hang on printer power cut 2026-09-09 18:40:01 +08:00
Ian Chua b0b78c296c feat: check printer storage status before sending 2026-09-09 18:35:38 +08:00
Ian Chua 62791fabfa fix: revert sdcard check 2026-09-09 14:10:12 +08:00
Ian Chua b0ada2dee5 fix: ffmpeg http camera stream jittering due to incomplete frames 2026-09-09 13:40:51 +08:00
Ian Chua 8f4df1aa8c fix: model_id resolution method for non bambu printers 2026-09-09 13:40:24 +08:00
Ian Chua 5fec9b8d19 Merge pull request #139 from OrcaSlicer/feat/webrtc-impl
feat: webrtc implementation
2026-09-08 19:37:31 +08:00
Ian Chua 75ed5afbac Merge branch 'feat/orca-printer-agent' into feat/webrtc-impl 2026-09-08 19:31:14 +08:00
Ian Chua 01c553bad9 feat: LAN impl for Orca Printer Agent 2026-09-08 19:13:48 +08:00
Ian Chua a4376c77c3 Merge branch 'feat/orca-printer-agent' of https://github.com/OrcaSlicer/OrcaSlicer_priv into feat/orca-printer-agent 2026-09-08 12:06:25 +08:00
peachismomo c459994290 fix: connect via ip dialog 2026-09-08 04:40:12 +08:00
Ian Chua 3a0fda7d18 fix: make model_id/dev_type optional instead of blocking 2026-09-07 17:28:40 +08:00
Ian Chua eb8733f0f4 Merge branch 'feat/orca-printer-agent' into feat/webrtc-impl 2026-09-04 18:28:48 +08:00
Ian Chua 55acb940a8 Merge branch 'feat/printer-agent-impl' into feat/orca-printer-agent 2026-09-04 18:28:25 +08:00
Ian Chua 0d691403de Merge branch 'main' into feat/printer-agent-impl 2026-09-04 18:26:58 +08:00
Ian Chua 9b2b75a51d Merge branch 'feat/orca-printer-agent' into feat/webrtc-impl 2026-09-04 18:23:26 +08:00
Ian Chua 25da273ea0 Merge pull request #142 from OrcaSlicer/fix/orca-printer-agent-refactor
fix: orcaprinteragent refactor
2026-09-04 18:05:27 +08:00
Ian Chua dbcb82075f feat: use ffmpeg to render http camera stream 2026-09-04 16:42:36 +08:00
Ian Chua 2f82cfe40f fix: LAN paths and camera stream 2026-09-04 16:18:43 +08:00
Ian Chua 972031cf06 fix: orcaprinteragent refactor 2026-09-03 19:42:25 +08:00
Ian Chua 2a4792e762 feat: remove frame assembler and change config to set protocol 2026-09-02 15:59:13 +08:00
Ian Chua dd15ac6146 fix: cmake 2026-09-02 11:51:40 +08:00
Ian Chua b0469254bc Merge branch 'feat/orca-printer-agent' into feat/webrtc-impl 2026-09-01 19:24:04 +08:00
Ian Chua 2228589e16 Merge branch 'feat/printer-agent-impl' into feat/orca-printer-agent 2026-09-01 19:23:47 +08:00
Ian Chua 4d8480e1a1 fix: build 2026-09-01 19:21:23 +08:00
Ian Chua 4320cc78d9 feat: camera via webrtc 2026-09-01 18:59:20 +08:00
Ian Chua d9d9678a7e Merge branch 'feat/orca-printer-agent' into feat/webrtc-impl 2026-09-01 18:38:01 +08:00
Ian Chua d55a0bfec6 fix: build errors 2026-09-01 18:37:04 +08:00
Ian Chua 3b1017df51 Merge branch 'feat/orca-printer-agent' of https://github.com/OrcaSlicer/OrcaSlicer_priv into feat/orca-printer-agent 2026-09-01 18:17:23 +08:00
Ian Chua f5b81d2ffc Merge branch 'feat/webrtc-impl' of https://github.com/OrcaSlicer/OrcaSlicer_priv into feat/webrtc-impl 2026-09-01 18:16:20 +08:00
Ian Chua eb9cfe0ecb feat: connect to cloud printer and monitor 2026-09-01 18:15:53 +08:00
Ian Chua 1e8805d43d feat: connect to cloud printer and monitor 2026-09-01 18:15:53 +08:00
Ian Chua e8f089dfa4 fix: build & access code UI 2026-09-01 18:13:11 +08:00
Ian Chua 84e929ea24 feat: generic camera stream support for http snapshot and rtsp 2026-09-01 18:13:11 +08:00
Ian Chua abec603e73 fix: build & access code UI 2026-09-01 18:04:37 +08:00
Ian Chua 0ed19d4826 feat: generic camera stream support for http snapshot and rtsp 2026-09-01 18:02:53 +08:00
Ian Chua b747c13ef4 Merge branch 'feat/orca-printer-agent' of https://github.com/OrcaSlicer/OrcaSlicer_priv into feat/orca-printer-agent 2026-09-01 17:40:07 +08:00
Ian Chua 6a12aca495 feat: connect to cloud printer and monitor 2026-09-01 17:38:52 +08:00
Ian Chua d486db6459 Merge branch 'main' into feat/printer-agent-impl 2026-09-01 16:51:34 +08:00
Ian Chua fe777b8801 feat: printer agent impl 2026-09-01 16:49:04 +08:00
Ian Chua 1b3e206f34 feat: connect to cloud printer and monitor 2026-08-28 18:10:31 +08:00
Ian Chua 4df1607ec4 fix: clear up some unrelated changes 2026-08-28 16:45:01 +08:00
Ian Chua a0ec8bec8a fix: merge artifact 2026-08-27 15:08:06 +08:00
Ian Chua 36c362cbd2 Merge branch 'main' into feat/printer-agent-impl 2026-08-27 14:54:22 +08:00
Ian Chua 84fde32616 Merge branch 'main' into refactor/printer-agent-interface 2026-08-27 14:52:50 +08:00
Ian Chua 34e349e323 fix: snapmaker U1 SelectMachineDialog blocking print 2026-08-27 14:47:09 +08:00
Ian Chua 44941e571f fix: remove unused variable 2026-08-26 18:40:48 +08:00
Ian Chua 876d6e2499 fix: printer agent switching on preset change 2026-08-26 18:40:47 +08:00
Ian Chua 858b3024ff fix: tests 2026-08-26 12:04:02 +08:00
Ian Chua 6595557c34 fix: access codes regression 2026-08-25 18:32:02 +08:00
Ian Chua c729849843 cleanup moonraker and snapmaker printer agents 2026-08-25 16:39:08 +08:00
Ian Chua c0563be36e Merge branch 'feat/printer-agent-impl' of https://github.com/OrcaSlicer/OrcaSlicer into feat/printer-agent-impl 2026-08-25 14:47:12 +08:00
Ian Chua 462f8dce30 fix: remote do_fetch_filament_info from tests 2026-08-25 14:46:11 +08:00
Ian Chua b2a0485139 fix: defer filesystem and camera abstractions 2026-08-25 14:34:11 +08:00
Ian Chua f16071f083 fix: remove heavy includes from IPrinterAgent 2026-08-25 13:54:40 +08:00
Ian Chua a1505e9bed Merge branch 'main' into refactor/printer-agent-interface 2026-08-25 13:50:25 +08:00
Ian Chua 8e48312b1f Merge branch 'main' into feat/printer-agent-impl 2026-08-25 13:47:11 +08:00
Ian Chua ba7fdb8ceb Merge branch 'main' into feat/printer-agent-impl 2026-08-21 14:17:56 +08:00
Ian Chua 8be9800567 remove irrelevant docs 2026-08-21 14:16:49 +08:00
Ian Chua c4eabc3c52 fix: extend access code requirements t 0, 8 or more characters. 2026-08-21 14:14:23 +08:00
Ian Chua 0d0d281d0c feat: parse nozzle information for qidi and moonraker printer agents 2026-08-20 20:11:31 +08:00
Ian Chua c4bbcd322b fix: skip filament sync dialog if filamentSyncMode is none 2026-08-20 19:28:02 +08:00
Ian Chua 7aea1b1235 Merge branch 'main' into feat/printer-agent-impl 2026-08-20 16:39:54 +08:00
Ian Chua 41c16436bf Merge branch 'main' into refactor/printer-agent-interface 2026-08-20 16:09:10 +08:00
Ian Chua 2bbb229c91 Merge branch 'main' into refactor/printer-agent-interface 2026-08-19 23:30:22 +08:00
Ian Chua c2a43157d2 Merge branch 'main' into feat/printer-agent-impl 2026-08-19 18:29:36 +08:00
Ian Chua 738b30a743 fix: ams sync info and periodic ams sync via subscription workflow 2026-08-19 18:29:19 +08:00
Ian Chua 6e6ffe13f8 Merge branch 'refactor/printer-agent-interface' of https://github.com/OrcaSlicer/OrcaSlicer into refactor/printer-agent-interface 2026-08-19 17:11:14 +08:00
Ian Chua eb96d127b0 fix: resolve stubgen byte header conflict 2026-08-19 17:11:10 +08:00
SoftFever 60421c33f4 Merge branch 'main' into refactor/printer-agent-interface 2026-08-19 14:32:42 +08:00
SoftFever 300c4b8afb Merge branch 'main' into refactor/printer-agent-interface 2026-08-18 20:44:56 +08:00
Ian Chua 96092ef2d3 feat: update qidi to use subscription based filament sync mode 2026-08-18 19:05:17 +08:00
Ian Chua d9002ad87d fix: ams filament mapping workflow 2026-08-18 18:07:22 +08:00
Ian Chua 4c1ea0a602 Merge branch 'main' into 'feat/printer-agent-impl' 2026-08-17 14:51:04 +08:00
Ian Chua 9415812d85 Merge branch 'main' into refactor/printer-agent-interface 2026-08-17 14:24:23 +08:00
Ian Chua d3c728557e Merge 'main' into 'refactor/printer-agent-interface' 2026-08-17 14:24:05 +08:00
Ian Chua 6558c52849 revert file transfer abstraction 2026-08-17 14:19:41 +08:00
Ian Chua a34d056de2 specify api for getting file transfer url 2026-08-14 15:31:42 +08:00
Ian Chua 7ba5718be6 fix: remove redundant cache 2026-08-12 18:41:51 +08:00
Ian Chua c1163ce7e5 Merge branch 'refactor/printer-agent-interface' of https://github.com/OrcaSlicer/OrcaSlicer into refactor/printer-agent-interface 2026-08-12 15:29:56 +08:00
Ian Chua da187eaaf9 fix callback error 2026-08-12 15:29:50 +08:00
Ian Chua de0268ce86 Merge branch 'main' into refactor/printer-agent-interface 2026-08-12 15:28:53 +08:00
Ian Chua 108923bdaa fix: default impl 2026-08-12 14:02:04 +08:00
Ian Chua 44793f7a21 remove unused 2026-08-12 13:51:54 +08:00
Ian Chua 7cb1805272 refactor: push bbl workflows to bbl printer agent 2026-08-12 13:50:47 +08:00
Ian Chua d5c528b7c7 Merge branch 'refactor/printer-agent-interface' of https://github.com/OrcaSlicer/OrcaSlicer into refactor/printer-agent-interface 2026-08-11 15:00:42 +08:00
Ian Chua 742cb712d8 refactor: abstract bambu specific protocol to printer agent 2026-08-11 15:00:34 +08:00
Ian Chua fd1c5d826c Merge branch 'main' into refactor/printer-agent-interface 2026-08-07 18:37:56 +08:00
Ian Chua 58be7f4861 feat: abstract remaining gcode commands in devicemanager 2026-08-07 18:36:38 +08:00
Ian Chua 4031b00915 Merge branch 'main' into refactor/printer-agent-interface 2026-08-06 16:27:57 +08:00
Ian Chua 07c62112b8 Reconcile implementation split with PR tip 2026-08-04 18:12:51 +08:00
Ian Chua f23e4963bf fix: merge access codes into one 2026-08-04 18:12:43 +08:00
Andrew 95279f7084 docs: document the printer-agent subsystem 2026-08-04 18:12:43 +08:00
Andrew 1014558c91 Keep printer-agent progress in sync
Keep the shared task progress aligned with agent
reports that lack Bambu cloud task identity.

Release the lazily allocated task during reset to
avoid leaks when machine objects reconnect.
2026-08-04 18:12:42 +08:00
Andrew 6515062a3a fix: stop Qidi slot parse throwing on null 2026-08-04 18:12:42 +08:00
Andrew 8153e26b6d fix: start stream when camera URL changes 2026-08-04 18:12:42 +08:00
Andrew 79fd33d49a Show Snapmaker U1 camera in Device tab
The U1 exposes no /server/webcams/list entry;
its camera only captures after an explicit
camera.start_monitor RPC, which the Moonraker
websocket executes unauthenticated but only
answers over MQTT - so the call is fire and
forget.

Start the camera when the camera view is
shown and renew every 300 s: the printer
retires the capture task at ~362 s and
stop_monitor is accepted but ineffective,
so teardown is simply to stop renewing.

Frames land in monitor.jpg as still JPEGs
(~2 fps at interval 0), so the webview loads
a local HTML wrapper that repolls with a
cache buster.
2026-08-04 18:12:41 +08:00
Andrew cdb662d4b2 Move Moonraker commands off the UI thread
Pause/resume/stop, g-code sends, temps, and
light ran synchronous HTTP on the UI thread,
freezing the app up to 10s per click on slow
or unreachable printers.

Run them on a single agent-owned FIFO worker
so g-code ordering is preserved, while command
translation stays synchronous so unsupported-
command dialogs still work.

Add a pending-disabled state to the pause,
resume, and abort buttons for Moonraker-family
printers: the icon only flips once the
WebSocket reports the real state, which also
rules out double-click races.
2026-08-04 18:12:41 +08:00
Andrew a27fa7b5df Fix multi-color filament logic
Reuse color decoding across functions to improve
code readability and maintain consistency in
multi-color filament handling.
2026-08-04 18:12:40 +08:00
Andrew cdeed107c5 Keep Bambu AMS dialect out of the agent waist
M620 is Bambu firmware dialect, not a
neutral command. Composing it in
MachineObject let non-Bambu agents
(Moonraker/Klipper) forward it and
report success on firmware that
cannot run it.

Agents now own the dialect: the
default refusal on IPrinterAgent
returns not-supported so the UI
can say so; BBLPrinterAgent keeps
the byte-identical composition.
2026-08-04 18:12:40 +08:00
Andrew be92f7e78c Surface Moonraker webcams and gate unrunnable controls 2026-08-04 18:12:39 +08:00
Andrew 76f9de4cfb fix: make Klipper macro lamp control reliable 2026-08-04 18:12:39 +08:00
Andrew cd6d2cbf5e Stop blocking print on unreported nozzle data 2026-08-04 18:12:39 +08:00
Andrew deaca0189f Bring Moonraker device panel to feature parity
The monitor panel showed wrong or missing
data for Moonraker printers, and its
controls did nothing.

Push payload now carries layer number and
total layers. Remaining time replaces the
wrong total_duration - print_duration
formula. The chamber light toggle maps to
Klipper SET_PIN / SET_LED, and pause,
resume and stop post to
/printer/print/{action}. Task thumbnails
resolve via /server/files/thumbnails onto
a new MachineObject thumbnail url.

Filament sync switches to pull mode so the
agent is queried on demand.

Not compiled or run.
2026-08-04 18:12:38 +08:00
Andrew 7d54ee286d fix: pin HTTP to prevent connection refusal
Set `use_ssl` to false to ensure Moonraker
connectivity, as the service uses HTTP rather
than HTTPS, preventing connection issues. Initialize
device info early for reliable name resolution.
2026-08-04 18:12:38 +08:00
Andrew dddfc7a8ad Add printer-agent and plugin status tests
Ports the agent lifecycle, duplicate
agent-id, built-in-id clash and
status-resolution tests. The loader
runs on a detached worker thread, so
the lifecycle tests live in their own
executable. Tests install the
production unload-side registry
wiring themselves (no GUI in the
test binary) and register agents
manually so concurrent loads stay
deterministic.
2026-08-04 18:12:37 +08:00
Andrew 673bfb0dee Track BBLPrinterAgentPlugin.py 2026-08-04 18:12:37 +08:00
Andrew b9e8c5a38c Gate agent mode behind use_printer_agents toggle
Replace per-printer auto-activation
(is_current_printer_agent_plugin)
with a global experimental AppConfig
toggle, default off: legacy
print-host behavior is unchanged
until the user opts in. The toggle
drives device-tab routing, print
button defaults, connect-button
visibility and sidebar layout, and
dedups machine-select dialog opens.
2026-08-04 18:12:36 +08:00
Andrew a2a4ca20e4 Reset device selection on agent swap or unload (#124)
set_live_printer_agent centralizes
the swap: deselect the machine,
clear stale sidebar state and the
previous agent's Other Devices, then
install the new agent (or null when
its provider vanished). Plugin
load/unload callbacks refresh the
dropdown and re-run agent selection.
load_last_machine no longer falls
back to the first available machine.
2026-08-04 18:12:36 +08:00
Andrew 12075459d2 Replace fake-enum printer agent dropdown (#121)
A dedicated PrinterAgentChoice field
reads rows straight from the live
agent registry and stores the agent
id string, replacing the fake-coEnum
index mapping. The field moves to
TabPrinter and registers with the
searcher so UnsavedChanges renders
it; the PhysicalPrinterDialog copy
and its update hook are removed
(#125). switch_printer_agent now
resolves ids via
resolve_printer_agent_id.
2026-08-04 18:12:36 +08:00
Andrew f3770f3106 fix: checkbox should depend on plugin is_loaded status 2026-08-04 18:12:35 +08:00
Andrew 90fbf1770f Resolve duplicate agent ID conflicts
Reject a printer-agent capability
whose agent ID is already owned by
another capability or built-in:
flag the plugin error, disable the
capability, and warn the user
instead of silently ignoring it.
2026-08-04 18:12:35 +08:00
Andrew bde94ab37f Add support for runtime error status in plugins
Distinguish a loaded plugin whose
capability errored (RuntimeError,
warn-styled, stays checked) from a
load-time Error. Status now derives
via resolve_plugin_status(); enum
ordinal keeps dialog sort priority.
Unloading clears stale errors.
2026-08-04 18:12:34 +08:00
Ian Chua 6d25e1777e Working Moonraker and Qidi printer agent transport (#104)
Folds the Qidi AMS box-mapping print
overrides (apply_box_mapping +
start_* wrappers) that the transport
fix builds on.
2026-08-04 18:12:34 +08:00
Andrew 7ce26ca8e5 Harden send flow and separate upload failure recovery (#111)
* fix(send): harden FT send path + IP pre-flight UX

* Remove early returns
2026-08-04 18:12:33 +08:00
Andrew 367fcce634 Prevent loss of user access code on LAN reselect
Keep user access code intact to maintain access rights even if
device slot is unpopulated, ensuring continuous connection
and status message reception.
2026-08-04 18:12:33 +08:00
Andrew 9b3a44b339 Parse user print info on the UI thread to prevent heap corruption (#119)
get_user_print_info()'s HTTP fetch can run on a worker thread (e.g. BindJob),
but parse_user_print_info() mutates userMachineList (insert/erase/delete
MachineObject). on_machine_alive (SSDP) mutates the same maps on the UI thread
without locking, so parsing off-thread races the map and frees MachineObjects
out from under it -> heap corruption.

Keep all device-list mutation on the UI thread: parse inline when already on
the main thread, otherwise marshal via CallAfter so it stays serialized with
on_machine_alive.
2026-08-04 18:12:33 +08:00
Ian Chua 6384191102 Add developer flag for printer agents 2026-08-04 18:12:32 +08:00
57 changed files with 7761 additions and 516 deletions
+2
View File
@@ -6,6 +6,8 @@
#include <array> #include <array>
#include <algorithm> #include <algorithm>
#include <cassert>
namespace Slic3r { namespace Slic3r {
using RGB = std::array<float, 3>; using RGB = std::array<float, 3>;
using RGBA = std::array<float, 4>; using RGBA = std::array<float, 4>;
+61 -29
View File
@@ -3838,42 +3838,58 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
ams_array_maps.push_back(temp); ams_array_maps.push_back(temp);
index++; index++;
if (filament_id.empty()) { if (filament_id.empty()) {
if (use_map) { // A tray written by the printer UI carries a material type but no
for (int j = maps.size() - 1; j >= 0; j--) { // OrcaSlicer preset id. Resolve it to the matching Generic preset with
if (maps[j].slot_id == slot_id && maps[j].ams_id == ams_id) { // the tray's own color instead of dropping it (direct sync) or forcing
maps.erase(j); // "Generic PLA" (mapping sync). Placeholders and typeless trays keep
// the previous behavior.
const auto tray_type = ams.opt_string("filament_type", 0u);
if (is_placeholder || tray_type.empty()) {
if (use_map) {
for (int j = maps.size() - 1; j >= 0; j--) {
if (maps[j].slot_id == slot_id && maps[j].ams_id == ams_id) {
maps.erase(j);
}
} }
ams_filament_presets.push_back("Generic PLA");//for unknow matieral
auto default_unknown_color = "#CECECE";
ams_filament_colors.push_back(default_unknown_color);
ams_filament_color_types.push_back("1");
if (filament_multi_color.size() == 0) {
filament_multi_color.push_back(default_unknown_color);
}
ams_multi_color_filment.push_back(filament_multi_color);
} else if (is_placeholder) {
// Orca: push placeholders to keep index alignment with ams_infos
ams_filament_presets.push_back("");
ams_filament_colors.push_back("");
ams_filament_color_types.push_back("");
ams_multi_color_filment.push_back({});
} }
ams_filament_presets.push_back("Generic PLA");//for unknow matieral continue;
auto default_unknown_color = "#CECECE";
ams_filament_colors.push_back(default_unknown_color);
ams_filament_color_types.push_back("1");
if (filament_multi_color.size() == 0) {
filament_multi_color.push_back(default_unknown_color);
}
ams_multi_color_filment.push_back(filament_multi_color);
} else if (is_placeholder) {
// Orca: push placeholders to keep index alignment with ams_infos
ams_filament_presets.push_back("");
ams_filament_colors.push_back("");
ams_filament_color_types.push_back("");
ams_multi_color_filment.push_back({});
} }
continue;
} }
if (!filament_changed && this->filament_presets.size() > ams_filament_presets.size()) { if (!filament_changed && this->filament_presets.size() > ams_filament_presets.size()) {
ams_filament_presets.push_back(this->filament_presets[ams_filament_presets.size()]); ams_filament_presets.push_back(this->filament_presets[ams_filament_presets.size()]);
ams_filament_colors.push_back(filament_color); ams_filament_colors.push_back(filament_color);
ams_filament_color_types.push_back(filament_color_type); ams_filament_color_types.push_back(filament_color_type);
ams_multi_color_filment.push_back(filament_multi_color); ams_multi_color_filment.push_back(filament_multi_color);
ams_infos.back().valid = true;
continue; continue;
} }
bool has_type = false; bool has_type = false;
auto filament_type = ams.opt_string("filament_type", 0u); auto filament_type = ams.opt_string("filament_type", 0u);
auto iter = std::find_if(filaments.begin(), filaments.end(), [this, &filament_id, &has_type, filament_type](auto &f) { auto iter = filaments.end();
has_type |= f.config.opt_string("filament_type", 0u) == filament_type; if (!filament_id.empty()) {
return f.is_compatible && filaments.get_preset_base(f) == &f && f.filament_id == filament_id; }); iter = std::find_if(filaments.begin(), filaments.end(), [this, &filament_id, &has_type, filament_type](auto &f) {
warn_ambiguous_filament_id_match(filaments, iter, filament_id); has_type |= f.config.opt_string("filament_type", 0u) == filament_type;
return f.is_compatible && filaments.get_preset_base(f) == &f && f.filament_id == filament_id; });
warn_ambiguous_filament_id_match(filaments, iter, filament_id);
} else {
// The material type is the only identity a printer-set tray carries.
has_type = std::any_of(filaments.begin(), filaments.end(), [&filament_type](auto &f) {
return f.is_compatible && f.config.opt_string("filament_type", 0u) == filament_type; });
}
if (iter == filaments.end()) { if (iter == filaments.end()) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": filament_id %1% not found or system or compatible") % filament_id; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": filament_id %1% not found or system or compatible") % filament_id;
if (!filament_type.empty()) { if (!filament_type.empty()) {
@@ -3920,6 +3936,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
ams_filament_colors.push_back(filament_color); ams_filament_colors.push_back(filament_color);
ams_filament_color_types.push_back(filament_color_type); ams_filament_color_types.push_back(filament_color_type);
ams_multi_color_filment.push_back(filament_multi_color); ams_multi_color_filment.push_back(filament_multi_color);
ams_infos.back().valid = true;
unknowns.emplace_back(&ams, has_type ? L("The filament may not be compatible with the current machine settings. Generic filament presets will be used.") : unknowns.emplace_back(&ams, has_type ? L("The filament may not be compatible with the current machine settings. Generic filament presets will be used.") :
L("The filament model is unknown. Still using the previous filament preset.")); L("The filament model is unknown. Still using the previous filament preset."));
continue; continue;
@@ -3946,6 +3963,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
ams_filament_colors.push_back(filament_color); ams_filament_colors.push_back(filament_color);
ams_filament_color_types.push_back(filament_color_type); ams_filament_color_types.push_back(filament_color_type);
ams_multi_color_filment.push_back(filament_multi_color); ams_multi_color_filment.push_back(filament_multi_color);
ams_infos.back().valid = true;
} }
if (ams_filament_presets.empty()) if (ams_filament_presets.empty())
return 0; return 0;
@@ -4385,9 +4403,21 @@ std::vector<std::vector<DynamicPrintConfig>> PresetBundle::get_extruder_filament
return filament_infos; return filament_infos;
} }
// ORCA TODO: currently, this function assumes the printer name follows the pattern of "<printer_model> <nozzle_diameter>", e.g. std::string PresetBundle::get_printer_model_display_name(const std::string &model_id) const
// printer_type: "Bambu Lab X2D", nozzle_diameter_str: "0.4 nozzle" => printer_name: "Bambu Lab X2D 0.4 nozzle". If the printer name does {
// not follow this pattern, the function may not work correctly. if (model_id.empty())
return {};
for (const auto &vendor_entry : vendors) {
for (const auto &model : vendor_entry.second.models) {
if (model.model_id == model_id)
return model.name;
}
}
return {};
}
// ORCA TODO: this assumes printer names follow "<printer_model> <nozzle_diameter>", e.g.
// "Bambu Lab X2D 0.4 nozzle". Other naming schemes may not resolve correctly.
std::set<std::string> PresetBundle::get_printer_names_by_printer_type_and_nozzle(const std::string &printer_type, std::string nozzle_diameter_str, bool system_only) std::set<std::string> PresetBundle::get_printer_names_by_printer_type_and_nozzle(const std::string &printer_type, std::string nozzle_diameter_str, bool system_only)
{ {
std::set<std::string> printer_names; std::set<std::string> printer_names;
@@ -4409,7 +4439,9 @@ std::set<std::string> PresetBundle::get_printer_names_by_printer_type_and_nozzle
if (printer_it->name.find(nozzle_diameter_str) != std::string::npos) printer_names.insert(printer_it->name); if (printer_it->name.find(nozzle_diameter_str) != std::string::npos) printer_names.insert(printer_it->name);
} }
assert(printer_names.size() == 1); // No match is normal for a connected machine the user has not installed; only an
// ambiguous match is a bug (the caller assumes one preset per model and nozzle).
assert(printer_names.size() <= 1);
for (auto& printer_name : printer_names) { for (auto& printer_name : printer_names) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " " << __LINE__ << " printer name: " << printer_name; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " " << __LINE__ << " printer name: " << printer_name;
@@ -4422,8 +4454,8 @@ std::vector<Preset *> PresetBundle::get_filament_presets_for_machine(const std::
const std::string &nozzle_diameter_str, const std::string &nozzle_diameter_str,
bool include_user_presets) bool include_user_presets)
{ {
// Printer model plus nozzle diameter is expected to resolve to a single system printer preset; // Printer model plus nozzle diameter normally resolves to a single system printer preset.
// get_printer_names_by_printer_type_and_nozzle asserts as much in debug builds. // Zero matches is normal for a connected machine the user never installed.
const std::set<std::string> printer_names = get_printer_names_by_printer_type_and_nozzle(printer_type, nozzle_diameter_str); const std::set<std::string> printer_names = get_printer_names_by_printer_type_and_nozzle(printer_type, nozzle_diameter_str);
const Preset *printer = printer_names.empty() ? nullptr : printers.find_preset(*printer_names.begin()); const Preset *printer = printer_names.empty() ? nullptr : printers.find_preset(*printer_names.begin());
if (printer == nullptr) if (printer == nullptr)
+3
View File
@@ -399,6 +399,9 @@ public:
std::vector<std::vector<DynamicPrintConfig>> get_extruder_filament_info() const; std::vector<std::vector<DynamicPrintConfig>> get_extruder_filament_info() const;
// Resolve a vendor model id (an agent-reported machine identity) to the display name
// printer profiles use as printer_model. Empty when no loaded vendor declares it.
std::string get_printer_model_display_name(const std::string &model_id) const;
std::set<std::string> get_printer_names_by_printer_type_and_nozzle(const std::string &printer_type, std::string nozzle_diameter_str, bool system_only = true); std::set<std::string> get_printer_names_by_printer_type_and_nozzle(const std::string &printer_type, std::string nozzle_diameter_str, bool system_only = true);
// Orca: the root filament presets a connected machine can use, resolved with the rule the rest // Orca: the root filament presets a connected machine can use, resolved with the rule the rest
// of the app applies (is_compatible_with_printer): an empty compatible_printers means every // of the app applies (is_compatible_with_printer): an empty compatible_printers means every
+5 -1
View File
@@ -778,8 +778,12 @@ set(SLIC3R_GUI_SOURCES
Utils/ICameraSignalingChannel.hpp Utils/ICameraSignalingChannel.hpp
Utils/OrcaCloudServiceAgent.cpp Utils/OrcaCloudServiceAgent.cpp
Utils/OrcaCloudServiceAgent.hpp Utils/OrcaCloudServiceAgent.hpp
Utils/OrcaMqttConnection.cpp
Utils/OrcaMqttConnection.hpp
Utils/OrcaPrinterAgent.cpp Utils/OrcaPrinterAgent.cpp
Utils/OrcaPrinterAgent.hpp Utils/OrcaPrinterAgent.hpp
Utils/OrcaCloudSignalingChannel.cpp
Utils/OrcaCloudSignalingChannel.hpp
Utils/QidiPrinterAgent.cpp Utils/QidiPrinterAgent.cpp
Utils/QidiPrinterAgent.hpp Utils/QidiPrinterAgent.hpp
Utils/SnapmakerPrinterAgent.cpp Utils/SnapmakerPrinterAgent.cpp
@@ -920,7 +924,7 @@ target_include_directories(libslic3r_gui PRIVATE Utils ${CMAKE_CURRENT_BINARY_DI
if (WIN32) if (WIN32)
target_include_directories(libslic3r_gui SYSTEM PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../deps/WebView2/include) target_include_directories(libslic3r_gui SYSTEM PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../deps/WebView2/include)
target_link_libraries(libslic3r_gui Advapi32) target_link_libraries(libslic3r_gui Advapi32 Crypt32)
endif() endif()
source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SLIC3R_GUI_SOURCES}) source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SLIC3R_GUI_SOURCES})
+10 -1
View File
@@ -7,6 +7,7 @@
#include "slic3r/GUI/DeviceCore/DevExtruderSystem.h" #include "slic3r/GUI/DeviceCore/DevExtruderSystem.h"
#include "slic3r/GUI/DeviceCore/DevManager.h" #include "slic3r/GUI/DeviceCore/DevManager.h"
#include "slic3r/GUI/DeviceManager.hpp"
#include "slic3r/GUI/MsgDialog.hpp" #include "slic3r/GUI/MsgDialog.hpp"
@@ -1602,8 +1603,16 @@ int AMSDryCtrWin::update_filament_list(DevAms* dev_ams, MachineObject* obj)
stream << std::fixed << std::setprecision(1) << obj->GetExtderSystem()->GetNozzleDiameter(extruder_id); stream << std::fixed << std::setprecision(1) << obj->GetExtderSystem()->GetNozzleDiameter(extruder_id);
std::string nozzle_diameter_str = stream.str(); std::string nozzle_diameter_str = stream.str();
// The connected device's model may not resolve (OrcaSonar's is optional); the
// helper falls back to the selected profile so the list is never empty.
const std::string filament_printer_model = resolve_filament_printer_model(obj->printer_type, preset_bundle);
if (filament_printer_model.empty()) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " cannot resolve a printer model for the filament list";
return false;
}
for (Preset *filament_it : preset_bundle->get_filament_presets_for_machine( for (Preset *filament_it : preset_bundle->get_filament_presets_for_machine(
DevPrinterConfigUtil::get_printer_display_name(obj->printer_type), nozzle_diameter_str, obj->is_support_user_preset)) { filament_printer_model, nozzle_diameter_str, obj->is_support_user_preset)) {
if (!filament_id_set.insert(filament_it->filament_id).second) if (!filament_id_set.insert(filament_it->filament_id).second)
continue; continue;
const std::string filament_alias = filaments.get_preset_alias(*filament_it, true); const std::string filament_alias = filaments.get_preset_alias(*filament_it, true);
+79 -43
View File
@@ -431,6 +431,11 @@ void AMSMaterialsSetting::update_filament_editing(bool is_printing)
m_button_confirm->Show(true); m_button_confirm->Show(true);
} }
// A third-party tray owns its temp range; BBL RFID trays keep the read-only preset values.
const bool can_edit = !is_printing || obj->is_support_filament_setting_inprinting;
m_input_nozzle_min->Enable(m_is_third && can_edit);
m_input_nozzle_max->Enable(m_is_third && can_edit);
if (!m_is_third) { if (!m_is_third) {
m_tip_readonly->SetLabelText(wxEmptyString); m_tip_readonly->SetLabelText(wxEmptyString);
m_tip_readonly->Hide(); m_tip_readonly->Hide();
@@ -453,6 +458,8 @@ void AMSMaterialsSetting::update_filament_editing(bool is_printing)
if (m_view_only) { // Orca: view-only (2D laser/cut) — lock every edit control and hide apply/reset if (m_view_only) { // Orca: view-only (2D laser/cut) — lock every edit control and hide apply/reset
m_comboBox_filament->Enable(false); m_comboBox_filament->Enable(false);
m_comboBox_cali_result->Enable(false); m_comboBox_cali_result->Enable(false);
m_input_nozzle_min->Enable(false);
m_input_nozzle_max->Enable(false);
m_input_k_val->Enable(false); m_input_k_val->Enable(false);
m_input_n_val->Enable(false); m_input_n_val->Enable(false);
m_button_confirm->Hide(); m_button_confirm->Hide();
@@ -502,7 +509,7 @@ void AMSMaterialsSetting::on_select_reset(wxCommandEvent& event) {
} }
// set k / n value // set k / n value
if (obj->cali_version <= -1 && obj->get_printer_series() == PrinterSeries::SERIES_P1P) { if (obj->supports_extrusion_cali() && obj->cali_version <= -1 && obj->get_printer_series() == PrinterSeries::SERIES_P1P) {
// set extrusion cali ratio // set extrusion cali ratio
int cali_tray_id = ams_id * 4 + slot_id; int cali_tray_id = ams_id * 4 + slot_id;
@@ -523,7 +530,7 @@ void AMSMaterialsSetting::on_select_reset(wxCommandEvent& event) {
} }
obj->command_extrusion_cali_set(cali_tray_id, "", "", k, n); obj->command_extrusion_cali_set(cali_tray_id, "", "", k, n);
} }
else { else if (obj->supports_extrusion_cali()) {
PACalibIndexInfo select_index_info; PACalibIndexInfo select_index_info;
int tray_id = ams_id * 4 + slot_id; int tray_id = ams_id * 4 + slot_id;
if (is_virtual_tray()) { if (is_virtual_tray()) {
@@ -705,7 +712,7 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event)
wxString k_text = m_input_k_val->GetTextCtrl()->GetValue(); wxString k_text = m_input_k_val->GetTextCtrl()->GetValue();
wxString n_text = m_input_n_val->GetTextCtrl()->GetValue(); wxString n_text = m_input_n_val->GetTextCtrl()->GetValue();
if (obj->cali_version <= -1 && (obj->get_printer_series() != PrinterSeries::SERIES_X1) && !ExtrusionCalibration::check_k_validation(k_text)) { if (obj->supports_extrusion_cali() && obj->cali_version <= -1 && (obj->get_printer_series() != PrinterSeries::SERIES_X1) && !ExtrusionCalibration::check_k_validation(k_text)) {
wxString k_tips = wxString::Format(_L("Please input a valid value (K in %.1f~%.1f)"), MIN_PA_K_VALUE, MAX_PA_K_VALUE); wxString k_tips = wxString::Format(_L("Please input a valid value (K in %.1f~%.1f)"), MIN_PA_K_VALUE, MAX_PA_K_VALUE);
wxString kn_tips = wxString::Format(_L("Please input a valid value (K in %.1f~%.1f, N in %.1f~%.1f)"), MIN_PA_K_VALUE, MAX_PA_K_VALUE, 0.6, 2.0); wxString kn_tips = wxString::Format(_L("Please input a valid value (K in %.1f~%.1f, N in %.1f~%.1f)"), MIN_PA_K_VALUE, MAX_PA_K_VALUE, 0.6, 2.0);
MessageDialog msg_dlg(nullptr, k_tips, wxEmptyString, wxICON_WARNING | wxOK); MessageDialog msg_dlg(nullptr, k_tips, wxEmptyString, wxICON_WARNING | wxOK);
@@ -735,7 +742,7 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event)
vt_tray = VIRTUAL_TRAY_DEPUTY_ID; vt_tray = VIRTUAL_TRAY_DEPUTY_ID;
} }
if (obj->cali_version >= 0) { if (obj->supports_extrusion_cali() && obj->cali_version >= 0) {
PACalibIndexInfo select_index_info; PACalibIndexInfo select_index_info;
select_index_info.tray_id = vt_tray; select_index_info.tray_id = vt_tray;
select_index_info.ams_id = ams_id; select_index_info.ams_id = ams_id;
@@ -754,7 +761,7 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event)
CalibUtils::select_PA_calib_result(select_index_info); CalibUtils::select_PA_calib_result(select_index_info);
} }
else { else if (obj->supports_extrusion_cali()) {
obj->command_extrusion_cali_set(vt_tray, "", "", k, n); obj->command_extrusion_cali_set(vt_tray, "", "", k, n);
} }
} }
@@ -776,7 +783,7 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event)
; ;
} }
if (obj->cali_version >= 0) { if (obj->supports_extrusion_cali() && obj->cali_version >= 0) {
PACalibIndexInfo select_index_info; PACalibIndexInfo select_index_info;
select_index_info.tray_id = cali_tray_id; select_index_info.tray_id = cali_tray_id;
select_index_info.ams_id = ams_id; select_index_info.ams_id = ams_id;
@@ -795,7 +802,7 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event)
CalibUtils::select_PA_calib_result(select_index_info); CalibUtils::select_PA_calib_result(select_index_info);
} }
else { else if (obj->supports_extrusion_cali()) {
obj->command_extrusion_cali_set(cali_tray_id, "", "", k, n); obj->command_extrusion_cali_set(cali_tray_id, "", "", k, n);
} }
} }
@@ -891,7 +898,12 @@ bool AMSMaterialsSetting::is_virtual_tray()
void AMSMaterialsSetting::update_widgets() void AMSMaterialsSetting::update_widgets()
{ {
if (obj && obj->get_printer_series() == PrinterSeries::SERIES_X1 && obj->cali_version <= -1) { if (obj && !obj->supports_extrusion_cali()) {
// No printer-side K/N records: do not offer them.
m_panel_normal->Show();
m_panel_kn->Hide();
}
else if (obj && obj->get_printer_series() == PrinterSeries::SERIES_X1 && obj->cali_version <= -1) {
// Low version firmware does not display k value // Low version firmware does not display k value
m_panel_kn->Hide(); m_panel_kn->Hide();
} }
@@ -952,6 +964,9 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
m_input_k_val->GetTextCtrl()->SetValue(k); m_input_k_val->GetTextCtrl()->SetValue(k);
m_input_n_val->GetTextCtrl()->SetValue(n); m_input_n_val->GetTextCtrl()->SetValue(n);
// Tray values are re-supplied by every caller; clear so a previous popup cannot leak in.
m_input_nozzle_min->GetTextCtrl()->SetValue(wxEmptyString);
m_input_nozzle_max->GetTextCtrl()->SetValue(wxEmptyString);
wxArrayString filament_items; wxArrayString filament_items;
wxString bambu_filament_name; wxString bambu_filament_name;
@@ -975,11 +990,14 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
} }
stream << std::fixed << std::setprecision(1) << machine_diameter; stream << std::fixed << std::setprecision(1) << machine_diameter;
std::string nozzle_diameter_str = stream.str(); std::string nozzle_diameter_str = stream.str();
// OrcaSonar's model id is optional, so the device model alone can resolve to nothing;
// the helper falls back to the selected profile so the dropdown is never empty.
const std::string filament_printer_model = resolve_filament_printer_model(obj->printer_type, preset_bundle);
if (preset_bundle) { if (preset_bundle && !filament_printer_model.empty()) {
BOOST_LOG_TRIVIAL(trace) << "system_preset_bundle filament number=" << preset_bundle->filaments.size(); BOOST_LOG_TRIVIAL(trace) << "system_preset_bundle filament number=" << preset_bundle->filaments.size();
for (Preset *filament_it : preset_bundle->get_filament_presets_for_machine( for (Preset *filament_it : preset_bundle->get_filament_presets_for_machine(
DevPrinterConfigUtil::get_printer_display_name(obj->printer_type), nozzle_diameter_str, obj->is_support_user_preset)) { filament_printer_model, nozzle_diameter_str, obj->is_support_user_preset)) {
if (!filament_id_set.insert(filament_it->filament_id).second) if (!filament_id_set.insert(filament_it->filament_id).second)
continue; continue;
const std::string alias = preset_bundle->filaments.get_preset_alias(*filament_it, true); const std::string alias = preset_bundle->filaments.get_preset_alias(*filament_it, true);
@@ -1046,6 +1064,12 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
else { else {
m_comboBox_filament->Show(); m_comboBox_filament->Show();
m_readonly_filament->Hide(); m_readonly_filament->Hide();
// A printer-set tray carries its own temps; the preset selection only fills
// them when the tray has none.
if (!temp_min.IsEmpty() && temp_min != "0")
m_input_nozzle_min->GetTextCtrl()->SetValue(temp_min);
if (!temp_max.IsEmpty() && temp_max != "0")
m_input_nozzle_max->GetTextCtrl()->SetValue(temp_max);
} }
if (obj->cali_version >= 0) { if (obj->cali_version >= 0) {
@@ -1182,7 +1206,12 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
} }
} }
if (filament_items.IsEmpty())
BOOST_LOG_TRIVIAL(warning) << "ams_materials_setting: no filament presets for printer model \""
<< filament_printer_model << "\" at nozzle " << nozzle_diameter_str;
m_comboBox_filament->Set(filament_items); m_comboBox_filament->Set(filament_items);
m_comboBox_from_printer = true;
m_comboBox_filament->SetSelection(selection_idx); m_comboBox_filament->SetSelection(selection_idx);
post_select_event(selection_idx); post_select_event(selection_idx);
@@ -1190,9 +1219,6 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
m_comboBox_filament->SetValue(wxEmptyString); m_comboBox_filament->SetValue(wxEmptyString);
} }
// Set the flag whether to open the filament setting dialog from the device page
m_comboBox_filament->SetClientData(new int(1));
update(); update();
Layout(); Layout();
Fit(); Fit();
@@ -1236,50 +1262,53 @@ int AMSMaterialsSetting::get_filament_variant_index(const Preset &filament, cons
void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
{ {
// Get the flag whether to open the filament setting dialog from the device page // True for the popup's own initial selection: the dialog pre-filled the tray's
int* from_printer = static_cast<int*>(m_comboBox_filament->GetClientData()); // temps, so the matching preset must not overwrite them.
const bool initial_printer_selection = m_comboBox_from_printer;
m_filament_type = ""; m_filament_type = "";
PresetBundle* preset_bundle = wxGetApp().preset_bundle; PresetBundle* preset_bundle = wxGetApp().preset_bundle;
std::string filament_printer_model;
if (preset_bundle) { if (preset_bundle) {
std::ostringstream stream; std::ostringstream stream;
if (obj) { if (obj) {
// Defensive: this dialog is opened only from StatusPanel (BBL-only) today, so the fallback fires // Use the selected profile's nozzle diameter until the connected device reports one.
// only during the brief BBL startup window before firmware reports nozzle info. Without this,
// the "0.0" lookup string returns an empty set and filament lookup yields no results.
float machine_diameter = obj->GetExtderSystem()->GetNozzleDiameter(0); float machine_diameter = obj->GetExtderSystem()->GetNozzleDiameter(0);
if (machine_diameter == 0.0f) { if (machine_diameter == 0.0f) {
const ConfigOption *opt = preset_bundle->printers.get_selected_preset().config.option("nozzle_diameter"); const ConfigOption *opt = preset_bundle->printers.get_selected_preset().config.option("nozzle_diameter");
if (opt) machine_diameter = static_cast<const ConfigOptionFloats *>(opt)->values[0]; if (opt) machine_diameter = static_cast<const ConfigOptionFloats *>(opt)->values[0];
} }
stream << std::fixed << std::setprecision(1) << machine_diameter; stream << std::fixed << std::setprecision(1) << machine_diameter;
filament_printer_model = resolve_filament_printer_model(obj->printer_type, preset_bundle);
} }
std::string nozzle_diameter_str = stream.str(); std::string nozzle_diameter_str = stream.str();
// Resolve the selection against the same list Popup() built the dropdown from, so the two // Resolve the selection against the same list Popup() built the dropdown from, so the two
// halves of the dialog cannot disagree about which filaments this machine can use. // halves of the dialog cannot disagree about which filaments this machine can use.
const std::string selected = m_comboBox_filament->GetValue().ToStdString(); const std::string selected = m_comboBox_filament->GetValue().ToStdString();
if (!selected.empty()) { if (!selected.empty() && !filament_printer_model.empty()) {
const std::string filament_id = map_filament_items[selected].filament_id; const std::string filament_id = map_filament_items[selected].filament_id;
for (Preset *it : preset_bundle->get_filament_presets_for_machine( for (Preset *it : preset_bundle->get_filament_presets_for_machine(
DevPrinterConfigUtil::get_printer_display_name(obj->printer_type), nozzle_diameter_str, obj->is_support_user_preset)) { filament_printer_model, nozzle_diameter_str, obj->is_support_user_preset)) {
if (it->filament_id != filament_id) if (it->filament_id != filament_id)
continue; continue;
// ) if nozzle_temperature_range is found if (!initial_printer_selection) {
const int variant_index = get_filament_variant_index(*it, nozzle_diameter_str); // ) if nozzle_temperature_range is found
ConfigOption* opt_min = it->config.option("nozzle_temperature_range_low"); const int variant_index = get_filament_variant_index(*it, nozzle_diameter_str);
if (opt_min) { ConfigOption* opt_min = it->config.option("nozzle_temperature_range_low");
ConfigOptionInts* opt_min_ints = dynamic_cast<ConfigOptionInts*>(opt_min); if (opt_min) {
if (opt_min_ints) { ConfigOptionInts* opt_min_ints = dynamic_cast<ConfigOptionInts*>(opt_min);
wxString text_nozzle_temp_min = wxString::Format("%d", opt_min_ints->get_at(variant_index)); if (opt_min_ints) {
m_input_nozzle_min->GetTextCtrl()->SetValue(text_nozzle_temp_min); wxString text_nozzle_temp_min = wxString::Format("%d", opt_min_ints->get_at(variant_index));
m_input_nozzle_min->GetTextCtrl()->SetValue(text_nozzle_temp_min);
}
} }
} ConfigOption* opt_max = it->config.option("nozzle_temperature_range_high");
ConfigOption* opt_max = it->config.option("nozzle_temperature_range_high"); if (opt_max) {
if (opt_max) { ConfigOptionInts* opt_max_ints = dynamic_cast<ConfigOptionInts*>(opt_max);
ConfigOptionInts* opt_max_ints = dynamic_cast<ConfigOptionInts*>(opt_max); if (opt_max_ints) {
if (opt_max_ints) { wxString text_nozzle_temp_max = wxString::Format("%d", opt_max_ints->get_at(variant_index));
wxString text_nozzle_temp_max = wxString::Format("%d", opt_max_ints->get_at(variant_index)); m_input_nozzle_max->GetTextCtrl()->SetValue(text_nozzle_temp_max);
m_input_nozzle_max->GetTextCtrl()->SetValue(text_nozzle_temp_max); }
} }
} }
ConfigOption* opt_type = it->config.option("filament_type"); ConfigOption* opt_type = it->config.option("filament_type");
@@ -1318,13 +1347,16 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
m_button_confirm->Disable(); // ORCA No need to change style m_button_confirm->Disable(); // ORCA No need to change style
m_comboBox_cali_result->Clear(); m_comboBox_cali_result->Clear();
m_comboBox_cali_result->SetValue(wxEmptyString); m_comboBox_cali_result->SetValue(wxEmptyString);
m_input_k_val->GetTextCtrl()->SetValue(wxEmptyString); if (!initial_printer_selection) {
m_input_n_val->GetTextCtrl()->SetValue(wxEmptyString); m_input_k_val->GetTextCtrl()->SetValue(wxEmptyString);
m_comboBox_filament->SetClientData(new int(0)); m_input_n_val->GetTextCtrl()->SetValue(wxEmptyString);
}
m_comboBox_from_printer = false;
return; return;
} }
else { else {
m_button_confirm->Enable(true); // ORCA No need to change style if (!m_view_only)
m_button_confirm->Enable(true); // ORCA No need to change style
} }
//filament id //filament id
@@ -1348,6 +1380,9 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
} }
} }
if (!ams_filament_id.empty())
m_clr_picker->is_empty(false);
wxArrayString items; wxArrayString items;
m_pa_profile_items.clear(); m_pa_profile_items.clear();
m_comboBox_cali_result->SetValue(wxEmptyString); m_comboBox_cali_result->SetValue(wxEmptyString);
@@ -1403,7 +1438,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
m_comboBox_cali_result->Set(items); m_comboBox_cali_result->Set(items);
if (ams_id == VIRTUAL_TRAY_MAIN_ID || ams_id == VIRTUAL_TRAY_DEPUTY_ID) { if (ams_id == VIRTUAL_TRAY_MAIN_ID || ams_id == VIRTUAL_TRAY_DEPUTY_ID) {
if (from_printer && (*from_printer == 1)) { if (initial_printer_selection) {
for (auto slot : obj->vt_slot) { for (auto slot : obj->vt_slot) {
if (slot.id == std::to_string(ams_id)) if (slot.id == std::to_string(ams_id))
cali_select_idx = CalibUtils::get_selected_calib_idx(m_pa_profile_items, slot.cali_idx); cali_select_idx = CalibUtils::get_selected_calib_idx(m_pa_profile_items, slot.cali_idx);
@@ -1420,10 +1455,11 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
} }
} }
else { else {
if (from_printer && (*from_printer == 1)) { if (initial_printer_selection) {
DevAmsTray* selected_tray = this->obj->GetFilaSystem()->GetAmsTray(std::to_string(ams_id), std::to_string(slot_id)); DevAmsTray* selected_tray = this->obj->GetFilaSystem()->GetAmsTray(std::to_string(ams_id), std::to_string(slot_id));
if (!selected_tray) if (!selected_tray)
{ {
m_comboBox_from_printer = false;
return; return;
} }
@@ -1454,7 +1490,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
else { else {
if (!ams_filament_id.empty()) { if (!ams_filament_id.empty()) {
//m_input_k_val->GetTextCtrl()->SetValue("0.00"); //m_input_k_val->GetTextCtrl()->SetValue("0.00");
m_input_k_val->Enable(true); m_input_k_val->Enable(!m_view_only);
} }
else { else {
//m_input_k_val->GetTextCtrl()->SetValue("0.00"); //m_input_k_val->GetTextCtrl()->SetValue("0.00");
@@ -1462,7 +1498,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
} }
} }
m_comboBox_filament->SetClientData(new int(0)); m_comboBox_from_printer = false;
} }
void AMSMaterialsSetting::on_dpi_changed(const wxRect &suggested_rect) void AMSMaterialsSetting::on_dpi_changed(const wxRect &suggested_rect)
+4 -1
View File
@@ -122,7 +122,7 @@ public:
std::string ams_filament_id; std::string ams_filament_id;
std::string ams_setting_id; std::string ams_setting_id;
bool m_is_third; bool m_is_third = false;
// Orca: view-only mode (laser/cut). When set, the dialog is inspectable but every // Orca: view-only mode (laser/cut). When set, the dialog is inspectable but every
// editing control is disabled and no command is sent. // editing control is disabled and no command is sent.
bool m_view_only = false; bool m_view_only = false;
@@ -184,6 +184,9 @@ protected:
wxStaticText* m_n_param; wxStaticText* m_n_param;
TextInput* m_input_n_val; TextInput* m_input_n_val;
int m_filament_selection; int m_filament_selection;
// True while the popup's initial, printer-driven selection is being handled; it
// keeps that selection from overwriting the tray's own temps with preset temps.
bool m_comboBox_from_printer = false;
int m_pa_cali_select_id = 0; int m_pa_cali_select_id = 0;
+7 -4
View File
@@ -516,7 +516,8 @@ void AMSSetting::on_insert_material_read(wxCommandEvent &event)
bool tray_read_opt = m_checkbox_Insert_material_auto_read->GetValue(); bool tray_read_opt = m_checkbox_Insert_material_auto_read->GetValue();
bool remain_opt = m_checkbox_remain->GetValue(); bool remain_opt = m_checkbox_remain->GetValue();
m_obj->command_ams_user_settings(start_read_opt, tray_read_opt, remain_opt); if (m_obj->command_ams_user_settings(start_read_opt, tray_read_opt, remain_opt) != 0)
UpdateByObj(m_obj);
m_sizer_Insert_material_tip_inline->Layout(); m_sizer_Insert_material_tip_inline->Layout();
Layout(); Layout();
@@ -542,7 +543,8 @@ void AMSSetting::on_starting_read(wxCommandEvent &event)
bool tray_read_opt = m_checkbox_Insert_material_auto_read->GetValue(); bool tray_read_opt = m_checkbox_Insert_material_auto_read->GetValue();
bool remain_opt = m_checkbox_remain->GetValue(); bool remain_opt = m_checkbox_remain->GetValue();
m_obj->command_ams_user_settings(start_read_opt, tray_read_opt, remain_opt); if (m_obj->command_ams_user_settings(start_read_opt, tray_read_opt, remain_opt) != 0)
UpdateByObj(m_obj);
m_sizer_starting_tip_inline->Layout(); m_sizer_starting_tip_inline->Layout();
Layout(); Layout();
@@ -556,7 +558,8 @@ void AMSSetting::on_remain(wxCommandEvent& event)
bool start_read_opt = m_checkbox_starting_auto_read->GetValue(); bool start_read_opt = m_checkbox_starting_auto_read->GetValue();
bool tray_read_opt = m_checkbox_Insert_material_auto_read->GetValue(); bool tray_read_opt = m_checkbox_Insert_material_auto_read->GetValue();
bool remain_opt = m_checkbox_remain->GetValue(); bool remain_opt = m_checkbox_remain->GetValue();
m_obj->command_ams_user_settings(start_read_opt, tray_read_opt, remain_opt); if (m_obj->command_ams_user_settings(start_read_opt, tray_read_opt, remain_opt) != 0)
UpdateByObj(m_obj);
event.Skip(); event.Skip();
} }
@@ -792,4 +795,4 @@ void AMSSettingArrangeAMSOrder::OnBtnRearrangeClicked(wxCommandEvent& event)
} }
#endif #endif
}} // namespace Slic3r::GUI }} // namespace Slic3r::GUI
+7 -1
View File
@@ -714,9 +714,15 @@ wxArrayString NewCalibrationHistoryDialog::get_all_filaments(const MachineObject
std::string nozzle_diameter_str = stream.str(); std::string nozzle_diameter_str = stream.str();
if (preset_bundle) { if (preset_bundle) {
// OrcaSonar's model id is optional; the helper falls back to the selected profile
// so the list is never empty.
const std::string filament_printer_model = resolve_filament_printer_model(obj->printer_type, preset_bundle);
if (filament_printer_model.empty())
return filament_items;
BOOST_LOG_TRIVIAL(trace) << "system_preset_bundle filament number=" << preset_bundle->filaments.size(); BOOST_LOG_TRIVIAL(trace) << "system_preset_bundle filament number=" << preset_bundle->filaments.size();
for (Preset *filament_it : preset_bundle->get_filament_presets_for_machine( for (Preset *filament_it : preset_bundle->get_filament_presets_for_machine(
DevPrinterConfigUtil::get_printer_display_name(obj->printer_type), nozzle_diameter_str, obj->is_support_user_preset)) { filament_printer_model, nozzle_diameter_str, obj->is_support_user_preset)) {
if (!filament_id_set.insert(filament_it->filament_id).second) if (!filament_id_set.insert(filament_it->filament_id).second)
continue; continue;
const std::string alias = preset_bundle->filaments.get_preset_alias(*filament_it, true); const std::string alias = preset_bundle->filaments.get_preset_alias(*filament_it, true);
+14 -1
View File
@@ -11,6 +11,8 @@
#include "DevUtil.h" #include "DevUtil.h"
#include "DevUtilBackend.h" #include "DevUtilBackend.h"
#include <algorithm>
using namespace nlohmann; using namespace nlohmann;
namespace Slic3r { namespace Slic3r {
@@ -42,6 +44,15 @@ void DevAmsTray::UpdateColorFromStr(const std::string& color)
} }
} }
void DevAmsTray::UpdateEmptyState(bool material_fields_present)
{
const auto is_zero_or_empty = [](const std::string& value) {
return value.empty() || std::all_of(value.begin(), value.end(), [](char c) { return c == '0'; });
};
is_empty = material_fields_present && setting_id.empty() && m_fila_type.empty() &&
is_zero_or_empty(color) && is_zero_or_empty(tag_uid);
}
void DevAmsTray::reset() void DevAmsTray::reset()
{ {
tag_uid = ""; tag_uid = "";
@@ -63,6 +74,7 @@ void DevAmsTray::reset()
k = 0.0f; k = 0.0f;
n = 0.0f; n = 0.0f;
is_bbl = false; is_bbl = false;
is_empty = false;
hold_count = 0; hold_count = 0;
remain = 0; remain = 0;
} }
@@ -662,7 +674,7 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS
//std::string type = (*tray_it)["tray_type"].get<std::string>(); //std::string type = (*tray_it)["tray_type"].get<std::string>();
std::string type = MachineObject::setting_id_to_type(curr_tray->setting_id, (*tray_it)["tray_type"].get<std::string>()); std::string type = MachineObject::setting_id_to_type(curr_tray->setting_id, (*tray_it)["tray_type"].get<std::string>());
// curr_tray->setting_id is our OF id; GFS00/GFS01 are the printer's own support-filament ids. // curr_tray->setting_id is our OF id; GFS00/GFS01 are the printer's own support-filament ids.
auto* agent = GUI::wxGetApp().getAgent(); auto* agent = wxTheApp != nullptr ? GUI::wxGetApp().getAgent() : nullptr;
const std::string printer_filament_id = agent ? agent->from_orca_filament_id(curr_tray->setting_id) : curr_tray->setting_id; const std::string printer_filament_id = agent ? agent->from_orca_filament_id(curr_tray->setting_id) : curr_tray->setting_id;
if (printer_filament_id == "GFS00") if (printer_filament_id == "GFS00")
{ {
@@ -762,6 +774,7 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS
{ {
curr_tray->remain = -1; curr_tray->remain = -1;
} }
curr_tray->UpdateEmptyState(tray_it->contains("tray_info_idx") && tray_it->contains("tray_type"));
// The tray objects are reused across status updates. Reset this // The tray objects are reused across status updates. Reset this
// state when a previously empty slot receives a filament again. // state when a previously empty slot receives a filament again.
curr_tray->is_slot_placeholder = tray_it->contains("tray_slot_placeholder"); curr_tray->is_slot_placeholder = tray_it->contains("tray_slot_placeholder");
+4 -1
View File
@@ -82,6 +82,7 @@ public:
wxColour wx_color; wxColour wx_color;
bool is_bbl; bool is_bbl;
bool is_exists = false; bool is_exists = false;
bool is_empty = false; // Explicitly reported as having no filament.
bool is_slot_placeholder = false; // Orca: True for empty tray slots from pull-mode agents bool is_slot_placeholder = false; // Orca: True for empty tray slots from pull-mode agents
int hold_count = 0; int hold_count = 0;
int remain = 0; // filament remain: 0 ~ 100 int remain = 0; // filament remain: 0 ~ 100
@@ -90,13 +91,15 @@ public:
// operators // operators
bool operator==(DevAmsTray const& o) const bool operator==(DevAmsTray const& o) const
{ {
return id == o.id && m_fila_type == o.m_fila_type && filament_setting_id == o.filament_setting_id && color == o.color; return id == o.id && m_fila_type == o.m_fila_type && filament_setting_id == o.filament_setting_id &&
color == o.color && is_empty == o.is_empty;
} }
bool operator!=(DevAmsTray const& o) const { return !operator==(o); } bool operator!=(DevAmsTray const& o) const { return !operator==(o); }
// setters // setters
void reset(); void reset();
void UpdateColorFromStr(const std::string& color); void UpdateColorFromStr(const std::string& color);
void UpdateEmptyState(bool material_fields_present);
void set_hold_count() { hold_count = HOLD_COUNT_MAX; } void set_hold_count() { hold_count = HOLD_COUNT_MAX; }
// getter // getter
+108 -11
View File
@@ -380,6 +380,32 @@ NozzleVolumeType convert_to_nozzle_type(const std::string &str)
return res; return res;
} }
std::string resolve_filament_printer_model(const std::string& printer_type, PresetBundle* preset_bundle)
{
if (!preset_bundle)
return {};
// Devices with a shipped printer config (Bambu) resolve directly.
if (const std::string display_name = DevPrinterConfigUtil::get_printer_display_name(printer_type); !display_name.empty())
return display_name;
// A vendor model id the device reported (SSDP modelNumber, manual binding).
if (!printer_type.empty()) {
if (const std::string model_name = preset_bundle->get_printer_model_display_name(printer_type); !model_name.empty())
return model_name;
}
// OrcaSonar's model id is optional; a generic device resolves against the selected profile.
if (const ConfigOptionString* model = preset_bundle->printers.get_selected_preset().config.opt<ConfigOptionString>("printer_model");
model && !model->value.empty()) {
BOOST_LOG_TRIVIAL(info) << "resolve_filament_printer_model: device type \"" << printer_type
<< "\" has no installed model; using the selected profile \"" << model->value << "\"";
return model->value;
}
return {};
}
wxString MachineObject::get_printer_type_display_str() const wxString MachineObject::get_printer_type_display_str() const
{ {
std::string display_name = DevPrinterConfigUtil::get_printer_display_name(printer_type); std::string display_name = DevPrinterConfigUtil::get_printer_display_name(printer_type);
@@ -1682,6 +1708,10 @@ int MachineObject::check_resume_condition()
} }
int MachineObject::command_ams_change_filament(bool load, std::string ams_id, std::string slot_id, int old_temp, int new_temp, std::optional<int> extruder_id) int MachineObject::command_ams_change_filament(bool load, std::string ams_id, std::string slot_id, int old_temp, int new_temp, std::optional<int> extruder_id)
{ {
if (!printer_supports_command("print.ams_change_filament")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_change_filament: printer agent does not support the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
json j; json j;
try { try {
auto tray_id = 0; auto tray_id = 0;
@@ -1725,6 +1755,10 @@ int MachineObject::command_ams_change_filament(bool load, std::string ams_id, st
int MachineObject::command_ams_user_settings(bool start_read_opt, bool tray_read_opt, bool remain_flag) int MachineObject::command_ams_user_settings(bool start_read_opt, bool tray_read_opt, bool remain_flag)
{ {
if (!printer_supports_command("print.ams_user_setting")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_user_settings: printer agent does not support the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
json j; json j;
j["print"]["command"] = "ams_user_setting"; j["print"]["command"] = "ams_user_setting";
j["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++); j["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++);
@@ -1733,22 +1767,59 @@ int MachineObject::command_ams_user_settings(bool start_read_opt, bool tray_read
j["print"]["tray_read_option"] = tray_read_opt; j["print"]["tray_read_option"] = tray_read_opt;
j["print"]["calibrate_remain_flag"] = remain_flag; j["print"]["calibrate_remain_flag"] = remain_flag;
m_fila_system->GetAmsSystemSetting().SetDetectOnInsertEnabled(tray_read_opt); const int rc = this->publish_json(j);
m_fila_system->GetAmsSystemSetting().SetDetectOnPowerupEnabled(start_read_opt); if (rc == 0) {
m_fila_system->GetAmsSystemSetting().SetDetectRemainEnabled(remain_flag); m_fila_system->GetAmsSystemSetting().SetDetectOnInsertEnabled(tray_read_opt);
ams_user_setting_start = time(nullptr); m_fila_system->GetAmsSystemSetting().SetDetectOnPowerupEnabled(start_read_opt);
m_fila_system->GetAmsSystemSetting().SetDetectRemainEnabled(remain_flag);
return this->publish_json(j); ams_user_setting_start = time(nullptr);
}
return rc;
} }
int MachineObject::command_ams_calibrate(int ams_id) int MachineObject::command_ams_calibrate(int ams_id)
{ {
if (!m_agent) return -1; if (!m_agent) return -1;
if (!m_agent->owns_agent(printer_agent_id))
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
return command_with_dialog(m_agent->command_ams_calibrate(get_dev_id(), ams_id, MachineObject::m_sequence_id++, is_lan_mode_printer())); return command_with_dialog(m_agent->command_ams_calibrate(get_dev_id(), ams_id, MachineObject::m_sequence_id++, is_lan_mode_printer()));
} }
bool MachineObject::printer_supports_command(const char* command) const
{
if (!command || !m_agent)
return false;
return m_agent->supports_command(printer_agent_id, get_dev_id(), command);
}
bool MachineObject::printer_uses_filament_mapping() const
{
if (!m_agent)
return false;
// An unrecorded owner goes through owns_agent() like any other, because
// whichever agent is active is also the one formatting the payload. Only a
// recorded owner that differs from the active agent is a mismatch.
return m_agent->uses_filament_mapping(printer_agent_id);
}
bool MachineObject::printer_supports_feature(const char* feature) const
{
return feature && m_agent && m_agent->supports_feature(printer_agent_id, get_dev_id(), feature);
}
bool MachineObject::supports_extrusion_cali() const
{
// Devices with no agent id predate the agent split and keep the Bambu path.
return printer_agent_id.empty() || printer_agent_id == BBL_PRINTER_AGENT_ID;
}
int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max) int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max)
{ {
if (!printer_supports_command("print.ams_filament_setting")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_filament_settings: printer agent does not support slot metadata updates";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
int tag_tray_id = 0; int tag_tray_id = 0;
int tag_ams_id = ams_id; int tag_ams_id = ams_id;
int tag_slot_id = slot_id; int tag_slot_id = slot_id;
@@ -1783,6 +1854,10 @@ int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::s
int MachineObject::command_ams_refresh_rfid(int ams_id, int slot_id) int MachineObject::command_ams_refresh_rfid(int ams_id, int slot_id)
{ {
if (!m_agent) return -1; if (!m_agent) return -1;
if (!printer_supports_command("print.ams_get_rfid")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_refresh_rfid: printer agent does not support the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
return command_with_dialog(m_agent->command_ams_refresh_rfid(get_dev_id(), ams_id, slot_id, MachineObject::m_sequence_id++, is_lan_mode_printer())); return command_with_dialog(m_agent->command_ams_refresh_rfid(get_dev_id(), ams_id, slot_id, MachineObject::m_sequence_id++, is_lan_mode_printer()));
} }
@@ -1796,11 +1871,19 @@ int MachineObject::command_start_camera()
int MachineObject::command_ams_select_tray(std::string tray_id) int MachineObject::command_ams_select_tray(std::string tray_id)
{ {
if (!m_agent) return -1; if (!m_agent) return -1;
if (!printer_supports_command("print.ams_change_filament")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_select_tray: printer agent does not support the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
return command_with_dialog(m_agent->command_ams_select_tray(get_dev_id(), tray_id, MachineObject::m_sequence_id++, is_lan_mode_printer())); return command_with_dialog(m_agent->command_ams_select_tray(get_dev_id(), tray_id, MachineObject::m_sequence_id++, is_lan_mode_printer()));
} }
int MachineObject::command_ams_control(std::string action) int MachineObject::command_ams_control(std::string action)
{ {
if (!printer_supports_command("print.ams_control")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_control: printer agent does not support the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
if (action == "resume" && check_resume_condition()) return 0; if (action == "resume" && check_resume_condition()) return 0;
//valid actions //valid actions
@@ -1816,6 +1899,10 @@ int MachineObject::command_ams_control(std::string action)
int MachineObject::command_ams_drying_stop() int MachineObject::command_ams_drying_stop()
{ {
if (!printer_supports_command("print.auto_stop_ams_dry")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_drying_stop: printer agent does not support the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
json j; json j;
j["print"]["command"] = "auto_stop_ams_dry"; j["print"]["command"] = "auto_stop_ams_dry";
j["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++); j["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++);
@@ -2766,7 +2853,9 @@ bool MachineObject::is_camera_busy_off()
int MachineObject::publish_json(const json& json_item, int qos, int flag) int MachineObject::publish_json(const json& json_item, int qos, int flag)
{ {
int rtn = 0; int rtn = 0;
if (is_lan_mode_printer()) { if (m_agent && !m_agent->owns_agent(printer_agent_id)) {
rtn = ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED;
} else if (is_lan_mode_printer()) {
rtn = local_publish_json(json_item.dump(), qos, flag); rtn = local_publish_json(json_item.dump(), qos, flag);
} else { } else {
rtn = cloud_publish_json(json_item.dump(), qos, flag); rtn = cloud_publish_json(json_item.dump(), qos, flag);
@@ -2810,6 +2899,7 @@ int MachineObject::local_publish_json(std::string json_str, int qos, int flag)
std::string MachineObject::setting_id_to_type(std::string setting_id, std::string tray_type) std::string MachineObject::setting_id_to_type(std::string setting_id, std::string tray_type)
{ {
std::string type; std::string type;
if (wxTheApp == nullptr) return tray_type;
PresetBundle* preset_bundle = GUI::wxGetApp().preset_bundle; PresetBundle* preset_bundle = GUI::wxGetApp().preset_bundle;
if (preset_bundle) { if (preset_bundle) {
for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) { for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) {
@@ -4146,6 +4236,9 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
vt_slot[0].setting_id = jj["tray_info_idx"].get<std::string>(); vt_slot[0].setting_id = jj["tray_info_idx"].get<std::string>();
//vt_tray.type = jj["tray_type"].get<std::string>(); //vt_tray.type = jj["tray_type"].get<std::string>();
vt_slot[0].m_fila_type = setting_id_to_type(vt_slot[0].setting_id, jj["tray_type"].get<std::string>()); vt_slot[0].m_fila_type = setting_id_to_type(vt_slot[0].setting_id, jj["tray_type"].get<std::string>());
// The ack carries the whole slot; re-derive empty so the panel flips off
// "Empty" without waiting out the hold.
vt_slot[0].UpdateEmptyState(true);
// delay update // delay update
vt_slot[0].set_hold_count(); vt_slot[0].set_hold_count();
} else { } else {
@@ -4171,6 +4264,9 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
tray_it->second->setting_id = jj["tray_info_idx"].get<std::string>(); tray_it->second->setting_id = jj["tray_info_idx"].get<std::string>();
tray_it->second->m_fila_type = setting_id_to_type(tray_it->second->setting_id, jj["tray_type"].get<std::string>()); tray_it->second->m_fila_type = setting_id_to_type(tray_it->second->setting_id, jj["tray_type"].get<std::string>());
// The ack carries the whole slot; re-derive empty so the panel flips off
// "Empty" without waiting out the hold.
tray_it->second->UpdateEmptyState(true);
// delay update // delay update
tray_it->second->set_hold_count(); tray_it->second->set_hold_count();
} else { } else {
@@ -5109,7 +5205,7 @@ DevAmsTray MachineObject::parse_vt_tray(json vtray)
//std::string type = vtray["tray_type"].get<std::string>(); //std::string type = vtray["tray_type"].get<std::string>();
std::string type = setting_id_to_type(vt_tray.setting_id, vtray["tray_type"].get<std::string>()); std::string type = setting_id_to_type(vt_tray.setting_id, vtray["tray_type"].get<std::string>());
// vt_tray.setting_id is our OF id (translated on the way in); the two support ids below are the printer's own. // vt_tray.setting_id is our OF id (translated on the way in); the two support ids below are the printer's own.
auto* agent = GUI::wxGetApp().getAgent(); auto* agent = wxTheApp != nullptr ? GUI::wxGetApp().getAgent() : nullptr;
const std::string printer_filament_id = agent ? agent->from_orca_filament_id(vt_tray.setting_id) : vt_tray.setting_id; const std::string printer_filament_id = agent ? agent->from_orca_filament_id(vt_tray.setting_id) : vt_tray.setting_id;
if (printer_filament_id == "GFS00") { if (printer_filament_id == "GFS00") {
vt_tray.m_fila_type = "PLA-S"; vt_tray.m_fila_type = "PLA-S";
@@ -5204,6 +5300,7 @@ DevAmsTray MachineObject::parse_vt_tray(json vtray)
else { else {
vt_tray.remain = -1; vt_tray.remain = -1;
} }
vt_tray.UpdateEmptyState(vtray.contains("tray_info_idx") && vtray.contains("tray_type"));
} }
return vt_tray; return vt_tray;
@@ -5497,11 +5594,11 @@ void MachineObject::parse_new_info2(const json& info)
if (capabilities_it == info.end() || !capabilities_it->is_object()) if (capabilities_it == info.end() || !capabilities_it->is_object())
return; return;
const auto flags_it = capabilities_it->find("flags"); const auto flags_it = capabilities_it->find("flags");
if (flags_it == capabilities_it->end() || !flags_it->is_object()) const bool has_flags = flags_it != capabilities_it->end() && flags_it->is_object();
if (!has_flags)
return; return;
const json& flags = *flags_it; const json& flags = *flags_it;
BOOST_LOG_TRIVIAL(info) << "parse_new_info2: OrcaSonar capability flags=" << flags.dump(); BOOST_LOG_TRIVIAL(info) << "parse_new_info2: capability flags=" << flags.dump();
auto parse_bool = [&flags](const char* name, bool& target) { auto parse_bool = [&flags](const char* name, bool& target) {
const auto it = flags.find(name); const auto it = flags.find(name);
+14
View File
@@ -8,6 +8,7 @@
#include <string> #include <string>
#include <memory> #include <memory>
#include <chrono> #include <chrono>
#include <set>
#include <unordered_set> #include <unordered_set>
#include <optional> #include <optional>
#include <boost/thread.hpp> #include <boost/thread.hpp>
@@ -66,6 +67,7 @@ class DeviceErrorDialog; // Previous definitions
class NetworkAgent; class NetworkAgent;
// Orca: ManualPaCaliMethod now provided by DeviceCore/DevCalib.h (enum class) // Orca: ManualPaCaliMethod now provided by DeviceCore/DevCalib.h (enum class)
class PresetBundle;
#define UpgradeNoError 0 #define UpgradeNoError 0
#define UpgradeDownloadFailed -1 #define UpgradeDownloadFailed -1
@@ -101,6 +103,11 @@ struct DevPrintTaskRatingInfo;
// given nozzle diameter (mm), bucketed per nozzle size to mirror the printer firmware. // given nozzle diameter (mm), bucketed per nozzle size to mirror the printer firmware.
bool is_stringing_prone_filament(const std::string& filament_id, float nozzle_diameter); bool is_stringing_prone_filament(const std::string& filament_id, float nozzle_diameter);
// The printer model filament presets are matched against for a connected machine. OrcaSonar's
// model id is optional, so a device with no installed vendor model falls back to the selected
// printer profile rather than resolving to no filament list at all.
std::string resolve_filament_printer_model(const std::string& printer_type, PresetBundle* preset_bundle);
class MachineObject class MachineObject
{ {
private: private:
@@ -789,6 +796,13 @@ public:
int command_refresh_nozzle(); int command_refresh_nozzle();
int command_set_chamber(int temp); int command_set_chamber(int temp);
int check_resume_condition(); int check_resume_condition();
bool printer_supports_command(const char* command) const;
bool printer_supports_feature(const char* feature) const;
bool printer_uses_filament_mapping() const;
// The per-tray flow-dynamics K/N records are a Bambu firmware feature. Other
// agents have no printer-side calibration to read or write, so the AMS UI must
// not offer K/N for them.
bool supports_extrusion_cali() const;
// ams controls // ams controls
//int command_ams_switch(int tray_index, int old_temp = 210, int new_temp = 210); //int command_ams_switch(int tray_index, int old_temp = 210, int new_temp = 210);
int command_ams_change_filament(bool load, std::string ams_id, std::string slot_id, int old_temp = 210, int new_temp = 210, std::optional<int> extruder_id = std::nullopt); int command_ams_change_filament(bool load, std::string ams_id, std::string slot_id, int old_temp = 210, int new_temp = 210, std::optional<int> extruder_id = std::nullopt);
@@ -943,7 +943,11 @@ int ReselectMachineDialog::CaculateSwitcherDistribution(MachineObject* obj, cons
const auto& can = ams.cans[j]; const auto& can = ams.cans[j];
auto id = getTrayID(obj, ams.ams_id, can.can_id); auto id = getTrayID(obj, ams.ams_id, can.can_id);
auto material = can.material_name; auto material = can.material_name;
if (can.material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND || if (can.is_empty || can.material_state == AMSCanType::AMS_CAN_TYPE_EMPTY)
{
material = L("Empty");
}
else if (can.material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND ||
can.material_state == AMSCanType::AMS_CAN_TYPE_BRAND || can.material_state == AMSCanType::AMS_CAN_TYPE_BRAND ||
can.material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL) can.material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL)
{ {
@@ -952,10 +956,6 @@ int ReselectMachineDialog::CaculateSwitcherDistribution(MachineObject* obj, cons
material = L("?"); material = L("?");
} }
} }
if (can.material_state == AMSCanType::AMS_CAN_TYPE_EMPTY)
{
material = "Empty";
}
auto itOK = std::find_if(posOK.begin(), posOK.end(), [&](const trayHelper& tray){ auto itOK = std::find_if(posOK.begin(), posOK.end(), [&](const trayHelper& tray){
auto amsID = std::get<0>(tray); auto amsID = std::get<0>(tray);
+105
View File
@@ -0,0 +1,105 @@
#pragma once
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include "libslic3r/ProjectTask.hpp"
#include "DeviceManager.hpp"
namespace Slic3r {
namespace GUI {
// True when the normalized ams_mapping2 carries any entry the agent's serializer
// would put on the wire: every integer pair except the {255,255} unmatched
// sentinel, external slots ({255,0}/{254,0}) included. This mirrors
// OrcaPrinterAgent::build_filament_mapping exactly, so the GUI gate and the
// agent gate agree; a mismatch lets an entry reach the agent and be refused late
// with a generic publish error instead of the designed message.
inline bool has_engaged_filament_mapping(const std::string& ams_mapping2)
{
const nlohmann::json mapping = nlohmann::json::parse(ams_mapping2, nullptr, false);
if (mapping.is_discarded() || !mapping.is_array())
return false;
for (const auto& entry : mapping) {
if (!entry.is_object())
continue;
const auto ams_id_it = entry.find("ams_id");
const auto slot_id_it = entry.find("slot_id");
if (ams_id_it == entry.end() || slot_id_it == entry.end())
continue;
if (!ams_id_it->is_number_integer() || !slot_id_it->is_number_integer())
continue;
if (ams_id_it->get<int>() == 255 && slot_id_it->get<int>() == 255)
continue; // unmatched/unused sentinel: the serializer drops it
return true;
}
return false;
}
// A device with no AMS units has a single source: the external spool. OrcaSlicer's
// auto-mapping force-selects it for every filament (DevMapping.cpp), but that is not
// a lane choice: keep it out of the send gate and off print.gcode_file.
inline void drop_forced_external_selection(bool device_has_ams, std::string& ams_mapping2)
{
if (!device_has_ams)
ams_mapping2.clear();
}
// A used filament with no target would be silently dropped from the wire
// mapping, so the print must be refused rather than run the wrong material.
// m_ams_mapping_result carries exactly the filaments the slice uses.
inline bool has_used_filament_without_target(const std::vector<FilamentInfo>& result)
{
for (const auto& f : result) {
if (f.get_ams_id() < 0 || f.get_slot_id() < 0)
return true;
}
return false;
}
// True when at least one used filament already has a target. The used-unmapped
// refusal applies to a partially mapped print; a print with no mapping at all
// is handled by the existing all-invalid send-path flow.
inline bool has_any_mapped_target(const std::vector<FilamentInfo>& result)
{
for (const auto& f : result) {
if (f.get_ams_id() >= 0 && f.get_slot_id() >= 0)
return true;
}
return false;
}
// Refusal reason for a printer agent that serializes lane selection into
// print.gcode_file's per-print filament_mapping field.
enum class MappingSendError {
none, // no refusal; ams_mapping2 is normalized for send
unsupported, // a mapping is engaged but the connector did not advertise it
incomplete, // a used filament has no target while others do
};
// Applies the send-time mapping policy and normalizes ams_mapping2 in place.
// Agents that do not speak filament_mapping keep their legacy payload untouched.
inline MappingSendError prepare_filament_mapping_for_send(MachineObject* obj,
std::string& ams_mapping2,
const std::vector<FilamentInfo>& mapping_result)
{
if (!obj || !obj->printer_uses_filament_mapping())
return MappingSendError::none;
// A device with no AMS units has one source, the external spool:
// auto-mapping force-selects it, which is not a lane choice. Drop it before
// the capability gate so it cannot refuse a print nobody mapped.
drop_forced_external_selection(obj->HasAms(), ams_mapping2);
if (!obj->printer_supports_feature("filament_mapping") && has_engaged_filament_mapping(ams_mapping2))
return MappingSendError::unsupported;
if (has_any_mapped_target(mapping_result) && has_used_filament_without_target(mapping_result))
return MappingSendError::incomplete;
return MappingSendError::none;
}
} // namespace GUI
} // namespace Slic3r
+27
View File
@@ -37,6 +37,8 @@
#include "libslic3r/MultiNozzleUtils.hpp" // filament-change-gap model for the best-position popup #include "libslic3r/MultiNozzleUtils.hpp" // filament-change-gap model for the best-position popup
#include "BackgroundSlicingProcess.hpp" // complete type for background_process().get_current_gcode_result() #include "BackgroundSlicingProcess.hpp" // complete type for background_process().get_current_gcode_result()
#include "DeviceCore/DevStorage.h" #include "DeviceCore/DevStorage.h"
#include "slic3r/Utils/NetworkAgentFactory.hpp"
#include "FilamentMappingUtils.hpp"
#include <wx/progdlg.h> #include <wx/progdlg.h>
#include <wx/clipbrd.h> #include <wx/clipbrd.h>
@@ -3479,6 +3481,9 @@ void SelectMachineDialog::navigate_to_timelapse_page()
this->EndModal(wxID_CANCEL); this->EndModal(wxID_CANCEL);
} }
// Mapping helpers live in FilamentMappingUtils.hpp (shared with
// SendMultiMachinePage); they mirror the agent serializer exactly.
void SelectMachineDialog::on_send_print() void SelectMachineDialog::on_send_print()
{ {
BOOST_LOG_TRIVIAL(info) << "print_job: on_ok to send"; BOOST_LOG_TRIVIAL(info) << "print_job: on_ok to send";
@@ -3533,6 +3538,28 @@ void SelectMachineDialog::on_send_print()
get_ams_mapping_result(ams_mapping_array,ams_mapping_array2, ams_mapping_info); get_ams_mapping_result(ams_mapping_array,ams_mapping_array2, ams_mapping_info);
// The policy lives in FilamentMappingUtils.hpp; this dialog only differs
// from the multi-device page in how it reports a refusal.
switch (prepare_filament_mapping_for_send(obj_, ams_mapping_array2, m_ams_mapping_result)) {
case MappingSendError::unsupported:
BOOST_LOG_TRIVIAL(warning) << "print_job: connector does not advertise filament_mapping; refusing mapped print";
m_status_bar->set_status_text(_L("AMS filament mapping is not available for this printer. Clear the AMS mapping before printing."));
Enable_Send_Button(true);
return;
case MappingSendError::incomplete:
BOOST_LOG_TRIVIAL(warning) << "print_job: a used filament has no AMS target; refusing print";
m_status_bar->set_status_text(_L("A filament used by this print has no AMS mapping. Assign it before printing."));
Enable_Send_Button(true);
return;
case MappingSendError::none:
break;
default:
// A refusal added without a handler here must not silently send.
BOOST_LOG_TRIVIAL(warning) << "print_job: unrecognized mapping refusal; refusing print";
Enable_Send_Button(true);
return;
}
if (m_print_type == PrintFromType::FROM_NORMAL) { if (m_print_type == PrintFromType::FROM_NORMAL) {
result = m_plater->send_gcode(m_print_plate_idx, [this](int export_stage, int current, int total, bool& cancel) { result = m_plater->send_gcode(m_print_plate_idx, [this](int export_stage, int current, int total, bool& cancel) {
if (this->m_is_canceled) return; if (this->m_is_canceled) return;
+28
View File
@@ -10,6 +10,8 @@
#include "DeviceCore/DevManager.h" #include "DeviceCore/DevManager.h"
#include "DeviceCore/DevStorage.h" #include "DeviceCore/DevStorage.h"
#include "slic3r/Utils/NetworkAgentFactory.hpp"
#include "FilamentMappingUtils.hpp"
namespace Slic3r { namespace Slic3r {
namespace GUI { namespace GUI {
@@ -692,6 +694,8 @@ bool SendMultiMachinePage::get_ams_mapping_result(std::string &mapping_array_str
return true; return true;
} }
// Mapping helpers live in FilamentMappingUtils.hpp, shared with SelectMachine.
void SendMultiMachinePage::on_send(wxCommandEvent& event) void SendMultiMachinePage::on_send(wxCommandEvent& event)
{ {
event.Skip(); event.Skip();
@@ -740,6 +744,30 @@ void SendMultiMachinePage::on_send(wxCommandEvent& event)
if (!wxGetApp().is_blocking_printing(obj)) { if (!wxGetApp().is_blocking_printing(obj)) {
PrintParams params = request_params(obj); PrintParams params = request_params(obj);
switch (prepare_filament_mapping_for_send(obj, params.ams_mapping2, m_ams_mapping_result)) {
case MappingSendError::unsupported:
{
BOOST_LOG_TRIVIAL(warning) << "SendMultiMachinePage: connector does not advertise filament_mapping; refusing mapped print for "
<< obj->get_dev_id();
MessageDialog unavailable_msg(nullptr, _L("AMS filament mapping is not available for this printer. Clear the AMS mapping before printing."), "", wxICON_WARNING | wxOK);
unavailable_msg.ShowModal();
return;
}
case MappingSendError::incomplete:
{
BOOST_LOG_TRIVIAL(warning) << "SendMultiMachinePage: a used filament has no target; refusing print for " << obj->get_dev_id();
MessageDialog incomplete_msg(nullptr, _L("A filament used by this print has no AMS mapping. Assign it before printing."), "", wxICON_WARNING | wxOK);
incomplete_msg.ShowModal();
return;
}
case MappingSendError::none:
break;
default:
// A refusal added without a handler here must not silently send.
BOOST_LOG_TRIVIAL(warning) << "SendMultiMachinePage: unrecognized mapping refusal for "
<< obj->get_dev_id();
return;
}
print_params.push_back(params); print_params.push_back(params);
} }
} }
+6 -4
View File
@@ -4634,12 +4634,14 @@ void StatusPanel::on_filament_edit(wxCommandEvent &event)
} }
m_filament_setting_dlg->m_is_third = !DevFilaSystem::IsBBL_Filament(tray->tag_uid); m_filament_setting_dlg->m_is_third = !DevFilaSystem::IsBBL_Filament(tray->tag_uid);
// The tray's own temps pre-fill the dialog for every tray type; a BBL tray
// additionally carries the SN and brand.
temp_max = tray->nozzle_temp_max;
temp_min = tray->nozzle_temp_min;
if (!m_filament_setting_dlg->m_is_third) if (!m_filament_setting_dlg->m_is_third)
{ {
sn_number = tray->uuid; sn_number = tray->uuid;
filament = tray->sub_brands; filament = tray->sub_brands;
temp_max = tray->nozzle_temp_max;
temp_min = tray->nozzle_temp_min;
} }
} }
@@ -4700,11 +4702,11 @@ void StatusPanel::on_ext_spool_edit(wxCommandEvent &event)
} }
m_filament_setting_dlg->m_is_third = !DevFilaSystem::IsBBL_Filament(obj->vt_slot[nozzle_index].tag_uid); m_filament_setting_dlg->m_is_third = !DevFilaSystem::IsBBL_Filament(obj->vt_slot[nozzle_index].tag_uid);
temp_max = obj->vt_slot[nozzle_index].nozzle_temp_max;
temp_min = obj->vt_slot[nozzle_index].nozzle_temp_min;
if (!m_filament_setting_dlg->m_is_third) { if (!m_filament_setting_dlg->m_is_third) {
sn_number = obj->vt_slot[nozzle_index].uuid; sn_number = obj->vt_slot[nozzle_index].uuid;
filament = obj->vt_slot[nozzle_index].sub_brands; filament = obj->vt_slot[nozzle_index].sub_brands;
temp_max = obj->vt_slot[nozzle_index].nozzle_temp_max;
temp_min = obj->vt_slot[nozzle_index].nozzle_temp_min;
} }
m_filament_setting_dlg->Move(wxPoint(current_position_x,current_position_y)); m_filament_setting_dlg->Move(wxPoint(current_position_x,current_position_y));
+6
View File
@@ -1373,6 +1373,12 @@ bool SyncAmsInfoDialog::get_ams_mapping_result(std::string &mapping_array_str, s
BOOST_LOG_TRIVIAL(error) << "get_ams_mapping_result, plater is nullptr"; BOOST_LOG_TRIVIAL(error) << "get_ams_mapping_result, plater is nullptr";
} }
// mapping_v1_json is built one entry per filament preset, in preset order, so the
// array position is the logical filament index the generated G-code toolchange
// references (the identifier handed to the Klipper toolchange macro; see
// OrcaPrinterAgent::build_filament_mapping and the index-correlation test in
// tests/slic3rutils/test_orca_printer_agent.cpp). Never re-densify after dropping
// sentinel entries, or a used filament would be aimed at the wrong lane.
for (int i = 0; i < wxGetApp().preset_bundle->filament_presets.size(); i++) { for (int i = 0; i < wxGetApp().preset_bundle->filament_presets.size(); i++) {
int tray_id = -1; int tray_id = -1;
json mapping_item_v1; json mapping_item_v1;
+17 -11
View File
@@ -72,6 +72,7 @@ bool AMSinfo::parse_ams_info(MachineObject *obj, DevAms *ams, bool remain_flag,
Caninfo info; Caninfo info;
// tray is exists // tray is exists
if (it != ams->GetTrays().end() && it->second->is_exists) { if (it != ams->GetTrays().end() && it->second->is_exists) {
info.is_empty = it->second->is_empty;
if (it->second->is_tray_info_ready()) { if (it->second->is_tray_info_ready()) {
info.can_id = it->second->id; info.can_id = it->second->id;
info.ctype = it->second->ctype; info.ctype = it->second->ctype;
@@ -139,6 +140,7 @@ void AMSinfo::parse_ext_info(MachineObject* obj, DevAmsTray tray) {
this->ams_type = AMSModel::EXT_AMS; this->ams_type = AMSModel::EXT_AMS;
Caninfo info; Caninfo info;
info.can_id = std::to_string(0); info.can_id = std::to_string(0);
info.is_empty = tray.is_empty;
this->cans.clear(); this->cans.clear();
if (tray.id == std::to_string(VIRTUAL_TRAY_MAIN_ID)) if (tray.id == std::to_string(VIRTUAL_TRAY_MAIN_ID))
@@ -1101,9 +1103,9 @@ void AMSLib::render_lite_text(wxDC& dc)
dc.SetTextForeground(temp_text_colour); dc.SetTextForeground(temp_text_colour);
auto libsize = GetSize(); auto libsize = GetSize();
if (m_info.material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND if (!m_info.is_empty && (m_info.material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND
|| m_info.material_state == AMSCanType::AMS_CAN_TYPE_BRAND || m_info.material_state == AMSCanType::AMS_CAN_TYPE_BRAND
|| m_info.material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL) { || m_info.material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL)) {
if (m_info.material_name.empty()) { if (m_info.material_name.empty()) {
auto tsize = dc.GetMultiLineTextExtent("?"); auto tsize = dc.GetMultiLineTextExtent("?");
@@ -1151,7 +1153,7 @@ void AMSLib::render_lite_text(wxDC& dc)
} }
} }
if (m_info.material_state == AMSCanType::AMS_CAN_TYPE_EMPTY) { if (m_info.material_state == AMSCanType::AMS_CAN_TYPE_EMPTY || m_info.is_empty) {
auto tsize = dc.GetMultiLineTextExtent(_L("/")); auto tsize = dc.GetMultiLineTextExtent(_L("/"));
auto pot = wxPoint((libsize.x - tsize.x) / 2 + FromDIP(2), (libsize.y - tsize.y) / 2 + FromDIP(3)); auto pot = wxPoint((libsize.x - tsize.x) / 2 + FromDIP(2), (libsize.y - tsize.y) / 2 + FromDIP(3));
dc.DrawText(_L("/"), pot); dc.DrawText(_L("/"), pot);
@@ -1160,8 +1162,12 @@ void AMSLib::render_lite_text(wxDC& dc)
void AMSLib::render_generic_text(wxDC &dc) void AMSLib::render_generic_text(wxDC &dc)
{ {
bool show_k_value = true; // K/N is Bambu firmware's flow-dynamics calibration; agents with no printer-side
if (m_info.material_name.empty()) { // records (OrcaSonar, Moonraker) must not show a synthesized value.
const bool k_supported = !m_obj || m_obj->supports_extrusion_cali();
const bool show_kn = m_show_kn && k_supported;
bool show_k_value = k_supported;
if (!k_supported || m_info.material_name.empty()) {
show_k_value = false; show_k_value = false;
} }
else if (m_info.cali_idx == -1 || (m_obj && (CalibUtils::get_selected_calib_idx(m_obj->pa_calib_tab, m_info.cali_idx) == -1))) { else if (m_info.cali_idx == -1 || (m_obj && (CalibUtils::get_selected_calib_idx(m_obj->pa_calib_tab, m_info.cali_idx) == -1))) {
@@ -1202,9 +1208,9 @@ void AMSLib::render_generic_text(wxDC &dc)
} }
auto libsize = GetSize(); auto libsize = GetSize();
if (m_info.material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND if (!m_info.is_empty && (m_info.material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND
|| m_info.material_state == AMSCanType::AMS_CAN_TYPE_BRAND || m_info.material_state == AMSCanType::AMS_CAN_TYPE_BRAND
|| m_info.material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL) { || m_info.material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL)) {
if (m_info.material_name.empty() /*&& m_info.material_state != AMSCanType::AMS_CAN_TYPE_VIRTUAL*/) { if (m_info.material_name.empty() /*&& m_info.material_state != AMSCanType::AMS_CAN_TYPE_VIRTUAL*/) {
auto tsize = dc.GetMultiLineTextExtent("?"); auto tsize = dc.GetMultiLineTextExtent("?");
@@ -1242,7 +1248,7 @@ void AMSLib::render_generic_text(wxDC &dc)
auto line_top_tsize = dc.GetMultiLineTextExtent(line_top); auto line_top_tsize = dc.GetMultiLineTextExtent(line_top);
auto line_bottom_tsize = dc.GetMultiLineTextExtent(line_bottom); auto line_bottom_tsize = dc.GetMultiLineTextExtent(line_bottom);
if (!m_show_kn) { if (!show_kn) {
auto pot_top = wxPoint((libsize.x - line_top_tsize.x) / 2, (libsize.y - line_top_tsize.y) / 2 - line_top_tsize.y + FromDIP(6)); auto pot_top = wxPoint((libsize.x - line_top_tsize.x) / 2, (libsize.y - line_top_tsize.y) / 2 - line_top_tsize.y + FromDIP(6));
dc.DrawText(line_top, pot_top); dc.DrawText(line_top, pot_top);
@@ -1273,7 +1279,7 @@ void AMSLib::render_generic_text(wxDC &dc)
//draw k&n //draw k&n
if (m_obj && show_k_value) { if (m_obj && show_k_value) {
if (m_show_kn) { if (show_kn) {
wxString str_k = wxString::Format("K %1.3f", m_info.k); wxString str_k = wxString::Format("K %1.3f", m_info.k);
wxString str_n = wxString::Format("N %1.3f", m_info.n); wxString str_n = wxString::Format("N %1.3f", m_info.n);
dc.SetFont(::Label::Body_11); dc.SetFont(::Label::Body_11);
@@ -1284,7 +1290,7 @@ void AMSLib::render_generic_text(wxDC &dc)
} }
} }
if (m_info.material_state == AMSCanType::AMS_CAN_TYPE_EMPTY) { if (m_info.material_state == AMSCanType::AMS_CAN_TYPE_EMPTY || m_info.is_empty) {
auto tsize = dc.GetMultiLineTextExtent(_L("Empty")); auto tsize = dc.GetMultiLineTextExtent(_L("Empty"));
auto pot = wxPoint((libsize.x - tsize.x) / 2, (libsize.y - tsize.y) / 2 + FromDIP(3)); auto pot = wxPoint((libsize.x - tsize.x) / 2, (libsize.y - tsize.y) / 2 + FromDIP(3));
dc.DrawText(_L("Empty"), pot); dc.DrawText(_L("Empty"), pot);
@@ -2781,7 +2787,7 @@ void AMSPreview::doRender(wxDC &dc)
} }
else { else {
wxRect rect(left, (size.y - AMS_ITEM_CUBE_SIZE.y) / 2, AMS_ITEM_CUBE_SIZE.x, AMS_ITEM_CUBE_SIZE.y); wxRect rect(left, (size.y - AMS_ITEM_CUBE_SIZE.y) / 2, AMS_ITEM_CUBE_SIZE.x, AMS_ITEM_CUBE_SIZE.y);
if (iter->material_state == AMSCanType::AMS_CAN_TYPE_EMPTY) { if (iter->material_state == AMSCanType::AMS_CAN_TYPE_EMPTY || iter->is_empty) {
dc.SetPen(wxPen(wxColor(0, 0, 0))); dc.SetPen(wxPen(wxColor(0, 0, 0)));
dc.DrawLine(rect.GetRight() - FromDIP(1), rect.GetTop() + FromDIP(1), rect.GetLeft() + FromDIP(1), rect.GetBottom() - FromDIP(1)); dc.DrawLine(rect.GetRight() - FromDIP(1), rect.GetTop() + FromDIP(1), rect.GetLeft() + FromDIP(1), rect.GetBottom() - FromDIP(1));
} }
+2
View File
@@ -183,6 +183,7 @@ struct Caninfo
wxString material_name; wxString material_name;
wxColour material_colour = {*wxWHITE}; wxColour material_colour = {*wxWHITE};
AMSCanType material_state; AMSCanType material_state;
bool is_empty = false;
int ctype=0; int ctype=0;
int material_remain = 100; int material_remain = 100;
int cali_idx = -1; int cali_idx = -1;
@@ -198,6 +199,7 @@ public:
material_name == other.material_name && material_name == other.material_name &&
material_colour == other.material_colour && material_colour == other.material_colour &&
material_state == other.material_state && material_state == other.material_state &&
is_empty == other.is_empty &&
ctype == other.ctype && ctype == other.ctype &&
material_remain == other.material_remain && material_remain == other.material_remain &&
cali_idx == other.cali_idx && cali_idx == other.cali_idx &&
+20 -9
View File
@@ -2,8 +2,8 @@
#include "BBLNetworkPlugin.hpp" #include "BBLNetworkPlugin.hpp"
#include "IPrinterAgent.hpp" #include "IPrinterAgent.hpp"
#include "NetworkAgentFactory.hpp" #include "NetworkAgentFactory.hpp"
#include "NetworkAgent.hpp"
#include "libslic3r/Utils.hpp" #include "libslic3r/Utils.hpp"
#include "NetworkAgent.hpp"
#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/DeviceCore/DevManager.h" #include "slic3r/GUI/DeviceCore/DevManager.h"
@@ -11,13 +11,14 @@
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <boost/nowide/fstream.hpp> #include <boost/nowide/fstream.hpp>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <cmath>
#include <slic3r/GUI/DeviceManager.hpp>
using json = nlohmann::json; using json = nlohmann::json;
#include <type_traits> #include <type_traits>
#include <unordered_map> #include <unordered_map>
#include <memory> #include <memory>
#include <nlohmann/json.hpp>
#include <cmath>
#include <slic3r/GUI/DeviceManager.hpp>
namespace Slic3r { namespace Slic3r {
@@ -197,8 +198,9 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
int dir = input_val > 0 ? 1 : -1; int dir = input_val > 0 ? 1 : -1;
// i3-arch printers move the bed for Y/Z, so the on-screen direction is // i3-arch printers move the bed for Y/Z, so the on-screen direction is
// reversed -- same negation the g-code fallback below applies. // reversed -- same negation the g-code fallback below applies.
if (!is_core_xy && (axis == "Y" || axis == "Z")) if (!is_core_xy && (axis == "Y" || axis == "Z")) {
dir = -dir; dir = -dir;
}
j["print"]["command"] = "xyz_ctrl"; j["print"]["command"] = "xyz_ctrl";
j["print"]["axis"] = axis; j["print"]["axis"] = axis;
@@ -208,8 +210,9 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
} }
double value = input_val; double value = input_val;
if (!is_core_xy && (axis == "Y" || axis == "Z")) if (!is_core_xy && (axis == "Y" || axis == "Z")) {
value = -input_val; value = -1.0 * input_val;
}
std::string value_str = (boost::format("%.1f") % (value * unit)).str(); std::string value_str = (boost::format("%.1f") % (value * unit)).str();
std::string gcode; std::string gcode;
@@ -230,10 +233,11 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
int BBLPrinterAgent::publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode) int BBLPrinterAgent::publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode)
{ {
const int rtn = lan_mode ? send_message_to_printer(dev_id, j.dump(), 0, 0) : send_message(dev_id, j.dump(), 0, 0); const int rtn = lan_mode ? send_message_to_printer(dev_id, j.dump(), 0, 0) : send_message(dev_id, j.dump(), 0, 0);
if (rtn == 0) if (rtn == 0) {
BOOST_LOG_TRIVIAL(info) << "publish_json: " << j.dump() << " code: " << rtn; BOOST_LOG_TRIVIAL(info) << "publish_json: " << j.dump() << " code: " << rtn;
else } else {
BOOST_LOG_TRIVIAL(error) << "publish_json: " << j.dump() << " code: " << rtn; BOOST_LOG_TRIVIAL(error) << "publish_json: " << j.dump() << " code: " << rtn;
}
return rtn; return rtn;
} }
@@ -579,8 +583,15 @@ int BBLPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateS
int BBLPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) int BBLPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{ {
return dispatch_start<func_start_send_gcode_to_sdcard_legacy, func_start_send_gcode_to_sdcard_0203>( int result = dispatch_start<func_start_send_gcode_to_sdcard_legacy, func_start_send_gcode_to_sdcard_0203>(
BBLNetworkPlugin::instance().get_start_send_gcode_to_sdcard(), params, update_fn, cancel_fn, wait_fn); BBLNetworkPlugin::instance().get_start_send_gcode_to_sdcard(), params, update_fn, cancel_fn, wait_fn);
if (result != 0) {
BOOST_LOG_TRIVIAL(error) << "start_send_gcode_to_sdcard failed: result=" << result
<< ", try_emmc_print=" << params.try_emmc_print
<< ", legacy_mode=" << BBLNetworkPlugin::instance().use_legacy_network()
<< ", dev_ip=" << params.dev_ip << ", dev_id=" << params.dev_id;
}
return result;
} }
int BBLPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) int BBLPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
+1
View File
@@ -106,6 +106,7 @@ public:
static std::string from_orca_payload(std::string json_text); static std::string from_orca_payload(std::string json_text);
private: private:
// why: the lan/cloud DECISION stays machine-side; keep this mechanical branch in sync with publish_json.
int publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode); int publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode);
}; };
+4 -1
View File
@@ -282,8 +282,11 @@ bool CrealityPrintAgent::parse_cfs_response(const std::string& response,
return true; return true;
} }
bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/) bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
{ {
if (sync_mode != get_filament_sync_mode())
return false;
if (device_info.dev_ip.empty()) { if (device_info.dev_ip.empty()) {
BOOST_LOG_TRIVIAL(warning) BOOST_LOG_TRIVIAL(warning)
<< "CrealityPrintAgent::fetch_filament_info: no device IP, falling back to base agent"; << "CrealityPrintAgent::fetch_filament_info: no device IP, falling back to base agent";
+1
View File
@@ -1,6 +1,7 @@
#ifndef __CREALITY_PRINT_AGENT_HPP__ #ifndef __CREALITY_PRINT_AGENT_HPP__
#define __CREALITY_PRINT_AGENT_HPP__ #define __CREALITY_PRINT_AGENT_HPP__
#include "IPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp" #include "MoonrakerPrinterAgent.hpp"
#include <string> #include <string>
+51 -1
View File
@@ -14,8 +14,19 @@
#include <curl/curl.h> #include <curl/curl.h>
#ifdef OPENSSL_CERT_OVERRIDE #include <openssl/err.h>
#include <openssl/ssl.h>
#include <openssl/x509.h> #include <openssl/x509.h>
#include <openssl/x509err.h>
#ifdef _WIN32
# ifndef NOMINMAX
# define NOMINMAX
# endif
# include <windows.h>
# include <wincrypt.h>
// wincrypt.h uses this token for a certificate-name property identifier.
# undef X509_NAME
#endif #endif
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
@@ -939,6 +950,45 @@ std::string Http::tls_system_cert_store()
return ret; return ret;
} }
void Http::add_platform_root_certificates(SSL_CTX* ssl_context)
{
#ifdef _WIN32
X509_STORE* openssl_store = SSL_CTX_get_cert_store(ssl_context);
if (!openssl_store)
throw std::runtime_error("unable to get OpenSSL certificate store");
const auto load_store = [&](DWORD location) {
HCERTSTORE windows_store = CertOpenStore(CERT_STORE_PROV_SYSTEM_W, 0, 0,
location | CERT_STORE_OPEN_EXISTING_FLAG | CERT_STORE_READONLY_FLAG,
L"ROOT");
if (!windows_store)
return;
PCCERT_CONTEXT windows_certificate = nullptr;
while ((windows_certificate = CertEnumCertificatesInStore(windows_store, windows_certificate)) != nullptr) {
const unsigned char* encoded = windows_certificate->pbCertEncoded;
X509* certificate = d2i_X509(nullptr, &encoded, static_cast<long>(windows_certificate->cbCertEncoded));
if (!certificate) {
ERR_clear_error();
continue;
}
ERR_clear_error();
if (X509_STORE_add_cert(openssl_store, certificate) != 1)
ERR_clear_error();
X509_free(certificate);
}
CertCloseStore(windows_store, 0);
};
load_store(CERT_SYSTEM_STORE_CURRENT_USER);
load_store(CERT_SYSTEM_STORE_LOCAL_MACHINE);
#else
(void)ssl_context;
#endif
}
std::string Http::url_encode(const std::string &str) std::string Http::url_encode(const std::string &str)
{ {
::CURL *curl = ::curl_easy_init(); ::CURL *curl = ::curl_easy_init();
+6
View File
@@ -11,6 +11,8 @@
#include "libslic3r/Exception.hpp" #include "libslic3r/Exception.hpp"
#include "libslic3r_version.h" #include "libslic3r_version.h"
typedef struct ssl_ctx_st SSL_CTX;
#define MAX_SIZE_TO_FILE 3*1024 #define MAX_SIZE_TO_FILE 3*1024
namespace Slic3r { namespace Slic3r {
@@ -198,6 +200,10 @@ public:
// Return empty string on success or error message on fail. // Return empty string on success or error message on fail.
static std::string tls_global_init(); static std::string tls_global_init();
static std::string tls_system_cert_store(); static std::string tls_system_cert_store();
// Add platform root certificates to a standalone OpenSSL context. This
// supplements set_default_verify_paths() on platforms where OpenSSL does
// not use the native certificate store.
static void add_platform_root_certificates(SSL_CTX* ssl_context);
// converts the given string to an url_encoded_string // converts the given string to an url_encoded_string
static std::string url_encode(const std::string &str); static std::string url_encode(const std::string &str);
+10
View File
@@ -109,6 +109,16 @@ public:
*/ */
virtual int send_message(std::string dev_id, std::string json_str, int qos, int flag) = 0; virtual int send_message(std::string dev_id, std::string json_str, int qos, int flag) = 0;
// Capability queries are per-device because one agent may own many printers.
// Legacy agents keep their existing behavior unless they override these.
virtual bool supports_command(const std::string& /*dev_id*/, const std::string& /*command*/) const { return true; }
virtual bool supports_feature(const std::string& /*dev_id*/, const std::string& /*feature*/) const { return false; }
// Whether this agent serializes AMS lane selection into print.gcode_file's
// per-print filament_mapping field. A dialect question, not a capability one:
// the no-AMS external-spool normalization runs before capabilities are known.
virtual bool uses_filament_mapping() const { return false; }
// why: gcode is firmware dialect, not a waist concept - commands whose body is Bambu-dialect // why: gcode is firmware dialect, not a waist concept - commands whose body is Bambu-dialect
// gcode live on the agent that speaks it; the default is an honest refusal that MachineObject's // gcode live on the agent that speaks it; the default is an honest refusal that MachineObject's
// publish funnel turns into a dialog. // publish funnel turns into a dialog.
File diff suppressed because it is too large Load Diff
+128 -19
View File
@@ -9,11 +9,48 @@
#include <set> #include <set>
#include <string> #include <string>
#include <thread> #include <thread>
#include <chrono>
#include <condition_variable>
#include <deque>
#include <functional>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
namespace Slic3r { namespace Slic3r {
class Http;
bool moonraker_is_light_name(const std::string& name);
class MoonrakerWebsocket
{
public:
enum class ReadResult
{
message,
timeout,
closed,
error,
};
MoonrakerWebsocket(bool secure, std::string api_key, std::string ca_file);
~MoonrakerWebsocket();
void connect(const std::string& host, const std::string& port, std::chrono::seconds timeout);
void tls_handshake(const std::string& host);
void handshake(const std::string& host, const std::string& target);
void text(bool enabled);
void write(const std::string& body);
ReadResult read(std::string& payload, std::string& error_message);
void close();
void expires_after(std::chrono::seconds timeout);
void abort();
private:
struct Impl;
std::unique_ptr<Impl> m_impl;
};
class MoonrakerPrinterAgent : public IPrinterAgent class MoonrakerPrinterAgent : public IPrinterAgent
{ {
public: public:
@@ -59,12 +96,20 @@ public:
int set_on_local_connect_fn(OnLocalConnectedFn fn) override; int set_on_local_connect_fn(OnLocalConnectedFn fn) override;
int set_on_local_message_fn(OnMessageFn fn) override; int set_on_local_message_fn(OnMessageFn fn) override;
int set_queue_on_main_fn(QueueOnMainFn fn) override; int set_queue_on_main_fn(QueueOnMainFn fn) override;
// Pull-mode agent (on-demand filament sync)
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::pull; }
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override; bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
CameraStreamMode get_camera_stream_mode() const override;
std::string get_camera_url() const override;
protected: protected:
struct ConnectionSettings
{
std::string dev_id;
std::string base_url;
std::string api_key;
bool use_ssl = false;
std::string ca_file;
};
struct MoonrakerDeviceInfo struct MoonrakerDeviceInfo
{ {
std::string dev_id; std::string dev_id;
@@ -76,7 +121,9 @@ protected:
std::string dev_name; std::string dev_name;
std::string version; std::string version;
std::string klippy_state; std::string klippy_state;
float nozzle_diameter = 0.0f;
bool use_ssl = false; bool use_ssl = false;
std::string ca_file;
} device_info; } device_info;
// Tray data for AMS payload building // Tray data for AMS payload building
@@ -94,12 +141,21 @@ protected:
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays); void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays);
// Methods that derived classes may need to override or access // Methods that derived classes may need to override or access
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port); virtual bool init_device_info(const PrinterConnectionParams& params);
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const; virtual bool fetch_device_info(const ConnectionSettings& connection, MoonrakerDeviceInfo& info, std::string& error) const;
ConnectionSettings get_connection_settings() const;
void configure_http(Http& http, const ConnectionSettings& connection) const;
static float parse_nozzle_diameter(const nlohmann::json& response);
// State access for derived classes // State access for derived classes
mutable std::recursive_mutex state_mutex; mutable std::recursive_mutex state_mutex;
// Counts detached fetch_filament_info() background threads currently touching `this`
// (see QidiPrinterAgent::fetch_filament_info). Those threads hold a raw `this` with no
// other lifetime protection, so the destructor waits for this to reach 0 before any part
// of the object is torn down — see ~MoonrakerPrinterAgent().
std::atomic<int> filament_fetch_in_flight{0};
// Helpers // Helpers
bool is_numeric(const std::string& value); bool is_numeric(const std::string& value);
std::string normalize_base_url(bool use_ssl, const std::string& host, const std::string& port); std::string normalize_base_url(bool use_ssl, const std::string& host, const std::string& port);
@@ -112,13 +168,29 @@ protected:
// Map filament type to OrcaFilamentLibrary preset ID for AMS sync compatibility // Map filament type to OrcaFilamentLibrary preset ID for AMS sync compatibility
static std::string map_filament_type_to_generic_id(const std::string& filament_type); static std::string map_filament_type_to_generic_id(const std::string& filament_type);
// Send a G-code script via Moonraker (/printer/gcode/script)
bool send_gcode(const std::string& dev_id, const std::string& gcode) const;
bool send_gcode(const std::string& dev_id, const std::string& gcode,
const ConnectionSettings& connection) const;
bool post_print_action(const std::string& action) const;
bool post_print_action(const std::string& action,
const ConnectionSettings& connection) const;
bool send_ws_rpc(const std::string& method, const nlohmann::json& params);
virtual void on_status_loop_tick(const std::string& dev_id) {}
// Queue work that may use agent state. The command worker is joined during
// destruction, so queued commands cannot outlive the agent.
void enqueue_command(std::function<void()> fn);
private: private:
int handle_request(const std::string& dev_id, const std::string& json_str); int handle_request(const std::string& dev_id, const std::string& json_str);
int send_version_info(const std::string& dev_id); int send_version_info(const std::string& dev_id);
int send_access_code(const std::string& dev_id); int send_access_code(const std::string& dev_id);
bool fetch_object_list(const std::string& base_url, const std::string& api_key, std::set<std::string>& objects, std::string& error) const; bool fetch_object_list(const ConnectionSettings& connection, std::set<std::string>& objects, std::string& error) const;
bool query_printer_status(const std::string& base_url, const std::string& api_key, nlohmann::json& status, std::string& error) const; bool query_printer_status(const ConnectionSettings& connection, nlohmann::json& status, std::string& error) const;
bool send_gcode_sync(const std::string& dev_id, const std::string& gcode) const; bool send_gcode_sync(const std::string& dev_id, const std::string& gcode) const;
void send_gcode_async(const std::string& dev_id, const std::string& gcode, void send_gcode_async(const std::string& dev_id, const std::string& gcode,
std::function<void(bool)> on_result = {}) const; std::function<void(bool)> on_result = {}) const;
@@ -127,10 +199,11 @@ private:
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg); void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
void dispatch_printer_connected(const std::string& dev_id); void dispatch_printer_connected(const std::string& dev_id);
void dispatch_message(const std::string& dev_id, const std::string& payload); void dispatch_message(const std::string& dev_id, const std::string& payload);
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key); void start_status_stream(const std::string& dev_id, ConnectionSettings connection);
void stop_status_stream(); void stop_status_stream();
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key); void run_status_stream(std::string dev_id, ConnectionSettings connection);
void handle_ws_message(const std::string& dev_id, const std::string& payload); void handle_ws_message(std::string dev_id, std::string payload, ConnectionSettings connection);
void refresh_thumbnail_url(const ConnectionSettings& connection);
void update_status_cache(const nlohmann::json& updates); void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const; nlohmann::json build_print_payload_locked() const;
@@ -141,22 +214,28 @@ private:
// File upload // File upload
bool upload_gcode(const std::string& local_path, const std::string& filename, bool upload_gcode(const std::string& local_path, const std::string& filename,
const std::string& base_url, const std::string& api_key, const ConnectionSettings& connection,
OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn); OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn);
// JSON-RPC helper // Start a print of a previously uploaded G-code file (path relative to the
bool send_jsonrpc_command(const std::string& base_url, const std::string& api_key, // Moonraker gcodes root).
const nlohmann::json& request, std::string& response) const; bool start_print_file(const ConnectionSettings& connection,
const std::string& filename, std::string& error_msg) const;
// Connection thread management // Connection thread management
void perform_connection_async(const std::string& dev_id, void perform_connection_async(const std::string& dev_id,
const std::string& base_url, ConnectionSettings connection,
const std::string& api_key,
uint64_t generation); uint64_t generation);
// why: a printer with no /server/webcams/list entry can still name its stream directly;
// subclasses (e.g. printers with a fixed webcam path) can override this instead.
virtual std::string webcam_stream_override(const std::string& base_url) const { return {}; }
void refresh_webcam_info() const;
bool fetch_webcam_info(const ConnectionSettings& connection, uint64_t generation) const;
// System-specific filament fetch methods // System-specific filament fetch methods
bool fetch_hh_filament_info(std::vector<AmsTrayData>& trays, int& max_lane_index); bool fetch_hh_filament_info(const ConnectionSettings& connection, std::vector<AmsTrayData>& trays, int& max_lane_index);
bool fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index); bool fetch_moonraker_filament_data(const ConnectionSettings& connection, std::vector<AmsTrayData>& trays, int& max_lane_index);
// JSON helper methods // JSON helper methods
static std::string safe_json_string(const nlohmann::json& obj, const char* key); static std::string safe_json_string(const nlohmann::json& obj, const char* key);
@@ -182,15 +261,38 @@ private:
mutable std::recursive_mutex payload_mutex; mutable std::recursive_mutex payload_mutex;
nlohmann::json status_cache; nlohmann::json status_cache;
// note: guarded by payload_mutex; filled by refresh_thumbnail_url(), empty url = looked up, none found
std::string thumbnail_filename;
std::string thumbnail_url;
mutable std::string webcam_stream_url;
mutable CameraStreamMode webcam_stream_mode = CameraStreamMode::none;
mutable uint64_t webcam_info_last_lookup_ms = 0;
mutable uint64_t webcam_info_generation = 0;
unsigned thumbnail_lookup_attempts = 0;
static constexpr uint64_t WEBCAM_INFO_REFRESH_INTERVAL_MS = 1000;
std::atomic<int> next_jsonrpc_id{1}; std::atomic<int> next_jsonrpc_id{1};
std::set<std::string> available_objects; // Track for feature detection std::set<std::string> available_objects; // Track for feature detection
bool assumed_light_on = false;
std::atomic<bool> ws_stop{false}; std::atomic<bool> ws_stop{false};
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
std::atomic<uint64_t> ws_last_emit_ms{0}; std::atomic<uint64_t> ws_last_emit_ms{0};
std::thread ws_thread; std::thread ws_thread;
// stop_status_stream() invokes ws_abort_io to wake a blocked synchronous
// ws.read()/ws.write()/handshake in run_status_stream(): ws_stop is only
// observed between reads, and Beast's expires_after() does not bound
// synchronous operations.
std::mutex ws_abort_mutex;
std::function<void()> ws_abort_io; // guarded by ws_abort_mutex
// AMS/filament refresh cadence, independent of telemetry dispatch so a steady
// stream of status updates can't starve it (ws_last_emit_ms is reset by those).
static constexpr uint64_t AMS_REFRESH_INTERVAL_MS = 10000;
std::atomic<uint64_t> ams_last_fetch_ms{0};
// Throttling configuration for WebSocket updates // Throttling configuration for WebSocket updates
// Critical changes (state transitions) dispatch immediately; telemetry is throttled // Critical changes (state transitions) dispatch immediately; telemetry is throttled
static constexpr uint64_t STATUS_UPDATE_INTERVAL_MS = 1000; // 1 update/sec for telemetry static constexpr uint64_t STATUS_UPDATE_INTERVAL_MS = 1000; // 1 update/sec for telemetry
@@ -200,7 +302,14 @@ private:
// Connection thread management // Connection thread management
std::atomic<uint64_t> connect_generation{0}; std::atomic<uint64_t> connect_generation{0};
std::thread connect_thread; std::thread connect_thread;
std::recursive_mutex connect_mutex; mutable std::recursive_mutex connect_mutex;
void run_command_worker();
std::thread cmd_thread;
std::deque<std::function<void()>> cmd_queue;
std::mutex cmd_mutex;
std::condition_variable cmd_cv;
bool cmd_stop = false;
}; };
} // namespace Slic3r } // namespace Slic3r
+30
View File
@@ -793,6 +793,36 @@ int NetworkAgent::send_message(std::string dev_id, std::string json_str, int qos
return -1; return -1;
} }
bool NetworkAgent::owns_agent(const std::string& expected_agent_id) const
{
if (!m_printer_agent)
return false;
return expected_agent_id.empty() || m_printer_agent->get_agent_info().id == expected_agent_id;
}
bool NetworkAgent::supports_command(const std::string& expected_agent_id, const std::string& dev_id,
const std::string& command) const
{
if (!owns_agent(expected_agent_id))
return false;
return m_printer_agent->supports_command(dev_id, command);
}
bool NetworkAgent::supports_feature(const std::string& expected_agent_id, const std::string& dev_id,
const std::string& feature) const
{
if (!owns_agent(expected_agent_id))
return false;
return m_printer_agent->supports_feature(dev_id, feature);
}
bool NetworkAgent::uses_filament_mapping(const std::string& expected_agent_id) const
{
if (!owns_agent(expected_agent_id))
return false;
return m_printer_agent->uses_filament_mapping();
}
int NetworkAgent::command_ams_refresh_rfid(std::string dev_id, int ams_id, int slot_id, int sequence_id, bool lan_mode) int NetworkAgent::command_ams_refresh_rfid(std::string dev_id, int ams_id, int slot_id, int sequence_id, bool lan_mode)
{ {
if (m_printer_agent) if (m_printer_agent)
+4
View File
@@ -150,6 +150,10 @@ public:
int set_on_local_message_fn(OnMessageFn fn); int set_on_local_message_fn(OnMessageFn fn);
int set_server_callback(OnServerErrFn fn); int set_server_callback(OnServerErrFn fn);
int send_message(std::string dev_id, std::string json_str, int qos, int flag); int send_message(std::string dev_id, std::string json_str, int qos, int flag);
bool owns_agent(const std::string& expected_agent_id) const;
bool supports_command(const std::string& expected_agent_id, const std::string& dev_id, const std::string& command) const;
bool supports_feature(const std::string& expected_agent_id, const std::string& dev_id, const std::string& feature) const;
bool uses_filament_mapping(const std::string& expected_agent_id) const;
int command_ams_refresh_rfid(std::string dev_id, int ams_id, int slot_id, int sequence_id, bool lan_mode); int command_ams_refresh_rfid(std::string dev_id, int ams_id, int slot_id, int sequence_id, bool lan_mode);
int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode); int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode);
int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode); int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode);
+11 -8
View File
@@ -15,7 +15,7 @@
namespace Slic3r { namespace Slic3r {
static constexpr char ORCA_PRINTER_AGENT_ID[] = "orca"; static constexpr char ORCA_PRINTER_AGENT_ID[] = "orca";
static constexpr char BBL_PRINTER_AGENT_ID[] = "bbl"; static constexpr char BBL_PRINTER_AGENT_ID[] = "bbl";
// Factory function type for creating printer agents // Factory function type for creating printer agents
using PrinterAgentFactory = using PrinterAgentFactory =
@@ -24,17 +24,20 @@ using PrinterAgentFactory =
// Information about a registered printer agent // Information about a registered printer agent
struct PrinterAgentInfo struct PrinterAgentInfo
{ {
std::string id; // Registry/config key, e.g. "orca" or a plugin AgentInfo::id std::string id; // Registry/config key, e.g. "orca" or a plugin AgentInfo::id
std::string display_name; // e.g., "Orca Native", "Bambu Lab" std::string display_name; // e.g., "Orca Native", "Bambu Lab"
std::string plugin_identifier; // Empty for built-ins, otherwise <plugin_key>;<uuid>;<capability_name> std::string plugin_identifier; // Empty for built-ins, otherwise <plugin_key>;<uuid>;<capability_name>
PrinterAgentFactory factory; // Function to create the agent PrinterAgentFactory factory; // Function to create the agent
bool is_plugin() const { return !plugin_identifier.empty(); } bool is_plugin() const { return !plugin_identifier.empty(); }
PrinterAgentInfo(const std::string& id_, const std::string& display_name_, PrinterAgentFactory factory_) PrinterAgentInfo(const std::string& id_, const std::string& display_name_, PrinterAgentFactory factory_)
: id(id_), display_name(display_name_), factory(std::move(factory_)) : id(id_), display_name(display_name_), factory(std::move(factory_))
{} {}
PrinterAgentInfo(const std::string& id_, const std::string& display_name_, const std::string& plugin_identifier, PrinterAgentFactory factory_) PrinterAgentInfo(const std::string& id_,
const std::string& display_name_,
const std::string& plugin_identifier,
PrinterAgentFactory factory_)
: id(id_), display_name(display_name_), plugin_identifier(plugin_identifier), factory(std::move(factory_)) : id(id_), display_name(display_name_), plugin_identifier(plugin_identifier), factory(std::move(factory_))
{} {}
}; };
@@ -116,9 +119,9 @@ public:
* @param log_dir Directory for log files * @param log_dir Directory for log files
* @return Shared pointer to IPrinterAgent, or nullptr if ID not found * @return Shared pointer to IPrinterAgent, or nullptr if ID not found
*/ */
static std::shared_ptr<IPrinterAgent> create_printer_agent_by_id(const std::string& id, static std::shared_ptr<IPrinterAgent> create_printer_agent_by_id(const std::string& id,
std::shared_ptr<ICloudServiceAgent> cloud_agent, std::shared_ptr<ICloudServiceAgent> cloud_agent,
const std::string& log_dir); const std::string& log_dir);
/** /**
* Clear the printer agent cache. * Clear the printer agent cache.
+296 -12
View File
@@ -1,11 +1,15 @@
#include "OrcaCloudServiceAgent.hpp" #include "OrcaCloudServiceAgent.hpp"
#include "Http.hpp" #include "Http.hpp"
#include "ICameraSignalingChannel.hpp"
#include "OrcaCloudSignalingChannel.hpp"
#include "libslic3r/Utils.hpp" #include "libslic3r/Utils.hpp"
#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_App.hpp"
#include "libslic3r/AppConfig.hpp" #include "libslic3r/AppConfig.hpp"
#include <boost/asio.hpp> #include <boost/asio.hpp>
#include <boost/beast/core/detail/base64.hpp> #include <boost/beast/core/detail/base64.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/websocket.hpp>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <boost/uuid/uuid.hpp> #include <boost/uuid/uuid.hpp>
@@ -21,14 +25,18 @@
#include <openssl/hmac.h> #include <openssl/hmac.h>
#include <openssl/rand.h> #include <openssl/rand.h>
#include <openssl/sha.h> #include <openssl/sha.h>
#include <openssl/ssl.h>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <condition_variable>
#include <cstdint>
#include <cstdlib> #include <cstdlib>
#include <fstream> #include <fstream>
#include <iomanip> #include <iomanip>
#include <optional> #include <optional>
#include <random> #include <random>
#include <set>
#include <sstream> #include <sstream>
#include <string> #include <string>
@@ -492,6 +500,7 @@ OrcaCloudServiceAgent::OrcaCloudServiceAgent(std::string log_dir)
, api_base_url(ORCA_DEFAULT_API_URL) , api_base_url(ORCA_DEFAULT_API_URL)
, auth_base_url(ORCA_DEFAULT_AUTH_URL) , auth_base_url(ORCA_DEFAULT_AUTH_URL)
, cloud_base_url(ORCA_DEFAULT_CLOUD_URL) , cloud_base_url(ORCA_DEFAULT_CLOUD_URL)
, mqtt_connection(std::make_unique<OrcaMqttConnection>())
{ {
auth_headers["apikey"] = ORCA_DEFAULT_PUB_KEY; auth_headers["apikey"] = ORCA_DEFAULT_PUB_KEY;
pkce_bundle.loopback_port = choose_loopback_port(); pkce_bundle.loopback_port = choose_loopback_port();
@@ -502,6 +511,8 @@ OrcaCloudServiceAgent::OrcaCloudServiceAgent(std::string log_dir)
OrcaCloudServiceAgent::~OrcaCloudServiceAgent() OrcaCloudServiceAgent::~OrcaCloudServiceAgent()
{ {
if (mqtt_connection)
mqtt_connection->stop();
if (refresh_thread.joinable()) { if (refresh_thread.joinable()) {
refresh_thread.join(); refresh_thread.join();
} }
@@ -944,22 +955,45 @@ bool OrcaCloudServiceAgent::ensure_token_fresh(const std::string& reason) { retu
int OrcaCloudServiceAgent::connect_server() int OrcaCloudServiceAgent::connect_server()
{ {
const bool logged_in = is_user_login();
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::connect_server logged_in=" << logged_in
<< " api_base_url=" << api_base_url;
if (!logged_in) {
if (mqtt_connection)
mqtt_connection->stop();
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
is_connected = false;
}
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: connect_server requires a logged-in user";
invoke_server_connected_callback(-1, 401);
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
std::string response; std::string response;
unsigned int http_code = 0; unsigned int http_code = 0;
int result = http_get(ORCA_HEALTH_PATH, &response, &http_code); int result = http_get(ORCA_HEALTH_PATH, &response, &http_code);
bool connected = (result == BAMBU_NETWORK_SUCCESS && http_code >= 200 && http_code < 300); bool connected = (result == BAMBU_NETWORK_SUCCESS && http_code >= 200 && http_code < 300);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cloud health result=" << result << " http_code=" << http_code
<< " connected=" << connected << " response_bytes=" << response.size();
// connect_server() remains a REST health probe. The long-lived fleet MQTT socket
// is started lazily by set_user_selected_machine -> configure_selected_printer_mqtt;
// subscriptions queued before that point are replayed when it starts.
{ {
std::lock_guard<std::recursive_mutex> lock(state_mutex); std::lock_guard<std::recursive_mutex> lock(state_mutex);
is_connected = connected; is_connected = connected;
} }
invoke_server_connected_callback(connected ? 0 : -1, http_code); invoke_server_connected_callback(connected ? 0 : -1, http_code);
return connected ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; return connected ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
} }
bool OrcaCloudServiceAgent::is_server_connected() bool OrcaCloudServiceAgent::is_server_connected()
{ {
// The REST health probe is the signal; the per-printer MQTT socket does not gate
// whole-cloud connectivity (one printer reconnecting must not report the whole
// cloud as lost).
std::lock_guard<std::recursive_mutex> lock(state_mutex); std::lock_guard<std::recursive_mutex> lock(state_mutex);
return is_connected; return is_connected;
} }
@@ -980,13 +1014,238 @@ int OrcaCloudServiceAgent::stop_subscribe(std::string module)
int OrcaCloudServiceAgent::add_subscribe(std::vector<std::string> dev_list) int OrcaCloudServiceAgent::add_subscribe(std::vector<std::string> dev_list)
{ {
(void) dev_list; BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::add_subscribe count=" << dev_list.size()
return BAMBU_NETWORK_SUCCESS; << " logged_in=" << is_user_login() << " mqtt_connection=" << (mqtt_connection ? "set" : "null");
if (!is_user_login() || !mqtt_connection) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: add_subscribe rejected because cloud is not ready";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
bool queued = true;
for (const std::string& dev_id : dev_list)
queued = mqtt_connection->subscribe(dev_id) && queued;
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: add_subscribe queued=" << queued;
return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
} }
int OrcaCloudServiceAgent::del_subscribe(std::vector<std::string> dev_list) int OrcaCloudServiceAgent::del_subscribe(std::vector<std::string> dev_list)
{ {
(void) dev_list; BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::del_subscribe count=" << dev_list.size()
<< " logged_in=" << is_user_login() << " mqtt_connection=" << (mqtt_connection ? "set" : "null");
if (!is_user_login() || !mqtt_connection) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: del_subscribe rejected because cloud is not ready";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
bool queued = true;
for (const std::string& dev_id : dev_list)
queued = mqtt_connection->unsubscribe(dev_id) && queued;
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: del_subscribe queued=" << queued;
return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
int OrcaCloudServiceAgent::configure_selected_printer_mqtt(const std::string& dev_id,
OrcaMqttConnection::StateHandler state_handler)
{
(void) dev_id;
if (!ensure_token_fresh("configure_selected_printer_mqtt"))
{
BOOST_LOG_TRIVIAL(warning) << "ensure_token_fresh returned false";
return BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
}
OrcaMqttConnection::Config cfg;
cfg.url = "wss://" + api_base_url + "/api/v1/printers/mqtt";
cfg.use_tls = true;
cfg.bearer_provider = [this] { return get_access_token(); };
cfg.client_id = "OrcaSlicer";
cfg.keepalive_seconds = 300;
{
std::lock_guard<std::mutex> lock(m_selected_url_mutex);
m_selected_printer_mqtt_url = cfg.url;
}
if (mqtt_connection->is_running()) {
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: fleet MQTT connection already running";
return BAMBU_NETWORK_SUCCESS;
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: configuring fleet MQTT endpoint=" << cfg.url;
// NOTE: no lock is held across start() — it blocks for the whole initial connect
// attempt (up to ~10s), and the message handler below re-enters callback_mutex on
// the MQTT worker thread.
const bool ok = mqtt_connection->start(
cfg,
[this](const std::string& id, const std::string& payload) { deliver_cloud_message(id, payload); },
std::move(state_handler));
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: fleet MQTT start returned=" << ok;
return ok ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
}
void OrcaCloudServiceAgent::teardown_selected_printer_mqtt()
{
if (mqtt_connection) {
mqtt_connection->stop();
// The connection object is reused for the next printer; drop this printer's
// report topic so its 1:1 socket does not re-subscribe the previous device.
mqtt_connection->clear_subscriptions();
}
std::lock_guard<std::mutex> lock(m_selected_url_mutex);
m_selected_printer_mqtt_url.clear();
}
std::string OrcaCloudServiceAgent::selected_printer_mqtt_url() const
{
std::lock_guard<std::mutex> lock(m_selected_url_mutex);
return m_selected_printer_mqtt_url;
}
void OrcaCloudServiceAgent::deliver_cloud_message(const std::string& dev_id, const std::string& payload)
{
OnMessageFn callback;
{
std::lock_guard<std::mutex> lock(callback_mutex);
callback = printer_status_callback;
}
if (callback)
callback(dev_id, payload);
}
int OrcaCloudServiceAgent::set_printer_status_callback(OnMessageFn fn)
{
std::lock_guard<std::mutex> lock(callback_mutex);
printer_status_callback = std::move(fn);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: printer status callback=" << (printer_status_callback ? "set" : "clear");
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::send_printer_command(const std::string& dev_id, const std::string& body)
{
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: send_printer_command dev_id=" << dev_id
<< " body_bytes=" << body.size() << " logged_in=" << is_user_login();
if (dev_id.empty() || !is_user_login()) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: send_printer_command rejected";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
const std::string path = std::string(ORCA_CLOUD_PRINTER) + "/" + dev_id + "/commands";
std::string response;
unsigned int http_code = 0;
int result = http_post(path, body, &response, &http_code);
BOOST_LOG_TRIVIAL(info) << "OrcaCloudServiceAgent: command dev=" << dev_id
<< " http=" << http_code << " result=" << result
<< " response_bytes=" << response.size();
return (result == BAMBU_NETWORK_SUCCESS && http_code >= 200 && http_code < 300)
? BAMBU_NETWORK_SUCCESS
: BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
int OrcaCloudServiceAgent::upload_gcode_via_cloud(const std::string& dev_id,
const std::string& local_gcode_path,
std::string* job_id,
OnUpdateStatusFn update_fn,
WasCancelledFn cancel_fn)
{
if (dev_id.empty() || local_gcode_path.empty() || !is_user_login())
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
if (cancel_fn && cancel_fn())
return BAMBU_NETWORK_ERR_CANCELED;
// Step 1: POST print-jobs/uploads -> a short-lived presigned R2 PUT URL. No
// metadata rides this request; filename/start are only relevant to the HTTP
// .../start finalize route, which this MQTT-driven flow does not call.
const std::string uploads_path = std::string(ORCA_CLOUD_PRINTER) + "/" + Http::url_encode(dev_id) + "/print-jobs/uploads";
std::string response;
unsigned int http_code = 0;
int result = http_post(uploads_path, "{}", &response, &http_code);
if (result != BAMBU_NETWORK_SUCCESS || http_code < 200 || http_code >= 300) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: print-jobs/uploads failed http_code=" << http_code;
return BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
std::string upload_job_id;
std::string upload_url;
try {
const nlohmann::json j = nlohmann::json::parse(response);
upload_job_id = j.value("job_id", "");
upload_url = j.value("upload_url", "");
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: failed to parse print-jobs/uploads response: " << e.what();
return BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
if (upload_job_id.empty() || upload_url.empty()) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: print-jobs/uploads response missing job_id/upload_url";
return BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
if (cancel_fn && cancel_fn())
return BAMBU_NETWORK_ERR_CANCELED;
// Step 2: PUT the G-code straight to R2 with the one-time URL from step 1. This
// is a scoped, PUT-only, short-TTL capability with no bearer token of its own,
// so it bypasses http_put (which always prefixes api_base_url and attaches the
// cloud session's Authorization header - neither belongs on an R2 PUT).
bool canceled = false;
unsigned put_status = 0;
std::string put_error;
Http::put(upload_url)
.tls_verify(true)
.header("Content-Type", "text/x.gcode")
.set_put_body(boost::filesystem::path(local_gcode_path))
.timeout_connect(5)
.timeout_max(300) // large G-code over a slow link
.on_progress([&](Http::Progress progress, bool& cancel) {
if (cancel_fn && cancel_fn()) {
cancel = true;
canceled = true;
return;
}
if (update_fn && progress.ultotal > 0) {
const int percent = static_cast<int>((progress.ulnow * 100) / progress.ultotal);
update_fn(PrintingStageUpload, percent, "Uploading...");
}
})
.on_complete([&](std::string, unsigned status) { put_status = status; })
.on_error([&](std::string, std::string err, unsigned status) {
put_status = status;
put_error = std::move(err);
})
.perform_sync();
if (canceled)
return BAMBU_NETWORK_ERR_CANCELED;
if (put_status < 200 || put_status >= 300) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: R2 upload failed status=" << put_status << " error=" << put_error;
return BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED;
}
if (job_id)
*job_id = std::move(upload_job_id);
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::start_cloud_print_job(const std::string& dev_id,
const std::string& job_id,
const std::string& filename,
bool start)
{
if (dev_id.empty() || job_id.empty() || !is_user_login())
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
nlohmann::json body;
if (!filename.empty())
body["filename"] = filename;
body["start"] = start;
const std::string path = std::string(ORCA_CLOUD_PRINTER) + "/" + Http::url_encode(dev_id) + "/print-jobs/" +
Http::url_encode(job_id) + "/start";
std::string response;
unsigned int http_code = 0;
const int result = http_post(path, body.dump(), &response, &http_code);
if (result != BAMBU_NETWORK_SUCCESS || http_code < 200 || http_code >= 300) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: print-jobs/" << job_id << "/start failed http_code=" << http_code;
return BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
return BAMBU_NETWORK_SUCCESS; return BAMBU_NETWORK_SUCCESS;
} }
@@ -2040,6 +2299,10 @@ bool OrcaCloudServiceAgent::set_user_session(const json& session_json, bool noti
void OrcaCloudServiceAgent::clear_session(bool all_backends) void OrcaCloudServiceAgent::clear_session(bool all_backends)
{ {
if (mqtt_connection) {
mqtt_connection->stop();
mqtt_connection->clear_subscriptions();
}
{ {
std::lock_guard<std::mutex> lock(session_mutex); std::lock_guard<std::mutex> lock(session_mutex);
session = SessionInfo{}; session = SessionInfo{};
@@ -2155,7 +2418,11 @@ int OrcaCloudServiceAgent::http_get(const std::string& path, std::string* respon
return (res.success && !suppress) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; return (res.success && !suppress) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
} }
int OrcaCloudServiceAgent::http_post(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code) int OrcaCloudServiceAgent::http_post(const std::string& path,
const std::string& body,
std::string* response_body,
unsigned int* http_code,
const std::string& content_type)
{ {
std::string url = api_base_url + path; std::string url = api_base_url + path;
BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: POST " << url; BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: POST " << url;
@@ -2179,7 +2446,7 @@ int OrcaCloudServiceAgent::http_post(const std::string& path, const std::string&
http.header("Authorization", "Bearer " + token); http.header("Authorization", "Bearer " + token);
} }
http.header("Content-Type", "application/json"); http.header("Content-Type", content_type);
http.set_post_body(body); http.set_post_body(body);
http.on_complete([&](std::string resp_body, unsigned resp_status) { http.on_complete([&](std::string resp_body, unsigned resp_status) {
@@ -2646,19 +2913,28 @@ int OrcaCloudServiceAgent::get_user_print_info(unsigned int* http_code, std::str
if (http_code) if (http_code)
*http_code = code; *http_code = code;
if (result != 0 || code != 200) if (result != 0 || code != 200) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: get_user_print_info failed - http_code=" << code << ", response=" << response;
return result != 0 ? result : BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED; return result != 0 ? result : BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED;
}
BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: get_user_print_info fetched - http_code=" << code << ", response=" << response;
try { try {
auto resp_json = nlohmann::json::parse(response); auto resp_json = nlohmann::json::parse(response);
nlohmann::json devices = nlohmann::json::array(); nlohmann::json devices = nlohmann::json::array();
for (const auto& printer : resp_json.value("data", nlohmann::json::array())) { for (const auto& printer : resp_json.value("data", nlohmann::json::array())) {
const std::string role = printer.value("access_role", ""); nlohmann::json device;
std::string role = printer.value("access_role", "");
// A printer with the role "view" only has monitoring access for orca cloud.
// The printer is owned by a different person and was shared to the current user without
// any permission to control the printer so we discard this printer. Comment this out if
// OrcaSlicer wants to support view only printers.
if (role.empty() || role == "viewer") if (role.empty() || role == "viewer")
continue; continue;
nlohmann::json device;
device["dev_id"] = printer.value("id", ""); device["dev_id"] = printer.value("id", "");
device["dev_name"] = printer.value("name", ""); device["dev_name"] = printer.value("name", "");
if (printer.contains("model") && printer["model"].is_string()) if (printer.contains("model") && printer["model"].is_string())
@@ -2672,15 +2948,20 @@ int OrcaCloudServiceAgent::get_user_print_info(unsigned int* http_code, std::str
device["task_status"] = status["job"].value("state", ""); device["task_status"] = status["job"].value("state", "");
} }
device["dev_online"] = online; device["dev_online"] = online;
devices.push_back(std::move(device));
devices.push_back(device);
} }
if (http_body) { if (http_body) {
nlohmann::json out; nlohmann::json out;
out["devices"] = std::move(devices); out["devices"] = devices;
*http_body = out.dump(); *http_body = out.dump();
} }
} catch (const std::exception&) {
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_user_print_info parsed - device_count=" << devices.size()
<< ", devices=" << devices.dump();
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: get_user_print_info parse exception - " << e.what();
return BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED; return BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED;
} }
@@ -2765,6 +3046,9 @@ int OrcaCloudServiceAgent::get_camera_url(std::string dev_id, std::function<void
return BAMBU_NETWORK_SUCCESS; return BAMBU_NETWORK_SUCCESS;
} }
std::unique_ptr<ICameraSignalingChannel> OrcaCloudServiceAgent::create_camera_signaling_channel(const std::string& dev_id)
{ return std::make_unique<OrcaCloudSignalingChannel>(this, dev_id); }
int OrcaCloudServiceAgent::get_design_staffpick(int offset, int limit, std::function<void(std::string)> callback) int OrcaCloudServiceAgent::get_design_staffpick(int offset, int limit, std::function<void(std::string)> callback)
{ {
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_design_staffpick (stub)"; BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_design_staffpick (stub)";
+58 -2
View File
@@ -3,6 +3,12 @@
#include "ICameraSignalingChannel.hpp" #include "ICameraSignalingChannel.hpp"
#include "ICloudServiceAgent.hpp" #include "ICloudServiceAgent.hpp"
#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/beast/websocket.hpp>
#include <cstdlib> #include <cstdlib>
#include <string> #include <string>
#include <map> #include <map>
@@ -10,12 +16,17 @@
#include <atomic> #include <atomic>
#include <chrono> #include <chrono>
#include <functional> #include <functional>
#include <condition_variable>
#include <cstdint>
#include <set>
#include <memory> #include <memory>
#include <thread> #include <thread>
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include "OrcaMqttConnection.hpp"
class wxSecretStore; class wxSecretStore;
namespace Slic3r { namespace Slic3r {
@@ -152,6 +163,8 @@ public:
// Configuration // Configuration
void configure_urls(AppConfig* app_config); void configure_urls(AppConfig* app_config);
// Hostname only; cloud REST, MQTT, and WebRTC signaling use fixed TLS
// endpoints on port 443.
void set_api_base_url(const std::string& url); void set_api_base_url(const std::string& url);
void set_auth_base_url(const std::string& url); void set_auth_base_url(const std::string& url);
void set_cloud_base_url(const std::string& url); void set_cloud_base_url(const std::string& url);
@@ -207,6 +220,19 @@ public:
int add_subscribe(std::vector<std::string> dev_list) override; int add_subscribe(std::vector<std::string> dev_list) override;
int del_subscribe(std::vector<std::string> dev_list) override; int del_subscribe(std::vector<std::string> dev_list) override;
void enable_multi_machine(bool enable) override; void enable_multi_machine(bool enable) override;
int set_printer_status_callback(OnMessageFn fn);
int send_printer_command(const std::string& dev_id, const std::string& body);
int upload_gcode_via_cloud(const std::string& dev_id,
const std::string& local_gcode_path,
std::string* job_id,
OnUpdateStatusFn update_fn,
WasCancelledFn cancel_fn);
int start_cloud_print_job(const std::string& dev_id,
const std::string& job_id,
const std::string& filename,
bool start = true);
// ======================================================================== // ========================================================================
// ICloudServiceAgent Interface Implementation - Settings Synchronization // ICloudServiceAgent Interface Implementation - Settings Synchronization
@@ -242,7 +268,7 @@ public:
// ICloudServiceAgent Interface Implementation - Model Mall & Publishing // ICloudServiceAgent Interface Implementation - Model Mall & Publishing
// ======================================================================== // ========================================================================
int get_camera_url(std::string dev_id, std::function<void(std::string)> callback) override; int get_camera_url(std::string dev_id, std::function<void(std::string)> callback) override;
// std::unique_ptr<ICameraSignalingChannel> create_camera_signaling_channel(const std::string& dev_id) override; std::unique_ptr<ICameraSignalingChannel> create_camera_signaling_channel(const std::string& dev_id) override;
int get_design_staffpick(int offset, int limit, std::function<void(std::string)> callback) override; int get_design_staffpick(int offset, int limit, std::function<void(std::string)> callback) override;
int start_publish(PublishParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, std::string* out) override; int start_publish(PublishParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, std::string* out) override;
int get_model_publish_url(std::string* url) override; int get_model_publish_url(std::string* url) override;
@@ -348,7 +374,29 @@ public:
static std::string generate_uuid_for_setting_id(const std::string& name, const std::string& user_id = ""); static std::string generate_uuid_for_setting_id(const std::string& name, const std::string& user_id = "");
OrcaMqttConnection* get_mqtt_connection() noexcept {
return mqtt_connection.get();
}
const OrcaMqttConnection* get_mqtt_connection() const noexcept {
return mqtt_connection.get();
}
// Account-scoped cloud socket: wss://<api_base_url>/api/v1/printers/mqtt.
// configure_ blocks for the duration of the initial connect attempt, so callers
// drive it off the UI thread; teardown_ is synchronous. The dev_id argument is
// retained for source compatibility with the printer-agent lifecycle; it does
// not participate in endpoint construction.
int configure_selected_printer_mqtt(const std::string& dev_id,
OrcaMqttConnection::StateHandler state_handler = {});
void teardown_selected_printer_mqtt();
// Test hook: the wss:// URL of the current fleet socket ("" when none).
std::string selected_printer_mqtt_url() const;
private: private:
// Fans one inbound fleet MQTT message out to printer_status_callback.
void deliver_cloud_message(const std::string& dev_id, const std::string& payload);
// Sync protocol helpers // Sync protocol helpers
int sync_pull( int sync_pull(
std::function<void(const SyncPullResponse&)> on_success, std::function<void(const SyncPullResponse&)> on_success,
@@ -372,7 +420,11 @@ private:
// HTTP request helpers // HTTP request helpers
int http_get(const std::string& path, std::string* response_body, unsigned int* http_code); int http_get(const std::string& path, std::string* response_body, unsigned int* http_code);
int http_post(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code); int http_post(const std::string& path,
const std::string& body,
std::string* response_body,
unsigned int* http_code,
const std::string& content_type = "application/json");
int http_put(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code); int http_put(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code);
int http_delete(const std::string& path, std::string* response_body, unsigned int* http_code); int http_delete(const std::string& path, std::string* response_body, unsigned int* http_code);
std::map<std::string, std::string> data_headers(); std::map<std::string, std::string> data_headers();
@@ -430,6 +482,9 @@ private:
std::chrono::system_clock::now().time_since_epoch()).count()}; std::chrono::system_clock::now().time_since_epoch()).count()};
// Member variables - connection state // Member variables - connection state
std::unique_ptr<OrcaMqttConnection> mqtt_connection;
std::string m_selected_printer_mqtt_url; // guarded by m_selected_url_mutex
mutable std::mutex m_selected_url_mutex;
bool is_connected{false}; bool is_connected{false};
bool enable_track{false}; bool enable_track{false};
bool multi_machine_enabled{false}; bool multi_machine_enabled{false};
@@ -443,6 +498,7 @@ private:
AppOnHttpErrorFn on_http_error_fn; AppOnHttpErrorFn on_http_error_fn;
GetCountryCodeFn get_country_code_fn; GetCountryCodeFn get_country_code_fn;
QueueOnMainFn queue_on_main_fn; QueueOnMainFn queue_on_main_fn;
OnMessageFn printer_status_callback;
mutable std::mutex callback_mutex; mutable std::mutex callback_mutex;
// Thread safety // Thread safety
@@ -0,0 +1,337 @@
#include "OrcaCloudSignalingChannel.hpp"
#include "Http.hpp"
#include <boost/asio/connect.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/post.hpp>
#include <boost/beast/core.hpp>
#include <boost/log/trivial.hpp>
#include <nlohmann/json.hpp>
#include <openssl/ssl.h>
#include <cctype>
#include <iomanip>
#include <sstream>
#include <stdexcept>
namespace Slic3r {
OrcaCloudSignalingChannel::OrcaCloudSignalingChannel(OrcaCloudServiceAgent* cloud, std::string dev_id)
: m_cloud(cloud)
, m_dev_id(std::move(dev_id))
{
}
OrcaCloudSignalingChannel::~OrcaCloudSignalingChannel()
{
close();
}
void OrcaCloudSignalingChannel::open()
{
bool expected = false;
if (!m_open.compare_exchange_strong(expected, true))
return;
m_stop.store(false);
m_thread = std::thread([this] { run(); });
}
void OrcaCloudSignalingChannel::close()
{
m_stop.store(true);
std::shared_ptr<Connection> conn;
Http::Ptr request;
{
std::lock_guard<std::mutex> lock(m_mutex);
conn = m_conn;
request = m_inflight_requests;
}
// m_conn is installed only after the live-token request completes, so
// cancel the request independently before waiting for the worker.
if (request)
request->cancel();
if (conn) {
// Established session: close the socket on the io_context's own thread so
// the pending async_read completes and io_context.run() unwinds.
boost::asio::post(conn->io_context, [conn] {
boost::system::error_code ec;
boost::beast::get_lowest_layer(conn->websocket).cancel(ec);
boost::beast::get_lowest_layer(conn->websocket).close(ec);
});
// Pre-run() phase (still in the synchronous connect/handshake): best-effort
// direct interruption.
boost::system::error_code ec;
boost::beast::get_lowest_layer(conn->websocket).cancel(ec);
boost::beast::get_lowest_layer(conn->websocket).close(ec);
}
if (m_thread.joinable())
m_thread.join();
m_open.store(false);
}
void OrcaCloudSignalingChannel::send_offer(std::string sdp)
{
send_json(nlohmann::json{{"type", "webrtc.offer"}, {"sdp", std::move(sdp)}}.dump());
}
void OrcaCloudSignalingChannel::send_ice(std::string candidate, std::string mid)
{
send_json(nlohmann::json{{"type", "webrtc.ice"},
{"candidate", std::move(candidate)},
{"sdpMid", std::move(mid)}}
.dump());
}
std::string OrcaCloudSignalingChannel::encode_path_component(const std::string& value)
{
std::ostringstream encoded;
encoded << std::uppercase << std::hex;
for (unsigned char c : value) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~')
encoded << c;
else
encoded << '%' << std::setw(2) << std::setfill('0') << static_cast<unsigned int>(c);
}
return encoded.str();
}
void OrcaCloudSignalingChannel::unavailable(CameraUnavailableReason reason, std::string detail)
{
if (on_unavailable)
on_unavailable(reason, std::move(detail));
}
void OrcaCloudSignalingChannel::run()
{
try {
if (!m_cloud || !m_cloud->ensure_token_fresh("camera")) {
unavailable(CameraUnavailableReason::Error, "Unable to refresh OrcaCloud credentials");
m_open.store(false);
return;
}
const std::string token = m_cloud->get_access_token();
// OrcaCloud exposes a bare API hostname. WebRTC signaling uses the
// fixed HTTPS/WSS endpoints on port 443; custom schemes, ports, and
// base paths are not supported by this agent.
const std::string host = m_cloud->get_cloud_service_host();
if (token.empty() || host.empty()) {
unavailable(CameraUnavailableReason::Error, "OrcaCloud session is unavailable");
m_open.store(false);
return;
}
const std::string live_token_url =
"https://" + host + "/api/v1/printers/" + encode_path_component(m_dev_id) + "/live-token";
BOOST_LOG_TRIVIAL(info) << "signaling: POST " << live_token_url << " (dev_id=" << m_dev_id << ")";
nlohmann::json token_response;
std::string token_body;
unsigned int http_code = 0;
auto request = std::make_shared<Slic3r::Http>(Http::post(live_token_url));
request->set_post_body(std::string("{}"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.tls_verify(true)
.timeout_max(30)
.on_complete([&token_body, &http_code](std::string body, unsigned status) {
http_code = status;
token_body = std::move(body);
})
.on_error([&http_code](std::string, std::string, unsigned status) {
http_code = status;
});
{
std::lock_guard<std::mutex> lock(m_mutex);
m_inflight_requests = request;
}
// If close() raced with request setup, make sure this request is
// cancelled before entering the blocking call.
if (m_stop.load())
request->cancel();
try {
request->perform_sync();
} catch (...) {
std::lock_guard<std::mutex> lock(m_mutex);
if (m_inflight_requests == request)
m_inflight_requests.reset();
throw;
}
{
std::lock_guard<std::mutex> lock(m_mutex);
if (m_inflight_requests == request)
m_inflight_requests.reset();
}
try {
token_response = nlohmann::json::parse(token_body);
} catch (const std::exception&) {
}
if (http_code < 200 || http_code >= 300 || !token_response.contains("token")) {
unavailable(CameraUnavailableReason::Error, "Unable to mint camera live token");
m_open.store(false);
return;
}
std::vector<CameraIceServer> ice_servers;
if (token_response.contains("ice_servers") && token_response["ice_servers"].is_array()) {
for (const auto& entry : token_response["ice_servers"]) {
if (entry.is_string()) {
ice_servers.push_back({entry.get<std::string>(), {}, {}});
} else if (entry.is_object()) {
// RTCIceServer.urls is "string | string[]" (Cloudflare
// Realtime returns an array). Emit one CameraIceServer per
// URL, sharing the credentials.
const std::string username = entry.value("username", std::string{});
const std::string credential = entry.value("credential", std::string{});
const auto add_url = [&](const nlohmann::json& url) {
if (url.is_string() && !url.get<std::string>().empty())
ice_servers.push_back({url.get<std::string>(), username, credential});
};
const auto urls = entry.find("urls");
if (urls != entry.end()) {
if (urls->is_array()) {
for (const auto& url : *urls)
add_url(url);
} else {
add_url(*urls);
}
}
}
}
}
auto conn = std::make_shared<Connection>();
conn->ssl_context.set_default_verify_paths();
Http::add_platform_root_certificates(conn->ssl_context.native_handle());
{
std::lock_guard<std::mutex> lock(m_mutex);
m_conn = conn;
}
auto& websocket = conn->websocket;
boost::asio::ip::tcp::resolver resolver(conn->io_context);
const auto endpoints = resolver.resolve(host, "443");
boost::asio::connect(boost::beast::get_lowest_layer(websocket), endpoints);
if (!SSL_set_tlsext_host_name(websocket.next_layer().native_handle(), host.c_str()))
throw std::runtime_error("Unable to configure TLS server name");
websocket.next_layer().set_verify_mode(boost::asio::ssl::verify_peer);
websocket.next_layer().set_verify_callback(boost::asio::ssl::host_name_verification(host));
websocket.next_layer().handshake(boost::asio::ssl::stream_base::client);
const std::string ws_target = "/api/v1/printers/" + encode_path_component(m_dev_id) +
"/camera/live?token=" +
encode_path_component(token_response["token"].get<std::string>());
websocket.handshake(host, ws_target);
BOOST_LOG_TRIVIAL(info) << "signaling: websocket handshake ok (" << ice_servers.size()
<< " ice servers)";
if (on_ready)
on_ready(std::move(ice_servers));
send_json(nlohmann::json{{"type", "camera.mode"}, {"mode", "webrtc"}}.dump());
// Async read loop, driven by the connection's own io_context. run()
// returns once close() has shut the socket down, giving a bounded,
// deadlock-free teardown from any thread.
do_read(conn);
conn->io_context.run();
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << "signaling: run() exception: " << e.what();
if (!m_stop.load())
unavailable(CameraUnavailableReason::Closed, e.what());
}
{
std::lock_guard<std::mutex> lock(m_mutex);
m_conn.reset();
}
m_open.store(false);
}
void OrcaCloudSignalingChannel::do_read(std::shared_ptr<Connection> conn)
{
auto buffer = std::make_shared<boost::beast::flat_buffer>();
conn->websocket.async_read(
*buffer, [this, conn, buffer](boost::system::error_code ec, std::size_t) {
if (ec) {
if (!m_stop.load())
unavailable(CameraUnavailableReason::Closed, ec.message());
return; // do not re-arm; io_context.run() unwinds
}
const std::string raw = boost::beast::buffers_to_string(buffer->data());
// A malformed or unexpectedly-shaped message must not tear down the
// session: parse/dispatch is guarded.
try {
dispatch_message(nlohmann::json::parse(raw), raw);
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << "signaling: ignoring malformed message: " << e.what()
<< " raw=" << raw.substr(0, 256);
}
if (!m_stop.load())
do_read(conn);
});
}
// Returns the string at key, or "" if absent or not a string (JSON null included).
static std::string json_string(const nlohmann::json& object, const char* key)
{
const auto it = object.find(key);
return (it != object.end() && it->is_string()) ? it->get<std::string>() : std::string{};
}
void OrcaCloudSignalingChannel::dispatch_message(const nlohmann::json& message, const std::string& raw)
{
const std::string type = json_string(message, "type");
BOOST_LOG_TRIVIAL(info) << "signaling: recv type=" << type << " raw=" << raw.substr(0, 256);
if (type == "webrtc.answer") {
const std::string sdp = json_string(message, "sdp");
if (on_answer && !sdp.empty())
on_answer(sdp);
} else if (type == "webrtc.ice" && message.contains("candidate")) {
// The peer may send "candidate" as a flat string or as a nested
// RTCIceCandidateInit object { candidate, sdpMid, sdpMLineIndex }.
const nlohmann::json& candidate = message["candidate"];
std::string sdp_candidate;
std::string mid = json_string(message, "sdpMid");
if (candidate.is_string()) {
sdp_candidate = candidate.get<std::string>();
} else if (candidate.is_object()) {
sdp_candidate = json_string(candidate, "candidate");
std::string nested_mid = json_string(candidate, "sdpMid");
if (!nested_mid.empty())
mid = std::move(nested_mid);
}
if (on_ice && !sdp_candidate.empty())
on_ice(sdp_candidate, mid);
} else if (type == "webrtc.unavailable") {
const std::string reason = json_string(message, "reason");
unavailable(reason == "busy" ? CameraUnavailableReason::Busy
: reason == "disabled" ? CameraUnavailableReason::Disabled
: CameraUnavailableReason::Error,
reason.empty() ? "error" : reason);
}
}
void OrcaCloudSignalingChannel::send_json(const std::string& message)
{
std::shared_ptr<Connection> conn;
{
std::lock_guard<std::mutex> lock(m_mutex);
conn = m_conn;
}
if (!conn || m_stop.load())
return;
// Serialize the write onto the io_context thread (same thread that runs
// async_read), so reads and writes never touch the stream concurrently.
auto payload = std::make_shared<std::string>(message);
boost::asio::post(conn->io_context, [this, conn, payload] {
if (m_stop.load())
return;
boost::system::error_code ec;
conn->websocket.write(boost::asio::buffer(*payload), ec);
if (ec && !m_stop.load())
unavailable(CameraUnavailableReason::Closed, ec.message());
});
}
} // namespace Slic3r
@@ -0,0 +1,66 @@
#pragma once
#include "ICameraSignalingChannel.hpp"
#include "OrcaCloudServiceAgent.hpp"
#include "Http.hpp"
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/beast/websocket.hpp>
#include <nlohmann/json_fwd.hpp>
#include <atomic>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
namespace Slic3r {
class OrcaCloudSignalingChannel : public ICameraSignalingChannel {
public:
OrcaCloudSignalingChannel(OrcaCloudServiceAgent* cloud, std::string dev_id);
~OrcaCloudSignalingChannel() override;
void open() override;
void close() override;
void send_offer(std::string sdp) override;
void send_ice(std::string candidate, std::string mid) override;
private:
using WebSocket = boost::beast::websocket::stream<
boost::beast::ssl_stream<boost::asio::ip::tcp::socket>>;
// The io_context and ssl_context must outlive the websocket stream that
// references them. Bundling them here with the stream declared last makes
// the destruction order correct (stream first, then contexts), and lets a
// single shared_ptr own the whole set.
struct Connection {
boost::asio::io_context io_context;
boost::asio::ssl::context ssl_context{boost::asio::ssl::context::tls_client};
WebSocket websocket{io_context, ssl_context};
};
void run();
void do_read(std::shared_ptr<Connection> conn);
void dispatch_message(const nlohmann::json& message, const std::string& raw);
void send_json(const std::string& message);
void unavailable(CameraUnavailableReason reason, std::string detail);
static std::string encode_path_component(const std::string& value);
OrcaCloudServiceAgent* m_cloud;
Http::Ptr m_inflight_requests{nullptr};
std::string m_dev_id;
std::atomic<bool> m_stop{false};
std::atomic<bool> m_open{false};
std::thread m_thread;
mutable std::mutex m_mutex;
std::shared_ptr<Connection> m_conn;
};
} // namespace Slic3r
+805
View File
@@ -0,0 +1,805 @@
#include "OrcaMqttConnection.hpp"
#include "Http.hpp"
#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/beast/websocket.hpp>
#include <openssl/ssl.h>
#include <algorithm>
#include <chrono>
#include <memory>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <utility>
namespace Slic3r {
struct OrcaMqttConnection::Connection {
boost::asio::io_context io_context;
boost::asio::ssl::context ssl_context;
boost::asio::ip::tcp::resolver resolver;
boost::asio::steady_timer keepalive_timer;
boost::beast::flat_buffer read_buffer;
std::deque<std::shared_ptr<std::vector<uint8_t>>> outbound_packets;
boost::system::error_code terminal_error;
std::atomic_bool async_session_started{false};
bool write_in_progress{false};
// Exactly one of these is engaged once ws_handshake() has run: wss for
// wss:// endpoints, ws for plaintext ws://.
std::optional<TlsWebSocket> wss;
std::optional<PlainWebSocket> ws;
Connection()
: ssl_context(boost::asio::ssl::context::tls_client)
, resolver(io_context)
, keepalive_timer(io_context)
{}
};
namespace {
// Apply / clear a tcp_stream timeout on whichever websocket is engaged.
// Templated on the connection type only because Connection is a private nested
// type: a deduced parameter needs no (inaccessible) name for it.
template<class Conn> void expires_after(Conn& conn, std::chrono::seconds timeout) {
if (conn.wss) boost::beast::get_lowest_layer(*conn.wss).expires_after(timeout);
else if (conn.ws) boost::beast::get_lowest_layer(*conn.ws).expires_after(timeout);
}
template<class Conn> void expires_never(Conn& conn) {
if (conn.wss) boost::beast::get_lowest_layer(*conn.wss).expires_never();
else if (conn.ws) boost::beast::get_lowest_layer(*conn.ws).expires_never();
}
} // namespace
OrcaMqttConnection::~OrcaMqttConnection() { stop(); }
bool OrcaMqttConnection::start(const Config& config, MessageHandler on_message, StateHandler on_state) {
std::lock_guard<std::recursive_mutex> lifecycle_lock(lifecycle_mutex);
stop();
{
std::lock_guard<std::mutex> lock(mutex);
current_config = config;
this->on_message = std::move(on_message);
this->on_state = std::move(on_state);
initial_result = false;
initial_completed = false;
connected = false;
m_last_connack_rc.store(-1);
}
stopping.store(false);
worker = std::thread(&OrcaMqttConnection::run, this);
std::unique_lock<std::mutex> lock(mutex);
if (!initial_cv.wait_for(lock, std::chrono::seconds(10), [this] { return initial_completed; })) {
initial_completed = true;
initial_result = false;
}
return initial_result;
}
void OrcaMqttConnection::stop() {
std::lock_guard<std::recursive_mutex> lifecycle_lock(lifecycle_mutex);
stopping.store(true);
state_cv.notify_all();
{
std::lock_guard<std::mutex> lock(connection_mutex);
if (active_connection) {
if (active_connection->async_session_started.load()) {
// The worker owns the live WebSocket. Stop dispatching its
// asynchronous operations; the worker closes the socket after
// leaving the event loop.
active_connection->io_context.stop();
} else {
// Setup still uses synchronous operations on the worker. Wake a
// pending resolve/connect/CONNACK read without competing with a
// live asynchronous session.
auto shutdown_socket = [](auto& websocket) {
auto& socket = boost::beast::get_lowest_layer(websocket).socket();
boost::system::error_code socket_error;
if (socket.cancel(socket_error))
return;
if (socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, socket_error))
return;
if (socket.close(socket_error))
return;
};
if (active_connection->wss)
shutdown_socket(*active_connection->wss);
else if (active_connection->ws)
shutdown_socket(*active_connection->ws);
active_connection->resolver.cancel();
}
}
}
if (worker.joinable())
worker.join();
{
std::lock_guard<std::mutex> lock(mutex);
connected = false;
acknowledged_subscriptions.clear();
pending_subscribe_packets.clear();
pending_requests.clear();
if (!initial_completed) {
initial_completed = true;
initial_result = false;
}
}
initial_cv.notify_all();
}
bool OrcaMqttConnection::is_running() const {
return worker.joinable() && !stopping.load();
}
void OrcaMqttConnection::flush_subscription_change() {
std::shared_ptr<Connection> conn;
{
std::lock_guard<std::mutex> lock(connection_mutex);
conn = active_connection;
}
bool connacked;
{
std::lock_guard<std::mutex> lock(mutex);
connacked = connected;
}
if (!conn || !connacked)
return; // no live MQTT session yet — the worker sends the set on CONNACK
boost::asio::post(conn->io_context, [this, conn] {
if (!stopping.load() && connected.load())
send_pending_subscriptions(conn);
});
}
bool OrcaMqttConnection::subscribe(const std::string& dev_id) {
if (dev_id.empty()) {
return false;
}
const std::string topic = report_topic(dev_id);
if (topic.size() > 96) { // MQTT topic filter cap enforced by the service
return false;
}
{
std::lock_guard<std::mutex> lock(mutex);
if (subscriptions.count(topic) != 0 && pending_unsubscriptions.count(topic) == 0) {
return true;
}
subscriptions.insert(topic);
pending_unsubscriptions.erase(topic);
pending_subscriptions.insert(topic);
}
state_cv.notify_all();
flush_subscription_change(); // ask the worker to emit SUBSCRIBE now (no reconnect)
return true;
}
bool OrcaMqttConnection::unsubscribe(const std::string& dev_id) {
const std::string topic = report_topic(dev_id);
{
std::lock_guard<std::mutex> lock(mutex);
subscriptions.erase(topic);
acknowledged_subscriptions.erase(topic);
pending_subscriptions.erase(topic);
pending_unsubscriptions.insert(topic);
for (auto it = pending_subscribe_packets.begin(); it != pending_subscribe_packets.end();) {
if (it->second == topic)
it = pending_subscribe_packets.erase(it);
else
++it;
}
for (auto it = pending_requests.begin(); it != pending_requests.end();) {
if (it->first == dev_id)
it = pending_requests.erase(it);
else
++it;
}
}
state_cv.notify_all();
flush_subscription_change(); // ask the worker to emit UNSUBSCRIBE now (no reconnect)
return true;
}
void OrcaMqttConnection::clear_subscriptions() {
std::lock_guard<std::mutex> lock(mutex);
subscriptions.clear();
pending_subscriptions.clear();
pending_unsubscriptions.clear();
acknowledged_subscriptions.clear();
pending_subscribe_packets.clear();
pending_requests.clear();
}
bool OrcaMqttConnection::parse_endpoint(const std::string& url, Endpoint& endpoint) {
std::string rest;
std::string default_port;
if (url.rfind("wss://", 0) == 0) { rest = url.substr(6); default_port = "443"; }
else if (url.rfind("ws://", 0) == 0) { rest = url.substr(5); default_port = "80"; }
else return false;
const auto slash = rest.find('/');
const std::string authority = rest.substr(0, slash);
endpoint.target = (slash == std::string::npos) ? "/" : rest.substr(slash);
// host[:port] — leave an unbracketed IPv6 literal alone
const auto colon = authority.rfind(':');
if (colon != std::string::npos && authority.find(']') == std::string::npos) {
endpoint.host = authority.substr(0, colon);
endpoint.port = authority.substr(colon + 1);
} else {
endpoint.host = authority;
endpoint.port = default_port;
}
return !endpoint.host.empty() && !endpoint.port.empty() && !endpoint.target.empty();
}
void OrcaMqttConnection::append_string(std::vector<uint8_t>& packet, const std::string& value) {
if (value.size() > 0xffff)
throw std::runtime_error("MQTT string is too long");
packet.push_back(static_cast<uint8_t>(value.size() >> 8));
packet.push_back(static_cast<uint8_t>(value.size() & 0xff));
packet.insert(packet.end(), value.begin(), value.end());
}
void OrcaMqttConnection::prepend_remaining_length(std::vector<uint8_t>& packet, size_t length) {
std::vector<uint8_t> encoded;
do {
uint8_t byte = static_cast<uint8_t>(length % 128);
length /= 128;
if (length != 0)
byte |= 0x80;
encoded.push_back(byte);
} while (length != 0);
packet.insert(packet.begin() + 1, encoded.begin(), encoded.end());
}
std::vector<uint8_t> OrcaMqttConnection::make_connect_packet(
const std::string& client_id, const std::string& username,
const std::string& password, int keepalive_seconds) {
std::vector<uint8_t> packet{0x10};
append_string(packet, "MQTT");
packet.push_back(4); // protocol level 3.1.1
uint8_t flags = 0x02; // clean session
if (!username.empty()) { flags |= 0x80; if (!password.empty()) flags |= 0x40; }
packet.push_back(flags);
packet.push_back(static_cast<uint8_t>(keepalive_seconds >> 8));
packet.push_back(static_cast<uint8_t>(keepalive_seconds & 0xff));
append_string(packet, client_id.empty() ? "OrcaSlicer" : client_id);
if (!username.empty()) {
append_string(packet, username);
if (!password.empty()) append_string(packet, password);
}
prepend_remaining_length(packet, packet.size() - 1);
return packet;
}
std::string OrcaMqttConnection::report_topic(const std::string& device_id) { return "device/" + device_id + "/report"; }
std::string OrcaMqttConnection::request_topic(const std::string& id) { return "device/" + id + "/request"; }
std::vector<uint8_t> OrcaMqttConnection::make_publish_packet(const std::string& topic, const std::string& payload) {
std::vector<uint8_t> packet{0x30}; // PUBLISH, QoS 0, no retain
append_string(packet, topic); // no packet id at QoS 0
packet.insert(packet.end(), payload.begin(), payload.end());
prepend_remaining_length(packet, packet.size() - 1);
return packet;
}
std::vector<uint8_t> OrcaMqttConnection::make_subscribe_packet(uint16_t id, const std::string& topic, uint8_t qos) {
std::vector<uint8_t> packet{0x82};
packet.push_back(id >> 8); packet.push_back(id & 0xff);
append_string(packet, topic);
packet.push_back(qos);
prepend_remaining_length(packet, packet.size() - 1);
return packet;
}
std::vector<uint8_t> OrcaMqttConnection::make_unsubscribe_packet(uint16_t id, const std::string& topic) {
std::vector<uint8_t> packet{0xA2};
packet.push_back(id >> 8); packet.push_back(id & 0xff);
append_string(packet, topic);
prepend_remaining_length(packet, packet.size() - 1);
return packet;
}
std::vector<uint8_t> OrcaMqttConnection::make_ping_packet() { return {0xc0, 0}; }
void OrcaMqttConnection::ws_write(Connection& conn, const std::vector<uint8_t>& packet) {
if (packet.empty()) {
return;
}
if (conn.wss) {
conn.wss->binary(true);
conn.wss->write(boost::asio::buffer(packet));
} else if (conn.ws) {
conn.ws->binary(true);
conn.ws->write(boost::asio::buffer(packet));
}
}
void OrcaMqttConnection::close_connection(Connection& conn) {
boost::system::error_code error;
if (conn.wss) {
auto& socket = boost::beast::get_lowest_layer(*conn.wss).socket();
if (socket.cancel(error))
return;
if (socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, error))
return;
if (socket.close(error))
return;
} else if (conn.ws) {
auto& socket = boost::beast::get_lowest_layer(*conn.ws).socket();
if (socket.cancel(error))
return;
if (socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, error))
return;
if (socket.close(error))
return;
}
}
void OrcaMqttConnection::enqueue_packet(const std::shared_ptr<Connection>& conn,
std::vector<uint8_t> packet) {
if (!conn || packet.empty())
return;
conn->outbound_packets.emplace_back(std::make_shared<std::vector<uint8_t>>(std::move(packet)));
start_async_write(conn);
}
void OrcaMqttConnection::start_async_write(const std::shared_ptr<Connection>& conn) {
if (!conn || conn->write_in_progress || conn->outbound_packets.empty() || stopping.load())
return;
conn->write_in_progress = true;
const auto packet = conn->outbound_packets.front();
auto on_write = [this, conn](const boost::system::error_code& error, std::size_t) {
conn->write_in_progress = false;
if (error) {
if (!stopping.load())
conn->terminal_error = error;
conn->io_context.stop();
return;
}
conn->outbound_packets.pop_front();
start_async_write(conn);
};
if (conn->wss) {
conn->wss->binary(true);
conn->wss->async_write(boost::asio::buffer(*packet), std::move(on_write));
} else if (conn->ws) {
conn->ws->binary(true);
conn->ws->async_write(boost::asio::buffer(*packet), std::move(on_write));
} else {
conn->write_in_progress = false;
conn->outbound_packets.pop_front();
}
}
void OrcaMqttConnection::start_async_read(const std::shared_ptr<Connection>& conn) {
if (!conn || stopping.load())
return;
auto on_read = [this, conn](const boost::system::error_code& error, std::size_t) {
if (error) {
if (!stopping.load())
conn->terminal_error = error;
conn->io_context.stop();
return;
}
const std::string packet = boost::beast::buffers_to_string(conn->read_buffer.data());
conn->read_buffer.consume(conn->read_buffer.size());
handle_packet(packet);
start_async_read(conn);
};
if (conn->wss)
conn->wss->async_read(conn->read_buffer, std::move(on_read));
else if (conn->ws)
conn->ws->async_read(conn->read_buffer, std::move(on_read));
}
void OrcaMqttConnection::schedule_keepalive(const std::shared_ptr<Connection>& conn) {
const int keepalive = current_config.keepalive_seconds;
if (!conn || keepalive <= 0 || stopping.load())
return;
conn->keepalive_timer.expires_after(std::chrono::seconds(std::max(1, keepalive / 2)));
conn->keepalive_timer.async_wait([this, conn](const boost::system::error_code& error) {
if (error || stopping.load())
return;
enqueue_packet(conn, make_ping_packet());
schedule_keepalive(conn);
});
}
void OrcaMqttConnection::post_packet(const std::shared_ptr<Connection>& conn,
std::vector<uint8_t> packet) {
if (!conn || packet.empty())
return;
boost::asio::post(conn->io_context, [this, conn, packet = std::move(packet)]() mutable {
if (!stopping.load())
enqueue_packet(conn, std::move(packet));
});
}
std::size_t OrcaMqttConnection::ws_read(Connection& conn, boost::beast::flat_buffer& buffer,
boost::system::error_code& ec) {
if (conn.wss)
return conn.wss->read(buffer, ec);
if (conn.ws)
return conn.ws->read(buffer, ec);
ec = boost::asio::error::not_connected;
return 0;
}
void OrcaMqttConnection::ws_handshake(Connection& conn, const Config& config, const Endpoint& endpoint) {
const auto results = conn.resolver.resolve(endpoint.host, endpoint.port);
std::string token;
if (config.bearer_provider) {
token = config.bearer_provider();
}
auto decorator = [token](boost::beast::websocket::request_type& request) {
request.set(boost::beast::http::field::user_agent, "OrcaSlicer");
if (!token.empty())
request.set(boost::beast::http::field::authorization, "Bearer " + token);
request.set("Sec-WebSocket-Protocol", "mqtt");
};
boost::beast::http::response<boost::beast::http::string_body> response;
boost::system::error_code handshake_error;
if (config.use_tls) {
// stop() inspects the engaged optional under connection_mutex; publish it
// under the same lock, then release before the blocking connect.
{
std::lock_guard<std::mutex> lock(connection_mutex);
conn.wss.emplace(conn.io_context, conn.ssl_context);
}
auto& websocket = *conn.wss;
auto& stream = boost::beast::get_lowest_layer(websocket);
stream.expires_after(std::chrono::seconds(10));
stream.connect(results);
// Set SNI before the TLS handshake so the cloud edge selects the correct
// certificate.
auto& tls_stream = websocket.next_layer();
if (!SSL_set_tlsext_host_name(tls_stream.native_handle(), endpoint.host.c_str()))
throw std::runtime_error("failed to set Orca Cloud TLS server name");
if (!config.ca_file.empty())
conn.ssl_context.load_verify_file(config.ca_file);
else
conn.ssl_context.set_default_verify_paths();
Http::add_platform_root_certificates(conn.ssl_context.native_handle());
tls_stream.set_verify_mode(boost::asio::ssl::verify_peer);
tls_stream.set_verify_callback(boost::asio::ssl::host_name_verification(endpoint.host));
tls_stream.handshake(boost::asio::ssl::stream_base::client);
websocket.set_option(boost::beast::websocket::stream_base::decorator(decorator));
websocket.handshake(response, endpoint.host, endpoint.target, handshake_error);
} else {
{
std::lock_guard<std::mutex> lock(connection_mutex);
conn.ws.emplace(conn.io_context);
}
auto& websocket = *conn.ws;
auto& stream = boost::beast::get_lowest_layer(websocket);
stream.expires_after(std::chrono::seconds(10));
stream.connect(results);
websocket.set_option(boost::beast::websocket::stream_base::decorator(decorator));
websocket.handshake(response, endpoint.host, endpoint.target, handshake_error);
}
if (handshake_error) {
throw boost::system::system_error(handshake_error, "Orca WebSocket handshake");
}
if (response["Sec-WebSocket-Protocol"] != "mqtt") {
throw std::runtime_error("Orca WebSocket did not negotiate MQTT");
}
}
bool OrcaMqttConnection::send_request(const std::string& dev_id, const std::string& payload) {
if (dev_id.empty()) {
return false;
}
if (!connected.load()) {
return false;
}
std::shared_ptr<Connection> conn;
{
std::lock_guard<std::mutex> lock(connection_mutex);
conn = active_connection;
}
if (!conn) {
return false;
}
const std::string report = report_topic(dev_id);
{
std::lock_guard<std::mutex> lock(mutex);
if (subscriptions.count(report) != 0 && acknowledged_subscriptions.count(report) == 0) {
pending_requests.emplace_back(dev_id, payload);
return true;
}
}
post_packet(conn, make_publish_packet(request_topic(dev_id), payload));
return true;
}
void OrcaMqttConnection::connect_and_read() {
auto connection = std::make_shared<Connection>();
{
std::lock_guard<std::mutex> lock(connection_mutex);
active_connection = connection;
if (stopping.load()) {
active_connection.reset();
return;
}
}
auto clear_connection = [this, connection] {
close_connection(*connection);
std::lock_guard<std::mutex> lock(connection_mutex);
if (active_connection == connection)
active_connection.reset();
};
try {
Endpoint endpoint;
if (!parse_endpoint(current_config.url, endpoint)) {
throw std::runtime_error("invalid Orca Cloud WebSocket endpoint");
}
ws_handshake(*connection, current_config, endpoint);
// Auth precedence: a bearer_provider authenticates the WebSocket upgrade, so the
// CONNECT username/password fields are omitted entirely (the cloud form).
const bool use_bearer = static_cast<bool>(current_config.bearer_provider);
ws_write(*connection, make_connect_packet(current_config.client_id,
use_bearer ? std::string() : current_config.username,
use_bearer ? std::string() : current_config.password,
current_config.keepalive_seconds));
boost::beast::flat_buffer buffer;
expires_after(*connection, std::chrono::seconds(10));
boost::system::error_code connack_error;
ws_read(*connection, buffer, connack_error);
if (connack_error)
throw boost::system::system_error(connack_error, "read Orca MQTT CONNACK");
// Beast leaves this expiry armed after the synchronous CONNACK read above;
// disable it before starting the long-lived async WebSocket session.
expires_never(*connection);
const std::string connack = boost::beast::buffers_to_string(buffer.data());
// rc: 0 accepted, 1..5 refusal, -1 malformed/not a CONNACK.
const int rc = (connack.size() == 4 && static_cast<uint8_t>(connack[0]) == 0x20)
? static_cast<int>(static_cast<uint8_t>(connack[3]))
: -1;
m_last_connack_rc.store(rc);
if (rc != 0) {
if (rc == 4 || rc == 5) {
// Bad credentials / not authorized — retrying cannot help. Make run()'s
// loop exit and unblock any waiting start().
stopping.store(true);
{
std::lock_guard<std::mutex> lock(mutex);
initial_completed = true;
initial_result = false;
}
initial_cv.notify_all();
}
throw std::runtime_error("Orca MQTT CONNECT refused rc=" + std::to_string(rc));
}
// The subscription acknowledgement belongs to this MQTT session. Clear
// the previous session's state before notifying the owner, because the
// reconnect callback immediately queues the printer's initial requests.
{
std::lock_guard<std::mutex> lock(mutex);
acknowledged_subscriptions.clear();
pending_subscribe_packets.clear();
}
connection->async_session_started.store(true);
notify_state(true);
reconnect_delay_seconds.store(1); // a fresh CONNACK resets the backoff
send_current_subscriptions(connection);
start_async_read(connection);
schedule_keepalive(connection);
const std::size_t handlers_run = connection->io_context.run();
if (handlers_run == 0 && !stopping.load())
throw std::runtime_error("Orca MQTT event loop stopped unexpectedly");
if (connection->terminal_error && !stopping.load())
throw boost::system::system_error(connection->terminal_error, "read Orca MQTT message");
clear_connection();
if (!stopping.load())
notify_state(false);
} catch (...) {
clear_connection();
throw;
}
}
void OrcaMqttConnection::send_current_subscriptions(const std::shared_ptr<Connection>& conn) {
std::vector<std::string> topics;
{
std::lock_guard<std::mutex> lock(mutex);
topics.assign(subscriptions.begin(), subscriptions.end());
acknowledged_subscriptions.clear();
pending_subscribe_packets.clear();
for (const std::string& topic : topics)
pending_subscriptions.erase(topic);
}
for (const std::string& topic : topics) {
const uint16_t packet_id = next_packet_id++;
{
std::lock_guard<std::mutex> lock(mutex);
pending_subscribe_packets[packet_id] = topic;
}
enqueue_packet(conn, make_subscribe_packet(packet_id, topic, 1));
}
}
void OrcaMqttConnection::send_pending_subscriptions(const std::shared_ptr<Connection>& conn) {
std::vector<std::string> subscribe_topics;
std::vector<std::string> unsubscribe_topics;
{
std::lock_guard<std::mutex> lock(mutex);
subscribe_topics.assign(pending_subscriptions.begin(), pending_subscriptions.end());
unsubscribe_topics.assign(pending_unsubscriptions.begin(), pending_unsubscriptions.end());
pending_subscriptions.clear();
pending_unsubscriptions.clear();
}
for (const std::string& topic : subscribe_topics) {
const uint16_t packet_id = next_packet_id++;
{
std::lock_guard<std::mutex> lock(mutex);
pending_subscribe_packets[packet_id] = topic;
}
enqueue_packet(conn, make_subscribe_packet(packet_id, topic, 1));
}
for (const std::string& topic : unsubscribe_topics) {
const uint16_t packet_id = next_packet_id++;
enqueue_packet(conn, make_unsubscribe_packet(packet_id, topic));
}
}
void OrcaMqttConnection::handle_packet(const std::string& packet) {
if (packet.size() < 2) {
return;
}
const uint8_t header = static_cast<uint8_t>(packet[0]);
const uint8_t packet_type = header >> 4;
if (packet_type != 3) { // Only QoS 0 PUBLISH carries printer status.
if (packet_type == 9 && packet.size() >= 5) {
const uint16_t packet_id = (static_cast<unsigned int>(static_cast<uint8_t>(packet[2])) << 8) |
static_cast<unsigned int>(static_cast<uint8_t>(packet[3]));
std::ostringstream result_codes;
for (size_t index = 4; index < packet.size(); ++index) {
if (index != 4)
result_codes << ',';
result_codes << "0x" << std::hex << static_cast<unsigned int>(static_cast<uint8_t>(packet[index]));
}
// Each production SUBSCRIBE packet currently contains one topic.
// MQTT grants QoS 0 or 1 for a requested QoS 1 subscription; 0x80
// means the subscription was rejected.
const uint8_t result = static_cast<uint8_t>(packet[4]);
std::string topic;
std::deque<std::pair<std::string, std::string>> requests;
{
std::lock_guard<std::mutex> lock(mutex);
auto pending = pending_subscribe_packets.find(packet_id);
if (pending != pending_subscribe_packets.end()) {
topic = pending->second;
pending_subscribe_packets.erase(pending);
for (auto it = pending_requests.begin(); it != pending_requests.end();) {
if (report_topic(it->first) == topic) {
requests.push_back(std::move(*it));
it = pending_requests.erase(it);
} else {
++it;
}
}
if (result == 0 || result == 1) {
acknowledged_subscriptions.insert(topic);
}
}
}
if (topic.empty()) {
} else if (result == 0 || result == 1) {
for (const auto& request : requests) {
if (!send_request(request.first, request.second)) {
}
}
} else {
}
}
return;
}
size_t index = 1;
size_t multiplier = 1;
size_t remaining = 0;
uint8_t encoded = 0;
do {
if (index >= packet.size() || multiplier > 128 * 128 * 128) {
return;
}
encoded = static_cast<uint8_t>(packet[index++]);
remaining += (encoded & 0x7f) * multiplier;
multiplier *= 128;
} while ((encoded & 0x80) != 0);
const size_t remaining_end = index + remaining;
if (remaining_end > packet.size() || remaining < 2 || index + 2 > remaining_end) {
return;
}
const uint16_t topic_length = (static_cast<uint8_t>(packet[index]) << 8) |
static_cast<uint8_t>(packet[index + 1]);
index += 2;
if (topic_length > packet.size() - index) {
return;
}
const std::string topic(packet.data() + index, topic_length);
index += topic_length;
if (((header >> 1) & 0x03) != 0) {
if (index + 2 > remaining_end) {
return;
}
index += 2; // QoS 1/2 packet identifier; the service currently sends QoS 0.
}
const size_t payload_size = remaining_end - index;
// topic is "device/<id>/report" (or "/request"); hand the id up, drop anything else.
std::string dev_id;
if (topic.rfind("device/", 0) == 0) {
const size_t id_start = 7;
const size_t id_end = topic.rfind('/');
if (id_end != std::string::npos && id_end > id_start)
dev_id = topic.substr(id_start, id_end - id_start);
}
if (dev_id.empty()) {
} else if (on_message) {
on_message(dev_id, packet.substr(index, remaining_end - index));
} else {
}
}
void OrcaMqttConnection::notify_state(bool is_now_connected) {
StateHandler callback;
bool initial = false;
{
std::lock_guard<std::mutex> lock(mutex);
connected = is_now_connected;
initial = !initial_completed;
if (initial) {
initial_result = is_now_connected;
initial_completed = true;
}
callback = on_state;
}
if (initial)
initial_cv.notify_all();
else if (callback)
callback(is_now_connected, false);
}
void OrcaMqttConnection::run() {
while (!stopping.load()) {
const int retry_seconds = reconnect_delay_seconds.load();
const uint64_t attempt = ++m_attempt_number;
try {
connect_and_read();
} catch (const std::exception& error) {
if (!stopping.load())
notify_state(false);
}
if (stopping.load())
break;
// Grow the backoff only across attempts that never reached CONNACK; a
// successful connection resets reconnect_delay_seconds to 1 (connect_and_read).
reconnect_delay_seconds.store(std::min(retry_seconds * 2, 30));
std::unique_lock<std::mutex> lock(mutex);
state_cv.wait_for(lock, std::chrono::seconds(retry_seconds), [this] { return stopping.load(); });
}
}
} // namespace Slic3r
+155
View File
@@ -0,0 +1,155 @@
#ifndef slic3r_OrcaMqttConnection_hpp_
#define slic3r_OrcaMqttConnection_hpp_
#include <boost/asio/ssl.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/beast/websocket.hpp>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <functional>
#include <map>
#include <memory>
#include <mutex>
#include <set>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include <cstddef>
#include <cstdint>
namespace Slic3r {
// Minimal MQTT 3.1.1 codec + WebSocket transport (ws:// and wss://), shared by the
// LAN (OrcaSonar) and cloud (fleet) printer connections. Both PUBLISH
// commands to device/<id>/request and SUBSCRIBE device/<id>/report; Config is the
// only per-transport difference.
class OrcaMqttConnection
{
public:
using TokenProvider = std::function<std::string()>;
using MessageHandler = std::function<void(const std::string&, const std::string&)>;
using StateHandler = std::function<void(bool connected, bool initial)>;
struct Endpoint { std::string host; std::string port; std::string target; };
struct Config {
std::string url;
bool use_tls = false;
std::string ca_file;
TokenProvider bearer_provider; // set => bearer on WS upgrade, CONNECT creds omitted
std::string username;
std::string password;
std::string client_id = "OrcaSlicer";
int keepalive_seconds = 60;
};
static bool parse_endpoint(const std::string& url, Endpoint& endpoint);
// Build an MQTT 3.1.1 CONNECT packet. Clean-session is always set; the
// username/password connect flags and payload fields are added only when
// username is non-empty (the cloud form authenticates via a bearer on the
// WebSocket upgrade and omits CONNECT credentials). Public for unit tests.
static std::vector<uint8_t> make_connect_packet(const std::string& client_id,
const std::string& username,
const std::string& password,
int keepalive_seconds);
// Topic-string helpers for the per-device request/report channels and the
// MQTT 3.1.1 PUBLISH / SUBSCRIBE / UNSUBSCRIBE packet builders. All public
// for unit tests. make_publish_packet emits QoS 0 (no packet identifier).
static std::string request_topic(const std::string& dev_id); // "device/<id>/request"
static std::string report_topic(const std::string& dev_id); // "device/<id>/report"
static std::vector<uint8_t> make_publish_packet(const std::string& topic, const std::string& payload);
static std::vector<uint8_t> make_subscribe_packet(uint16_t packet_id, const std::string& topic, uint8_t qos);
static std::vector<uint8_t> make_unsubscribe_packet(uint16_t packet_id, const std::string& topic);
~OrcaMqttConnection();
bool start(const Config& config, MessageHandler on_message, StateHandler on_state);
void stop();
// True while the worker thread is alive (connected OR retrying). Lets callers
// avoid restarting a healthy connection.
bool is_running() const;
// True once CONNACK has been received and the socket has not since dropped.
bool is_connected() const { return connected.load(); }
bool subscribe(const std::string& dev_id);
bool unsubscribe(const std::string& dev_id);
// Last MQTT CONNACK return code: 0 ok, 1..5 refusal, -1 none seen this attempt.
int last_connack_rc() const { return m_last_connack_rc.load(); }
void clear_subscriptions();
bool send_request(const std::string& dev_id, const std::string& payload);
private:
// The endpoint may be either a TLS (wss://) or a plaintext (ws://) WebSocket;
// Connection holds whichever one is engaged and the ws_* helpers below
// dispatch on it.
using TlsWebSocket = boost::beast::websocket::stream<
boost::asio::ssl::stream<boost::beast::tcp_stream>>;
using PlainWebSocket = boost::beast::websocket::stream<boost::beast::tcp_stream>;
struct Connection;
static void append_string(std::vector<uint8_t>& packet, const std::string& value);
static void prepend_remaining_length(std::vector<uint8_t>& packet, size_t length);
static std::vector<uint8_t> make_ping_packet();
// Transport dispatch: each forwards to conn.wss (TLS) or conn.ws (plaintext).
// These synchronous operations are called only by the MQTT worker during
// connection setup. Once the MQTT session is established, all socket I/O is
// asynchronous and owned by that worker's io_context.
void ws_write(Connection& conn, const std::vector<uint8_t>& packet);
std::size_t ws_read(Connection& conn, boost::beast::flat_buffer& buffer, boost::system::error_code& ec);
void ws_handshake(Connection& conn, const Config& config, const Endpoint& endpoint);
void close_connection(Connection& conn);
void enqueue_packet(const std::shared_ptr<Connection>& conn, std::vector<uint8_t> packet);
void start_async_write(const std::shared_ptr<Connection>& conn);
void start_async_read(const std::shared_ptr<Connection>& conn);
void schedule_keepalive(const std::shared_ptr<Connection>& conn);
void post_packet(const std::shared_ptr<Connection>& conn, std::vector<uint8_t> packet);
// Ask the MQTT worker to emit subscription changes on its own io_context.
void flush_subscription_change();
void connect_and_read();
void send_current_subscriptions(const std::shared_ptr<Connection>& conn);
void send_pending_subscriptions(const std::shared_ptr<Connection>& conn);
void handle_packet(const std::string& packet);
void notify_state(bool is_now_connected);
void run();
std::atomic_bool stopping{true};
std::atomic_int reconnect_delay_seconds{1};
// Serialises the whole of start() and stop() against each other, so the UI
// thread's stop() (disconnect / dtor) cannot race the connect thread's start()
// into a concurrent worker.join(). Recursive because start() calls stop().
std::recursive_mutex lifecycle_mutex;
std::thread worker;
std::mutex mutex;
std::mutex connection_mutex;
std::shared_ptr<Connection> active_connection;
std::condition_variable initial_cv;
std::condition_variable state_cv;
Config current_config;
MessageHandler on_message;
StateHandler on_state;
// Full report-topic strings ("device/<id>/report"), not bare device ids.
std::set<std::string> subscriptions;
std::set<std::string> pending_subscriptions;
std::set<std::string> pending_unsubscriptions;
// Requests for a subscribed device wait until the corresponding SUBACK is
// received. Otherwise an immediate pushall response can be published by
// the broker before this client is actually subscribed to the report topic.
std::set<std::string> acknowledged_subscriptions;
std::map<uint16_t, std::string> pending_subscribe_packets;
std::deque<std::pair<std::string, std::string>> pending_requests;
std::atomic<uint16_t> next_packet_id{1};
std::atomic<int> m_last_connack_rc{-1};
uint64_t m_attempt_number{0}; // worker-thread diagnostic sequence
bool initial_result{false};
bool initial_completed{false};
std::atomic_bool connected{false};
};
} // namespace Slic3r
#endif // slic3r_OrcaMqttConnection_hpp_
File diff suppressed because it is too large Load Diff
+157 -4
View File
@@ -3,19 +3,28 @@
#include "IPrinterAgent.hpp" #include "IPrinterAgent.hpp"
#include "ICloudServiceAgent.hpp" #include "ICloudServiceAgent.hpp"
#include "OrcaCloudServiceAgent.hpp"
#include "OrcaMqttConnection.hpp"
#include <atomic>
#include <cstdint>
#include <functional>
#include <string> #include <string>
#include <mutex> #include <mutex>
#include <memory> #include <memory>
#include <thread>
namespace Slic3r { namespace Slic3r {
class OrcaCloudServiceAgent;
/** /**
* OrcaPrinterAgent - Stub implementation for printer operations. * OrcaPrinterAgent - OrcaSonar MQTT printer agent.
* *
* All printer-related operations are currently stubs that return success. * LAN and cloud commands use the same OrcaSonar protocol payloads; only the
* Actual printer connectivity requires the BBL SDK or future Orca implementation. * MQTT connection selected by route_send() differs.
*/ */
class OrcaPrinterAgent : public IPrinterAgent { class OrcaPrinterAgent : public IPrinterAgent
{
public: public:
explicit OrcaPrinterAgent(std::string log_dir); explicit OrcaPrinterAgent(std::string log_dir);
~OrcaPrinterAgent() override; ~OrcaPrinterAgent() override;
@@ -25,9 +34,14 @@ public:
// ======================================================================== // ========================================================================
void set_cloud_agent(std::shared_ptr<ICloudServiceAgent> cloud) override; void set_cloud_agent(std::shared_ptr<ICloudServiceAgent> cloud) override;
CameraStreamMode get_camera_stream_mode() const override;
std::string get_camera_url() const override;
// Communication // Communication
int send_message(std::string dev_id, std::string json_str, int qos, int flag) override; int send_message(std::string dev_id, std::string json_str, int qos, int flag) override;
bool supports_command(const std::string& dev_id, const std::string& command) const override;
bool supports_feature(const std::string& dev_id, const std::string& feature) const override;
bool uses_filament_mapping() const override { return true; }
int connect_printer(const PrinterConnectionParams& params) override; int connect_printer(const PrinterConnectionParams& params) override;
int disconnect_printer() override; int disconnect_printer() override;
int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override; int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override;
@@ -42,6 +56,7 @@ public:
// Machine Selection // Machine Selection
std::string get_user_selected_machine() override; std::string get_user_selected_machine() override;
int set_user_selected_machine(std::string dev_id) override; int set_user_selected_machine(std::string dev_id) override;
int unbind(std::string dev_id) override;
/** /**
* Get agent information. * Get agent information.
@@ -53,6 +68,8 @@ public:
// Print Job Operations // Print Job Operations
int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override; int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
// Unimplemented on OrcaSonar: reports a non-success result so callers fall back to
// start_print() instead of treating the missing send as success.
int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override; int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
int start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override; int start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override; int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override;
@@ -68,10 +85,146 @@ public:
int set_on_local_message_fn(OnMessageFn fn) override; int set_on_local_message_fn(OnMessageFn fn) override;
int set_queue_on_main_fn(QueueOnMainFn fn) override; int set_queue_on_main_fn(QueueOnMainFn fn) override;
int command_ams_refresh_rfid(std::string dev_id, int ams_id, int tray_id, int sequence_id, bool lan_mode) override;
int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) override;
int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override;
int command_start_camera(std::string dev_id) override;
int command_xyz_abs(std::string dev_id, int sequence_id, bool lan_mode) override;
int command_auto_leveling(std::string dev_id, int sequence_id, bool lan_mode) override;
int command_go_home(std::string dev_id, bool is_printing, bool supports_mqtt_homing, int sequence_id, bool lan_mode) override;
int command_set_bed(std::string dev_id, int temp, bool supports_mqtt_bed_ctrl, int sequence_id, bool lan_mode) override;
int command_set_nozzle(std::string dev_id, int temp, int sequence_id, bool lan_mode) override;
int command_axis_control(std::string dev_id,
std::string axis,
double unit,
double input_val,
int speed,
bool is_core_xy,
bool supports_mqtt_axis_control,
int sequence_id,
bool lan_mode) override;
// Test-only: drive emit_connect_sequence directly (no socket).
void run_connect_sequence_for_test(const std::string& dev_id)
{
emit_connect_sequence(dev_id, [](const std::string&) {}, [](const std::string&) {});
}
// Test-only: advance the LAN connection epoch without a connect/disconnect cycle.
void bump_lan_generation_for_test() { ++m_lan_generation; }
// Test-only: the same for the (independent) cloud selection epoch.
void bump_cloud_generation_for_test() { ++m_cloud_generation; }
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::subscription; }
protected:
// Forward one inbound printer message to on_message_fn or on_local_message_fn (marshalled onto the UI
// thread via queue_on_main_fn when set). Body of every connection's MessageHandler.
void deliver_to_sink(const std::string& dev_id, const std::string& payload, bool local);
// Extract OrcaSonar's print.ipcam.stream_mode from LAN reports before they
// are forwarded to the GUI. The getters below then read this agent-owned state.
void parse_ipcam_info(const std::string& dev_id, const std::string& payload);
// Orca-dialect -> Bambu-dialect compatibility shim for inbound reports: the single
// place Orca Protocol JSON is rewritten into the shapes MachineObject::parse_json
// already handles, so parse_json needs no Orca-specific changes. Self-contained
// (its cache is a function-local static) and deletable together with its call site
// once parse_json reads the Orca dialect natively. See the definition for the
// per-rule detail. Returns the payload unchanged when no rule applies.
std::string merge_capabilities(const std::string& dev_id, const std::string& payload);
void forget_device_capabilities(const std::string& dev_id);
int prepare_outgoing_request(const std::string& dev_id, const std::string& payload,
std::string& command, std::string& prepared) const;
// Report the asynchronous LAN connection state using the same callback contract as
// the other printer agents. The transport result cannot be returned by
// connect_printer(), which only starts the worker.
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& message);
// The LAN inbound-message handler for one connection generation: forwards to
// deliver_to_sink only while `generation` is still the live epoch.
std::function<void(const std::string&, const std::string&)> make_lan_message_handler(uint64_t generation);
// Pure LAN-address parsing + client-id. protected static so the test Probe reaches them.
static bool parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port);
static std::string make_lan_client_id(const std::string& dev_id);
// Pure serializer for PrintParams::ams_mapping2 -> print.gcode_file.filament_mapping.
// Entries are re-keyed by their array position; {255,255} is dropped. Empty when the
// input is empty, malformed, or has no usable entries. protected static for the test Probe.
static nlohmann::json build_filament_mapping(const std::string& ams_mapping2);
// Pure builder for the print.gcode_file command payload. A non-empty mapping is
// included as filament_mapping; an empty one is omitted so the payload is
// byte-identical to an unmapped print. protected static for the test Probe.
static nlohmann::json build_gcode_file_payload(const std::string& sequence_id,
const std::string& target,
const nlohmann::json& filament_mapping);
// Test hook: the ws:// URL connect_printer built for the current LAN session ("" if none).
std::string lan_connection_target() const;
// Shared post-connect sequence: SUBSCRIBE, then pushing.start, pushall,
// info.get_version, info.get_capabilities. Runs identically on LAN and cloud.
void on_connected(const std::string& dev_id, OrcaMqttConnection* conn, uint64_t generation);
// The post-connect command sequence, factored behind a seam so a test can
// observe the SUBSCRIBE + 4 request payloads without a live OrcaMqttConnection.
virtual void emit_connect_sequence(const std::string& dev_id,
std::function<void(const std::string&)> subscribe,
std::function<void(const std::string&)> request);
static std::string seq(int n); // decimal string in the OrcaSlicer 20000..29999 band
static std::string build_pushing_start(const std::string& sequence_id);
static std::string build_pushing_stop(const std::string& sequence_id);
static std::string build_pushall(const std::string& sequence_id);
static std::string build_get_version(const std::string& sequence_id);
static std::string build_get_capabilities(const std::string& sequence_id);
private: private:
class OrcaSonarDiscovery;
std::string log_dir; std::string log_dir;
std::string selected_machine; std::string selected_machine;
enum CurrentConn { NONE, CLOUD, LAN };
static const char* connection_type_name(CurrentConn connection);
// The transport for the printer currently selected by the UI. LAN and
// cloud sessions have separate connection objects, so this is selection
// state rather than an inference from whichever socket happens to exist.
CurrentConn m_current_connection = NONE;
std::shared_ptr<ICloudServiceAgent> m_cloud_agent; std::shared_ptr<ICloudServiceAgent> m_cloud_agent;
std::unique_ptr<OrcaMqttConnection> lan_mqtt_connection;
// Two independent epochs: a cloud (de)selection must not fence the live LAN
// feed, and vice versa. Each transport's connect thread and inbound handler
// compare against their own counter only.
std::atomic<uint64_t> m_lan_generation{0};
std::atomic<uint64_t> m_cloud_generation{0};
// The short-lived threads that run the blocking initial connect for the current
// LAN / cloud session. Joined members (never detached) so they cannot outlive
// *this or the connection they hold a raw pointer to.
std::thread m_lan_connect_thread;
std::thread m_cloud_connect_thread;
std::unique_ptr<OrcaSonarDiscovery> m_discovery;
std::string m_lan_dev_id; // guarded by state_mutex
std::string m_lan_url; // guarded by state_mutex — the Config.url of the live LAN session
bool m_lan_use_ssl = false; // guarded by state_mutex
std::string m_lan_ca_file; // guarded by state_mutex
CameraStreamMode m_camera_stream_mode = CameraStreamMode::none; // guarded by state_mutex
std::string m_camera_url; // guarded by state_mutex
OrcaCloudServiceAgent* get_orca_cloud_agent();
OrcaMqttConnection* get_appropriate_mqtt_connection(bool is_lan = true);
static bool parse_nonnegative_command_id(const std::string& value, int& result);
// Route one command payload to device/<dev_id>/request on the LAN or the shared
// cloud connection. The uniform send path for both send_message* overrides.
int route_send(bool is_lan, const std::string& dev_id, const std::string& json_str);
// Callbacks // Callbacks
OnMsgArrivedFn on_ssdp_msg_fn; OnMsgArrivedFn on_ssdp_msg_fn;
+176 -52
View File
@@ -1,5 +1,6 @@
#include "QidiPrinterAgent.hpp" #include "QidiPrinterAgent.hpp"
#include "Http.hpp" #include "Http.hpp"
#include "IPrinterAgent.hpp"
#include "libslic3r/PresetBundle.hpp" #include "libslic3r/PresetBundle.hpp"
#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_App.hpp"
@@ -8,6 +9,7 @@
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <cctype> #include <cctype>
#include <sstream> #include <sstream>
#include <thread>
using json = nlohmann::json; using json = nlohmann::json;
@@ -27,6 +29,15 @@ bool has_visible_base_preset(const PresetCollection& filaments, const std::strin
return false; return false;
} }
// RAII decrement for MoonrakerPrinterAgent::filament_fetch_in_flight — guarantees the
// counter drops back down on every exit path (early return or fall-through) inside the
// detached fetch thread below, so ~MoonrakerPrinterAgent()'s wait loop can't stall forever.
struct InFlightGuard
{
std::atomic<int>& counter;
~InFlightGuard() { counter.fetch_sub(1, std::memory_order_relaxed); }
};
} // anonymous namespace } // anonymous namespace
const std::string QidiPrinterAgent_VERSION = "0.0.1"; const std::string QidiPrinterAgent_VERSION = "0.0.1";
@@ -40,51 +51,148 @@ AgentInfo QidiPrinterAgent::get_agent_info_static()
return AgentInfo{"qidi", "Qidi", QidiPrinterAgent_VERSION, "Qidi printer agent"}; return AgentInfo{"qidi", "Qidi", QidiPrinterAgent_VERSION, "Qidi printer agent"};
} }
bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/) FilamentSyncMode QidiPrinterAgent::get_filament_sync_mode() const
{ {
std::string error; if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
return FilamentSyncMode::subscription;
return FilamentSyncMode::pull;
}
// 1. Fetch device info and infer series_id bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
std::string series_id; {
{ if (sync_mode != get_filament_sync_mode())
MoonrakerDeviceInfo info;
if (fetch_device_info(device_info.base_url, device_info.api_key, info, error)) {
series_id = infer_series_id(info.model_id, info.dev_name);
}
}
if (series_id.empty()) {
// Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier.
series_id = infer_series_id(device_info.model_id, device_info.model_name);
}
// 2. Fetch filament dictionary
QidiFilamentDict dict;
if (!fetch_filament_dict(device_info.base_url, device_info.api_key, dict, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch filament dict: " << error;
}
// 3. Fetch slot info and build AmsTrayData directly
std::vector<AmsTrayData> trays;
int box_count = 0;
if (!fetch_slot_info(device_info.base_url, device_info.api_key, dict, series_id, trays, box_count, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
return false; return false;
}
// 4. Build the AMS payload // Snapshot only what the fetch needs, rather than reading device_info live from the
build_ams_payload(box_count, box_count * 4 - 1, trays); // background thread below — device_info can be concurrently rewritten by a reconnect
// on another thread while this fetch is still in flight.
ConnectionSettings connection = get_connection_settings();
std::string model_id = device_info.model_id;
std::string model_name = device_info.model_name;
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
std::thread([this, connection = std::move(connection), model_id, model_name]() mutable {
InFlightGuard guard{filament_fetch_in_flight};
std::string error;
// 1. Fetch device info and infer series_id
std::string series_id;
{
MoonrakerDeviceInfo info;
if (fetch_device_info(connection, info, error)) {
series_id = infer_series_id(info.model_id, info.dev_name);
}
}
if (series_id.empty()) {
// Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier.
series_id = infer_series_id(model_id, model_name);
}
// 2. Fetch filament dictionary
QidiFilamentDict dict;
if (!fetch_filament_dict(connection, dict, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch filament dict: " << error;
}
// 3. Fetch slot info and build AmsTrayData directly
std::vector<AmsTrayData> trays;
int box_count = 0;
if (!fetch_slot_info(connection, dict, series_id, trays, box_count, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
return;
}
// 4. Build the AMS payload
build_ams_payload(box_count, box_count * 4 - 1, trays);
}).detach();
return true; return true;
} }
bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url, bool QidiPrinterAgent::apply_box_mapping(const PrintParams& params) const
const std::string& api_key, {
// enable_box mirrors task_use_ams: engage the multi-color box only when this
// job actually routes filament through it. (See qidi-ams-findings.md §2/§8.3 —
// if firmware treats enable_box as "a box exists" rather than "use it this job",
// switch this gate to HasAms()/box_count instead.)
const int enable = params.task_use_ams ? 1 : 0;
if (!send_gcode(device_info.dev_id, "SAVE_VARIABLE VARIABLE=enable_box VALUE=" + std::to_string(enable))) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to set enable_box";
return false;
}
// When the box isn't used this job, leave the existing value_t<tool> slot
// assignments untouched (enable_box=0 is enough to disengage it).
if (!enable)
return true;
if (params.ams_mapping.empty()) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::apply_box_mapping: enable_box set but ams_mapping is empty";
return true;
}
// ams_mapping (v0) is a JSON array indexed by filament/tool; each value is the
// physical box slot (-1 = unmapped). Mirror it onto the printer's value_t<tool>
// variables: SAVE_VARIABLE VARIABLE=value_t<tool> VALUE='slot<n>'.
auto mapping = nlohmann::json::parse(params.ams_mapping, nullptr, /*allow_exceptions*/ false);
if (mapping.is_discarded() || !mapping.is_array()) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: invalid ams_mapping: " << params.ams_mapping;
return false;
}
for (size_t tool = 0; tool < mapping.size(); ++tool) {
if (!mapping[tool].is_number_integer())
continue;
const int slot = mapping[tool].get<int>();
if (slot < 0)
continue; // unmapped filament — skip
const std::string gcode = "SAVE_VARIABLE VARIABLE=value_t" + std::to_string(tool) +
" VALUE=\"'slot" + std::to_string(slot) + "'\"";
if (!send_gcode(device_info.dev_id, gcode)) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to set value_t" << tool;
return false;
}
}
return true;
}
int QidiPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_local_print(std::move(params), update_fn, cancel_fn);
}
int QidiPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_print(std::move(params), update_fn, cancel_fn, wait_fn);
}
int QidiPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_FAILED;
return MoonrakerPrinterAgent::start_local_print_with_record(std::move(params), update_fn, cancel_fn, wait_fn);
}
int QidiPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_sdcard_print(std::move(params), update_fn, cancel_fn);
}
bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
const QidiFilamentDict& dict, const QidiFilamentDict& dict,
const std::string& series_id, const std::string& series_id,
std::vector<AmsTrayData>& trays, std::vector<AmsTrayData>& trays,
int& box_count, int& box_count,
std::string& error) std::string& error)
{ {
std::string url = join_url(base_url, "/printer/objects/query?save_variables=variables"); std::string url = join_url(connection.base_url, "/printer/objects/query?save_variables=variables");
for (int i = 0; i < 16; ++i) { for (int i = 0; i < 16; ++i) {
url += "&box_stepper%20slot" + std::to_string(i) + "=runout_button"; url += "&box_stepper%20slot" + std::to_string(i) + "=runout_button";
} }
@@ -94,8 +202,9 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
std::string http_error; std::string http_error;
auto http = Http::get(url); auto http = Http::get(url);
if (!api_key.empty()) { configure_http(http, connection);
http.header("X-Api-Key", api_key); if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
} }
http.timeout_connect(5) http.timeout_connect(5)
.timeout_max(10) .timeout_max(10)
@@ -120,20 +229,10 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
return false; return false;
} }
auto json = nlohmann::json::parse(response_body, nullptr, false, true); nlohmann::json status;
if (json.is_discarded()) { nlohmann::json variables;
error = "Invalid JSON response"; if (!parse_slot_response(response_body, status, variables, error))
return false; return false;
}
if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("save_variables") ||
!json["result"]["status"]["save_variables"].contains("variables")) {
error = "Unexpected JSON structure";
return false;
}
auto& variables = json["result"]["status"]["save_variables"]["variables"];
auto& status = json["result"]["status"];
box_count = variables.value("box_count", 1); box_count = variables.value("box_count", 1);
if (box_count < 0) { if (box_count < 0) {
@@ -208,20 +307,45 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
return true; return true;
} }
bool QidiPrinterAgent::fetch_filament_dict(const std::string& base_url, bool QidiPrinterAgent::parse_slot_response(const std::string& response_body,
const std::string& api_key, nlohmann::json& status,
nlohmann::json& variables,
std::string& error)
{
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
error = "Invalid JSON response";
return false;
}
if (!json.is_object() || !json.contains("result") || !json["result"].is_object() || !json["result"].contains("status") ||
!json["result"]["status"].is_object() || !json["result"]["status"].contains("save_variables") ||
!json["result"]["status"]["save_variables"].is_object() || !json["result"]["status"]["save_variables"].contains("variables") ||
!json["result"]["status"]["save_variables"]["variables"].is_object()) {
// why: Qidi firmware may send null here, but json::value() throws for it.
error = "Unexpected JSON structure: save_variables.variables must be an object";
return false;
}
status = json["result"]["status"];
variables = status["save_variables"]["variables"];
return true;
}
bool QidiPrinterAgent::fetch_filament_dict(const ConnectionSettings& connection,
QidiFilamentDict& dict, QidiFilamentDict& dict,
std::string& error) const std::string& error) const
{ {
std::string url = join_url(base_url, "/server/files/config/officiall_filas_list.cfg"); std::string url = join_url(connection.base_url, "/server/files/config/officiall_filas_list.cfg");
std::string response_body; std::string response_body;
bool success = false; bool success = false;
std::string http_error; std::string http_error;
auto http = Http::get(url); auto http = Http::get(url);
if (!api_key.empty()) { configure_http(http, connection);
http.header("X-Api-Key", api_key); if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
} }
http.timeout_connect(5) http.timeout_connect(5)
.timeout_max(10) .timeout_max(10)
+20 -3
View File
@@ -1,7 +1,9 @@
#ifndef __QIDI_PRINTER_AGENT_HPP__ #ifndef __QIDI_PRINTER_AGENT_HPP__
#define __QIDI_PRINTER_AGENT_HPP__ #define __QIDI_PRINTER_AGENT_HPP__
#include "IPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp" #include "MoonrakerPrinterAgent.hpp"
#include "nlohmann/json_fwd.hpp"
#include <map> #include <map>
#include <string> #include <string>
@@ -21,7 +23,23 @@ public:
// Override filament sync (Qidi-specific implementation) // Override filament sync (Qidi-specific implementation)
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override; bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
static bool parse_slot_response(const std::string& response_body,
nlohmann::json& status,
nlohmann::json& variables,
std::string& error);
// Print operations — emit QiDi multi-color box config, then delegate to base.
int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override;
int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
int start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override;
FilamentSyncMode get_filament_sync_mode() const override;
private: private:
// Push enable_box + value_t<tool> SAVE_VARIABLEs before a print starts.
// Returns false if any command fails (caller should abort the print).
bool apply_box_mapping(const PrintParams& params) const;
struct QidiFilamentDict struct QidiFilamentDict
{ {
std::map<int, std::string> colors; std::map<int, std::string> colors;
@@ -29,14 +47,13 @@ private:
}; };
// Qidi-specific methods // Qidi-specific methods
bool fetch_slot_info(const std::string& base_url, bool fetch_slot_info(const ConnectionSettings& connection,
const std::string& api_key,
const QidiFilamentDict& dict, const QidiFilamentDict& dict,
const std::string& series_id, const std::string& series_id,
std::vector<AmsTrayData>& trays, std::vector<AmsTrayData>& trays,
int& box_count, int& box_count,
std::string& error); std::string& error);
bool fetch_filament_dict(const std::string& base_url, const std::string& api_key, QidiFilamentDict& dict, std::string& error) const; bool fetch_filament_dict(const ConnectionSettings& connection, QidiFilamentDict& dict, std::string& error) const;
std::string normalize_filament_type(const std::string& filament_type); std::string normalize_filament_type(const std::string& filament_type);
std::string infer_series_id(const std::string& model_id, const std::string& dev_name); std::string infer_series_id(const std::string& model_id, const std::string& dev_name);
std::string normalize_model_key(std::string value); std::string normalize_model_key(std::string value);
+164 -106
View File
@@ -1,10 +1,14 @@
#include "SnapmakerPrinterAgent.hpp" #include "SnapmakerPrinterAgent.hpp"
#include "Http.hpp" #include "Http.hpp"
#include "IPrinterAgent.hpp"
#include "libslic3r/PresetBundle.hpp" #include "libslic3r/PresetBundle.hpp"
#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_App.hpp"
#include "nlohmann/json.hpp" #include "nlohmann/json.hpp"
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <chrono>
#include <sstream>
#include <thread>
using json = nlohmann::json; using json = nlohmann::json;
@@ -13,6 +17,13 @@ namespace Slic3r {
namespace { namespace {
constexpr const char* SNAPMAKER_AGENT_VERSION = "0.0.1"; constexpr const char* SNAPMAKER_AGENT_VERSION = "0.0.1";
constexpr int64_t CAMERA_REFRESH_INTERVAL_MS = 300'000;
int64_t now_ms()
{
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
}
// Safely access a parallel array by index, returning a fallback if out of bounds. // Safely access a parallel array by index, returning a fallback if out of bounds.
template<typename T> template<typename T>
@@ -69,6 +80,31 @@ std::string find_closest_color_preset_by_vendor_and_type(const PresetCollection&
SnapmakerPrinterAgent::SnapmakerPrinterAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {} SnapmakerPrinterAgent::SnapmakerPrinterAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
void SnapmakerPrinterAgent::start_camera_monitor()
{
enqueue_command([this] {
send_ws_rpc("camera.start_monitor",
{{"domain", "lan"}, {"interval", 0}, {"expect_pw", false}});
});
m_camera_last_fire_ms.store(now_ms());
}
void SnapmakerPrinterAgent::on_status_loop_tick(const std::string& dev_id)
{
(void) dev_id;
const int64_t last = m_camera_last_fire_ms.load();
if (last == 0 || now_ms() - last >= CAMERA_REFRESH_INTERVAL_MS) {
start_camera_monitor();
}
}
int SnapmakerPrinterAgent::command_start_camera(std::string dev_id)
{
(void) dev_id;
start_camera_monitor();
return BAMBU_NETWORK_SUCCESS;
}
AgentInfo SnapmakerPrinterAgent::get_agent_info_static() AgentInfo SnapmakerPrinterAgent::get_agent_info_static()
{ {
return AgentInfo{"snapmaker", "Snapmaker", SNAPMAKER_AGENT_VERSION, "Snapmaker printer agent"}; return AgentInfo{"snapmaker", "Snapmaker", SNAPMAKER_AGENT_VERSION, "Snapmaker printer agent"};
@@ -104,127 +140,149 @@ std::string SnapmakerPrinterAgent::combine_filament_type(const std::string& type
return base; return base;
} }
bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/) bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
{ {
std::string url = join_url(device_info.base_url, "/printer/objects/query?print_task_config&filament_detect"); (void) dev_id;
if (sync_mode != get_filament_sync_mode())
std::string response_body;
bool success = false;
std::string http_error;
auto http = Http::get(url);
if (!device_info.api_key.empty()) {
http.header("X-Api-Key", device_info.api_key);
}
http.timeout_connect(5)
.timeout_max(10)
.on_complete([&](std::string body, unsigned status) {
if (status == 200) {
response_body = body;
success = true;
} else {
http_error = "HTTP error: " + std::to_string(status);
}
})
.on_error([&](std::string body, std::string err, unsigned status) {
http_error = err;
if (status > 0) {
http_error += " (HTTP " + std::to_string(status) + ")";
}
})
.perform_sync();
if (!success) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error;
return false; return false;
}
auto json = nlohmann::json::parse(response_body, nullptr, false, true); const std::string base_url = device_info.base_url;
if (json.is_discarded()) { const std::string api_key = device_info.api_key;
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Invalid JSON response";
return false;
}
// Navigate to result.status.print_task_config filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
if (!json.contains("result") || !json["result"].contains("status") ||
!json["result"]["status"].contains("print_task_config")) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Missing print_task_config in response";
return false;
}
auto& ptc = json["result"]["status"]["print_task_config"]; std::thread([this, base_url, api_key]() {
struct InFlightGuard
{
std::atomic<int>& counter;
~InFlightGuard() { counter.fetch_sub(1, std::memory_order_relaxed); }
} guard{filament_fetch_in_flight};
// Read parallel arrays from print_task_config const std::string url = join_url(base_url, "/printer/objects/query?print_task_config&filament_detect");
auto filament_exist = ptc.value("filament_exist", std::vector<bool>{});
auto filament_type = ptc.value("filament_type", std::vector<std::string>{});
auto filament_sub_type = ptc.value("filament_sub_type", std::vector<std::string>{});
auto filament_color = ptc.value("filament_color_rgba", std::vector<std::string>{});
auto filament_vendor = ptc.value("filament_vendor", std::vector<std::string>{});
const int slot_count = static_cast<int>(filament_exist.size()); std::string response_body;
if (slot_count == 0) { bool success = false;
BOOST_LOG_TRIVIAL(info) << "SnapmakerPrinterAgent::fetch_filament_info: No filament slots reported"; std::string http_error;
return false;
}
// Read NFC filament_detect data for temperature info (optional) auto http = Http::get(url);
nlohmann::json nfc_info; if (!api_key.empty()) {
if (json["result"]["status"].contains("filament_detect") && http.header("X-Api-Key", api_key);
json["result"]["status"]["filament_detect"].contains("info")) { }
nfc_info = json["result"]["status"]["filament_detect"]["info"]; http.timeout_connect(5)
} .timeout_max(10)
.on_complete([&](std::string body, unsigned status) {
static const std::string empty_str; if (status == 200) {
static const std::string default_color = "FFFFFFFF"; response_body = body;
success = true;
std::vector<AmsTrayData> trays;
trays.reserve(slot_count);
for (int i = 0; i < slot_count; ++i) {
AmsTrayData tray;
tray.slot_index = i;
tray.has_filament = filament_exist[i];
if (tray.has_filament) {
tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str),
safe_at(filament_sub_type, i, empty_str));
tray.tray_color = safe_at(filament_color, i, default_color);
auto* bundle = GUI::wxGetApp().preset_bundle;
// Try to find a matching preset for this filament based on vendor, type and color.
// If not found, default to traditional search by type only or generic type mapping.
if (bundle) {
std::string vendor = safe_at(filament_vendor, i, empty_str);
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type,
tray.tray_color);
if (!filament_id.empty()) {
tray.tray_info_idx = filament_id;
BOOST_LOG_TRIVIAL(warning) << "Filament sync: Found manufacturer-specific profile for slot " << i << ": "
<< filament_id;
} else { } else {
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type); http_error = "HTTP error: " + std::to_string(status);
} }
} else { })
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type); .on_error([&](std::string body, std::string err, unsigned status) {
} http_error = err;
if (status > 0) {
http_error += " (HTTP " + std::to_string(status) + ")";
}
})
.perform_sync();
// Extract NFC temperature data if available if (!success) {
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) { BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error;
auto& nfc_slot = nfc_info[i]; return;
std::string vendor = nfc_slot.value("VENDOR", "NONE");
if (vendor != "NONE" && !vendor.empty()) {
tray.bed_temp = nfc_slot.value("BED_TEMP", 0);
tray.nozzle_temp = nfc_slot.value("FIRST_LAYER_TEMP", 0);
}
}
} }
trays.emplace_back(std::move(tray)); auto json = nlohmann::json::parse(response_body, nullptr, false, true);
} if (json.is_discarded()) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Invalid JSON response";
return;
}
// Navigate to result.status.print_task_config
if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("print_task_config")) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Missing print_task_config in response";
return;
}
auto& ptc = json["result"]["status"]["print_task_config"];
// Read parallel arrays from print_task_config
auto filament_exist = ptc.value("filament_exist", std::vector<bool>{});
auto filament_type = ptc.value("filament_type", std::vector<std::string>{});
auto filament_sub_type = ptc.value("filament_sub_type", std::vector<std::string>{});
auto filament_color = ptc.value("filament_color_rgba", std::vector<std::string>{});
auto filament_vendor = ptc.value("filament_vendor", std::vector<std::string>{});
const int slot_count = static_cast<int>(filament_exist.size());
if (slot_count == 0) {
BOOST_LOG_TRIVIAL(info) << "SnapmakerPrinterAgent::fetch_filament_info: No filament slots reported";
return;
}
// Read NFC filament_detect data for temperature info (optional)
nlohmann::json nfc_info;
if (json["result"]["status"].contains("filament_detect") && json["result"]["status"]["filament_detect"].contains("info")) {
nfc_info = json["result"]["status"]["filament_detect"]["info"];
}
static const std::string empty_str;
static const std::string default_color = "FFFFFFFF";
std::vector<AmsTrayData> trays;
trays.reserve(slot_count);
for (int i = 0; i < slot_count; ++i) {
AmsTrayData tray;
tray.slot_index = i;
tray.has_filament = filament_exist[i];
if (tray.has_filament) {
tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str), safe_at(filament_sub_type, i, empty_str));
tray.tray_color = safe_at(filament_color, i, default_color);
auto* bundle = GUI::wxGetApp().preset_bundle;
// Try to find a matching preset for this filament based on vendor, type and color.
// If not found, default to traditional search by type only or generic type mapping.
if (bundle) {
std::string vendor = safe_at(filament_vendor, i, empty_str);
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type,
tray.tray_color);
if (!filament_id.empty()) {
tray.tray_info_idx = filament_id;
BOOST_LOG_TRIVIAL(warning)
<< "Filament sync: Found manufacturer-specific profile for slot " << i << ": " << filament_id;
} else {
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
}
} else {
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
}
// Extract NFC temperature data if available
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
auto& nfc_slot = nfc_info[i];
std::string vendor = nfc_slot.value("VENDOR", "NONE");
if (vendor != "NONE" && !vendor.empty()) {
tray.bed_temp = nfc_slot.value("BED_TEMP", 0);
tray.nozzle_temp = nfc_slot.value("FIRST_LAYER_TEMP", 0);
}
}
}
trays.emplace_back(std::move(tray));
}
build_ams_payload(1, slot_count - 1, trays);
}).detach();
build_ams_payload(1, slot_count - 1, trays);
return true; return true;
} }
FilamentSyncMode SnapmakerPrinterAgent::get_filament_sync_mode() const
{
if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
return FilamentSyncMode::subscription;
return FilamentSyncMode::pull;
}
} // namespace Slic3r } // namespace Slic3r
@@ -1,7 +1,10 @@
#pragma once #pragma once
#include "IPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp" #include "MoonrakerPrinterAgent.hpp"
#include <atomic>
#include <cstdint>
#include <string> #include <string>
namespace Slic3r { namespace Slic3r {
@@ -16,10 +19,19 @@ public:
AgentInfo get_agent_info() override { return get_agent_info_static(); } AgentInfo get_agent_info() override { return get_agent_info_static(); }
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override; bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
FilamentSyncMode get_filament_sync_mode() const override;
int command_start_camera(std::string dev_id) override;
CameraStreamMode get_camera_stream_mode() const override { return CameraStreamMode::http_snapshot; }
std::string get_camera_url() const override { return device_info.base_url + "/server/files/camera/monitor.jpg"; }
private: private:
// Combine filament_type + filament_sub_type into a unified type string // Combine filament_type + filament_sub_type into a unified type string
static std::string combine_filament_type(const std::string& type, const std::string& sub_type); static std::string combine_filament_type(const std::string& type, const std::string& sub_type);
void start_camera_monitor();
void on_status_loop_tick(const std::string& dev_id) override;
std::atomic<int64_t> m_camera_last_fire_ms{0};
}; };
} // namespace Slic3r } // namespace Slic3r
+37
View File
@@ -40,6 +40,20 @@ static std::set<int> tools_for_role(const std::string& gcode, const std::string&
return tools; return tools;
} }
// Every id named by a Tn command anywhere in `gcode`. A non-BBL single-extruder multi-filament
// setup emits T<filament_id>, and that id is what the Klipper toolchange macro consumes.
static std::set<int> tool_ids(const std::string& gcode)
{
std::set<int> tools;
GCodeReader reader;
reader.parse_buffer(gcode, [&tools](GCodeReader&, const GCodeReader::GCodeLine& line) {
const std::string cmd(line.cmd());
if (cmd.size() >= 2 && cmd[0] == 'T' && std::isdigit((unsigned char)cmd[1]))
tools.insert(std::stoi(cmd.substr(1)));
});
return tools;
}
// X where the nozzle sits while each tagged _WAIT_FOR_TEMP_ON_WIPE_TOWER M109 blocks: // X where the nozzle sits while each tagged _WAIT_FOR_TEMP_ON_WIPE_TOWER M109 blocks:
// the nearest preceding G1 carrying an X (the park travel emitted just before the wait). // the nearest preceding G1 carrying an X (the park travel emitted just before the wait).
static std::vector<double> wait_park_xs(const std::string& gcode) static std::vector<double> wait_park_xs(const std::string& gcode)
@@ -287,6 +301,29 @@ TEST_CASE("Each feature prints with its assigned filament (three filaments)", "[
CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 1 }); // filament 2 CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 1 }); // filament 2
} }
// The per-print wire mapping keys each entry by the filament's index in the config filament
// arrays (the ams_mapping2 position). Pin that the id the toolchange emits is that index even
// when the used filaments skip a middle one: filament 2 (index 1) is configured but unused, so
// a renumbering to the used set would emit {0, 1} instead of {0, 2} and aim the map at the
// wrong lane.
TEST_CASE("Toolchange ids keep the config index of the used filament when a middle filament is unused", "[MultiFilament]")
{
const std::string gcode = slice({ cube(20) },
multifilament_config(3, {
{ "sparse_infill_filament_id", 1 },
{ "internal_solid_filament_id", 1 },
{ "top_surface_filament_id", 1 },
{ "bottom_surface_filament_id", 1 },
{ "outer_wall_filament_id", 3 },
{ "inner_wall_filament_id", 3 },
{ "skirt_loops", 0 },
{ "brim_type", "no_brim" },
}));
CHECK(tools_for_role(gcode, "perimeter") == std::set<int>{ 2 }); // filament 3
CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 0 }); // filament 1
CHECK(tool_ids(gcode) == std::set<int>{ 0, 2 });
}
// The override must survive tool ordering: object 1's walls print on their filament's // The override must survive tool ordering: object 1's walls print on their filament's
// tool, object 0 stays on the first. If dropped, every wall prints on tool 0. // tool, object 0 stays on the first. If dropped, every wall prints on tool 0.
TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]") TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]")
+1
View File
@@ -21,6 +21,7 @@ add_executable(${_TEST_NAME}_tests
test_toolordering_nozzle_group.cpp test_toolordering_nozzle_group.cpp
test_parallel_resolve.cpp test_parallel_resolve.cpp
test_preset_bundle_loading.cpp test_preset_bundle_loading.cpp
test_preset_bundle_ams_sync.cpp
test_preset_setting_id.cpp test_preset_setting_id.cpp
test_preset_diff.cpp test_preset_diff.cpp
test_vendor_cache.cpp test_vendor_cache.cpp
@@ -0,0 +1,155 @@
#include <catch2/catch_all.hpp>
#include <map>
#include <string>
#include <utility>
#include <vector>
#include "libslic3r/Preset.hpp"
#include "libslic3r/PresetBundle.hpp"
using namespace Slic3r;
namespace {
// In-memory system filament preset. Mirrors the helper in test_preset_bundle_loading.cpp.
Preset &add_system_filament(PresetBundle &bundle, const std::string &name, const std::string &filament_id, const std::string &filament_type)
{
DynamicPrintConfig config(bundle.filaments.default_preset().config);
config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "";
config.option<ConfigOptionStrings>("filament_type", true)->values = {filament_type};
Preset &preset = bundle.filaments.load_preset(std::string(), name, config, /*select=*/false);
preset.is_system = true;
preset.filament_id = filament_id;
return preset;
}
// One AMS tray entry as build_filament_ams_list produces it.
DynamicPrintConfig make_tray(const std::string &filament_id, const std::string &filament_type, const std::string &color, const std::string &ams_id,
const std::string &slot_id)
{
DynamicPrintConfig tray;
tray.set_key_value("filament_id", new ConfigOptionStrings{filament_id});
tray.set_key_value("filament_type", new ConfigOptionStrings{filament_type});
tray.set_key_value("filament_colour", new ConfigOptionStrings{color});
tray.set_key_value("filament_colour_type", new ConfigOptionStrings{"1"});
tray.set_key_value("filament_multi_colour", new ConfigOptionStrings{});
tray.set_key_value("ams_id", new ConfigOptionStrings{ams_id});
tray.set_key_value("slot_id", new ConfigOptionStrings{slot_id});
return tray;
}
} // namespace
// A tray the printer UI wrote carries a material type but no OrcaSlicer preset id. It must still
// sync, resolved to Generic <type> with the tray's own color.
TEST_CASE("AMS sync resolves a printer-set tray to Generic by material type", "[Preset][AMS]")
{
PresetBundle bundle;
add_system_filament(bundle, "Generic PETG @Q2", "OFYPdQJh", "PETG");
add_system_filament(bundle, "Generic PLA @Q2", "GFL99", "PLA");
bundle.filament_ams_list[0] = make_tray("OFYPdQJh", "PETG", "#FE717A", "0", "0"); // preset-backed
bundle.filament_ams_list[1] = make_tray("", "PETG", "#898F9B", "0", "1"); // printer-set, no id
bundle.filament_ams_list[2] = make_tray("", "PLA", "#FAFAFA", "0", "2"); // printer-set, no id
bundle.filament_ams_list[3] = make_tray("", "", "#000000", "0", "3"); // empty slot
std::vector<std::pair<DynamicPrintConfig *, std::string>> unknowns;
std::map<int, AMSMapInfo> maps;
MergeFilamentInfo merge;
const unsigned int count = bundle.sync_ams_list(unknowns, /*use_map=*/false, maps, /*enable_append=*/false, merge);
CHECK(count == 3);
REQUIRE(bundle.filament_presets.size() == 3);
CHECK(bundle.filament_presets[0] == "Generic PETG @Q2");
CHECK(bundle.filament_presets[1] == "Generic PETG @Q2");
CHECK(bundle.filament_presets[2] == "Generic PLA @Q2");
const ConfigOptionStrings *colors = bundle.project_config.option<ConfigOptionStrings>("filament_colour");
REQUIRE(colors != nullptr);
REQUIRE(colors->values.size() == 3);
CHECK(colors->values[0] == "#FE717A");
CHECK(colors->values[1] == "#898F9B");
CHECK(colors->values[2] == "#FAFAFA");
}
// Without a preset id and without a material type there is nothing to resolve; the tray stays
// skipped in a direct sync.
TEST_CASE("AMS sync skips a tray without a preset id or a material type", "[Preset][AMS]")
{
PresetBundle bundle;
add_system_filament(bundle, "Generic PETG @Q2", "OFYPdQJh", "PETG");
bundle.filament_ams_list[0] = make_tray("", "", "#000000", "0", "0");
std::vector<std::pair<DynamicPrintConfig *, std::string>> unknowns;
std::map<int, AMSMapInfo> maps;
MergeFilamentInfo merge;
CHECK(bundle.sync_ams_list(unknowns, /*use_map=*/false, maps, /*enable_append=*/false, merge) == 0);
CHECK(bundle.filament_presets.empty());
}
// Guard the pre-existing behavior: a non-empty id that matches no preset still resolves by type.
TEST_CASE("AMS sync still resolves an unmatched preset id by material type", "[Preset][AMS]")
{
PresetBundle bundle;
add_system_filament(bundle, "Generic PETG @Q2", "OFYPdQJh", "PETG");
bundle.filament_ams_list[0] = make_tray("REMOVED_ID", "PETG", "#123456", "0", "0");
std::vector<std::pair<DynamicPrintConfig *, std::string>> unknowns;
std::map<int, AMSMapInfo> maps;
MergeFilamentInfo merge;
CHECK(bundle.sync_ams_list(unknowns, /*use_map=*/false, maps, /*enable_append=*/false, merge) == 1);
REQUIRE(bundle.filament_presets.size() == 1);
CHECK(bundle.filament_presets[0] == "Generic PETG @Q2");
}
// Mapping-mode sync used to force "Generic PLA" for an id-less tray; it must use the type instead.
TEST_CASE("AMS sync in mapping mode resolves a printer-set tray to Generic, not Generic PLA", "[Preset][AMS]")
{
PresetBundle bundle;
add_system_filament(bundle, "Generic PETG @Q2", "OFYPdQJh", "PETG");
add_system_filament(bundle, "Generic PLA @Q2", "GFL99", "PLA");
bundle.filament_presets = {"Generic PLA @Q2"};
bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values = {"#000000"};
bundle.project_config.option<ConfigOptionStrings>("filament_colour_type")->values = {"1"};
bundle.filament_ams_list[0] = make_tray("", "PETG", "#898F9B", "0", "1");
std::map<int, AMSMapInfo> maps;
maps[0] = AMSMapInfo{"0", "1"};
std::vector<std::pair<DynamicPrintConfig *, std::string>> unknowns;
MergeFilamentInfo merge;
const unsigned int count = bundle.sync_ams_list(unknowns, /*use_map=*/true, maps, /*enable_append=*/false, merge);
CHECK(count == 1);
REQUIRE(bundle.filament_presets.size() == 1);
CHECK(bundle.filament_presets[0] == "Generic PETG @Q2");
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values[0] == "#898F9B");
}
// The dialog builds ams_mapping2 one entry per filament_presets index, while the generated
// G-code's toolchange names the filament's index in the slice's filament config arrays. Those
// are the same index only if full_config() collects the filament arrays in filament_presets
// order; if a refactor reorders one side, every mapped print aims at the wrong lane.
TEST_CASE("Full config filament arrays follow the selected filament preset order", "[Preset][AMS]")
{
PresetBundle bundle;
add_system_filament(bundle, "Generic PETG @Q2", "OFYPdQJh", "PETG");
add_system_filament(bundle, "Generic PLA @Q2", "GFL99", "PLA");
// Deliberately not the load order: the arrays must follow this selection.
bundle.filament_presets = {"Generic PLA @Q2", "Generic PETG @Q2"};
const DynamicPrintConfig full = bundle.full_config(/*apply_extruder=*/false);
REQUIRE(full.option<ConfigOptionStrings>("filament_type") != nullptr);
CHECK(full.option<ConfigOptionStrings>("filament_settings_id")->values == std::vector<std::string>{"Generic PLA @Q2", "Generic PETG @Q2"});
CHECK(full.option<ConfigOptionStrings>("filament_type")->values == std::vector<std::string>{"PLA", "PETG"});
// The AMS sync matches trays by filament_id, so this array must follow the same order.
CHECK(full.option<ConfigOptionStrings>("filament_ids")->values == std::vector<std::string>{"GFL99", "OFYPdQJh"});
}
@@ -1573,6 +1573,23 @@ TEST_CASE("Filaments offered for a machine follow the app's compatibility rule",
} }
} }
// An agent-reported model id (SSDP modelNumber, manual binding) must resolve to the display
// name printer profiles use as printer_model; the AMS dialogs look filaments up by it.
TEST_CASE("Printer model ids resolve to vendor display names", "[Preset][Bundle]")
{
PresetBundle bundle;
VendorProfile qidi("Qidi");
qidi.name = "Qidi";
VendorProfile::PrinterModel model;
model.model_id = "Qidi-Q1Pro";
model.name = "Qidi Q1 Pro";
qidi.models.push_back(model);
bundle.vendors.emplace(qidi.id, qidi);
CHECK(bundle.get_printer_model_display_name("Qidi-Q1Pro") == "Qidi Q1 Pro");
CHECK(bundle.get_printer_model_display_name("unknown-model").empty());
}
namespace { namespace {
+4
View File
@@ -6,6 +6,7 @@ add_executable(${_TEST_NAME}_tests
test_dev_mapping.cpp test_dev_mapping.cpp
test_filament_bitmap_utils.cpp test_filament_bitmap_utils.cpp
test_device_progress.cpp test_device_progress.cpp
test_ams_item.cpp
test_device_manager_integration.cpp test_device_manager_integration.cpp
test_web_media_controller.cpp test_web_media_controller.cpp
test_scene_raycaster.cpp test_scene_raycaster.cpp
@@ -23,6 +24,9 @@ add_executable(${_TEST_NAME}_tests
test_plugin_capabilities_in_use.cpp test_plugin_capabilities_in_use.cpp
test_plugin_status.cpp test_plugin_status.cpp
test_printer_agent.cpp test_printer_agent.cpp
test_qidi_printer_agent.cpp
test_orca_mqtt_connection.cpp
test_orca_printer_agent.cpp
test_plugin_install.cpp test_plugin_install.cpp
test_plugin_lifecycle.cpp test_plugin_lifecycle.cpp
test_plugin_printer_agent.cpp test_plugin_printer_agent.cpp
+325
View File
@@ -0,0 +1,325 @@
#pragma once
// In-process plaintext MQTT-over-WebSocket broker for the OrcaMqtt tests.
//
// It speaks just enough of MQTT 3.1.1 to drive OrcaMqttConnection /
// OrcaPrinterAgent end to end without a real network: CONNECT/CONNACK,
// SUBSCRIBE/SUBACK, UNSUBSCRIBE/UNSUBACK, client PUBLISH (QoS 0), PINGREQ and
// DISCONNECT. The outbound PUBLISH frame is built with the production
// OrcaMqttConnection::make_publish_packet() so the tests never depend on a
// second, hand-rolled MQTT encoder.
#include <slic3r/Utils/OrcaMqttConnection.hpp>
#include <boost/asio.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/websocket.hpp>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <utility>
#include <vector>
namespace orca_mqtt_test {
namespace net = boost::asio;
namespace beast = boost::beast;
namespace ws = boost::beast::websocket;
using tcp = boost::asio::ip::tcp;
// Decode an MQTT remaining-length varint starting at packet[offset].
// Returns {value, bytes_consumed}; bytes_consumed == 0 means malformed.
inline std::pair<std::size_t, std::size_t> mqtt_decode_remaining_length(const std::string& packet, std::size_t offset)
{
std::size_t value = 0;
std::size_t multiplier = 1;
std::size_t used = 0;
while (offset + used < packet.size() && used < 4) {
const std::uint8_t byte = static_cast<std::uint8_t>(packet[offset + used]);
value += static_cast<std::size_t>(byte & 0x7f) * multiplier;
multiplier *= 128;
++used;
if ((byte & 0x80) == 0)
return {value, used};
}
return {0, 0};
}
inline bool mqtt_topic_is_request(const std::string& topic)
{
static const std::string suffix = "/request";
return topic.size() >= suffix.size() &&
topic.compare(topic.size() - suffix.size(), suffix.size(), suffix) == 0;
}
class MockBroker
{
public:
// refuse_auth: answer every CONNECT with CONNACK rc 5 (not authorized) and
// close, so the reconnect/refusal paths can be exercised.
explicit MockBroker(bool refuse_auth = false) : m_refuse_auth(refuse_auth), m_acceptor(m_io)
{
const tcp::endpoint endpoint(net::ip::make_address("127.0.0.1"), 0);
m_acceptor.open(endpoint.protocol());
m_acceptor.set_option(net::socket_base::reuse_address(true));
m_acceptor.bind(endpoint);
m_acceptor.listen(net::socket_base::max_listen_connections);
m_port = std::to_string(m_acceptor.local_endpoint().port());
// why: a non-blocking acceptor lets the accept loop poll a stop flag, so
// the destructor never has to interrupt a blocking accept().
m_acceptor.non_blocking(true);
m_thread = std::thread([this] { run(); });
}
~MockBroker()
{
m_stopping.store(true);
drop_client(); // unblocks the worker's blocking read
if (m_thread.joinable())
m_thread.join();
boost::system::error_code ec;
m_acceptor.close(ec); // after join: the acceptor is worker-owned
m_io.stop();
}
MockBroker(const MockBroker&) = delete;
MockBroker& operator=(const MockBroker&) = delete;
std::string ws_url() const { return "ws://127.0.0.1:" + m_port + "/mqtt"; }
std::pair<std::string, std::string> host_port() const { return {std::string("127.0.0.1"), m_port}; }
// Server -> client PUBLISH on device/<dev_id>/report.
void push_report(const std::string& dev_id, const std::string& payload)
{
const std::vector<std::uint8_t> packet =
Slic3r::OrcaMqttConnection::make_publish_packet("device/" + dev_id + "/report", payload);
std::lock_guard<std::mutex> lock(m_mutex);
if (!m_stream || !m_stream_ready)
return;
boost::system::error_code ec;
m_stream->binary(true);
m_stream->write(net::buffer(packet), ec); // a vanished client is not a test failure
}
// Force-close the live client socket; the worker's read returns an error and
// the accept loop picks up the client's reconnect.
void drop_client()
{
std::lock_guard<std::mutex> lock(m_mutex);
close_client_locked();
}
// Payloads the client PUBLISHed to any device/<id>/request topic.
std::vector<std::string> received_requests() const
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_received_requests;
}
// MQTT CONNECTs seen; increments again after a reconnect.
int connect_count() const { return m_connect_count.load(); }
// MQTT PINGREQs seen while the client has no other traffic.
int ping_count() const { return m_ping_count.load(); }
private:
void run()
{
try {
while (!m_stopping.load()) {
tcp::socket socket(m_io);
boost::system::error_code ec;
m_acceptor.accept(socket, ec);
if (ec == net::error::would_block || ec == net::error::try_again) {
std::this_thread::sleep_for(std::chrono::milliseconds(5));
continue;
}
if (ec)
return;
try {
serve(std::move(socket));
} catch (...) {
// a client dying mid-session must not take the broker down
}
std::lock_guard<std::mutex> lock(m_mutex);
close_client_locked();
m_stream.reset();
}
} catch (...) {
// never let an exception escape the broker thread
}
}
void serve(tcp::socket socket)
{
ws::stream<beast::tcp_stream>* stream = nullptr;
{
std::lock_guard<std::mutex> lock(m_mutex);
m_stream.emplace(std::move(socket));
m_stream_ready = false;
stream = &*m_stream;
}
// why: no io_context is ever run here, so a tcp_stream timer would never
// fire; the sync operations below carry no timeout of their own.
beast::get_lowest_layer(*stream).expires_never();
stream->set_option(ws::stream_base::decorator(
[](ws::response_type& res) { res.set("Sec-WebSocket-Protocol", "mqtt"); }));
boost::system::error_code ec;
stream->accept(ec);
if (ec)
return;
stream->binary(true);
{
std::lock_guard<std::mutex> lock(m_mutex);
m_stream_ready = true;
}
read_loop(*stream);
}
// The client sends every MQTT packet as one binary WebSocket message, so one
// read yields exactly one packet.
void read_loop(ws::stream<beast::tcp_stream>& stream)
{
beast::flat_buffer buffer;
while (!m_stopping.load()) {
boost::system::error_code ec;
buffer.clear();
stream.read(buffer, ec);
if (ec)
return;
const std::string packet = beast::buffers_to_string(buffer.data());
if (packet.empty())
continue;
if (!handle_packet(stream, packet))
return;
}
}
// Returns false when the session must be closed.
bool handle_packet(ws::stream<beast::tcp_stream>& stream, const std::string& packet)
{
switch (static_cast<std::uint8_t>(packet[0]) & 0xf0) {
case 0x10: { // CONNECT
++m_connect_count;
if (m_refuse_auth) {
write_packet(stream, {0x20, 0x02, 0x00, 0x05}); // CONNACK not authorized
return false;
}
write_packet(stream, {0x20, 0x02, 0x00, 0x00}); // CONNACK accepted
return true;
}
case 0x80: { // SUBSCRIBE (0x82) - packet id follows the remaining-length varint
const auto id = packet_id(packet);
if (id)
write_packet(stream, {0x90, 0x03, id->first, id->second, 0x00}); // SUBACK, QoS 0
return true;
}
case 0xa0: { // UNSUBSCRIBE (0xa2)
const auto id = packet_id(packet);
if (id)
write_packet(stream, {0xb0, 0x02, id->first, id->second}); // UNSUBACK
return true;
}
case 0x30: { // PUBLISH, QoS 0 (no packet identifier)
record_publish(packet);
return true;
}
case 0xc0: // PINGREQ
++m_ping_count;
write_packet(stream, {0xd0, 0x00});
return true;
case 0xe0: // DISCONNECT
return false;
default:
return true;
}
}
// The two packet-identifier bytes sitting right after the remaining-length varint.
static std::optional<std::pair<std::uint8_t, std::uint8_t>> packet_id(const std::string& packet)
{
const auto varint = mqtt_decode_remaining_length(packet, 1);
if (varint.second == 0)
return std::nullopt;
const std::size_t pos = 1 + varint.second;
if (pos + 2 > packet.size())
return std::nullopt;
return std::make_pair(static_cast<std::uint8_t>(packet[pos]), static_cast<std::uint8_t>(packet[pos + 1]));
}
void record_publish(const std::string& packet)
{
const auto varint = mqtt_decode_remaining_length(packet, 1);
if (varint.second == 0)
return;
std::size_t pos = 1 + varint.second;
if (pos + 2 > packet.size())
return;
const std::size_t topic_len = (static_cast<std::size_t>(static_cast<std::uint8_t>(packet[pos])) << 8) |
static_cast<std::uint8_t>(packet[pos + 1]);
pos += 2;
if (pos + topic_len > packet.size())
return;
const std::string topic = packet.substr(pos, topic_len);
pos += topic_len;
const std::size_t end = std::min(packet.size(), 1 + varint.second + varint.first);
if (end < pos)
return;
if (!mqtt_topic_is_request(topic))
return;
std::lock_guard<std::mutex> lock(m_mutex);
m_received_requests.push_back(packet.substr(pos, end - pos));
}
// Every write - the worker's own replies and push_report() from the test
// thread - is serialised by m_mutex. The production client keeps all of its
// WebSocket operations on its MQTT worker instead.
void write_packet(ws::stream<beast::tcp_stream>& stream, const std::vector<std::uint8_t>& packet)
{
std::lock_guard<std::mutex> lock(m_mutex);
boost::system::error_code ec;
stream.binary(true);
stream.write(net::buffer(packet), ec);
}
void close_client_locked()
{
if (!m_stream)
return;
m_stream_ready = false;
boost::system::error_code ec;
auto& socket = beast::get_lowest_layer(*m_stream).socket();
if (socket.cancel(ec))
return;
// shutdown() before close() is what actually wakes a blocking read on the
// worker thread; close() alone does not on POSIX.
if (socket.shutdown(tcp::socket::shutdown_both, ec))
return;
if (socket.close(ec))
return;
}
const bool m_refuse_auth;
net::io_context m_io;
tcp::acceptor m_acceptor;
std::string m_port;
std::thread m_thread;
std::atomic_bool m_stopping{false};
std::atomic<int> m_connect_count{0};
std::atomic<int> m_ping_count{0};
mutable std::mutex m_mutex;
std::optional<ws::stream<beast::tcp_stream>> m_stream; // guarded by m_mutex
bool m_stream_ready = false; // guarded by m_mutex
std::vector<std::string> m_received_requests; // guarded by m_mutex
};
} // namespace orca_mqtt_test
+86
View File
@@ -0,0 +1,86 @@
// why: match the GUI include order to avoid rpcndr.h byte/std::byte
// ambiguity in the Windows COM headers.
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_all.hpp>
#include <wx/timer.h>
#include <nlohmann/json.hpp>
#include "slic3r/GUI/Widgets/AMSItem.hpp"
#include "slic3r/GUI/DeviceCore/DevFilaSystem.h"
using json = nlohmann::json;
using namespace Slic3r;
using namespace Slic3r::GUI;
// A configured slot the wire reports as having no filament (all material fields
// present and empty) is "Empty", not the unknown "?" the UI draws for a tray
// whose info is missing. The distinction must survive into the AMS panel model.
TEST_CASE("Configured empty AMS trays remain distinct from unknown trays", "[AMSItem]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "orca";
const json print_json = json::parse(R"({
"ams": {
"ams_exist_bits": "1",
"tray_exist_bits": "3",
"ams": [ { "id": "0", "info": "0001", "tray": [
{ "id": "0", "tag_uid": "0000000000000000", "tray_info_idx": "", "tray_type": "", "tray_color": "00000000" },
{ "id": "1" }
] } ]
}
})");
DevFilaSystemParser::ParseV1_0(print_json, &machine, machine.GetFilaSystem().get(), false);
const auto& ams_list = machine.GetFilaSystem()->GetAmsList();
const auto ams_it = ams_list.find("0");
REQUIRE(ams_it != ams_list.end());
auto* ams = ams_it->second;
REQUIRE(ams != nullptr);
REQUIRE(ams->GetTray("0") != nullptr);
CHECK(ams->GetTray("0")->is_empty);
AMSinfo info;
REQUIRE(info.parse_ams_info(&machine, ams));
REQUIRE(info.cans.size() == 2);
CHECK(info.cans[0].is_empty);
CHECK(info.cans[0].material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND);
CHECK_FALSE(info.cans[1].is_empty);
CHECK(info.cans[1].material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND);
}
TEST_CASE("Empty external slots remain distinct from unknown slots", "[AMSItem]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "orca";
DevAmsTray empty_slot = machine.parse_vt_tray(json::parse(R"({
"id": "255", "tag_uid": "0000000000000000", "tray_info_idx": "", "tray_type": "", "tray_color": "00000000"
})"));
CHECK(empty_slot.is_empty);
AMSinfo empty_info;
empty_info.parse_ext_info(&machine, empty_slot);
CHECK(empty_info.cans[0].is_empty);
CHECK(empty_info.cans[0].material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL);
DevAmsTray unknown_slot = machine.parse_vt_tray(json::parse(R"({"id": "254"})"));
CHECK_FALSE(unknown_slot.is_empty);
AMSinfo unknown_info;
unknown_info.parse_ext_info(&machine, unknown_slot);
CHECK_FALSE(unknown_info.cans[0].is_empty);
CHECK(unknown_info.cans[0].material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL);
}
@@ -2,7 +2,11 @@
#include <slic3r/GUI/DeviceCore/DevManager.h> #include <slic3r/GUI/DeviceCore/DevManager.h>
#include <slic3r/GUI/DeviceManager.hpp> #include <slic3r/GUI/DeviceManager.hpp>
#include <slic3r/GUI/DeviceCore/DevFilaSystem.h>
#include <slic3r/GUI/FilamentMappingUtils.hpp>
#include <libslic3r/AppConfig.hpp> #include <libslic3r/AppConfig.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <libslic3r/PrintConfig.hpp>
#include <slic3r/Utils/NetworkAgent.hpp> #include <slic3r/Utils/NetworkAgent.hpp>
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp> #include <slic3r/Utils/OrcaCloudServiceAgent.hpp>
#include <slic3r/Utils/OrcaPrinterAgent.hpp> #include <slic3r/Utils/OrcaPrinterAgent.hpp>
@@ -15,6 +19,8 @@
#include <utility> #include <utility>
using namespace Slic3r; using namespace Slic3r;
using Slic3r::GUI::MappingSendError;
using Slic3r::GUI::prepare_filament_mapping_for_send;
using json = nlohmann::json; using json = nlohmann::json;
namespace { namespace {
@@ -46,6 +52,30 @@ public:
AgentInfo get_agent_info() override { return m_info; } AgentInfo get_agent_info() override { return m_info; }
// This test double represents a legacy agent, not OrcaPrinterAgent behavior.
bool supports_command(const std::string&, const std::string&) const override { return true; }
// It models whichever agent is registered under its id: only the Orca agent
// serializes per-print filament_mapping.
bool uses_filament_mapping() const override { return m_info.id == "orca"; }
using OrcaPrinterAgent::deliver_to_sink;
int send_message(std::string, std::string json_str, int, int) override
{
last_message = std::move(json_str);
return send_result;
}
int send_message_to_printer(std::string, std::string json_str, int, int) override
{
last_message = std::move(json_str);
return send_result;
}
std::string last_message;
int send_result = 0;
private: private:
AgentInfo m_info; AgentInfo m_info;
}; };
@@ -157,3 +187,183 @@ TEST_CASE("Device manager filters and rehomes devices by printer-agent ownership
CHECK(manager.get_my_machine_list("integration-agent-a").empty()); CHECK(manager.get_my_machine_list("integration-agent-a").empty());
CHECK(manager.get_my_machine_list("integration-agent-b").count(machine.dev_id) == 1); CHECK(manager.get_my_machine_list("integration-agent-b").count(machine.dev_id) == 1);
} }
TEST_CASE("Network agent rejects capability queries for a different device owner", "[DeviceManager][integration]")
{
NetworkAgent network(nullptr, std::make_shared<TestPrinterAgent>("bbl"));
CHECK(network.owns_agent("bbl"));
CHECK_FALSE(network.owns_agent("orca"));
CHECK(network.supports_command("bbl", "device", "print.ams_control"));
CHECK_FALSE(network.supports_command("orca", "device", "print.ams_control"));
CHECK_FALSE(network.supports_feature("orca", "device", "filament_mapping"));
// The mapping dialect is queried the same way: a legacy owner never inherits
// Orca's, and a non-owner is never queried at all.
CHECK_FALSE(network.uses_filament_mapping("bbl"));
CHECK_FALSE(network.uses_filament_mapping("orca"));
NetworkAgent orca_network(nullptr, std::make_shared<TestPrinterAgent>("orca"));
CHECK(orca_network.uses_filament_mapping("orca"));
CHECK_FALSE(orca_network.uses_filament_mapping("bbl"));
}
// Send-time mapping policy belongs to the printer agent's dialect, not to an
// agent id: the Orca dialect gets the no-AMS normalization plus both refusals.
TEST_CASE("Per-print mapping send policy follows the owning agent's dialect", "[DeviceManager][integration]")
{
auto orca_agent = std::make_shared<TestPrinterAgent>("orca");
NetworkAgent network(nullptr, orca_agent);
MachineObject obj(nullptr, &network, "test", "orca-mapping-policy", "127.0.0.1");
obj.printer_agent_id = "orca";
// No AMS: the auto-selected external spool is dropped before the capability
// gate, so it cannot refuse a print nobody mapped.
std::string external_only = R"([{"ams_id":255,"slot_id":0}])";
CHECK(prepare_filament_mapping_for_send(&obj, external_only, {}) == MappingSendError::none);
CHECK(external_only.empty());
// An AMS makes that a real target, but nothing advertised the capability yet.
obj.GetFilaSystem()->GetAmsList()["0"] = new DevAms("0", 0, DevAms::AMS);
std::string mapped = R"([{"ams_id":0,"slot_id":0}])";
CHECK(prepare_filament_mapping_for_send(&obj, mapped, {}) == MappingSendError::unsupported);
CHECK(mapped == R"([{"ams_id":0,"slot_id":0}])"); // the refusal leaves it intact
// Once the connector advertises the capability the mapping passes the gate.
orca_agent->deliver_to_sink(obj.get_dev_id(),
R"({"info":{"command":"get_capabilities","supported_features":{"filament_mapping":true}}})",
false);
CHECK(obj.printer_supports_feature("filament_mapping"));
CHECK(prepare_filament_mapping_for_send(&obj, mapped, {}) == MappingSendError::none);
// An unrecorded owner still sends through the active agent, so it takes
// that agent's dialect too rather than falling back to the legacy payload.
MachineObject unowned(nullptr, &network, "test", "orca-unowned", "127.0.0.1");
CHECK(unowned.printer_agent_id.empty());
CHECK(unowned.printer_uses_filament_mapping());
std::string unowned_external = R"([{"ams_id":255,"slot_id":0}])";
CHECK(prepare_filament_mapping_for_send(&unowned, unowned_external, {}) == MappingSendError::none);
CHECK(unowned_external.empty()); // the Orca normalization ran
// A partially mapped print is refused even while the capability holds.
FilamentInfo mapped_entry;
mapped_entry.ams_id = "0";
mapped_entry.slot_id = "0";
FilamentInfo unmapped_entry;
CHECK(prepare_filament_mapping_for_send(&obj, mapped, {mapped_entry, unmapped_entry}) ==
MappingSendError::incomplete);
}
// The legacy payload is the thing being protected: no normalization, no refusal.
TEST_CASE("A legacy printer agent keeps its mapping payload untouched", "[DeviceManager][integration]")
{
NetworkAgent network(nullptr, std::make_shared<TestPrinterAgent>("bbl"));
MachineObject obj(nullptr, &network, "test", "bbl-mapping-policy", "127.0.0.1");
obj.printer_agent_id = "bbl";
FilamentInfo mapped_entry;
mapped_entry.ams_id = "0";
mapped_entry.slot_id = "0";
FilamentInfo unmapped_entry;
std::string external_only = R"([{"ams_id":255,"slot_id":0}])";
CHECK(prepare_filament_mapping_for_send(&obj, external_only, {mapped_entry, unmapped_entry}) ==
MappingSendError::none);
CHECK(external_only == R"([{"ams_id":255,"slot_id":0}])");
}
TEST_CASE("AMS metadata retains setting_id for legacy printer agents", "[DeviceManager][integration]")
{
ScopedAppConfig app_config;
auto printer_agent = std::make_shared<TestPrinterAgent>("bbl");
NetworkAgent network(nullptr, printer_agent);
DeviceManager manager(&network, false, &app_config.config);
BBLocalMachine machine;
machine.dev_id = "bbl-setting-id";
machine.dev_name = "Bambu setting id";
machine.dev_ip = "192.0.2.32";
machine.printer_type = "C11";
MachineObject* obj = manager.insert_local_device(machine, "lan", "free", "", "access-code");
REQUIRE(obj != nullptr);
REQUIRE(obj->command_ams_filament_settings(0, 1, "GFL99", "preset-setting", "00FF00FF", "PLA", 190, 220) == 0);
const json payload = json::parse(printer_agent->last_message);
CHECK(payload["print"]["setting_id"] == "preset-setting");
}
TEST_CASE("AMS user settings do not update local state when publishing fails", "[DeviceManager][integration]")
{
ScopedAppConfig app_config;
auto printer_agent = std::make_shared<TestPrinterAgent>("bbl");
NetworkAgent network(nullptr, printer_agent);
DeviceManager manager(&network, false, &app_config.config);
BBLocalMachine machine;
machine.dev_id = "bbl-ams-setting-failure";
machine.dev_name = "Bambu AMS settings";
machine.dev_ip = "192.0.2.33";
machine.printer_type = "C11";
MachineObject* obj = manager.insert_local_device(machine, "lan", "free", "", "access-code");
REQUIRE(obj != nullptr);
auto& settings = obj->GetFilaSystem()->GetAmsSystemSetting();
settings.SetDetectOnInsertEnabled(false);
settings.SetDetectOnPowerupEnabled(false);
settings.SetDetectRemainEnabled(false);
printer_agent->send_result = -1;
CHECK(obj->command_ams_user_settings(true, true, true) != 0);
CHECK(settings.IsDetectOnInsertEnabled() == false);
CHECK(settings.IsDetectOnPowerupEnabled() == false);
CHECK(settings.IsDetectRemainEnabled() == false);
}
// The AMS dialogs resolve their filament list from the connected device's model. OrcaSonar's
// model id is optional (the agent falls back to "orcasonar"), so the resolver must stand in
// with the selected printer profile instead of yielding no model at all.
TEST_CASE("Filament printer model resolution falls back to the selected profile", "[DeviceManager][integration]")
{
PresetBundle bundle;
VendorProfile qidi("Qidi");
qidi.name = "Qidi";
VendorProfile::PrinterModel model;
model.model_id = "Qidi-Q1Pro";
model.name = "Qidi Q1 Pro";
qidi.models.push_back(model);
bundle.vendors.emplace(qidi.id, qidi);
// A vendor model id the device reported resolves through the vendor catalog.
CHECK(resolve_filament_printer_model("Qidi-Q1Pro", &bundle) == "Qidi Q1 Pro");
// The OrcaSonar fallback id has no vendor model; the selected profile stands in.
CHECK(resolve_filament_printer_model("orcasonar", &bundle).empty());
bundle.printers.get_selected_preset().config.set_key_value("printer_model", new ConfigOptionString("Generic Klipper Printer"));
CHECK(resolve_filament_printer_model("orcasonar", &bundle) == "Generic Klipper Printer");
CHECK(resolve_filament_printer_model("", &bundle) == "Generic Klipper Printer");
CHECK(resolve_filament_printer_model("orcasonar", nullptr).empty());
}
// The per-tray K/N records are Bambu firmware's flow-dynamics calibration. Agents with no
// printer-side records must not offer the AMS K/N controls (they would show a synthesized
// default and then refuse to confirm it).
TEST_CASE("Flow-dynamics K/N is offered for Bambu agents only", "[DeviceManager][integration]")
{
MachineObject bbl(nullptr, nullptr, "test", "bbl-device", "127.0.0.1");
bbl.printer_agent_id = "bbl";
CHECK(bbl.supports_extrusion_cali());
MachineObject orca(nullptr, nullptr, "test", "orca-device", "127.0.0.1");
orca.printer_agent_id = "orca";
CHECK_FALSE(orca.supports_extrusion_cali());
MachineObject moonraker(nullptr, nullptr, "test", "moonraker-device", "127.0.0.1");
moonraker.printer_agent_id = "moonraker";
CHECK_FALSE(moonraker.supports_extrusion_cali());
// No agent id predates the agent split and keeps the Bambu path.
MachineObject legacy(nullptr, nullptr, "test", "legacy-device", "127.0.0.1");
CHECK(legacy.supports_extrusion_cali());
}
@@ -0,0 +1,243 @@
#include <catch2/catch_test_macros.hpp>
#include <slic3r/Utils/OrcaMqttConnection.hpp>
#include "orca_mqtt_mock_broker.hpp"
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
using Slic3r::OrcaMqttConnection;
// Offset of the CONNECT variable header: 1 (fixed header) + N remaining-length varint bytes.
static size_t mqtt_varheader_offset(const std::vector<uint8_t>& p) {
size_t i = 1;
while (i < p.size() && (p[i] & 0x80)) ++i; // skip varint continuation bytes
return i + 1; // + the final varint byte
}
TEST_CASE("OrcaMqtt parse_endpoint handles ws and wss", "[OrcaMqtt]") {
OrcaMqttConnection::Endpoint ep;
REQUIRE(OrcaMqttConnection::parse_endpoint("ws://printer.local:8280/mqtt", ep));
CHECK(ep.host == "printer.local");
CHECK(ep.port == "8280");
CHECK(ep.target == "/mqtt");
REQUIRE(OrcaMqttConnection::parse_endpoint("ws://10.0.0.5/mqtt", ep));
CHECK(ep.port == "80");
REQUIRE(OrcaMqttConnection::parse_endpoint("wss://api.example.com/api/v1/printers/abc/mqtt", ep));
CHECK(ep.host == "api.example.com");
CHECK(ep.port == "443");
CHECK(ep.target == "/api/v1/printers/abc/mqtt");
CHECK_FALSE(OrcaMqttConnection::parse_endpoint("http://x/y", ep));
}
TEST_CASE("OrcaMqtt CONNECT packet - no auth (cloud form)", "[OrcaMqtt]") {
auto p = OrcaMqttConnection::make_connect_packet("OrcaSlicer", "", "", 300);
REQUIRE(p.size() >= 12);
CHECK(p[0] == 0x10); // CONNECT fixed header
const size_t v = mqtt_varheader_offset(p);
CHECK(p[v + 0] == 0x00); CHECK(p[v + 1] == 0x04); // protocol name length
CHECK(p[v + 2] == 'M'); CHECK(p[v + 3] == 'Q');
CHECK(p[v + 4] == 'T'); CHECK(p[v + 5] == 'T');
CHECK(p[v + 6] == 0x04); // protocol level 3.1.1
CHECK(p[v + 7] == 0x02); // connect flags: clean session only
CHECK(((p[v + 8] << 8) | p[v + 9]) == 300); // keepalive
}
TEST_CASE("OrcaMqtt CONNECT packet - username/password (LAN form)", "[OrcaMqtt]") {
auto p = OrcaMqttConnection::make_connect_packet("orcaslicer-lan-x", "orcasonar", "code123", 60);
CHECK(p[0] == 0x10);
const size_t v = mqtt_varheader_offset(p);
CHECK(p[v + 7] == (0x02 | 0x80 | 0x40)); // clean session + username + password flags
const std::string blob(p.begin(), p.end());
CHECK(blob.find("orcaslicer-lan-x") != std::string::npos);
CHECK(blob.find("orcasonar") != std::string::npos);
CHECK(blob.find("code123") != std::string::npos);
}
// Auth precedence (spec O3): when a bearer_provider is configured, connect_and_read
// passes empty CONNECT credentials, so the packet must carry clean-session only and
// no username/password flags or payload fields. (The precedence branch itself lives
// in connect_and_read; the [.integration] cloud-style round trip exercises it live.)
TEST_CASE("OrcaMqtt CONNECT omits creds when a bearer is configured", "[OrcaMqtt]") {
auto p = OrcaMqttConnection::make_connect_packet("cid", "", "", 60);
const size_t v = mqtt_varheader_offset(p);
CHECK(p[v + 7] == 0x02); // clean session only: no 0x80 / 0x40
const std::string blob(p.begin(), p.end());
CHECK(blob.find("orcasonar") == std::string::npos);
}
TEST_CASE("OrcaMqtt topic helpers", "[OrcaMqtt]") {
CHECK(OrcaMqttConnection::request_topic("abc") == "device/abc/request");
CHECK(OrcaMqttConnection::report_topic("abc") == "device/abc/report");
}
TEST_CASE("OrcaMqtt PUBLISH packet QoS0", "[OrcaMqtt]") {
auto p = OrcaMqttConnection::make_publish_packet("device/abc/request", "{\"ok\":1}");
CHECK((p[0] & 0xf0) == 0x30); // PUBLISH
CHECK((p[0] & 0x06) == 0x00); // QoS 0
const std::string blob(p.begin(), p.end());
CHECK(blob.find("device/abc/request") != std::string::npos);
CHECK(blob.find("{\"ok\":1}") != std::string::npos);
}
TEST_CASE("OrcaMqtt SUBSCRIBE packet", "[OrcaMqtt]") {
auto p = OrcaMqttConnection::make_subscribe_packet(7, "device/abc/report", 1);
CHECK(p[0] == 0x82); // SUBSCRIBE + reserved bit
const size_t v = mqtt_varheader_offset(p);
CHECK(((p[v] << 8) | p[v + 1]) == 7); // packet id
CHECK(p.back() == 1); // requested QoS
}
TEST_CASE("OrcaMqtt send_request refuses when not connected", "[OrcaMqtt]") {
OrcaMqttConnection conn;
CHECK_FALSE(conn.send_request("abc", "{\"pushing\":{\"command\":\"pushall\",\"sequence_id\":\"20001\"}}"));
}
TEST_CASE("OrcaMqtt start takes a Config", "[OrcaMqtt]") {
OrcaMqttConnection conn;
OrcaMqttConnection::Config cfg;
cfg.url = "ws://127.0.0.1:1/mqtt"; // nothing listening
cfg.keepalive_seconds = 42;
// start() returns false (no server) but must compile with the Config overload
const bool ok = conn.start(cfg, [](auto, auto){}, [](bool, bool){});
CHECK_FALSE(ok);
CHECK(conn.last_connack_rc() == -1);
conn.stop();
}
TEST_CASE("MockBroker starts and reports a url", "[OrcaMqtt][.integration]") {
orca_mqtt_test::MockBroker b;
CHECK(b.ws_url().rfind("ws://127.0.0.1:", 0) == 0);
CHECK(b.connect_count() == 0);
}
// --- End-to-end integration: OrcaMqttConnection against the in-process MockBroker.
// All hidden behind [.integration] (run explicitly). These prove a LAN-style config
// (CONNECT username/password) and a cloud-style config (bearer on the WS upgrade,
// no CONNECT creds) drive the *same* OrcaMqttConnection code path with identical
// assertions.
static void run_round_trip(bool use_tls_flag_only) {
orca_mqtt_test::MockBroker broker;
OrcaMqttConnection conn;
OrcaMqttConnection::Config cfg;
cfg.url = broker.ws_url(); // plaintext regardless
cfg.use_tls = false; // the mock is plaintext; the flag path is unit-tested elsewhere
if (use_tls_flag_only) cfg.bearer_provider = []{ return std::string("tok"); };
else { cfg.username = "orcasonar"; cfg.password = "code"; }
// A mutex + condition_variable rather than a promise: the handler runs on the MQTT
// worker thread and a second inbound message would throw std::future_error there.
std::mutex got_mutex;
std::condition_variable got_cv;
bool got_any = false;
std::string got_id, got_payload;
REQUIRE(conn.start(cfg,
[&](const std::string& id, const std::string& payload){
{
std::lock_guard<std::mutex> l(got_mutex);
if (got_any) return; // keep the first message only
got_any = true; got_id = id; got_payload = payload;
}
got_cv.notify_all();
},
[](bool,bool){}));
REQUIRE(conn.subscribe("dev-1"));
REQUIRE(conn.send_request("dev-1", R"({"pushing":{"command":"pushall","sequence_id":"20001"}})"));
broker.push_report("dev-1", R"({"print":{"command":"push_status","sequence_id":"20001","result":"success"}})");
std::string id, payload;
{
std::unique_lock<std::mutex> l(got_mutex);
REQUIRE(got_cv.wait_for(l, std::chrono::seconds(3), [&]{ return got_any; }));
id = got_id; payload = got_payload;
}
CHECK(id == "dev-1");
CHECK(payload.find("push_status") != std::string::npos);
// the client's command reached the broker on the request topic. The mock records
// the PUBLISH on its own read-loop thread, so poll rather than check immediately.
std::vector<std::string> reqs;
for (int i = 0; i < 200; ++i) {
reqs = broker.received_requests();
if (!reqs.empty()) break;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
REQUIRE(reqs.size() >= 1);
CHECK(reqs.front().find("pushall") != std::string::npos);
conn.stop();
}
TEST_CASE("OrcaMqtt round-trip — LAN-style config", "[OrcaMqtt][.integration]") { run_round_trip(false); }
TEST_CASE("OrcaMqtt round-trip — cloud-style config", "[OrcaMqtt][.integration]") { run_round_trip(true); }
TEST_CASE("OrcaMqtt keepalive runs while the connection is idle", "[OrcaMqtt][.integration]") {
orca_mqtt_test::MockBroker broker;
OrcaMqttConnection conn;
OrcaMqttConnection::Config cfg;
cfg.url = broker.ws_url();
cfg.keepalive_seconds = 2;
REQUIRE(conn.start(cfg, [](const std::string&, const std::string&) {}, [](bool, bool) {}));
for (int i = 0; i < 200 && broker.ping_count() == 0; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
CHECK(broker.ping_count() > 0);
conn.stop();
}
TEST_CASE("OrcaMqtt reconnects and re-subscribes after a socket drop", "[OrcaMqtt][.integration]") {
orca_mqtt_test::MockBroker broker;
OrcaMqttConnection conn;
OrcaMqttConnection::Config cfg; cfg.url = broker.ws_url(); cfg.use_tls = false; cfg.username = "u"; cfg.password = "p";
std::mutex m; std::vector<std::string> got;
REQUIRE(conn.start(cfg,
[&](const std::string&, const std::string& p){ std::lock_guard<std::mutex> l(m); got.push_back(p); },
[](bool,bool){}));
REQUIRE(conn.subscribe("dev-1"));
broker.drop_client();
// the worker reconnects with ~1s backoff
for (int i = 0; i < 300 && broker.connect_count() < 2; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(20));
CHECK(broker.connect_count() >= 2);
// a report after the reconnect must still be delivered -> the SUBSCRIBE was re-sent
broker.push_report("dev-1", R"({"print":{"command":"push_status","sequence_id":"20002"}})");
bool delivered = false;
for (int i = 0; i < 200 && !delivered; ++i) {
{ std::lock_guard<std::mutex> l(m); delivered = !got.empty(); }
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
CHECK(delivered);
conn.stop();
}
TEST_CASE("OrcaMqtt auth rejection is terminal (no retry storm)", "[OrcaMqtt][.integration]") {
orca_mqtt_test::MockBroker broker(/*refuse_auth=*/true);
OrcaMqttConnection conn;
OrcaMqttConnection::Config cfg; cfg.url = broker.ws_url(); cfg.use_tls = false; cfg.username = "u"; cfg.password = "bad";
const bool ok = conn.start(cfg, [](const std::string&, const std::string&){}, [](bool,bool){});
CHECK_FALSE(ok);
CHECK(conn.last_connack_rc() == 5);
// worker must have stopped itself (rc 5 is terminal) — give it a moment
for (int i = 0; i < 100 && conn.is_running(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
CHECK_FALSE(conn.is_running());
// and it must NOT have hammered the broker with retries
std::this_thread::sleep_for(std::chrono::milliseconds(200));
CHECK(broker.connect_count() <= 2);
conn.stop();
}
@@ -0,0 +1,448 @@
#include <catch2/catch_test_macros.hpp>
#include <slic3r/GUI/FilamentMappingUtils.hpp>
#include <slic3r/Utils/IPrinterAgent.hpp>
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp>
#include <slic3r/Utils/OrcaPrinterAgent.hpp>
#include "orca_mqtt_mock_broker.hpp"
#include <chrono>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#include <vector>
using Slic3r::OrcaPrinterAgent;
namespace {
// Probe exposes the protected internals the tests drive.
struct Probe : OrcaPrinterAgent {
using OrcaPrinterAgent::OrcaPrinterAgent;
using OrcaPrinterAgent::deliver_to_sink;
using OrcaPrinterAgent::parse_lan_endpoint;
using OrcaPrinterAgent::make_lan_client_id;
using OrcaPrinterAgent::lan_connection_target;
using OrcaPrinterAgent::build_filament_mapping;
using OrcaPrinterAgent::build_gcode_file_payload;
using OrcaPrinterAgent::prepare_outgoing_request;
};
}
TEST_CASE("OrcaPrinterAgent forwards a status payload to on_message_fn", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
std::string got_id, got_payload;
agent.set_on_message_fn([&](std::string id, std::string p){ got_id = std::move(id); got_payload = std::move(p); });
agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status"}})", /*local=*/false);
CHECK(got_id == "dev-1");
CHECK(got_payload.find("push_status") != std::string::npos);
}
TEST_CASE("OrcaPrinterAgent stamps the get_capabilities nozzle diameter onto push_status frames", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
std::string last_payload;
agent.set_on_message_fn([&](std::string, std::string p){ last_payload = std::move(p); });
// Before any capabilities reply, a push_status frame is forwarded untouched.
agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status","mc_percent":10}})", /*local=*/false);
CHECK(last_payload.find("nozzle_diameter") == std::string::npos);
// The get_capabilities reply is forwarded verbatim; its topology nozzle diameter
// is cached for the device.
agent.deliver_to_sink(
"dev-1",
R"({"info":{"command":"get_capabilities","capabilities":{"topology":{"tools":[{"id":"T0","nozzle":{"diameter_mm":0.4}}]}}}})",
/*local=*/false);
CHECK(last_payload.find("\"command\":\"get_capabilities\"") != std::string::npos);
CHECK(last_payload.find("\"print\"") == std::string::npos);
// Later push_status frames for that device get the cached diameter plus a neutral
// nozzle_type, so MachineObject::parse_json's legacy nozzle parser can run.
agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status","mc_percent":20}})", /*local=*/false);
CHECK(last_payload.find("\"nozzle_diameter\":0.4") != std::string::npos);
CHECK(last_payload.find("\"nozzle_type\":\"N/A\"") != std::string::npos);
// A different device is unaffected.
agent.deliver_to_sink("dev-2", R"({"print":{"command":"push_status"}})", /*local=*/false);
CHECK(last_payload.find("nozzle_diameter") == std::string::npos);
// A frame that already carries real nozzle data is not overridden.
agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status","nozzle_diameter":0.6}})", /*local=*/false);
CHECK(last_payload.find("\"nozzle_diameter\":0.6") != std::string::npos);
CHECK(last_payload.find("N/A") == std::string::npos);
}
TEST_CASE("OrcaPrinterAgent owns connector capabilities and fails closed for AMS commands", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
agent.deliver_to_sink("orca-caps", R"({
"info": {
"command": "get_capabilities",
"supported_features": {"fms": true, "filament_slots": true, "filament_mapping": true},
"supported_commands": ["print.ams_get_rfid"],
"capabilities": {
"protocol": {
"features": {"filament_mapping": true},
"supported_commands": ["print.ams_change_filament"]
}
}
}
})", false);
CHECK(agent.supports_feature("orca-caps", "filament_mapping"));
CHECK(agent.supports_command("orca-caps", "print.ams_filament_setting"));
CHECK(agent.supports_command("orca-caps", "print.ams_change_filament"));
CHECK(agent.supports_command("orca-caps", "print.ams_get_rfid"));
CHECK_FALSE(agent.supports_command("orca-caps", "print.ams_control"));
agent.deliver_to_sink("orca-caps", R"({
"info": {
"command": "get_capabilities",
"supported_features": {"fms": false, "filament_slots": false, "filament_mapping": false},
"supported_commands": ["print.ams_control"],
"capabilities": {"protocol": {"features": {}, "supported_commands": []}}
}
})", false);
CHECK_FALSE(agent.supports_feature("orca-caps", "filament_mapping"));
CHECK_FALSE(agent.supports_command("orca-caps", "print.ams_filament_setting"));
CHECK_FALSE(agent.supports_command("orca-caps", "print.ams_change_filament"));
CHECK_FALSE(agent.supports_command("orca-caps", "print.ams_control"));
CHECK_FALSE(agent.supports_command("orca-caps", "print.ams_calibrate"));
CHECK(agent.supports_command("orca-caps", "print.gcode_file"));
}
TEST_CASE("OrcaPrinterAgent clears cached capabilities when a device is unbound", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
agent.deliver_to_sink("orca-forget", R"({
"info": {
"command": "get_capabilities",
"supported_features": {"filament_slots": true},
"supported_commands": [],
"capabilities": {"protocol": {"features": {}, "supported_commands": []}}
}
})", false);
REQUIRE(agent.supports_command("orca-forget", "print.ams_filament_setting"));
agent.unbind("orca-forget");
CHECK_FALSE(agent.supports_command("orca-forget", "print.ams_filament_setting"));
}
TEST_CASE("OrcaPrinterAgent removes setting_id from AMS metadata and gates the request", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
agent.deliver_to_sink("orca-ams-write", R"({
"info": {
"command": "get_capabilities",
"supported_features": {"fms": false, "filament_slots": true},
"supported_commands": [],
"capabilities": {"protocol": {"features": {"filament_slots": true}, "supported_commands": []}}
}
})", false);
const std::string request = R"({"print":{"command":"ams_filament_setting","sequence_id":"9","tray_info_idx":"GFL99","setting_id":"preset-setting"}})";
std::string command;
std::string prepared;
CHECK(agent.prepare_outgoing_request("orca-ams-write", request, command, prepared) == BAMBU_NETWORK_SUCCESS);
CHECK(command == "print.ams_filament_setting");
const nlohmann::json parsed = nlohmann::json::parse(prepared);
CHECK_FALSE(parsed["print"].contains("setting_id"));
CHECK(parsed["print"]["tray_info_idx"] == "GFL99");
agent.deliver_to_sink("orca-ams-write", R"({
"info": {
"command": "get_capabilities",
"supported_features": {"filament_slots": false},
"capabilities": {"protocol": {"features": {"filament_slots": false}}}
}
})", false);
CHECK(agent.prepare_outgoing_request("orca-ams-write", request, command, prepared) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
TEST_CASE("OrcaPrinterAgent::parse_lan_endpoint", "[OrcaPrinterAgent]") {
std::string h, p;
REQUIRE(Probe::parse_lan_endpoint("192.168.1.9", h, p));
CHECK(h == "192.168.1.9"); CHECK(p == "8280");
REQUIRE(Probe::parse_lan_endpoint("http://host.local:9000/x", h, p));
CHECK(h == "host.local"); CHECK(p == "9000");
CHECK_FALSE(Probe::parse_lan_endpoint("", h, p));
}
TEST_CASE("OrcaPrinterAgent::make_lan_client_id is stable and prefixed", "[OrcaPrinterAgent]") {
const auto a = Probe::make_lan_client_id("dev-1");
const auto b = Probe::make_lan_client_id("dev-1");
CHECK(a == b); // drawn once per process
CHECK(a.rfind("orcaslicer-lan-dev-1-", 0) == 0);
}
TEST_CASE("filament mapping is keyed by the ams_mapping2 array position", "[OrcaPrinterAgent]") {
const nlohmann::json mapping = Probe::build_filament_mapping(
R"([{"ams_id":1,"slot_id":5},{"ams_id":255,"slot_id":255},{"ams_id":255,"slot_id":0}])");
REQUIRE(mapping.is_array());
REQUIRE(mapping.size() == 2);
CHECK(mapping[0]["filament_index"] == 0);
CHECK(mapping[0]["ams_id"] == 1);
CHECK(mapping[0]["slot_id"] == 5);
// The unmatched middle entry is dropped; the third entry keeps index 2.
CHECK(mapping[1]["filament_index"] == 2);
CHECK(mapping[1]["ams_id"] == 255);
CHECK(mapping[1]["slot_id"] == 0);
}
// Index-correlation merge gate (plan PR 3). `ams_mapping2` is built one entry
// per logical filament, so its array position is the identifier the generated
// G-code toolchange passes to the Klipper macro (`next_filament_id`). The
// serializer must key `filament_index` by that position and must never
// re-densify after dropping unused/sentinel entries, or a used filament would
// be aimed at the wrong lane. This test covers the plan's matrix: preset order
// differing from used order, a middle filament unused, and external slots.
TEST_CASE("filament mapping index correlates with the ams_mapping2 position", "[OrcaPrinterAgent]") {
// Positions 0..4. Used filaments are 0, 2 and 4; 1 and 3 are unused.
const nlohmann::json mapping = Probe::build_filament_mapping(
R"([{"ams_id":0,"slot_id":1},{"ams_id":255,"slot_id":255},{"ams_id":2,"slot_id":3},{"ams_id":255,"slot_id":255},{"ams_id":255,"slot_id":0}])");
REQUIRE(mapping.size() == 3);
CHECK(mapping[0]["filament_index"] == 0);
CHECK(mapping[1]["filament_index"] == 2);
CHECK(mapping[2]["filament_index"] == 4); // external slot keeps its position
CHECK(mapping[2]["ams_id"] == 255);
CHECK(mapping[2]["slot_id"] == 0);
// No re-densification: a used filament after a dropped sentinel keeps its
// original logical index.
for (const auto& entry : mapping)
CHECK(entry.contains("filament_index"));
}
TEST_CASE("filament mapping is omitted when nothing remains", "[OrcaPrinterAgent]") {
CHECK(Probe::build_filament_mapping("").empty());
CHECK(Probe::build_filament_mapping(R"([{"ams_id":255,"slot_id":255}])").empty());
CHECK(Probe::build_filament_mapping("not json").empty());
CHECK(Probe::build_filament_mapping(R"({"ams_id":1,"slot_id":0})").empty()); // not an array
}
// The GUI capability gate and the agent serializer must classify the same
// entries as engaged. External slots ({255,0}/{254,0}) are normalized as-is, so
// they engage; only the {255,255} unmatched sentinel is dropped. A mismatch lets
// an entry past the GUI and refused late with a generic publish error.
TEST_CASE("the GUI mapping gate engages exactly the entries the serializer sends", "[OrcaPrinterAgent]") {
using Slic3r::GUI::has_engaged_filament_mapping;
CHECK_FALSE(has_engaged_filament_mapping(""));
CHECK_FALSE(has_engaged_filament_mapping("[]"));
CHECK_FALSE(has_engaged_filament_mapping("not json"));
CHECK_FALSE(has_engaged_filament_mapping(R"([{"ams_id":255,"slot_id":255}])"));
CHECK(has_engaged_filament_mapping(R"([{"ams_id":255,"slot_id":0}])")); // external main
CHECK(has_engaged_filament_mapping(R"([{"ams_id":254,"slot_id":0}])")); // external deputy
CHECK(has_engaged_filament_mapping(R"([{"ams_id":0,"slot_id":0}])")); // box slot
CHECK(has_engaged_filament_mapping(R"([{"ams_id":255,"slot_id":255},{"ams_id":1,"slot_id":2}])"));
for (const char* s : {"", "[]", "not json", R"([{"ams_id":255,"slot_id":255}])",
R"([{"ams_id":255,"slot_id":0}])", R"([{"ams_id":254,"slot_id":0}])",
R"([{"ams_id":0,"slot_id":0}])",
R"([{"ams_id":255,"slot_id":255},{"ams_id":1,"slot_id":2}])"}) {
CHECK(has_engaged_filament_mapping(s) == !Probe::build_filament_mapping(s).empty());
}
}
// The used-unmapped refusal reads m_ams_mapping_result: an entry with no target carries
// empty ams_id/slot_id, while an external-spool assignment (ams_id 255/254) is a real
// target. A wholly unmapped print reports no target and falls to the existing send flow.
TEST_CASE("used-filament targets split mapped from unmapped", "[OrcaPrinterAgent]") {
using Slic3r::GUI::has_any_mapped_target;
using Slic3r::GUI::has_used_filament_without_target;
Slic3r::FilamentInfo box; box.ams_id = "0"; box.slot_id = "1"; // box slot
Slic3r::FilamentInfo external; external.ams_id = "255"; external.slot_id = "0"; // external spool
Slic3r::FilamentInfo unmapped; // no target
CHECK_FALSE(has_used_filament_without_target({box, external}));
CHECK(has_used_filament_without_target({box, unmapped}));
CHECK(has_used_filament_without_target({external, unmapped}));
CHECK(has_used_filament_without_target({unmapped})); // wholly unmapped: all-invalid flow, not this refusal
CHECK(has_any_mapped_target({box, unmapped}));
CHECK(has_any_mapped_target({external, unmapped}));
CHECK_FALSE(has_any_mapped_target({unmapped}));
}
// A device with no AMS units has one source, the external spool. OrcaSlicer's
// auto-mapping force-selects it for every filament; that is not a lane choice, so it
// must not gate the print or reach print.gcode_file.
TEST_CASE("a no-AMS device drops the forced external-spool selection", "[OrcaPrinterAgent]") {
using Slic3r::GUI::drop_forced_external_selection;
using Slic3r::GUI::has_engaged_filament_mapping;
std::string external_only = R"([{"ams_id":255,"slot_id":0}])";
drop_forced_external_selection(/*device_has_ams=*/false, external_only);
CHECK(external_only.empty());
CHECK_FALSE(has_engaged_filament_mapping(external_only));
CHECK(Probe::build_filament_mapping(external_only).empty());
std::string box_mapping = R"([{"ams_id":0,"slot_id":2}])";
drop_forced_external_selection(/*device_has_ams=*/true, box_mapping);
CHECK(box_mapping == R"([{"ams_id":0,"slot_id":2}])");
CHECK(has_engaged_filament_mapping(box_mapping));
}
// An empty mapping must leave the gcode_file payload byte-identical to today:
// exactly command, sequence_id and param, with no filament_mapping key.
TEST_CASE("gcode_file payload omits filament_mapping when the map is empty", "[OrcaPrinterAgent]") {
const nlohmann::json empty = Probe::build_gcode_file_payload("7", "job.gcode", nlohmann::json::array());
REQUIRE(empty.contains("print"));
CHECK(empty["print"].size() == 3);
CHECK(empty["print"]["command"] == "gcode_file");
CHECK(empty["print"]["sequence_id"] == "7");
CHECK(empty["print"]["param"] == "job.gcode");
CHECK_FALSE(empty["print"].contains("filament_mapping"));
const nlohmann::json mapping = Probe::build_filament_mapping(R"([{"ams_id":1,"slot_id":0},{"ams_id":255,"slot_id":255}])");
const nlohmann::json with = Probe::build_gcode_file_payload("8", "job.gcode", mapping);
REQUIRE(with["print"].contains("filament_mapping"));
REQUIRE(with["print"]["filament_mapping"].size() == 1);
CHECK(with["print"]["filament_mapping"][0]["filament_index"] == 0);
}
// The defensive gate: a mapped print is refused before anything is published when the
// connector never advertised filament_mapping. The GUI send gates make this visible first;
// this covers callers that bypass them (calibration, plugin). A sentinel-only mapping does
// not engage the gate and falls through to the normal publish path.
TEST_CASE("an engaged mapping is refused when the connector never advertised filament_mapping", "[OrcaPrinterAgent]") {
OrcaPrinterAgent agent("/tmp");
Slic3r::PrintParams params;
params.dev_id = "dev-no-mapping-cap";
params.dst_file = "/tmp/job.gcode";
params.ams_mapping2 = R"([{"ams_id":0,"slot_id":2}])";
CHECK(agent.start_sdcard_print(params, {}, {}) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
params.ams_mapping2 = R"([{"ams_id":255,"slot_id":255}])";
CHECK(agent.start_sdcard_print(params, {}, {}) == BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED);
}
// The FTP "send with record" transport does not exist on OrcaSonar. It must
// report a non-success result so PrintJob falls back to start_print() rather
// than treating a print that was never sent as successful.
TEST_CASE("start_local_print_with_record never reports silent success", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
Slic3r::PrintParams params;
const int rc = agent.start_local_print_with_record(params, {}, {}, {});
CHECK(rc < 0);
}
TEST_CASE("connect_printer wires up a LAN Config", "[OrcaPrinterAgent][.integration]") {
Probe agent("/tmp");
Slic3r::PrinterConnectionParams params{
"dev-1", "10.255.255.1", "orcasonar", "code", "", false, ""
};
const int rc = agent.connect_printer(params);
CHECK(rc == BAMBU_NETWORK_SUCCESS);
CHECK(agent.lan_connection_target() == "ws://10.255.255.1:8280/mqtt");
CHECK(agent.get_user_selected_machine().empty()); // LAN path must not touch the cloud selection
agent.disconnect_printer();
}
TEST_CASE("post-connect sequence is subscribe then 4 requests in order", "[OrcaPrinterAgent]") {
struct SeqProbe : OrcaPrinterAgent {
using OrcaPrinterAgent::OrcaPrinterAgent;
std::vector<std::string> calls;
void emit_connect_sequence(const std::string& dev_id,
std::function<void(const std::string&)> /*sub*/,
std::function<void(const std::string&)> /*req*/) override {
OrcaPrinterAgent::emit_connect_sequence(dev_id,
[&](const std::string& id){ calls.push_back("sub:" + id); },
[&](const std::string& body){ calls.push_back(body); });
}
} probe("/tmp");
probe.run_connect_sequence_for_test("dev-1");
REQUIRE(probe.calls.size() == 5);
CHECK(probe.calls[0] == "sub:dev-1");
CHECK(probe.calls[1].find("\"pushing\"") != std::string::npos);
CHECK(probe.calls[1].find("\"start\"") != std::string::npos);
CHECK(probe.calls[2].find("pushall") != std::string::npos);
CHECK(probe.calls[3].find("get_version") != std::string::npos);
CHECK(probe.calls[4].find("get_capabilities") != std::string::npos);
for (auto& c : probe.calls)
if (auto pos = c.find("sequence_id"); pos != std::string::npos)
CHECK(c.substr(pos).find("\"2") != std::string::npos);
}
// Hidden: spawns the connect worker and attempts a real (failing) connect.
TEST_CASE("selecting a cloud printer configures the fleet socket", "[OrcaPrinterAgent][.integration]") {
auto cloud = std::make_shared<Slic3r::OrcaCloudServiceAgent>("/tmp");
cloud->set_api_base_url("api.example.com");
OrcaPrinterAgent agent("/tmp");
agent.set_cloud_agent(cloud);
agent.set_user_selected_machine("printer-uuid-1");
// The configure runs on the connect worker; poll rather than racing it.
std::string url;
for (int i = 0; i < 300; ++i) {
url = cloud->selected_printer_mqtt_url();
if (!url.empty()) break;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
CHECK(url == "wss://api.example.com/api/v1/printers/mqtt");
agent.set_user_selected_machine(""); // selection changes do not tear down the fleet socket
CHECK(cloud->selected_printer_mqtt_url() == "wss://api.example.com/api/v1/printers/mqtt");
}
TEST_CASE("a stale-generation inbound message is dropped", "[OrcaPrinterAgent]") {
struct GenProbe : OrcaPrinterAgent {
using OrcaPrinterAgent::OrcaPrinterAgent;
using OrcaPrinterAgent::make_lan_message_handler; // expose for the test
};
GenProbe agent("/tmp");
int hits = 0;
agent.set_on_message_fn([&](std::string, std::string){ ++hits; });
auto handler_gen1 = agent.make_lan_message_handler(/*generation=*/1);
// m_lan_generation starts at 0; two bumps -> 2, so the epoch-1 handler is stale.
agent.bump_lan_generation_for_test();
agent.bump_lan_generation_for_test();
handler_gen1("dev-1", "{}"); // late callback from gen 1
CHECK(hits == 0);
}
TEST_CASE("connect_server does not start an MQTT socket", "[OrcaCloud]") {
auto cloud = std::make_shared<Slic3r::OrcaCloudServiceAgent>("/tmp");
cloud->set_api_base_url("127.0.0.1:1"); // no session -> connect_server short-circuits before any probe
cloud->connect_server();
REQUIRE(cloud->get_mqtt_connection() != nullptr); // created in the ctor
CHECK_FALSE(cloud->get_mqtt_connection()->is_running()); // never started
CHECK(cloud->selected_printer_mqtt_url().empty());
}
TEST_CASE("send_message* reject when there is no connection", "[OrcaPrinterAgent]") {
OrcaPrinterAgent agent("/tmp"); // no cloud agent, no LAN connection
CHECK(agent.send_message("d", "{}", 0, 0) == BAMBU_NETWORK_ERR_INVALID_HANDLE);
CHECK(agent.send_message_to_printer("d", "{}", 0, 0) == BAMBU_NETWORK_ERR_INVALID_HANDLE);
CHECK(agent.send_message("", "{}", 0, 0) == BAMBU_NETWORK_ERR_INVALID_HANDLE); // empty dev_id
}
TEST_CASE("send_message_to_printer publishes on the LAN connection", "[OrcaPrinterAgent][.integration]") {
orca_mqtt_test::MockBroker broker;
OrcaPrinterAgent agent("/tmp");
const auto ep = broker.host_port();
agent.connect_printer(Slic3r::PrinterConnectionParams{"dev-1", ep.first + ":" + ep.second, "orcasonar", "code", "", false, ""});
for (int i = 0; i < 150 && broker.connect_count() == 0; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(20));
REQUIRE(broker.connect_count() >= 1);
CHECK(agent.send_message_to_printer("dev-1", R"({"print":{"command":"pause","sequence_id":"20007"}})", 0, 0)
== BAMBU_NETWORK_SUCCESS);
// on_connected also publishes 4 requests; poll until "pause" specifically shows up.
bool saw_pause = false;
for (int i = 0; i < 150 && !saw_pause; ++i) {
for (const auto& r : broker.received_requests())
if (r.find("pause") != std::string::npos) { saw_pause = true; break; }
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
CHECK(saw_pause);
agent.disconnect_printer();
}
TEST_CASE("destroying an agent mid-connect does not hang or crash", "[OrcaPrinterAgent]") {
for (int i = 0; i < 20; ++i) {
auto agent = std::make_unique<OrcaPrinterAgent>("/tmp");
agent->connect_printer(Slic3r::PrinterConnectionParams{"dev-1", "127.0.0.1:1", "orcasonar", "code", "", false, ""}); // nothing listening: instant ECONNREFUSED
agent.reset(); // ~OrcaPrinterAgent must stop the conn, join the thread, and not hang/crash
}
SUCCEED();
}
+147
View File
@@ -1,5 +1,7 @@
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <slic3r/Utils/BBLPrinterAgent.hpp>
#include <slic3r/Utils/MoonrakerPrinterAgent.hpp>
#include <slic3r/Utils/NetworkAgentFactory.hpp> #include <slic3r/Utils/NetworkAgentFactory.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp> #include <slic3r/plugin/PythonPluginBridge.hpp>
@@ -8,11 +10,156 @@
#include <pybind11/embed.h> #include <pybind11/embed.h>
#include <pybind11/pybind11.h> #include <pybind11/pybind11.h>
#include <atomic>
#include <chrono>
#include <future>
#include <memory>
#include <string> #include <string>
#include <thread>
using namespace Slic3r; using namespace Slic3r;
namespace py = pybind11; namespace py = pybind11;
class MoonrakerParserProbe : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::parse_nozzle_diameter;
explicit MoonrakerParserProbe(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
};
TEST_CASE("Moonraker parses nozzle diameter from configfile settings", "[unit][moonraker]")
{
const auto response = nlohmann::json::parse(R"({
"result": {
"status": {
"configfile": {
"settings": {
"extruder": {
"nozzle_diameter": 0.6
}
}
}
}
}
})");
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(response) == Catch::Approx(0.6f));
}
TEST_CASE("Moonraker parses nozzle diameter from raw config and tolerates missing data", "[unit][moonraker]")
{
const auto raw_config_response = nlohmann::json::parse(R"({
"result": {
"status": {
"configfile": {
"config": {
"extruder": {
"nozzle_diameter": "0.8"
}
}
}
}
}
})");
const auto missing_response = nlohmann::json::object();
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(raw_config_response) == Catch::Approx(0.8f));
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(missing_response) == 0.0f);
}
// why: an agent without a Bambu-dialect translation must refuse these commands before any network or wx path.
TEST_CASE("unit: default AMS commands report not supported", "[unit][moonraker]")
{
MoonrakerPrinterAgent agent("");
CHECK(agent.command_ams_refresh_rfid("dev", 123, 1, 0, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.command_ams_calibrate("dev", 1, 2, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.command_ams_select_tray("dev", "123", 3, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
TEST_CASE("unit: Moonraker light name matching", "[unit][moonraker]")
{
CHECK(moonraker_is_light_name("caselight"));
CHECK(moonraker_is_light_name("LED_STRIP"));
CHECK_FALSE(moonraker_is_light_name("beeper"));
CHECK(moonraker_is_light_name("FLASHLIGHT_SWITCH"));
CHECK(moonraker_is_light_name("MODLELIGHT_SWITCH"));
}
// ===========================================================================
// UNIT - handle_request's not-supported default.
// The agent is the only thing that knows what it can translate, so an untranslated
// command has to say so instead of returning success and letting the UI believe the
// control worked. Guards the inverse too: the pushing namespace is genuinely
// satisfied by the websocket status stream, and it re-fires from the keepalive timer
// roughly once a second, so it must stay a success or it would raise a dialog on a
// timer. Only branches that touch neither the network nor wx are exercised.
// ===========================================================================
TEST_CASE("unit: Moonraker reports untranslated commands as not supported", "[unit][moonraker]")
{
MoonrakerPrinterAgent agent("");
CHECK(agent.send_message("dev", R"({"print":{"command":"ams_change_filament"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"system":{"command":"set_door_stat"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"xcam":{"command":"xcam_control_set"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"pushing":{"command":"pushall"}})", 0, 0) == BAMBU_NETWORK_SUCCESS);
CHECK(agent.send_message("dev", R"({"pushing":{"command":"start"}})", 0, 0) == BAMBU_NETWORK_SUCCESS);
// why: malformed input is a different failure than an untranslated command, and the
// default must not swallow it into a misleading not-supported verdict.
CHECK(agent.send_message("dev", "{not json", 0, 0) == BAMBU_NETWORK_ERR_INVALID_RESULT);
}
// why: IPrinterAgent::fetch_filament_info is the single virtual hook derived agents override
// (MoonrakerPrinterAgent's own override is synchronous, but QidiPrinterAgent's override is
// fire-and-forget: it spawns a detached thread and returns immediately). QidiPrinterAgent is
// `final`, so this probes the same contract with a controllable double instead.
TEST_CASE("unit: a fire-and-forget override of fetch_filament_info is not waited on by the caller",
"[unit][moonraker]")
{
class RecordingAgent : public Slic3r::MoonrakerPrinterAgent
{
public:
explicit RecordingAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
std::atomic<bool> invoked{false};
std::promise<void> release_gate;
std::promise<void> done_promise;
bool fetch_filament_info(std::string /*dev_id*/, FilamentSyncMode /*sync_mode*/ = FilamentSyncMode::pull) override
{
std::thread([this]() {
invoked.store(true);
// Block here until the test explicitly releases us, proving the caller
// (fetch_filament_info) does not wait for this to run.
release_gate.get_future().wait();
done_promise.set_value();
}).detach();
return true;
}
};
auto agent = std::make_shared<RecordingAgent>(std::string{});
auto done_future = agent->done_promise.get_future();
bool immediate_result = agent->fetch_filament_info("test-dev");
// fetch_filament_info must return before its background work completes — prove
// it by confirming the background call is still blocked on the gate right now.
REQUIRE(immediate_result == true);
REQUIRE(done_future.wait_for(std::chrono::milliseconds(100)) == std::future_status::timeout);
// Now let the background call finish and confirm it actually ran (polymorphic dispatch).
agent->release_gate.set_value();
REQUIRE(done_future.wait_for(std::chrono::seconds(2)) == std::future_status::ready);
REQUIRE(agent->invoked.load() == true);
}
// =========================================================================== // ===========================================================================
// UNIT - printer-agent registry duplicate handling. // UNIT - printer-agent registry duplicate handling.
// Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in // Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in
@@ -0,0 +1,131 @@
#include <catch2/catch_all.hpp>
#include <nlohmann/json.hpp>
#include <slic3r/Utils/QidiPrinterAgent.hpp>
#include <string>
using namespace Slic3r;
TEST_CASE("Qidi slot response rejects null variables without throwing", "[QidiPrinterAgent]")
{
const std::string response = R"({
"result": {
"status": {
"save_variables": {
"variables": null
}
}
}
})";
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = true;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
CHECK_FALSE(parsed);
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("variables"));
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("object"));
}
TEST_CASE("Qidi slot response rejects missing and non-object fields without throwing", "[QidiPrinterAgent]")
{
std::string response;
SECTION("missing result")
{
response = R"({})";
}
SECTION("non-object result")
{
response = R"({"result":null})";
}
SECTION("missing status")
{
response = R"({"result":{}})";
}
SECTION("non-object status")
{
response = R"({"result":{"status":null}})";
}
SECTION("missing save_variables")
{
response = R"({"result":{"status":{}}})";
}
SECTION("non-object save_variables")
{
response = R"({"result":{"status":{"save_variables":null}}})";
}
SECTION("missing variables")
{
response = R"({"result":{"status":{"save_variables":{}}}})";
}
SECTION("scalar")
{
response = R"({"result":{"status":{"save_variables":{"variables":42}}}})";
}
SECTION("array")
{
response = R"({"result":{"status":{"save_variables":{"variables":[]}}}})";
}
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = true;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
CHECK_FALSE(parsed);
}
TEST_CASE("Qidi slot response exposes valid status and variables", "[QidiPrinterAgent]")
{
const std::string response = R"({
"result": {
"status": {
"save_variables": {
"variables": {
"box_count": 2,
"color_slot0": 3
}
},
"box_stepper slot0": {
"runout_button": 0
}
}
}
})";
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = false;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
REQUIRE(parsed);
CHECK(status.is_object());
CHECK(variables.is_object());
CHECK(variables.at("box_count") == 2);
CHECK(status.contains("box_stepper slot0"));
}
TEST_CASE("Qidi slot response rejects invalid JSON", "[QidiPrinterAgent]")
{
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = true;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response("{not json", status, variables, error));
CHECK_FALSE(parsed);
CHECK(error == "Invalid JSON response");
}