Merge pull request #142 from OrcaSlicer/fix/orca-printer-agent-refactor

fix: orcaprinteragent refactor
This commit is contained in:
Ian Chua
2026-09-04 18:05:27 +08:00
committed by GitHub
24 changed files with 4627 additions and 841 deletions

View File

@@ -69,14 +69,14 @@ else ()
--disable-filters
--enable-filter=*null*,afade,*fifo,*format,*resample,aeval,allrgb,allyuv,atempo,pan,*bars,color,*key,crop,draw*,eq*,framerate,*_qsv,*_vaapi,*v4l2*,hw*,scale,volume,test*
--disable-protocols
--enable-protocol=file,fd,pipe,rtp,tcp,udp
--enable-protocol=file,fd,pipe,http,rtp,tcp,udp
--disable-muxers
--enable-muxer=rtp
--disable-encoders
--disable-decoders
--enable-decoder=*aac*,h264*,mp3*,mjpeg,rv*
--disable-demuxers
--enable-demuxer=h264,mp3,mov,rtsp,sdp
--enable-demuxer=h264,mp3,mov,mpjpeg,rtsp,sdp
--disable-zlib
--disable-avdevice
BUILD_IN_SOURCE ON

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,388 @@
# OrcaCloud / OrcaSonar MQTT contract consolidation — design
**Date:** 2026-09-03
**Status:** Approved design, pre-implementation
**Repos touched by this task:** OrcaSlicer (implementation), plus a written conformance
checklist handed off to OrcaCloud and OrcaSonar (not edited here).
---
## 1. Context & problem
`OrcaPrinterAgent` talks to two backends that are meant to expose the *same*
printer API:
- **OrcaCloud** — cloud relay. Cloudflare Durable Object "broker-lite" with a
hand-rolled MQTT 3.1.1 codec (`apps/gateway/src/lib/mqtt-codec.ts`,
`apps/gateway/src/durable-objects/printer-shard.ts`).
- **OrcaSonar** — LAN hub. Real mochi MQTT broker fronted by a `/mqtt`
WebSocket→TCP proxy (`internal/broker/broker.go`, `internal/httpws/server.go`).
A survey of all three codebases found the *payload and topic layer already ~90 %
identical* (Bambu-dialect JSON, `device/<id>/request` + `device/<id>/report`,
shared command set, numeric-string `sequence_id`), but the *transport mechanics
diverge*:
| Dimension | OrcaSonar LAN | OrcaCloud |
|---|---|---|
| Command send | client **PUBLISHes** `device/<id>/request` | viewers **receive-only**; commands via `POST /api/v1/printers/:id/commands` |
| Auth | MQTT CONNECT username/password | Bearer token on the HTTP upgrade |
| Endpoint | `ws://<host>:8280/mqtt` | `wss://<api>/api/v1/printers/{id}/mqtt` (and an aggregate `/printers/mqtt`) |
| Socket cardinality | 1 printer : 1 socket | aggregate socket, N printers, dynamic grant |
| Status stream | always on | demand-gated (`pushing.start` / `pushing.stop`) |
| Capability | retained `device/<id>/capability` | `info.get_capabilities` command, no retained |
| `sequence_id` bands | unenforced | enforced (OrcaSlicer must use 2000029999) |
| Spec of record | `spec/protocol/orca_printer_comm_spec.md` (OPCP v1.1.0 + JSON schemas) | `doc/gateway/printer_mqtt_facade_2026-08-07.md` + dialect code |
Both sides already track the drift: OrcaSonar `API.md:244-301` diffs itself against
OrcaCloud's dialect code; OrcaCloud's facade doc has an explicit "OrcaSonar
adoption" section.
The client today mirrors the divergence — `send_message` (cloud) has historically
gone via REST while `send_message_to_printer` (LAN) publishes over MQTT — so the
`IPrinterAgent` split is a transport split, not just a routing switch.
**Goal:** one contract (OPCP v1.2.0) that both backends conform to, and an
OrcaSlicer client where LAN and cloud run *identical code* differing only by a
`Config` value.
---
## 2. Decisions
| # | Decision |
|---|---|
| D1 | **Contract merge + thin client shim.** Merge to a canonical spec; fix payload-level divergences server-side; the client keeps only a `Config`-sized shim for endpoint/auth/keepalive. |
| D2 | **This task ships:** the OPCP v1.2.0 spec text (authored here), the OrcaSlicer client implementation, and an enumerated conformance checklist for OrcaCloud & OrcaSonar. The other two repos are **not** edited in this task. |
| D3 | **Command transport is MQTT PUBLISH `device/<id>/request` on both cloud and LAN.** OrcaCloud gains client PUBLISH via change CH-1. |
| D4 | **Client structure: one `OrcaMqttConnection` class, two `Config`-only instances, routed by `OrcaPrinterAgent`.** The cloud instance uses the **1:1 per-printer** endpoint `/api/v1/printers/{id}/mqtt`, exactly like LAN. |
| D5 | **One socket per transport, selected printer only.** The pre-existing aggregate cloud MQTT socket is removed; unselected printers show announce/REST status on both transports (see §4.3). |
| D6 | **No back-compat.** OPCP v1.2.0 replaces v1.1.0 outright. REST `/commands` is not a required alias. No `/tunnel` bridge concerns. |
| D7 | **OrcaSonar's OPCP is the source of truth.** The contract is OrcaSonar's spec (with the additions in §5.1, most of which absorb behaviours OrcaCloud already ships); OrcaCloud conforms to it. |
---
## 3. OPCP v1.2.0 — the unified contract
### 3.1 Canonical core (already aligned; ratified here)
- **Transport:** MQTT 3.1.1 over WebSocket, binary frames, subprotocol `mqtt`,
`cleanSession = 1`.
- **Topics:** `device/<dev_id>/request` (client → device),
`device/<dev_id>/report` (device → client). `<dev_id>` is the identifier the
client is already provisioned with — the cloud printer UUID on the cloud
binding; the mDNS-advertised `device_id` (TXT `device_id=`, SSDP UDN
`uuid:<device_id>`) on the LAN binding. The client never learns the id from
message traffic.
- **Envelope:** exactly one top-level namespace key ∈
`{pushing, info, print, system, camera, xcam, upgrade, files, event}`, plus
`command` and `sequence_id` (decimal string, `^[0-9]+$`).
- **Result echo (single-phase):** same namespace + command + `sequence_id`, plus
`result ∈ {success, fail}`, `reason` on `fail`, optional `errno`. No separate
dialect-layer transport ack.
- **Status:** `<ns>.push_status` on the report topic; `msg` 0 = full, 1 = diff.
- **`info.get_version` reply:** `module[]` entries with `name / sw_ver / hw_ver / sn`.
- **Optional extended header** (adopt OrcaSonar spec §3.1 verbatim):
`protocol_version`, `schema_version`, `sent_at_utc_ms`,
`source{role, agent_id, transport}`. Receivers ignore unknown top-level keys.
### 3.2 Divergence resolutions
| Divergence | Resolution | Owner |
|---|---|---|
| Command send: REST vs PUBLISH | Client PUBLISHes `device/<id>/request` on both transports. | OrcaCloud CH-1 |
| `sequence_id` bands | Normative registry: OrcaSlicer **2000029999**, dashboard 5000059999, gateway-minted 7000079999, status-mirror 90000+. Correlation is producer-scoped (match echoes against your own outstanding ids). | OPCP SPEC-2; client |
| Capability discovery | `info.get_capabilities` command is the REQUIRED path (returns the capability manifest). Retained `device/<id>/capability` is an OPTIONAL LAN optimization; clients MUST NOT depend on it. | client; OrcaSonar SN-2 |
| Status gating | Client issues `pushing.start` immediately after SUBSCRIBE and `pushing.stop` on deselect, on both transports. Always-streaming implementations accept both as `result:"success"` no-ops. | client; OrcaSonar SN-1; OrcaCloud CH-4 |
| Topic `<id>` | LAN topic id == advertised `device_id`, so the client subscribes the exact topic — **no `device/+/report` wildcard**. | OrcaSonar SN-3 |
| `system.set_settings` | Schema defined in OPCP (SPEC-5): curated toggles — camera, discovery, moonraker_compat. Unknown setting → `result:"fail"`, `errno = UNSUPPORTED_SETTING`. Not client-driven in this task. | OPCP SPEC-5; OrcaSonar SN-4 |
| Auth | Two mechanisms, both normative: (a) bearer token in the `Authorization` header of the WS upgrade (cloud); (b) MQTT CONNECT username/password (LAN local broker). The client `Config` carries whichever applies. | documented only |
| QoS | Client requests SUBSCRIBE QoS 1 and PUBLISH QoS 01; MUST tolerate a SUBACK that grants QoS 0. | client |
| Keepalive | `Config.keepalive_seconds` (default 60 LAN / 300 cloud). Binary PINGREQ. | client |
| Endpoint | LAN `ws://<host>:8280/mqtt`; cloud `wss://<api>/api/v1/printers/{id}/mqtt`. | `Config` |
Net effect: everything payload- and topic-level is identical on both transports;
the only per-transport variation is `Config` (URL + auth + keepalive) plus one
connect-time `pushing.start`.
---
## 4. OrcaSlicer client architecture
### 4.1 `OrcaMqttConnection` — the single transport class
Lives in its own translation unit (extracted from `OrcaCloudServiceAgent.cpp`,
where an earlier `OrcaCloudMqttConnection` / renamed `OrcaMqttConnection` still
sits). No LAN/cloud conditionals in the body.
```cpp
struct Config {
std::string url; // ws://host:8280/mqtt | wss://api/.../printers/{id}/mqtt
bool use_tls = false; // derived from the URL scheme
TokenProvider bearer_provider; // cloud: Authorization: Bearer <jwt> on the WS upgrade
std::string username, password; // LAN: MQTT CONNECT credentials
// Precedence: if bearer_provider is set it is used for the WS upgrade and the
// CONNECT username/password are omitted; otherwise CONNECT carries the creds.
std::string client_id; // stable for the process run, unique per instance
int keepalive_seconds = 60;
};
```
API (used identically by both instances):
| Method | Behaviour |
|---|---|
| `bool start(Config, MessageHandler on_message, StateHandler on_state, CancellationHandler = {})` | spawns the worker thread; blocks (bounded, ~10 s) for the first CONNACK; returns the initial result |
| `void stop()` | idempotent; joins the worker |
| `bool send_request(const std::string& dev_id, const std::string& payload)` | PUBLISH `device/<dev_id>/request` (QoS 0/1); thread-safe via the write mutex; false when there is no CONNACKed session. **The uniform outbound seam.** |
| `bool subscribe(const std::string& dev_id)` / `unsubscribe(...)` | SUBSCRIBE / UNSUBSCRIBE `device/<dev_id>/report`; persistent set re-sent after every CONNACK |
| `bool is_connected() const` / `int last_connack_rc() const` | CONNACK state; rc 0 = accepted, 1..5 = MQTT refusal, -1 = no CONNACK this attempt |
| `MessageHandler(dev_id, payload)` | inbound: strips the `device/<id>/report` topic, hands up raw JSON, on the worker thread |
All protocol logic (topic construction, MQTT framing, reconnect/backoff, write
serialization, QoS-downgrade tolerance, PINGREQ) is internal. The only internal
branches are `use_tls` (TLS handshake + SNI) and bearer-vs-CONNECT-creds during
the handshake.
Reusable pieces from `salvage/orcasonar-lan-agent-2026-09-03` (the generalized
`Config`, `ws://` support in `parse_endpoint`, `last_connack_rc`, auth-reject
handling, static frame builders + their tests) are lifted in rather than
re-derived.
### 4.2 `OrcaPrinterAgent` — routing + lifecycle
- `std::unique_ptr<OrcaMqttConnection> lan_mqtt_connection` — owned here;
lifecycle = LAN printer selection.
- Cloud per-printer `OrcaMqttConnection` — owned by `OrcaCloudServiceAgent`,
reached via `get_orca_cloud_agent()->get_mqtt_connection()`.
- `OrcaMqttConnection* get_appropriate_mqtt_connection(bool is_lan)` — the one
place that encodes the ownership split. Kept even though callers know their
`is_lan` bit; it is the seam and it is tiny. Where a caller has only a
`dev_id`, resolve via `DeviceManager::get_my_machine(dev_id)->is_lan_mode_printer()`.
- `enum CurrentConn { NONE, CLOUD, LAN } m_current_connection` — a label/gate,
**not** a socket selector.
**Outbound collapse.** Both methods reduce to the same body:
```cpp
int OrcaPrinterAgent::send_message(dev_id, json, qos, flag) // is_lan = false
int OrcaPrinterAgent::send_message_to_printer(dev_id, json, qos, flag) // is_lan = true
// ->
auto* conn = get_appropriate_mqtt_connection(is_lan);
if (!conn || dev_id.empty()) return BAMBU_NETWORK_ERR_INVALID_HANDLE;
return conn->send_request(dev_id, json) ? BAMBU_NETWORK_SUCCESS
: BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
```
The `command_*` helpers keep branching only to build the payload, then call one
or the other.
**Inbound: remove `read_loop()`.** Each `OrcaMqttConnection` already delivers on
its worker thread via `MessageHandler`. The agent registers one handler per
connection that funnels to `on_message_fn`, marshalled onto the UI thread via
`queue_on_main_fn`. This is already the pattern `set_cloud_agent()` wires for the
cloud side (`set_printer_status_callback`); the LAN side gets the symmetric
wiring when `lan_mqtt_connection` is created. A single reader that "picks the
current connection" reintroduces a one-transport-at-a-time asymmetry and a
busy-spin; the per-connection callback is already uniform.
**Lifecycle — the post-connect sequence is byte-identical on both transports:**
| Trigger | LAN | Cloud |
|---|---|---|
| select | `connect_printer(dev_id, dev_ip, user, code, ssl)` | `set_user_selected_machine(dev_id)` |
| agent builds `Config` | `ws://<dev_ip>:8280/mqtt` + username/password | `wss://<api>/api/v1/printers/{dev_id}/mqtt` + `bearer_provider` |
| then — shared `on_connected(dev_id, conn)` | `subscribe(dev_id)``send_request(dev_id, pushing.start)``send_request(dev_id, pushall)``send_request(dev_id, info.get_version)``send_request(dev_id, info.get_capabilities)` | *the same five calls* |
| deselect / change | `disconnect_printer()``stop()` + reset | `send_request(prev, pushing.stop)``unsubscribe(prev)``stop()` / retarget |
**Threading & lifetime:**
- The blocking `start()` runs on a short-lived thread so the UI is never blocked
(mirrors the salvaged WIP).
- A generation counter (atomic, captured by value into every handler lambda)
makes a superseded connection's late `MessageHandler` / `StateHandler` calls
no-ops.
- `~OrcaPrinterAgent` calls `stop()` on both connections (joining their workers)
before any member is destroyed. No detached threads may touch `*this`.
- CONNACK rc 4/5 (bad credentials / not authorised) is terminal: report
`ConnectStatusFailed`, do not retry (a retry storm would flap
`ConnectStatusLost``set_selected_machine("")`).
### 4.3 One socket per transport — the selected printer only
The client opens exactly one MQTT socket at a time, for the **selected** printer,
identically on both transports:
- LAN: `ws://<dev_ip>:8280/mqtt` — opened on `connect_printer`, closed on
`disconnect_printer`.
- Cloud: `wss://<api>/api/v1/printers/{dev_id}/mqtt` (the 1:1 per-printer
binding, D4) — opened on `set_user_selected_machine(dev_id)`, closed on
deselect. `OrcaCloudServiceAgent` owns the instance; `OrcaPrinterAgent`
drives it via `get_mqtt_connection()`.
Unselected printers are never connected on either transport. They populate the
Device list from announce data alone — mDNS/SSDP `on_machine_alive` for LAN
hubs, the account REST list for cloud printers — exactly as unselected LAN hubs
already behave.
**This removes the pre-existing aggregate cloud MQTT socket**
(`wss://<api>/api/v1/printers/mqtt`, started today by
`OrcaCloudServiceAgent::connect_server()`). `is_server_connected()` falls back to
the REST health probe `connect_server()` already performs on the same 5 s
`refresh_connection()` tick.
**Behaviour change (needs product sign-off, tracked as O2):** unselected cloud
printers in the Device list lose their live status feed and show last-known /
REST status. This is the price of LAN/cloud symmetry and matches how unselected
LAN hubs already appear. If live multi-printer status is later required it is an
`OrcaCloudServiceAgent` concern (its own aggregate consumer), and it must not add
a second `device/<id>/report` stream for the already-connected selected printer.
---
## 5. Conformance checklist (hand-off)
**Framing (D7):** the target state is "cloud and LAN expose an identical API",
and **OrcaSonar's OPCP spec is the source of truth**. Read this section as:
- §5.1 — additions the OPCP spec (and therefore OrcaSonar's implementation)
needs. Small, and mostly formalising behaviours OrcaCloud already ships
(`pushing.start/stop`, `system.set_settings`, the `sequence_id` registry).
- §5.2 — OrcaCloud is the participant furthest from the contract (commands over
REST, viewer PUBLISH forbidden, aggregate-only socket). These changes make it
conform.
- §5.3 — OrcaSonar's own work: implement the §5.1 additions, plus one or two
guarantees to make explicit.
### 5.1 OPCP spec additions → v1.2.0 (`OrcaSonar spec/protocol/orca_printer_comm_spec.md`)
| ID | Change |
|---|---|
| SPEC-1 | Add a normative **Transports** section: the two bindings from §3.1 (LAN local broker; cloud gateway). Both MUST accept client PUBLISH to `device/<id>/request`. |
| SPEC-2 | Normative `sequence_id` band registry (§3.2); correlation is producer-scoped. |
| SPEC-3 | `pushing.start` / `pushing.stop` are normative commands — "begin / stop streaming `push_status` to this subscriber". Always-streaming implementations MUST still return `result:"success"` (no-op). |
| SPEC-4 | `info.get_capabilities` is the REQUIRED capability path; retained `device/<id>/capability` is OPTIONAL and non-load-bearing. |
| SPEC-5 | Define the `system.set_settings` schema (curated toggles: camera, discovery, moonraker_compat); unknown setting → `result:"fail"`, `errno = UNSUPPORTED_SETTING`. |
| SPEC-6 | Errno registry: enumerate values already in use plus `UNSUPPORTED_COMMAND`, `UNSUPPORTED_SETTING`, `NOT_AUTHORIZED`. Align semantics (not numeric values) with the client's `ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED` / `ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE`. |
| SPEC-7 | Promote the extended envelope header (§3.1 of the OrcaSonar spec) to OPTIONAL on every message; receivers ignore unknown top-level keys. |
| SPEC-8 | Topic `<id>` MUST equal the id the client is provisioned with; no id discovery from traffic. |
### 5.2 OrcaCloud — conform to OPCP (`~/repos/OrcaCloud/apps/gateway`)
Assuming the OPCP contract (client PUBLISHes commands over a 1:1 MQTT-over-WS
socket, exactly as against OrcaSonar LAN), OrcaCloud must add:
| ID | Change | Acceptance |
|---|---|---|
| **CH-1** | `mqtt-viewer` sessions MAY `PUBLISH` to `device/{id}/request` (today → close 4403 in `printer-shard.ts::handleMqttPublish`). Relay viewer → connector via the existing `awaitConnectorReply` / `deliverToConnector` path. `/report` stays connector-only (anti-forgery preserved). | e2e: a viewer publishes `print.pause` `sequence_id` 20001 → connector receives it on `device/{id}/request` → the result echo fans back to that viewer. |
| CH-2 | Move operator-role authorization from the REST `/commands` route (`routes/printer.ts`) into the shard publish handler. READ set — `pushing.pushall`, `info.get_version`, `info.get_capabilities`, `files.list`, `files.metadata` — allowed for viewer / live-token; every other command requires an operator+ session. | live-token viewer publishing `print.stop` → 4403; JWT operator → success. |
| CH-3 | Provide a **1:1 per-printer** WS binding `GET /api/v1/printers/{id}/mqtt` that behaves like OrcaSonar's `/mqtt` (one printer per socket; subscribe the exact `device/{id}/report`; no `X-Orca-Printer-Ids`). If #948 ("shard-only routing") removed this facade path, re-adding it is the change — the client does not use the aggregate `/printers/mqtt`. | client connects, subscribes, publishes a command, receives the echo, with no aggregate-grant header. |
| CH-4 | `pushing.start` / `pushing.stop` over the viewer publish path arm / disarm the demand-mirror for that printer (today armed only by dashboard page polls, #984). Behaviour matches OPCP SPEC-3: on always-streaming backends it is a success no-op; OrcaCloud's mirror is genuinely demand-gated so it acts. | after a viewer `pushing.start`, `push_status` frames arrive on that viewer's `report` subscription; after `pushing.stop` (last viewer gone) they cease. |
| CH-5 | `info.get_capabilities` returns an OPCP capability manifest matching OrcaSonar's `orca_capability_manifest` schema (`protocol_version` ≥ 1.2.0), whether the connector is Bambu- or Klipper-backed. | manifest validates against the OPCP schema. |
### 5.3 OrcaSonar — implement the spec additions (`~/repos/OrcaSonar/internal`)
OrcaSonar owns the contract, so its work is small: land the §5.1 spec text, make
the LAN dispatcher match it, and turn two already-true facts into guarantees.
| ID | Change | Acceptance |
|---|---|---|
| **SN-1** | The LAN dispatcher accepts `pushing.start` / `pushing.stop` as `result:"success"` no-ops — the LAN broker always streams `push_status`, so these commands only need to be *recognised*, not fall through to "unsupported command". (Today they are handled only on the OrcaSonar→cloud connector path, not the LAN dispatcher.) `internal/protocol/dispatcher.go` specials + `internal/protocol/types.go` `SupportedCommands`. See O4: this is the "always-on, nothing to gate" reading, not a per-subscriber mirror. | golden: `{"pushing":{"command":"start","sequence_id":"20005"}}``{"pushing":{"command":"start","sequence_id":"20005","result":"success","errno":0}}`. |
| SN-2 | `info.get_capabilities` returns the manifest identically whether requested via command or read from the retained topic, and works before any `pushall`. | fresh connect → command → manifest with `protocol_version ≥ 1.2.0`. |
| SN-3 | Documented guarantee, plus a test, that topic `<id>` == advertised `device_id` (mDNS TXT `device_id=`, SSDP UDN). Lets the client drop the `device/+/report` wildcard. Likely already true (`internal/discovery/discovery.go`, `internal/config/config.go`). | client subscribing `device/<advertised-id>/report` receives reports. |
| SN-4 | `system.set_settings` → implement per SPEC-5, or return `result:"fail"`, `errno = UNSUPPORTED_SETTING` (not a bare "unsupported command", not a parse error / close). | unknown setting key → structured errno, session stays open. |
| SN-5 | Capability manifest `protocol_version` / `schema_version``1.2.0`. | manifest validates against the v1.2.0 schema. |
| SN-6 | Conformance test only (no code change expected): broker accepts client-id `orcaslicer-lan-<dev_id>-<hex>` and a QoS 1 SUBSCRIBE. | test passes. |
### 5.4 OrcaSlicer client (this repo — implemented, not enumerated)
Everything in §4, plus: uses `info.get_capabilities` (never the retained topic),
always issues `pushing.start` / `pushing.stop`, stays in `sequence_id` band
2000029999, subscribes the exact report topic, tolerates a SUBACK that grants
QoS 0.
---
## 6. Testing & verification
### 6.1 Client unit tests (`tests/slic3rutils/`, Catch2)
- `OrcaMqttConnection` static frame builders — CONNECT (bearer and
CONNECT-creds forms), SUBSCRIBE / UNSUBSCRIBE, PUBLISH, PINGREQ,
remaining-length codec, `parse_endpoint` for `ws://` and `wss://`. Byte-level
assertions.
- `send_request` produces topic `device/<id>/request` with a verbatim payload;
inbound strips `device/<id>/report``(id, payload)`.
- `Config` selects the handshake path (auth mode; `use_tls` from scheme).
- `command_*` payload builders match OPCP shapes (string `sequence_id`, band
2000029999, single namespace key).
- The post-connect sequence emits exactly
`subscribe → pushing.start → pushall → info.get_version → info.get_capabilities`,
in that order.
- Generation guard: a superseded connection's late handler calls are no-ops.
- SUBACK granting QoS 0 when 1 was requested → still connected, messages still
delivered.
### 6.2 Client integration (in-process MQTT-over-WS mock)
- **One parametrized test, two fixtures (LAN `Config` / cloud `Config`):**
connect → CONNACK → subscribe → publish command → mock emits the result echo →
assert `on_message_fn` fires with it. Identical assertions for both fixtures —
this is the "exactly the same" proof.
- Reconnect: mock drops the socket → worker backs off → reconnects →
subscriptions re-sent → `pushing.start` re-issued.
- Auth reject: CONNACK rc 4/5 → terminal `ConnectStatusFailed`, no retry storm.
### 6.3 Cross-repo conformance (run in those repos' CI, from §5 acceptance rows)
- OrcaCloud: extend `tests/e2e/lane-b-printers/printer_mqtt.e2e.test.ts` for
CH-1 / CH-2 / CH-3 / CH-4 / CH-5 (each row's acceptance criterion in §5.2).
- OrcaSonar: golden JSONL fixtures for SN-1 / SN-2 / SN-4.
### 6.4 Manual smoke (record a log / screenshot for each)
RelWithDebInfo build →
- (a) real OrcaSonar hub on the LAN: discover, connect, Device tab populates,
set nozzle temperature, observe the result echo;
- (b) OrcaCloud staging with a paired printer: select, same checks.
Both exercised through the *same* `OrcaMqttConnection` code path.
### 6.5 Gates before "done"
- New unit + integration tests green (ctest output as evidence).
- No regression in the existing `tests/slic3rutils` suites (printer-agent,
plugin, qidi).
- LAN and cloud smoke each confirmed with a log/screenshot.
- One `cmake --build build` at the end.
---
## 7. Out of scope
- Editing OrcaCloud or OrcaSonar in this task (the checklist in §5 is the
hand-off; those land as separate PRs in their own repos).
- A live multi-printer status feed for the Device list on cloud (would be its
own `OrcaCloudServiceAgent` aggregate consumer — see §4.3 / O2). The printer
agent connects the selected printer only.
- Reusing OrcaCloud's aggregate `/printers/mqtt` for the client (option B from
brainstorming — rejected; it forces aggregate-grant semantics into
`OrcaMqttConnection` that the LAN 1:1 case never needs).
- An `IPrinterTransport` abstraction above MQTT (option C — YAGNI while MQTT is
the only transport).
- Camera streaming, filesystem / file transfer, filament sync, AMS mapping.
- Back-compat: the legacy `/tunnel` envelope plane, retaining REST `/commands`
as a required alias, dual-schema acceptance on OrcaSonar.
---
## 8. Open items, risks & resolved questions
| # | Item |
|---|---|
| O1 | **Does OrcaCloud's 1:1 `/api/v1/printers/{id}/mqtt` still exist post-#948?** ("shard-only routing" made routing shard-backed.) Not a client fork any more — per CH-3 the client uses only the 1:1 endpoint, so if the facade path was removed, re-adding it *is* the OrcaCloud change. Just needs confirmation with the OrcaCloud team of whether CH-3 is "keep" or "re-add". |
| O2 | **Behaviour change: unselected cloud printers lose live status** (§4.3 — consequence of dropping the aggregate socket for LAN/cloud symmetry). They show last-known / REST status, matching unselected LAN hubs. Needs product sign-off before implementation. |
| O3 | **CONNECT-creds vs bearer through a fronting proxy.** If a deployment puts `wss://` in front of OrcaSonar, both auth inputs could be present. Precedence is fixed in §4.1 (`bearer_provider` set ⇒ bearer, CONNECT creds omitted); flagged only so the plan makes it a tested branch. |
| O4 | **Resolved.** `pushing.start` / `pushing.stop` mean "begin / stop streaming `push_status` to me". On OrcaCloud the mirror is genuinely demand-gated so the commands act; on OrcaSonar LAN the broker always streams, so they are recognised-and-succeed no-ops (SN-1). Not the "MQTT subscription granularity" reading — the client still SUBSCRIBEs `device/<id>/report` explicitly on both. |
| O5 | **`sequence_id` band collisions.** The client must never emit outside 2000029999, including for any gateway-minted flow it triggers (print jobs). Audit every `sequence_id` source in `OrcaPrinterAgent`. |

View File

@@ -725,6 +725,8 @@ set(SLIC3R_GUI_SOURCES
Utils/IPrinterAgent.hpp
Utils/OrcaCloudServiceAgent.cpp
Utils/OrcaCloudServiceAgent.hpp
Utils/OrcaMqttConnection.cpp
Utils/OrcaMqttConnection.hpp
Utils/OrcaPrinterAgent.cpp
Utils/OrcaPrinterAgent.hpp
Utils/QidiPrinterAgent.cpp

View File

@@ -35,7 +35,9 @@ ConnectPrinterDialog::ConnectPrinterDialog(wxWindow *parent, wxWindowID id, cons
sizer_connect = new wxBoxSizer(wxHORIZONTAL);
m_textCtrl_code = new TextInput(this, wxEmptyString);
m_textCtrl_code->GetTextCtrl()->SetMaxLength(10);
// OrcaSonar uses a 12-character base32 access code. Keep this field long
// enough for it while retaining the existing validation for LAN codes.
m_textCtrl_code->GetTextCtrl()->SetMaxLength(12);
m_textCtrl_code->SetFont(Label::Body_14);
m_textCtrl_code->SetCornerRadius(FromDIP(5));
m_textCtrl_code->SetSize(wxSize(FromDIP(330), FromDIP(40)));

View File

@@ -3046,6 +3046,13 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
}
} catch (...) {}
try {
if (j.contains("info"))
parse_new_info2(j["info"]);
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "parse_json: failed to parse OrcaSonar capability info";
}
try {
if (auto ptr = m_fila_system->GetAmsFirmwareSwitch().lock()) {
ptr->ParseFirmwareSwitch(j);
@@ -5432,6 +5439,86 @@ void MachineObject::parse_new_info(json print)
}
}
void MachineObject::parse_new_info2(const json& info)
{
if (!info.is_object() || info.value("command", "") != "get_capabilities")
return;
const auto capabilities_it = info.find("capabilities");
if (capabilities_it == info.end() || !capabilities_it->is_object())
return;
const auto flags_it = capabilities_it->find("flags");
if (flags_it == capabilities_it->end() || !flags_it->is_object())
return;
const json& flags = *flags_it;
BOOST_LOG_TRIVIAL(info) << "parse_new_info2: OrcaSonar capability flags=" << flags.dump();
auto parse_bool = [&flags](const char* name, bool& target) {
const auto it = flags.find(name);
if (it != flags.end() && it->is_boolean())
target = it->get<bool>();
};
parse_bool("support_send_to_sd", is_support_send_to_sdcard);
parse_bool("support_filament_backup", is_support_filament_backup);
parse_bool("support_update_remain", is_support_update_remain);
parse_bool("support_auto_recovery_step_loss", is_support_auto_recovery_step_loss);
parse_bool("support_ams_humidity", is_support_ams_humidity);
parse_bool("support_prompt_sound", is_support_prompt_sound);
parse_bool("support_filament_tangle_detect", is_support_filament_tangle_detect);
parse_bool("support_1080dpi", is_support_1080dpi);
parse_bool("support_cloud_print_only", is_support_cloud_print_only);
parse_bool("support_command_ams_switch", is_support_command_ams_switch);
parse_bool("support_mqtt_alive", is_support_mqtt_alive);
parse_bool("support_motor_noise_cali", is_support_motor_noise_cali);
parse_bool("support_timelapse", is_support_timelapse);
parse_bool("support_user_preset", is_support_user_preset);
parse_bool("support_refresh_nozzle", is_support_refresh_nozzle);
parse_bool("support_flow_calibration", is_support_flow_calibration);
parse_bool("support_build_plate_marker_detect", is_support_build_plate_marker_detect);
parse_bool("support_nozzle_blob_detect", is_support_nozzle_blob_detection);
if (!m_manager->IsMultiMachineEnabled() && !is_support_agora)
parse_bool("support_tunnel_mqtt", is_support_tunnel_mqtt);
const auto bed_leveling_it = flags.find("support_bed_leveling");
if (bed_leveling_it != flags.end() && bed_leveling_it->is_number_integer())
is_support_bed_leveling = bed_leveling_it->get<int>();
auto copy_bool = [&flags](json& target, const char* name) {
const auto it = flags.find(name);
if (it != flags.end() && it->is_boolean())
target[name] = *it;
};
// The capability manifest uses an object for this range, while the legacy
// DeviceCore parser consumes a boolean plus a two-element range array.
json device_config;
copy_bool(device_config, "support_chamber");
copy_bool(device_config, "support_first_layer_inspect");
copy_bool(device_config, "support_ai_monitoring");
copy_bool(device_config, "support_lidar_calibration");
const auto chamber_edit_it = flags.find("support_chamber_temp_edit");
if (chamber_edit_it != flags.end() && chamber_edit_it->is_boolean()) {
device_config["support_chamber_temp_edit"] = *chamber_edit_it;
} else if (chamber_edit_it != flags.end() && chamber_edit_it->is_object()) {
const auto min_it = chamber_edit_it->find("min");
const auto max_it = chamber_edit_it->find("max");
if (min_it != chamber_edit_it->end() && max_it != chamber_edit_it->end() && min_it->is_number() && max_it->is_number()) {
device_config["support_chamber_temp_edit"] = true;
device_config["support_chamber_temp_edit_range"] = {*min_it, *max_it};
}
}
json fan_config;
copy_bool(fan_config, "support_aux_fan");
copy_bool(fan_config, "support_chamber_fan");
m_config->ParseConfig(device_config);
m_fan->ParseV2_0(fan_config);
}
static bool is_hex_digit(char c) {
return std::isxdigit(static_cast<unsigned char>(c)) != 0;
}

View File

@@ -947,6 +947,7 @@ public:
/*for parse new info*/
bool check_enable_np(const json& print) const;
void parse_new_info(json print);
void parse_new_info2(const json& info);
int get_flag_bits(std::string str, int start, int count = 1) const;
uint32_t get_flag_bits_no_border(std::string str, int start_idx, int count = 1) const;
int get_flag_bits(int num, int start, int count = 1, int base = 10) const;

View File

@@ -3996,6 +3996,7 @@ void GUI_App::switch_printer_agent()
std::string log_dir = data_dir();
std::string cloud_agent_id = agent_info.id == BBL_PRINTER_AGENT_ID ? BBL_CLOUD_PROVIDER : ORCA_CLOUD_PROVIDER;
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " " << agent_info.id;
std::shared_ptr<ICloudServiceAgent> cloud_agent = m_agent->get_cloud_agent(cloud_agent_id);
// Create new printer agent via registry

View File

@@ -290,7 +290,6 @@ void refresh_agora_url(char const* device, char const* dev_ver, char const* chan
void MediaPlayCtrl::Play()
{
switch (current_mode()) {
case CameraStreamMode::http:
case CameraStreamMode::http_snapshot:
if (!m_next_retry.IsValid() || wxDateTime::Now() < m_next_retry)
return;
@@ -300,14 +299,13 @@ void MediaPlayCtrl::Play()
Stop(_L("Please confirm if the printer is connected."));
return;
}
if (auto agent = wxGetApp().getAgent())
agent->command_start_camera(m_machine);
m_button_play->SetIcon("media_stop");
m_web_ctrl->Load(wxURI(m_url), current_mode());
m_web_ctrl->Play();
m_last_state = wxMEDIASTATE_PLAYING;
SetStatus(_L("Playing..."), false);
return;
case CameraStreamMode::http:
case CameraStreamMode::rtsp:
if (m_next_retry.IsValid() && wxDateTime::Now() < m_next_retry)
return;
@@ -769,7 +767,8 @@ void MediaPlayCtrl::load()
{
m_last_state = MEDIASTATE_LOADING;
SetStatus(_L("Loading..."));
if (current_mode() != CameraStreamMode::rtsp) {
const auto mode = current_mode();
if (mode != CameraStreamMode::rtsp && mode != CameraStreamMode::http) {
std::string file_h264 = data_dir() + "/video.h264";
std::string file_info = data_dir() + "/video.info";
BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl dump video to " << file_h264;

View File

@@ -2308,8 +2308,8 @@ void StatusPanel::update_camera_state(MachineObject* obj)
auto agent = wxGetApp().getAgent();
const auto camera_mode = agent ? agent->get_camera_stream_mode() : CameraStreamMode::none;
const bool has_printer_webcam = camera_mode == CameraStreamMode::http || camera_mode == CameraStreamMode::http_snapshot;
if (has_printer_webcam) {
const bool use_webview = camera_mode == CameraStreamMode::http_snapshot;
if (use_webview) {
//m_camera_switch_button->Hide();
if (!m_custom_camera_view->IsShown()) {
// why: do not reload the WebView URL per tick, or redirects can cause a reload loop.
@@ -2317,10 +2317,14 @@ void StatusPanel::update_camera_state(MachineObject* obj)
m_custom_camera_view->Show();
m_media_ctrl->Hide();
}
} else if (m_custom_camera_view->IsShown()) {
m_custom_camera_view->Hide();
} else {
if (m_custom_camera_view->IsShown()) {
m_custom_camera_view->Hide();
// Stop the snapshot WebView before switching to native playback
// or leaving the camera mode.
m_media_play_ctrl->StopWebStream();
}
m_media_ctrl->Show();
m_media_play_ctrl->StopWebStream();
}
//sdcard
@@ -2354,7 +2358,7 @@ void StatusPanel::update_camera_state(MachineObject* obj)
m_last_recording = obj->is_recording() ? 1 : 0;
}
if (has_printer_webcam) {
if (use_webview) {
if (m_bitmap_recording_img->IsShown()) {
m_bitmap_recording_img->Hide();
m_panel_monitoring_title->Layout();
@@ -2417,7 +2421,7 @@ void StatusPanel::update_camera_state(MachineObject* obj)
m_camera_popup->update(show_vcamera);
}
m_setting_button->Show(!has_printer_webcam);
m_setting_button->Show(!use_webview);
}
StatusPanel::StatusPanel(wxWindow *parent, wxWindowID id, const wxPoint &pos, const wxSize &size, long style, const wxString &name)

View File

@@ -52,10 +52,13 @@ void WebMediaController::Play()
"refreshCameraFrame();"
"setInterval(refreshCameraFrame,200);"
"</script></body></html>";
m_webview->SetPage(html, url);
} else {
html += " src=\"" + url + "\"></body></html>";
// Load MJPEG streams as the top-level document. Some embedded WebView
// backends buffer a multipart stream when it is used as an <img> resource,
// which introduces noticeable live-view latency.
m_webview->LoadURL(url);
}
m_webview->SetPage(html, url);
}
void WebMediaController::Stop()

View File

@@ -188,14 +188,14 @@ void wxMediaCtrl3::bambu_log(void *ctx, int level, tchar const *msg2)
BOOST_LOG_TRIVIAL(info) << msg.ToUTF8().data();
}
int wxMediaCtrl3::rtsp_interrupt_callback(void *opaque)
int wxMediaCtrl3::ffmpeg_interrupt_callback(void *opaque)
{
auto *ctrl = static_cast<wxMediaCtrl3 *>(opaque);
std::lock_guard<std::mutex> lock(ctrl->m_mutex);
return ctrl->m_url != ctrl->m_active_url;
}
int wxMediaCtrl3::PlayRtsp(std::shared_ptr<wxURI> const &url, std::unique_lock<std::mutex> &lock)
int wxMediaCtrl3::PlayFfmpeg(std::shared_ptr<wxURI> const &url, std::unique_lock<std::mutex> &lock)
{
if (avformat_network_init() < 0)
return 2;
@@ -206,7 +206,9 @@ int wxMediaCtrl3::PlayRtsp(std::shared_ptr<wxURI> const &url, std::unique_lock<s
return 2;
}
format_context->interrupt_callback = {&wxMediaCtrl3::rtsp_interrupt_callback, this};
format_context->interrupt_callback = {&wxMediaCtrl3::ffmpeg_interrupt_callback, this};
format_context->flags |= AVFMT_FLAG_NOBUFFER;
format_context->max_delay = 0;
m_active_url = url;
auto finish = [&](int error) {
@@ -219,8 +221,20 @@ int wxMediaCtrl3::PlayRtsp(std::shared_ptr<wxURI> const &url, std::unique_lock<s
};
const std::string uri = url->BuildURI().ToUTF8().data();
const wxString scheme = url->GetScheme();
const bool http_stream = scheme.CmpNoCase("http") == 0 || scheme.CmpNoCase("https") == 0;
AVDictionary *options = nullptr;
av_dict_set(&options, "rtsp_transport", "tcp", 0);
if (http_stream) {
// This is a live multipart MJPEG stream. Keep FFmpeg from building a
// read-ahead buffer, otherwise the UI can display frames several
// seconds behind the camera.
av_dict_set(&options, "fflags", "nobuffer", 0);
av_dict_set(&options, "avioflags", "direct", 0);
av_dict_set(&options, "probesize", "32", 0);
av_dict_set(&options, "analyzeduration", "0", 0);
} else {
av_dict_set(&options, "rtsp_transport", "tcp", 0);
}
lock.unlock();
int error = avformat_open_input(&format_context, uri.c_str(), nullptr, &options);
av_dict_free(&options);
@@ -278,7 +292,12 @@ int wxMediaCtrl3::PlayRtsp(std::shared_ptr<wxURI> const &url, std::unique_lock<s
break;
if (frame.IsOk())
m_frame = frame;
CallAfter([this] { Refresh(); });
if (!m_refresh_pending.exchange(true)) {
CallAfter([this] {
m_refresh_pending.store(false);
Refresh();
});
}
}
}
av_packet_unref(packet);
@@ -301,10 +320,11 @@ void wxMediaCtrl3::PlayThread()
if (!url->HasScheme())
break;
const wxString scheme = url->GetScheme();
const bool generic_rtsp = scheme.CmpNoCase("rtsp") == 0 || scheme.CmpNoCase("rtsps") == 0;
const bool generic_ffmpeg = scheme.CmpNoCase("http") == 0 || scheme.CmpNoCase("https") == 0 ||
scheme.CmpNoCase("rtsp") == 0 || scheme.CmpNoCase("rtsps") == 0;
int error = 0;
if (generic_rtsp) {
error = PlayRtsp(url, lk);
if (generic_ffmpeg) {
error = PlayFfmpeg(url, lk);
} else {
lk.unlock();
Bambu_Tunnel tunnel = nullptr;

View File

@@ -16,6 +16,7 @@ wxDECLARE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent);
void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height);
#define BAMBU_DYNAMIC
#include <atomic>
#include <condition_variable>
#include <thread>
#ifndef _WIN32
@@ -56,10 +57,10 @@ protected:
void DoSetSize(int x, int y, int width, int height, int sizeFlags) override;
static void bambu_log(void *ctx, int level, tchar const *msg);
static int rtsp_interrupt_callback(void *opaque);
static int ffmpeg_interrupt_callback(void *opaque);
void PlayThread();
int PlayRtsp(std::shared_ptr<wxURI> const &url, std::unique_lock<std::mutex> &lock);
int PlayFfmpeg(std::shared_ptr<wxURI> const &url, std::unique_lock<std::mutex> &lock);
void NotifyStopped();
@@ -83,6 +84,7 @@ private:
std::mutex m_mutex;
std::condition_variable m_cond;
std::thread m_thread;
std::atomic_bool m_refresh_pending{false};
};
#endif /* wxMediaCtrl3_h */

View File

@@ -246,7 +246,7 @@ public:
virtual std::string get_user_selected_machine() = 0;
/**
* Update the selected machine preference.
* Update the selected cloud machine preference.
*/
virtual int set_user_selected_machine(std::string dev_id) = 0;

View File

@@ -65,522 +65,6 @@ using json = nlohmann::json;
namespace Slic3r {
struct OrcaCloudMqttConnection::Connection {
boost::asio::io_context io_context;
boost::asio::ssl::context ssl_context;
WebSocket websocket;
boost::asio::ip::tcp::resolver resolver;
Connection()
: ssl_context(boost::asio::ssl::context::tls_client)
, websocket(io_context, ssl_context)
, resolver(io_context)
{}
};
OrcaCloudMqttConnection::~OrcaCloudMqttConnection() { stop(); }
bool OrcaCloudMqttConnection::start(const std::string& endpoint, TokenProvider token_provider, MessageHandler message_handler, StateHandler state_handler) {
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start endpoint=" << endpoint
<< " token_callback=" << (token_provider ? "set" : "null")
<< " message_callback=" << (message_handler ? "set" : "null")
<< " state_callback=" << (state_handler ? "set" : "null");
stop();
{
std::lock_guard<std::mutex> lock(mutex);
endpoint_url = endpoint;
get_token = std::move(token_provider);
on_message = std::move(message_handler);
on_state = std::move(state_handler);
initial_result = false;
initial_completed = false;
connected = false;
}
stopping.store(false);
worker = std::thread(&OrcaCloudMqttConnection::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;
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT initial connection timed out after 10 seconds; worker will retry";
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start initial_result=" << initial_result
<< " initial_completed=" << initial_completed;
return initial_result;
}
void OrcaCloudMqttConnection::stop() {
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT stop requested";
stopping.store(true);
state_cv.notify_all();
{
std::lock_guard<std::mutex> lock(connection_mutex);
if (active_connection) {
auto& socket = boost::beast::get_lowest_layer(active_connection->websocket).socket();
boost::system::error_code socket_error;
socket.cancel(socket_error);
socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, socket_error);
socket.close(socket_error);
active_connection->resolver.cancel();
}
}
if (worker.joinable())
worker.join();
{
std::lock_guard<std::mutex> lock(mutex);
connected = false;
if (!initial_completed) {
initial_completed = true;
initial_result = false;
}
}
initial_cv.notify_all();
}
bool OrcaCloudMqttConnection::is_running() const {
return worker.joinable() && !stopping.load();
}
void OrcaCloudMqttConnection::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
// beast permits a concurrent writer while the worker is blocked in
// websocket.read(); every write is serialised by write_mutex inside send().
try {
send_pending_subscriptions(conn->websocket);
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: direct subscription write failed (" << e.what()
<< "); worker will resend the full set on reconnect";
}
}
bool OrcaCloudMqttConnection::subscribe(const std::vector<std::string>& device_ids) {
{
std::lock_guard<std::mutex> lock(mutex);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe requested count=" << device_ids.size()
<< " connected=" << connected.load();
for (const std::string& device_id : device_ids) {
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe requested dev_id=" << device_id;
if (device_id.empty() || report_topic(device_id).size() > 96) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT subscribe rejected invalid dev_id=" << device_id;
return false;
}
}
for (const std::string& device_id : device_ids) {
subscriptions.insert(device_id);
pending_subscriptions.insert(device_id);
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe queued total_subscriptions=" << subscriptions.size()
<< " pending_subscriptions=" << pending_subscriptions.size();
}
state_cv.notify_all();
flush_subscription_change(); // emit SUBSCRIBE now on the live socket (no reconnect)
return true;
}
bool OrcaCloudMqttConnection::unsubscribe(const std::vector<std::string>& device_ids) {
{
std::lock_guard<std::mutex> lock(mutex);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe requested count=" << device_ids.size()
<< " connected=" << connected.load();
for (const std::string& device_id : device_ids) {
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe requested dev_id=" << device_id;
subscriptions.erase(device_id);
pending_subscriptions.erase(device_id);
pending_unsubscriptions.insert(device_id);
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe queued total_subscriptions=" << subscriptions.size()
<< " pending_unsubscriptions=" << pending_unsubscriptions.size();
}
state_cv.notify_all();
flush_subscription_change(); // emit UNSUBSCRIBE now on the live socket (no reconnect)
return true;
}
void OrcaCloudMqttConnection::clear_subscriptions() {
std::lock_guard<std::mutex> lock(mutex);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT clear subscriptions count=" << subscriptions.size();
subscriptions.clear();
pending_subscriptions.clear();
pending_unsubscriptions.clear();
}
bool OrcaCloudMqttConnection::parse_endpoint(const std::string& url, Endpoint& endpoint) {
constexpr const char* scheme = "wss://";
constexpr size_t scheme_length = 6;
if (url.compare(0, scheme_length, scheme) != 0)
return false;
const size_t authority_start = scheme_length;
const size_t path_start = url.find('/', authority_start);
const std::string authority = url.substr(authority_start, path_start - authority_start);
if (authority.empty())
return false;
const size_t port_start = authority.rfind(':');
if (port_start != std::string::npos && authority.find(']') == std::string::npos) {
endpoint.host = authority.substr(0, port_start);
endpoint.port = authority.substr(port_start + 1);
} else {
endpoint.host = authority;
endpoint.port = "443";
}
endpoint.target = path_start == std::string::npos ? "/" : url.substr(path_start);
return !endpoint.host.empty() && !endpoint.port.empty() && !endpoint.target.empty();
}
void OrcaCloudMqttConnection::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 OrcaCloudMqttConnection::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> OrcaCloudMqttConnection::make_connect_packet() {
std::vector<uint8_t> packet{0x10};
append_string(packet, "MQTT");
packet.insert(packet.end(), {4, 2, 0, 60}); // level 4, clean session, 60 s keepalive
append_string(packet, "OrcaSlicer");
prepend_remaining_length(packet, packet.size() - 1);
return packet;
}
std::string OrcaCloudMqttConnection::report_topic(const std::string& device_id) { return "device/" + device_id + "/report"; }
std::vector<uint8_t> OrcaCloudMqttConnection::make_topic_packet(uint8_t type, uint16_t packet_id, const std::vector<std::string>& device_ids) {
std::vector<uint8_t> packet{type};
packet.push_back(static_cast<uint8_t>(packet_id >> 8));
packet.push_back(static_cast<uint8_t>(packet_id & 0xff));
for (const std::string& device_id : device_ids) {
append_string(packet, report_topic(device_id));
if (type == 0x82) // SUBSCRIBE, QoS 0 is sufficient for printer reports.
packet.push_back(0);
}
prepend_remaining_length(packet, packet.size() - 1);
return packet;
}
std::vector<uint8_t> OrcaCloudMqttConnection::make_ping_packet() { return {0xc0, 0}; }
void OrcaCloudMqttConnection::send(WebSocket& websocket, const std::vector<uint8_t>& packet) {
if (packet.empty()) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: attempted to send empty MQTT packet";
return;
}
// Writes come from the worker thread AND, for dynamic (un)subscribes, the
// caller thread. Serialise them; the worker's concurrent read is fine (beast
// allows one reader + one writer).
std::lock_guard<std::mutex> lock(write_mutex);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending MQTT packet type=0x" << std::hex
<< static_cast<unsigned int>(packet[0] >> 4) << std::dec
<< " bytes=" << packet.size();
websocket.binary(true);
websocket.write(boost::asio::buffer(packet));
}
void OrcaCloudMqttConnection::connect_and_read() {
auto connection = std::make_shared<Connection>();
{
std::lock_guard<std::mutex> lock(connection_mutex);
active_connection = connection;
if (stopping.load())
return;
}
Endpoint endpoint;
if (!parse_endpoint(endpoint_url, endpoint)) {
BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: invalid MQTT endpoint=" << endpoint_url;
throw std::runtime_error("invalid Orca Cloud WebSocket endpoint");
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connecting host=" << endpoint.host
<< " port=" << endpoint.port << " target=" << endpoint.target;
auto& websocket = connection->websocket;
const auto results = connection->resolver.resolve(endpoint.host, endpoint.port);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT DNS resolution succeeded host=" << endpoint.host;
auto& stream = boost::beast::get_lowest_layer(websocket);
stream.expires_after(std::chrono::seconds(10));
stream.connect(results);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TCP connection established host=" << endpoint.host
<< " port=" << endpoint.port;
// The aggregate viewer is a TLS WebSocket endpoint. 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");
connection->ssl_context.set_default_verify_paths();
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);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TLS handshake completed host=" << endpoint.host;
const std::string token = get_token ? get_token() : std::string();
if (token.empty()) {
BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: MQTT token callback returned an empty token";
throw std::runtime_error("no access token for Orca Cloud WebSocket");
}
websocket.set_option(boost::beast::websocket::stream_base::decorator(
[token](boost::beast::websocket::request_type& request) {
request.set(boost::beast::http::field::user_agent, "OrcaSlicer");
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;
websocket.handshake(response, endpoint.host, endpoint.target, handshake_error);
if (handshake_error) {
// Surface the server's HTTP status so a persistent rejection (stale token,
// missing api key, wrong route) is diagnosable from the log rather than an
// opaque "handshake declined".
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudMqttConnection: handshake rejected, http="
<< response.result_int() << " (" << response.reason() << "), "
<< handshake_error.message();
throw boost::system::system_error(handshake_error, "Orca Cloud WebSocket handshake");
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: WebSocket handshake completed http=" << response.result_int()
<< " negotiated_protocol=" << response["Sec-WebSocket-Protocol"];
if (response["Sec-WebSocket-Protocol"] != "mqtt") {
BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: WebSocket handshake did not negotiate MQTT";
throw std::runtime_error("Orca Cloud WebSocket did not negotiate MQTT");
}
stream.expires_never();
send(websocket, make_connect_packet());
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNECT packet sent";
boost::beast::flat_buffer buffer;
stream.expires_after(std::chrono::seconds(10));
websocket.read(buffer);
const std::string connack = boost::beast::buffers_to_string(buffer.data());
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNACK received bytes=" << connack.size()
<< " header=" << (connack.empty() ? -1 : static_cast<int>(static_cast<uint8_t>(connack[0])))
<< " return_code=" << (connack.size() > 3 ? static_cast<int>(static_cast<uint8_t>(connack[3])) : -1);
if (connack.size() != 4 || static_cast<uint8_t>(connack[0]) != 0x20 ||
static_cast<uint8_t>(connack[2]) != 0x00 || static_cast<uint8_t>(connack[3]) != 0x00) {
BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: MQTT CONNECT was refused or malformed";
throw std::runtime_error("Orca Cloud MQTT CONNECT was refused");
}
notify_state(true);
reconnect_delay_seconds.store(1); // a fresh CONNACK resets the backoff
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection is ready; sending current subscriptions";
send_current_subscriptions(websocket);
std::chrono::steady_clock::time_point next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30);
while (!stopping.load()) {
send_pending_subscriptions(websocket);
buffer.consume(buffer.size());
stream.expires_after(std::chrono::seconds(1));
boost::system::error_code error;
websocket.read(buffer, error);
if (error == boost::beast::error::timeout) {
if (std::chrono::steady_clock::now() >= next_ping) {
send(websocket, make_ping_packet());
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT PINGREQ sent";
next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30);
}
continue;
}
if (error) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT WebSocket read failed code=" << error.value()
<< " message=" << error.message();
throw boost::system::system_error(error, "read Orca Cloud MQTT message");
}
handle_packet(boost::beast::buffers_to_string(buffer.data()));
}
boost::system::error_code close_error;
websocket.close(boost::beast::websocket::close_code::normal, close_error);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection closed code=" << close_error.value()
<< " message=" << close_error.message();
if (!stopping.load())
notify_state(false);
}
void OrcaCloudMqttConnection::send_current_subscriptions(WebSocket& websocket) {
std::vector<std::string> devices;
{
std::lock_guard<std::mutex> lock(mutex);
devices.assign(subscriptions.begin(), subscriptions.end());
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending current MQTT subscriptions count=" << devices.size();
if (!devices.empty()) {
const uint16_t packet_id = next_packet_id++;
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending SUBSCRIBE packet_id=" << packet_id;
send(websocket, make_topic_packet(0x82, packet_id, devices));
}
}
void OrcaCloudMqttConnection::send_pending_subscriptions(WebSocket& websocket) {
std::vector<std::string> subscribe_ids;
std::vector<std::string> unsubscribe_ids;
{
std::lock_guard<std::mutex> lock(mutex);
subscribe_ids.assign(pending_subscriptions.begin(), pending_subscriptions.end());
unsubscribe_ids.assign(pending_unsubscriptions.begin(), pending_unsubscriptions.end());
pending_subscriptions.clear();
pending_unsubscriptions.clear();
}
if (!subscribe_ids.empty()) {
const uint16_t packet_id = next_packet_id++;
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending SUBSCRIBE count=" << subscribe_ids.size()
<< " packet_id=" << packet_id;
for (const std::string& device_id : subscribe_ids)
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: SUBSCRIBE topic=" << report_topic(device_id);
send(websocket, make_topic_packet(0x82, packet_id, subscribe_ids));
}
if (!unsubscribe_ids.empty()) {
const uint16_t packet_id = next_packet_id++;
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending UNSUBSCRIBE count=" << unsubscribe_ids.size()
<< " packet_id=" << packet_id;
for (const std::string& device_id : unsubscribe_ids)
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: UNSUBSCRIBE topic=" << report_topic(device_id);
send(websocket, make_topic_packet(0xa2, packet_id, unsubscribe_ids));
}
}
void OrcaCloudMqttConnection::handle_packet(const std::string& packet) {
if (packet.size() < 2) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: received undersized MQTT packet bytes=" << packet.size();
return;
}
const uint8_t header = static_cast<uint8_t>(packet[0]);
const uint8_t packet_type = header >> 4;
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received MQTT packet type=" << static_cast<unsigned int>(packet_type)
<< " header=0x" << std::hex << static_cast<unsigned int>(header) << std::dec
<< " bytes=" << packet.size();
if (packet_type != 3) { // Only QoS 0 PUBLISH carries printer status.
if (packet_type == 9 && packet.size() >= 5) {
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]));
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received SUBACK packet_id="
<< ((static_cast<unsigned int>(static_cast<uint8_t>(packet[2])) << 8) |
static_cast<unsigned int>(static_cast<uint8_t>(packet[3])))
<< " result_codes=" << result_codes.str();
}
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) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH remaining length";
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) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH body remaining=" << remaining
<< " packet_bytes=" << packet.size();
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) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH topic length=" << topic_length;
return;
}
const std::string topic(packet.data() + index, topic_length);
index += topic_length;
if (((header >> 1) & 0x03) != 0) {
if (index + 2 > remaining_end) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH packet identifier";
return;
}
index += 2; // QoS 1/2 packet identifier; the service currently sends QoS 0.
}
const size_t payload_size = remaining_end - index;
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received PUBLISH topic=" << topic
<< " payload_bytes=" << payload_size
<< " message_callback=" << (on_message ? "set" : "null");
if (on_message)
on_message(topic, packet.substr(index, remaining_end - index));
else
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping PUBLISH because message callback is not set";
}
void OrcaCloudMqttConnection::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;
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT state changed connected=" << is_now_connected
<< " initial=" << initial << " state_callback=" << (callback ? "set" : "null");
if (initial)
initial_cv.notify_all();
else if (callback)
callback(is_now_connected, false);
}
void OrcaCloudMqttConnection::run() {
while (!stopping.load()) {
const int retry_seconds = reconnect_delay_seconds.load();
try {
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection attempt retry_delay=" << retry_seconds;
connect_and_read();
} catch (const std::exception& error) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT connection attempt failed: " << error.what();
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);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT waiting before reconnect seconds=" << retry_seconds;
state_cv.wait_for(lock, std::chrono::seconds(retry_seconds), [this] { return stopping.load(); });
}
}
namespace {
constexpr const char* ORCA_DEFAULT_API_URL = "api.orcaslicer.com";
constexpr const char* ORCA_DEFAULT_AUTH_URL = "https://auth.orcaslicer.com";
@@ -1013,7 +497,7 @@ OrcaCloudServiceAgent::OrcaCloudServiceAgent(std::string log_dir)
, api_base_url(ORCA_DEFAULT_API_URL)
, auth_base_url(ORCA_DEFAULT_AUTH_URL)
, cloud_base_url(ORCA_DEFAULT_CLOUD_URL)
, mqtt_connection(std::make_unique<OrcaCloudMqttConnection>())
, mqtt_connection(std::make_unique<OrcaMqttConnection>())
{
auth_headers["apikey"] = ORCA_DEFAULT_PUB_KEY;
pkce_bundle.loopback_port = choose_loopback_port();
@@ -1487,79 +971,22 @@ int OrcaCloudServiceAgent::connect_server()
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cloud health result=" << result << " http_code=" << http_code
<< " connected=" << connected << " response_bytes=" << response.size();
if (connected && mqtt_connection && !mqtt_connection->is_running()) {
// Only (re)start when the worker isn't already alive. connect_server() is
// also called every ~5s by DeviceManagerRefresher::on_timer via
// refresh_connection(); start() begins with stop(), so calling it
// unconditionally tears down and rebuilds a healthy socket every tick.
const std::string endpoint = "wss://" + api_base_url + "/api/v1/printers/mqtt";
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: starting aggregate MQTT endpoint=" << endpoint;
// Fire-and-forget: start() spawns a worker that reconnects with exponential
// backoff. A failed *initial* attempt (token/network not ready yet during a
// startup gap) must NOT gate the socket's lifetime here — folding it into
// `connected` trips the stop() below and kills the retry loop for the whole
// session. The socket is torn down only on logout / clear_session.
const bool mqtt_started = mqtt_connection->start(
endpoint,
[this] { return get_access_token(); },
[this](const std::string& topic, const std::string& message) {
constexpr const char* prefix = "device/";
constexpr const char* suffix = "/report";
if (topic.compare(0, 7, prefix) != 0 || topic.size() <= 14 ||
topic.compare(topic.size() - 7, 7, suffix) != 0)
return;
const std::string device_id = topic.substr(7, topic.size() - 14);
OnMessageFn callback;
{
std::lock_guard<std::mutex> lock(callback_mutex);
callback = printer_status_callback;
}
if (callback)
callback(device_id, message);
},
[this](bool socket_connected, bool initial) {
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: aggregate MQTT state callback connected="
<< socket_connected << " initial=" << initial;
if (initial)
return;
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
is_connected = socket_connected;
}
invoke_server_connected_callback(socket_connected ? 0 : -1, 0);
});
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: aggregate MQTT start returned=" << mqtt_started;
// connect_server() is a REST health probe only. The long-lived MQTT socket is
// per-printer now, driven by set_user_selected_machine ->
// configure_selected_printer_mqtt; this method must not touch mqtt_connection.
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
is_connected = connected;
}
if (!connected) {
// Transient health-check failure (DNS blip / brief 5xx). Do NOT stop the
// MQTT worker — it owns its own reconnect loop, and connect_server() runs
// on the 5s refresher tick. The socket is torn down only on logout (the
// !logged_in branch above) and clear_session().
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cloud health check failed; leaving aggregate MQTT running";
}
// While the aggregate MQTT worker is alive it owns is_connected via its
// StateHandler. Don't let the 5s health probe overwrite it (a DNS blip would
// otherwise flap the "server connected" state and the Device tab).
const bool mqtt_alive = mqtt_connection && mqtt_connection->is_running();
if (!mqtt_alive) {
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
is_connected = connected;
}
invoke_server_connected_callback(connected ? 0 : -1, http_code);
}
return (connected || mqtt_alive) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
invoke_server_connected_callback(connected ? 0 : -1, http_code);
return connected ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
}
bool OrcaCloudServiceAgent::is_server_connected()
{
// The aggregate MQTT socket is the real signal. While its worker is alive,
// report its actual CONNACK state — immune to the 5s health probe's DNS blips.
// Fall back to the last health-check result only when there is no socket.
if (mqtt_connection && mqtt_connection->is_running())
return mqtt_connection->is_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);
return is_connected;
}
@@ -1586,7 +1013,9 @@ int OrcaCloudServiceAgent::add_subscribe(std::vector<std::string> dev_list)
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: add_subscribe rejected because cloud is not ready";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
const bool queued = mqtt_connection->subscribe(dev_list);
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;
}
@@ -1599,11 +1028,68 @@ int OrcaCloudServiceAgent::del_subscribe(std::vector<std::string> dev_list)
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: del_subscribe rejected because cloud is not ready";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
const bool queued = mqtt_connection->unsubscribe(dev_list);
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::Config cfg;
cfg.url = "wss://" + api_base_url + "/api/v1/printers/" + dev_id + "/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;
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: configuring per-printer 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); },
[this](bool, bool) {});
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: per-printer 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);
@@ -3293,6 +2779,15 @@ int OrcaCloudServiceAgent::get_user_print_info(unsigned int* http_code, std::str
for (const auto& printer : resp_json.value("data", nlohmann::json::array())) {
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")
continue;
device["dev_id"] = printer.value("id", "");
device["dev_name"] = printer.value("name", "");
if (printer.contains("model") && printer["model"].is_string())

View File

@@ -24,84 +24,14 @@
#include <vector>
#include <nlohmann/json.hpp>
#include "OrcaMqttConnection.hpp"
class wxSecretStore;
namespace Slic3r {
// Forward declarations
class AppConfig;
// MQTT 3.1.1 over the aggregate WebSocket is deliberately kept here instead
// of using the printer SDK. The endpoint is a read-only status stream; MQTT
// PUBLISH must never be sent on it because the cloud closes such sessions.
class OrcaCloudMqttConnection
{
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)>;
~OrcaCloudMqttConnection();
bool start(const std::string& endpoint, TokenProvider token_provider, MessageHandler message_handler, StateHandler state_handler);
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::vector<std::string>& device_ids);
bool unsubscribe(const std::vector<std::string>& device_ids);
void clear_subscriptions();
private:
struct Endpoint { std::string host; std::string port; std::string target; };
using WebSocket = boost::beast::websocket::stream<
boost::asio::ssl::stream<boost::beast::tcp_stream>>;
struct Connection;
static bool parse_endpoint(const std::string& url, Endpoint& endpoint);
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_connect_packet();
static std::string report_topic(const std::string& device_id);
static std::vector<uint8_t> make_topic_packet(uint8_t type, uint16_t packet_id, const std::vector<std::string>& device_ids);
static std::vector<uint8_t> make_ping_packet();
void send(WebSocket& websocket, const std::vector<uint8_t>& packet);
// Emit a queued SUBSCRIBE/UNSUBSCRIBE on the live socket right now (from the
// caller thread), so a selection change is applied without waiting for the
// blocking read loop to next return. No-op if no CONNACKed socket exists yet
// (the worker sends the set on connect). The aggregate viewer is dynamic — the
// WebSocket is never dropped for a subscription change.
void flush_subscription_change();
void connect_and_read();
void send_current_subscriptions(WebSocket& websocket);
void send_pending_subscriptions(WebSocket& websocket);
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};
std::thread worker;
std::mutex mutex;
std::mutex connection_mutex;
std::mutex write_mutex; // serialises every websocket write (worker + caller threads)
std::shared_ptr<Connection> active_connection;
std::condition_variable initial_cv;
std::condition_variable state_cv;
std::string endpoint_url;
TokenProvider get_token;
MessageHandler on_message;
StateHandler on_state;
std::set<std::string> subscriptions;
std::set<std::string> pending_subscriptions;
std::set<std::string> pending_unsubscriptions;
std::atomic<uint16_t> next_packet_id{1};
bool initial_result{false};
bool initial_completed{false};
std::atomic_bool connected{false};
};
struct BundleMetadata;
struct PluginDescriptor;
struct PluginChangelog;
@@ -288,10 +218,10 @@ public:
int del_subscribe(std::vector<std::string> dev_list) override;
void enable_multi_machine(bool enable) override;
// The aggregate printer socket is status-only. OrcaPrinterAgent registers
// its normal message callback here and adds/removes device report topics
// through add_subscribe()/del_subscribe(). Printer commands continue to
// use the REST commands endpoint; they must never be published here.
// The per-printer MQTT socket carries both directions: inbound reports from
// device/<id>/report and commands PUBLISHed to device/<id>/request on this
// socket. OrcaPrinterAgent registers its message callback here to receive the
// inbound half; pass an empty fn to clear it before the agent is destroyed.
int set_printer_status_callback(OnMessageFn fn);
// Send a Bambu-dialect command to one printer via the cloud relay's REST
@@ -439,7 +369,26 @@ public:
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();
}
// Per-printer cloud socket: wss://<api_base_url>/api/v1/printers/<dev_id>/mqtt.
// configure_ blocks for the duration of the initial connect attempt, so callers
// drive it off the UI thread; teardown_ is synchronous.
int configure_selected_printer_mqtt(const std::string& dev_id);
void teardown_selected_printer_mqtt();
// Test hook: the wss:// URL of the current per-printer socket ("" when none).
std::string selected_printer_mqtt_url() const;
private:
// Fans one inbound per-printer MQTT message out to printer_status_callback.
void deliver_cloud_message(const std::string& dev_id, const std::string& payload);
// Sync protocol helpers
int sync_pull(
std::function<void(const SyncPullResponse&)> on_success,
@@ -516,7 +465,9 @@ private:
std::chrono::system_clock::now().time_since_epoch()).count()};
// Member variables - connection state
std::unique_ptr<OrcaCloudMqttConnection> mqtt_connection;
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 enable_track{false};
bool multi_machine_enabled{false};

View File

@@ -0,0 +1,839 @@
#include "OrcaMqttConnection.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 <boost/log/trivial.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;
// 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)
{}
};
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);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start url=" << config.url
<< " use_tls=" << config.use_tls
<< " bearer_provider=" << (config.bearer_provider ? "set" : "null")
<< " username_present=" << (!config.username.empty())
<< " password_present=" << (!config.password.empty())
<< " client_id=" << config.client_id
<< " keepalive_seconds=" << config.keepalive_seconds
<< " message_callback=" << (on_message ? "set" : "null")
<< " state_callback=" << (on_state ? "set" : "null");
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;
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT initial connection timed out after 10 seconds"
<< " url=" << current_config.url
<< " last_connack_rc=" << m_last_connack_rc.load()
<< " connected=" << connected.load()
<< "; worker will retry";
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start initial_result=" << initial_result
<< " initial_completed=" << initial_completed
<< " last_connack_rc=" << m_last_connack_rc.load()
<< " worker_running=" << (worker.joinable() && !stopping.load());
return initial_result;
}
void OrcaMqttConnection::stop() {
std::lock_guard<std::recursive_mutex> lifecycle_lock(lifecycle_mutex);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT stop requested"
<< " url=" << current_config.url
<< " connected=" << connected.load()
<< " worker_joinable=" << worker.joinable()
<< " last_connack_rc=" << m_last_connack_rc.load();
stopping.store(true);
state_cv.notify_all();
{
std::lock_guard<std::mutex> lock(connection_mutex);
if (active_connection) {
// Generic so it accepts either the TLS or the plaintext websocket.
auto shutdown_socket = [](auto& websocket) {
auto& socket = boost::beast::get_lowest_layer(websocket).socket();
boost::system::error_code socket_error;
socket.cancel(socket_error);
socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, socket_error);
socket.close(socket_error);
};
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
// beast permits a concurrent writer while the worker is blocked in
// websocket.read(); every write is serialised by write_mutex inside ws_write().
try {
send_pending_subscriptions(*conn);
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: direct subscription write failed (" << e.what()
<< "); worker will resend the full set on reconnect";
}
}
bool OrcaMqttConnection::subscribe(const std::string& dev_id) {
if (dev_id.empty()) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT subscribe rejected empty dev_id";
return false;
}
const std::string topic = report_topic(dev_id);
if (topic.size() > 96) { // MQTT topic filter cap enforced by the service
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT subscribe rejected oversized topic=" << topic;
return false;
}
{
std::lock_guard<std::mutex> lock(mutex);
if (subscriptions.count(topic) != 0 && pending_unsubscriptions.count(topic) == 0) {
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe already queued or active topic=" << topic
<< " acknowledged=" << (acknowledged_subscriptions.count(topic) != 0);
return true;
}
subscriptions.insert(topic);
pending_unsubscriptions.erase(topic);
pending_subscriptions.insert(topic);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe queued topic=" << topic
<< " total_subscriptions=" << subscriptions.size()
<< " connected=" << connected.load();
}
state_cv.notify_all();
flush_subscription_change(); // emit SUBSCRIBE now on the live socket (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;
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe queued topic=" << topic
<< " total_subscriptions=" << subscriptions.size()
<< " connected=" << connected.load();
}
state_cv.notify_all();
flush_subscription_change(); // emit UNSUBSCRIBE now on the live socket (no reconnect)
return true;
}
void OrcaMqttConnection::clear_subscriptions() {
std::lock_guard<std::mutex> lock(mutex);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT clear subscriptions count=" << subscriptions.size();
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()) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: attempted to send empty MQTT packet";
return;
}
// Writes come from the worker thread AND, for dynamic (un)subscribes, the
// caller thread. Serialise them; the worker's concurrent read is fine (beast
// allows one reader + one writer).
std::lock_guard<std::mutex> lock(write_mutex);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending MQTT packet type=0x" << std::hex
<< static_cast<unsigned int>(packet[0] >> 4) << std::dec
<< " bytes=" << packet.size();
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));
}
}
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_close(Connection& conn) {
boost::system::error_code close_error;
if (conn.wss)
conn.wss->close(boost::beast::websocket::close_code::normal, close_error);
else if (conn.ws)
conn.ws->close(boost::beast::websocket::close_code::normal, close_error);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection closed code=" << close_error.value()
<< " message=" << close_error.message();
}
void OrcaMqttConnection::ws_handshake(Connection& conn, const Config& config, const Endpoint& endpoint) {
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: WebSocket resolve starting host=" << endpoint.host
<< " port=" << endpoint.port << " target=" << endpoint.target
<< " tls=" << config.use_tls;
const auto results = conn.resolver.resolve(endpoint.host, endpoint.port);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT DNS resolution succeeded host=" << endpoint.host;
std::string token;
if (config.bearer_provider) {
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: requesting bearer token for WebSocket upgrade";
token = config.bearer_provider();
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: bearer token callback completed token_present=" << !token.empty();
}
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);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TCP connection established host=" << endpoint.host
<< " port=" << endpoint.port;
// 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");
conn.ssl_context.set_default_verify_paths();
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);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TLS handshake completed host=" << endpoint.host;
websocket.set_option(boost::beast::websocket::stream_base::decorator(decorator));
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending TLS WebSocket upgrade target=" << endpoint.target
<< " bearer_header=" << (!token.empty());
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);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TCP connection established host=" << endpoint.host
<< " port=" << endpoint.port << " (plaintext)";
websocket.set_option(boost::beast::websocket::stream_base::decorator(decorator));
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending plaintext WebSocket upgrade target=" << endpoint.target
<< " bearer_header=" << (!token.empty());
websocket.handshake(response, endpoint.host, endpoint.target, handshake_error);
}
if (handshake_error) {
// Surface the server's HTTP status so a persistent rejection (stale token,
// missing api key, wrong route) is diagnosable from the log rather than an
// opaque "handshake declined".
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: WS handshake rejected http="
<< response.result_int() << " (" << response.reason() << "), "
<< handshake_error.message();
throw boost::system::system_error(handshake_error, "Orca WebSocket handshake");
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: WebSocket handshake completed http=" << response.result_int()
<< " negotiated_protocol=" << response["Sec-WebSocket-Protocol"];
if (response["Sec-WebSocket-Protocol"] != "mqtt") {
BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: WebSocket handshake did not negotiate 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()) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT send_request rejected empty dev_id";
return false;
}
if (!connected.load()) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT send_request rejected because connection is not ready"
<< " dev_id=" << dev_id << " last_connack_rc=" << m_last_connack_rc.load();
return false;
}
std::shared_ptr<Connection> conn;
{
std::lock_guard<std::mutex> lock(connection_mutex);
conn = active_connection;
}
if (!conn) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT send_request rejected because active connection is null"
<< " dev_id=" << dev_id;
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);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT request queued until SUBACK"
<< " dev_id=" << dev_id << " payload_bytes=" << payload.size()
<< " pending_requests=" << pending_requests.size();
return true;
}
}
try {
// ws_write() serialises the write via write_mutex; do not lock it here.
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT PUBLISH request dev_id=" << dev_id
<< " topic=" << request_topic(dev_id)
<< " payload_bytes=" << payload.size();
ws_write(*conn, make_publish_packet(request_topic(dev_id), payload));
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: send_request failed dev_id=" << dev_id
<< " (" << e.what() << ")";
return false;
}
return true;
}
void OrcaMqttConnection::connect_and_read() {
m_connection_stage = "creating connection";
auto connection = std::make_shared<Connection>();
{
std::lock_guard<std::mutex> lock(connection_mutex);
active_connection = connection;
if (stopping.load())
return;
}
m_connection_stage = "parsing endpoint";
Endpoint endpoint;
if (!parse_endpoint(current_config.url, endpoint)) {
BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: invalid MQTT endpoint=" << current_config.url;
throw std::runtime_error("invalid Orca Cloud WebSocket endpoint");
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connecting host=" << endpoint.host
<< " port=" << endpoint.port << " target=" << endpoint.target;
m_connection_stage = "WebSocket handshake";
ws_handshake(*connection, current_config, endpoint);
m_connection_stage = "sending MQTT CONNECT";
expires_never(*connection);
// 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_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNECT packet sent";
m_connection_stage = "waiting for MQTT CONNACK";
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");
const std::string connack = boost::beast::buffers_to_string(buffer.data());
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNACK received bytes=" << connack.size()
<< " header=" << (connack.empty() ? -1 : static_cast<int>(static_cast<uint8_t>(connack[0])))
<< " return_code=" << (connack.size() > 3 ? static_cast<int>(static_cast<uint8_t>(connack[3])) : -1);
// 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) {
BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: MQTT CONNECT refused rc=" << rc;
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));
}
m_connection_stage = "reading MQTT messages";
// 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();
}
notify_state(true);
reconnect_delay_seconds.store(1); // a fresh CONNACK resets the backoff
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection is ready; sending current subscriptions";
send_current_subscriptions(*connection);
std::chrono::steady_clock::time_point next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30);
while (!stopping.load()) {
send_pending_subscriptions(*connection);
// Keepalive is driven every iteration, not only from the read-timeout branch:
// a printer pushing faster than the 1s read deadline would otherwise keep the
// read hot and the broker would drop us at 1.5 x keepalive.
if (std::chrono::steady_clock::now() >= next_ping) {
ws_write(*connection, make_ping_packet());
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT PINGREQ sent";
next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30);
}
buffer.consume(buffer.size());
m_connection_stage = "reading MQTT frame";
expires_after(*connection, std::chrono::seconds(1));
boost::system::error_code error;
ws_read(*connection, buffer, error);
if (error == boost::beast::error::timeout)
continue;
if (error) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT WebSocket read failed code=" << error.value()
<< " message=" << error.message();
throw boost::system::system_error(error, "read Orca MQTT message");
}
handle_packet(boost::beast::buffers_to_string(buffer.data()));
}
ws_close(*connection);
if (!stopping.load())
notify_state(false);
}
void OrcaMqttConnection::send_current_subscriptions(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);
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending current MQTT subscriptions count=" << topics.size();
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;
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending SUBSCRIBE topic=" << topic << " packet_id=" << packet_id;
ws_write(conn, make_subscribe_packet(packet_id, topic, 1));
}
}
void OrcaMqttConnection::send_pending_subscriptions(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;
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending SUBSCRIBE topic=" << topic
<< " packet_id=" << packet_id;
ws_write(conn, make_subscribe_packet(packet_id, topic, 1));
}
for (const std::string& topic : unsubscribe_topics) {
const uint16_t packet_id = next_packet_id++;
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending UNSUBSCRIBE topic=" << topic
<< " packet_id=" << packet_id;
ws_write(conn, make_unsubscribe_packet(packet_id, topic));
}
}
void OrcaMqttConnection::handle_packet(const std::string& packet) {
if (packet.size() < 2) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: received undersized MQTT packet bytes=" << packet.size();
return;
}
const uint8_t header = static_cast<uint8_t>(packet[0]);
const uint8_t packet_type = header >> 4;
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received MQTT packet type=" << static_cast<unsigned int>(packet_type)
<< " header=0x" << std::hex << static_cast<unsigned int>(header) << std::dec
<< " bytes=" << packet.size();
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]));
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received SUBACK packet_id="
<< packet_id
<< " result_codes=" << result_codes.str();
// 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()) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: SUBACK has no pending topic packet_id=" << packet_id;
} else if (result == 0 || result == 1) {
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: report subscription active topic=" << topic
<< " granted_qos=" << static_cast<unsigned int>(result)
<< " releasing_requests=" << requests.size();
for (const auto& request : requests) {
if (!send_request(request.first, request.second)) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: queued MQTT request could not be sent"
<< " after SUBACK dev_id=" << request.first;
}
}
} else {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: report subscription rejected topic=" << topic
<< " result_code=0x" << std::hex << static_cast<unsigned int>(result) << std::dec
<< " dropped_requests=" << requests.size();
}
}
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) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH remaining length";
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) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH body remaining=" << remaining
<< " packet_bytes=" << packet.size();
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) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH topic length=" << topic_length;
return;
}
const std::string topic(packet.data() + index, topic_length);
index += topic_length;
if (((header >> 1) & 0x03) != 0) {
if (index + 2 > remaining_end) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH packet identifier";
return;
}
index += 2; // QoS 1/2 packet identifier; the service currently sends QoS 0.
}
const size_t payload_size = remaining_end - index;
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received PUBLISH topic=" << topic
<< " payload_bytes=" << payload_size
<< " message_callback=" << (on_message ? "set" : "null");
// 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()) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping PUBLISH on unrecognized topic=" << topic;
} else if (on_message) {
on_message(dev_id, packet.substr(index, remaining_end - index));
} else {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping PUBLISH because message callback is not set";
}
}
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;
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT state changed connected=" << is_now_connected
<< " initial=" << initial << " state_callback=" << (callback ? "set" : "null");
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;
m_connection_stage = "starting attempt";
try {
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection attempt=" << attempt
<< " retry_delay=" << retry_seconds
<< " url=" << current_config.url;
connect_and_read();
} catch (const std::exception& error) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT connection attempt=" << attempt
<< " failed stage=" << m_connection_stage
<< " error=" << error.what()
<< " last_connack_rc=" << m_last_connack_rc.load()
<< " stopping=" << stopping.load();
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);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT waiting before reconnect seconds=" << retry_seconds;
state_cv.wait_for(lock, std::chrono::seconds(retry_seconds), [this] { return stopping.load(); });
}
}
} // namespace Slic3r

View File

@@ -0,0 +1,152 @@
#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 (per-printer) 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;
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).
void ws_write(Connection& conn, const std::vector<uint8_t>& packet); // locks write_mutex
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 ws_close(Connection& conn);
// Emit a queued SUBSCRIBE/UNSUBSCRIBE on the live socket right now (from the
// caller thread), so a selection change is applied without waiting for the
// blocking read loop to next return. No-op if no CONNACKed socket exists yet
// (the worker sends the set on connect). The WebSocket is never dropped for a
// subscription change.
void flush_subscription_change();
void connect_and_read();
void send_current_subscriptions(Connection& conn);
void send_pending_subscriptions(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::mutex write_mutex; // serialises every websocket write (worker + caller threads)
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
std::string m_connection_stage; // worker-thread diagnostic stage
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

View File

@@ -3,21 +3,28 @@
#include "IPrinterAgent.hpp"
#include "ICloudServiceAgent.hpp"
#include "OrcaCloudServiceAgent.hpp"
#include "OrcaMqttConnection.hpp"
#include <atomic>
#include <cstdint>
#include <functional>
#include <string>
#include <mutex>
#include <memory>
#include <thread>
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.
* Actual printer connectivity requires the BBL SDK or future Orca implementation.
* LAN and cloud commands use the same OrcaSonar protocol payloads; only the
* MQTT connection selected by route_send() differs.
*/
class OrcaPrinterAgent : public IPrinterAgent {
class OrcaPrinterAgent : public IPrinterAgent
{
public:
explicit OrcaPrinterAgent(std::string log_dir);
~OrcaPrinterAgent() override;
@@ -44,12 +51,21 @@ public:
// Binding
int ping_bind(std::string ping_code) override;
int bind_detect(std::string dev_ip, std::string sec_link, detectResult& detect) override;
int bind(std::string dev_ip, std::string dev_id, std::string dev_model, std::string sec_link, std::string timezone, bool improved, OnUpdateStatusFn update_fn) override;
int bind(std::string dev_ip,
std::string dev_id,
std::string dev_model,
std::string sec_link,
std::string timezone,
bool improved,
OnUpdateStatusFn update_fn) override;
int unbind(std::string dev_id) override;
int request_bind_ticket(std::string* ticket) override;
int get_hms_snapshot(std::string dev_id, std::string file_name, std::function<void(std::string, int)> callback) override;
int set_server_callback(OnServerErrFn fn) override;
CameraStreamMode get_camera_stream_mode() const override;
std::string get_camera_url() const override;
// Machine Selection
std::string get_user_selected_machine() override;
int set_user_selected_machine(std::string dev_id) override;
@@ -79,16 +95,123 @@ public:
int set_on_local_message_fn(OnMessageFn fn) override;
int set_queue_on_main_fn(QueueOnMainFn fn) override;
int command_ams_refresh_rfid(std::string dev_id, std::string 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; }
protected:
// Forward one inbound printer message to on_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);
// 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);
// LAN has a separate callback in the existing IPrinterAgent contract because the
// GUI parses local reports with the "lan" dialect and looks up local machines.
void deliver_to_local_sink(const std::string& dev_id, const std::string& payload);
// 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);
// 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:
class OrcaSonarDiscovery;
std::string log_dir;
std::string selected_machine;
std::shared_ptr<ICloudServiceAgent> m_cloud_agent;
OrcaCloudServiceAgent* m_orca_cloud = nullptr; // == m_cloud_agent.get() when the Orca provider is active
// MOCK: OrcaCloud does not yet relay the printer's info.get_version reply, so
// synthesize it and feed it through on_message_fn (same sink as real report
// messages). Delete once the backend answers info.get_version.
void deliver_mock_get_version(const std::string& dev_id);
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::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
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 cloud
// per-printer 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
OnMsgArrivedFn on_ssdp_msg_fn;

View File

@@ -13,6 +13,8 @@ add_executable(${_TEST_NAME}_tests
test_plugin_status.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_lifecycle.cpp
test_slicing_pipeline_bindings.cpp

View File

@@ -0,0 +1,317 @@
#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(); }
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
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. beast permits a writer while the worker
// is blocked in read(), which is the same arrangement OrcaMqttConnection uses.
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();
socket.cancel(ec);
// shutdown() before close() is what actually wakes a blocking read on the
// worker thread; close() alone does not on POSIX.
socket.shutdown(tcp::socket::shutdown_both, ec);
socket.close(ec);
}
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};
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

View File

@@ -0,0 +1,229 @@
#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 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();
}

View File

@@ -0,0 +1,170 @@
#include <catch2/catch_test_macros.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;
};
}
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"}})");
CHECK(got_id == "dev-1");
CHECK(got_payload.find("push_status") != std::string::npos);
}
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("connect_printer wires up a LAN Config", "[OrcaPrinterAgent][.integration]") {
Probe agent("/tmp");
const int rc = agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false);
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 per-printer 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/printer-uuid-1/mqtt");
agent.set_user_selected_machine(""); // teardown is synchronous
CHECK(cloud->selected_printer_mqtt_url().empty());
}
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("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("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();
}