mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-16 13:32:44 +00:00
1195 lines
60 KiB
Markdown
1195 lines
60 KiB
Markdown
# OrcaSlicer MQTT Contract Consolidation — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Make `OrcaPrinterAgent` talk to OrcaCloud and OrcaSonar through one `OrcaMqttConnection` class where LAN and cloud differ only by a `Config` value — commands published to `device/<id>/request`, status subscribed on `device/<id>/report`, on both transports.
|
||
|
||
**Architecture:** One `OrcaMqttConnection` (MQTT 3.1.1 codec + WebSocket transport, `ws://` and `wss://`) in its own translation unit. Two instances: a LAN one owned by `OrcaPrinterAgent`, a per-printer cloud one owned by `OrcaCloudServiceAgent` and driven by `OrcaPrinterAgent` via `get_mqtt_connection()`. `OrcaPrinterAgent` is routing + lifecycle only; `get_appropriate_mqtt_connection(is_lan)` picks the instance and `send_request()` is the uniform outbound seam. Inbound is a per-connection `MessageHandler` callback funnelled to `on_message_fn` (no reader loop). Exactly one socket is connected at a time, for the selected printer.
|
||
|
||
**Tech Stack:** C++17, Boost.Beast (WebSocket + TLS), Boost.Asio, nlohmann/json, Catch2 (`tests/slic3rutils`, target `slic3rutils_tests`, discovered via `orcaslicer_discover_tests`).
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-03-orca-mqtt-contract-consolidation-design.md` — read it alongside this plan.
|
||
|
||
## Global Constraints
|
||
|
||
- **No git commits.** Per this repo's workflow, all changes stay unstaged; there is no per-task commit. Each task ends at "tests green" (see Workflow note).
|
||
- **Build once at the end.** Per-task verification builds and runs only the `slic3rutils_tests` target (`cmake --build build --target slic3rutils_tests`). The full `cmake --build build` runs only in the final task.
|
||
- **No back-compat.** OPCP v1.2.0 replaces v1.1.0. No REST `/commands` fallback in the client, no `/tunnel`, no dual-schema handling.
|
||
- **`sequence_id` band:** every `sequence_id` the client emits MUST be a decimal string in `20000`–`29999`.
|
||
- **Cross-platform:** Windows, macOS, Linux. No POSIX-only calls; use Boost/std.
|
||
- **Threading:** every `websocket::stream` write goes through `write_mutex`. Handlers run on the connection's worker thread and must marshal to the UI thread via `queue_on_main_fn` before touching wx.
|
||
- **Topic id:** the id in `device/<id>/...` is the id the client already holds (LAN: the mDNS-advertised `device_id` passed as `dev_id`; cloud: the cloud printer UUID). No wildcard subscribe, no learning the id from traffic.
|
||
|
||
## Workflow note (per-task loop, adapted for no-commits)
|
||
|
||
Each task's last step is **"Verify green"**, not "Commit":
|
||
|
||
```bash
|
||
cmake --build build --target slic3rutils_tests -j
|
||
ctest --test-dir build -R 'OrcaMqtt|OrcaPrinterAgent' --output-on-failure
|
||
```
|
||
|
||
Leave changes unstaged. Move to the next task.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**New:**
|
||
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `src/slic3r/Utils/OrcaMqttConnection.hpp` | `OrcaMqttConnection` class: `Config`, lifecycle (`start`/`stop`), `send_request`, `subscribe`/`unsubscribe`, state accessors, and the pure static codec helpers (`parse_endpoint`, `make_*_packet`) — public so tests reach them. |
|
||
| `src/slic3r/Utils/OrcaMqttConnection.cpp` | Its implementation (moved verbatim from `OrcaCloudServiceAgent.cpp`, then extended). |
|
||
| `tests/slic3rutils/test_orca_mqtt_connection.cpp` | Unit tests: codec byte-shapes, `parse_endpoint`, `Config`→handshake selection, `send_request`/`subscribe` topic strings. |
|
||
| `tests/slic3rutils/orca_mqtt_mock_broker.hpp` | Header-only in-process MQTT-over-WS mock (Beast server on `127.0.0.1:0`, plaintext): answers CONNECT→CONNACK, SUBSCRIBE→SUBACK, PUBLISH→PUBACK, and can push a canned message on a subscribed topic. Test-only. |
|
||
| `tests/slic3rutils/test_orca_printer_agent.cpp` | `OrcaPrinterAgent` routing + lifecycle tests + the parametrized LAN/cloud integration test. Seeded from `salvage/orcasonar-lan-agent-2026-09-03:tests/slic3rutils/test_orca_printer_agent.cpp` then rewritten for this design. |
|
||
|
||
**Modified:**
|
||
|
||
| File | Change |
|
||
|---|---|
|
||
| `src/slic3r/Utils/OrcaCloudServiceAgent.hpp` | Replace the inline `OrcaMqttConnection` class with `#include "OrcaMqttConnection.hpp"`. Keep `get_mqtt_connection()`. Add `configure_selected_printer_mqtt(std::string dev_id)` / `teardown_selected_printer_mqtt()` decls. |
|
||
| `src/slic3r/Utils/OrcaCloudServiceAgent.cpp` | Move the `OrcaMqttConnection` impl out. `connect_server()` stops starting the aggregate socket. Implement `configure_selected_printer_mqtt` / `teardown_selected_printer_mqtt`. `is_server_connected()` from the REST health result only. |
|
||
| `src/slic3r/Utils/OrcaPrinterAgent.hpp` | Remove `read_loop` / `m_read_loop_thread` / `m_should_end`. Add `std::atomic<uint64_t> m_lan_generation`, `std::string m_lan_dev_id`, `void on_connected(const std::string& dev_id, OrcaMqttConnection* conn, uint64_t generation)`, and the `sequence_id`-stamped payload builders. |
|
||
| `src/slic3r/Utils/OrcaPrinterAgent.cpp` | Implement `connect_printer`, `disconnect_printer`, `set_user_selected_machine`, the `send_message*` collapse, per-connection `MessageHandler` wiring, generation guard, destructor teardown. Remove `read_loop`. |
|
||
| `src/slic3r/CMakeLists.txt` | Add `Utils/OrcaMqttConnection.cpp` + `.hpp` to `SLIC3R_GUI_SOURCES`. |
|
||
| `tests/slic3rutils/CMakeLists.txt` | Add `test_orca_mqtt_connection.cpp` and `test_orca_printer_agent.cpp` to `slic3rutils_tests`. |
|
||
|
||
---
|
||
|
||
## Task 1: Extract `OrcaMqttConnection` to its own translation unit
|
||
|
||
Pure move, no behaviour change. Makes every later diff legible.
|
||
|
||
**Files:**
|
||
- Create: `src/slic3r/Utils/OrcaMqttConnection.hpp`, `src/slic3r/Utils/OrcaMqttConnection.cpp`
|
||
- Modify: `src/slic3r/Utils/OrcaCloudServiceAgent.hpp`, `src/slic3r/Utils/OrcaCloudServiceAgent.cpp`, `src/slic3r/CMakeLists.txt`
|
||
|
||
**Interfaces:**
|
||
- Produces: `class Slic3r::OrcaMqttConnection` at `src/slic3r/Utils/OrcaMqttConnection.hpp` with today's exact API (`start(const std::string& endpoint, TokenProvider, MessageHandler, StateHandler)`, `stop()`, `is_running()`, `is_connected()`, `subscribe(const std::vector<std::string>&)`, `unsubscribe(...)`, `clear_subscriptions()`, `send_request(const std::string&, const std::string&)`).
|
||
|
||
- [ ] **Step 1: Create `OrcaMqttConnection.hpp`**
|
||
|
||
Move the `class OrcaMqttConnection { ... };` block out of `OrcaCloudServiceAgent.hpp` (currently lines ~34–108) into a new header with include guard `#ifndef slic3r_OrcaMqttConnection_hpp_`. Carry the includes it needs: `<boost/beast/websocket.hpp>`, `<boost/beast/ssl.hpp>`, `<boost/asio/ssl.hpp>`, `<atomic>`, `<condition_variable>`, `<functional>`, `<memory>`, `<mutex>`, `<set>`, `<string>`, `<thread>`, `<vector>`, `<cstdint>`. Keep it in `namespace Slic3r`.
|
||
|
||
- [ ] **Step 2: Create `OrcaMqttConnection.cpp`**
|
||
|
||
Move every `OrcaMqttConnection::` and `OrcaMqttConnection::Connection` definition out of `OrcaCloudServiceAgent.cpp` (the block from `struct OrcaMqttConnection::Connection {` to the end of `OrcaMqttConnection::run()`), plus the file-local `using json = nlohmann::json;` / anonymous-namespace helpers those definitions use. `#include "OrcaMqttConnection.hpp"` at the top; carry the boost/log/json includes it references.
|
||
|
||
- [ ] **Step 3: Point `OrcaCloudServiceAgent` at the new header**
|
||
|
||
In `OrcaCloudServiceAgent.hpp` replace the removed class with `#include "OrcaMqttConnection.hpp"`. `OrcaCloudServiceAgent.cpp` keeps compiling (it already `#include`s its own header).
|
||
|
||
- [ ] **Step 4: Add to the build**
|
||
|
||
In `src/slic3r/CMakeLists.txt`, in `set(SLIC3R_GUI_SOURCES` next to `Utils/OrcaCloudServiceAgent.cpp`:
|
||
|
||
```cmake
|
||
Utils/OrcaMqttConnection.cpp
|
||
Utils/OrcaMqttConnection.hpp
|
||
```
|
||
|
||
- [ ] **Step 5: Verify green**
|
||
|
||
```bash
|
||
cmake --build build --target slic3rutils_tests -j
|
||
ctest --test-dir build -R 'printer_agent|Orca' --output-on-failure
|
||
```
|
||
Expected: builds; existing `test_printer_agent` / `test_qidi_printer_agent` unchanged and passing. No new tests yet.
|
||
|
||
---
|
||
|
||
## Task 2: `Config` struct + `parse_endpoint` for `ws://`
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaMqttConnection.hpp`, `src/slic3r/Utils/OrcaMqttConnection.cpp`
|
||
- Create: `tests/slic3rutils/test_orca_mqtt_connection.cpp`
|
||
- Modify: `tests/slic3rutils/CMakeLists.txt`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
```cpp
|
||
struct OrcaMqttConnection::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 OrcaMqttConnection::parse_endpoint(const std::string& url, Endpoint& out); // now public, ws:// + wss://
|
||
struct OrcaMqttConnection::Endpoint { std::string host; std::string port; std::string target; };
|
||
```
|
||
`Endpoint` and `parse_endpoint` move to the `public:` section.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/slic3rutils/test_orca_mqtt_connection.cpp`:
|
||
|
||
```cpp
|
||
#include <catch2/catch_test_macros.hpp>
|
||
#include "slic3r/Utils/OrcaMqttConnection.hpp"
|
||
|
||
using Slic3r::OrcaMqttConnection;
|
||
|
||
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));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Register the test file and run it (expect FAIL)**
|
||
|
||
Add `test_orca_mqtt_connection.cpp` to the `add_executable(${_TEST_NAME}_tests` list in `tests/slic3rutils/CMakeLists.txt`.
|
||
|
||
```bash
|
||
cmake --build build --target slic3rutils_tests -j
|
||
```
|
||
Expected: compile error — `parse_endpoint` is private / `Endpoint` is private / no `ws://` support.
|
||
|
||
- [ ] **Step 3: Make it pass**
|
||
|
||
In `OrcaMqttConnection.hpp` move `struct Endpoint` and `static bool parse_endpoint(...)` into `public:`. Add the `Config` struct (above) in `public:`.
|
||
|
||
In `OrcaMqttConnection.cpp` replace the `wss://`-only `parse_endpoint` body:
|
||
|
||
```cpp
|
||
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();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify green**
|
||
|
||
```bash
|
||
cmake --build build --target slic3rutils_tests -j
|
||
ctest --test-dir build -R OrcaMqtt --output-on-failure
|
||
```
|
||
Expected: PASS.
|
||
|
||
---
|
||
|
||
## Task 3: `make_connect_packet` with client-id / auth / keepalive
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaMqttConnection.hpp`, `.cpp`, `tests/slic3rutils/test_orca_mqtt_connection.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Config` (Task 2).
|
||
- Produces: `static std::vector<uint8_t> OrcaMqttConnection::make_connect_packet(const std::string& client_id, const std::string& username, const std::string& password, int keepalive_seconds);` — public. Clean-session always set; username/password flags + fields only when `username` non-empty.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```cpp
|
||
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
|
||
// variable header: protocol name "MQTT", level 4
|
||
CHECK(p[2] == 'M'); CHECK(p[3] == 'Q'); CHECK(p[4] == 'T'); CHECK(p[5] == 'T');
|
||
CHECK(p[6] == 0x04);
|
||
CHECK(p[7] == 0x02); // connect flags: clean session only
|
||
CHECK(((p[8] << 8) | p[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);
|
||
CHECK(p[7] == (0x02 | 0x80 | 0x40)); // clean session + username + password flags
|
||
// payload contains the client id, then username, then password strings
|
||
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);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL)**
|
||
|
||
```bash
|
||
cmake --build build --target slic3rutils_tests -j
|
||
```
|
||
Expected: compile error — `make_connect_packet` takes no args / is private.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Move `make_connect_packet` to `public:` and replace its body:
|
||
|
||
```cpp
|
||
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;
|
||
}
|
||
```
|
||
|
||
Update the one caller in `connect_and_read()` to pass
|
||
`current_config.client_id, current_config.username, current_config.password, current_config.keepalive_seconds`
|
||
(the `current_config` member arrives in Task 6; until then pass `"OrcaSlicer","","",60` and leave a `// TODO(Task 6): from Config` marker — **remove the marker in Task 6**).
|
||
|
||
- [ ] **Step 4: Verify green**
|
||
|
||
```bash
|
||
ctest --test-dir build -R OrcaMqtt --output-on-failure
|
||
```
|
||
Expected: PASS.
|
||
|
||
---
|
||
|
||
## Task 4: request / report topic packet builders
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaMqttConnection.hpp`, `.cpp`, `tests/slic3rutils/test_orca_mqtt_connection.cpp`
|
||
|
||
**Interfaces:**
|
||
- Produces (all public, all static):
|
||
```cpp
|
||
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" (already exists; make public)
|
||
static std::vector<uint8_t> make_publish_packet(const std::string& topic, const std::string& payload); // QoS 0
|
||
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);
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```cpp
|
||
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
|
||
CHECK(((p[2] << 8) | p[3]) == 7); // packet id
|
||
CHECK(p.back() == 1); // requested QoS
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL)** — `cmake --build build --target slic3rutils_tests -j`; missing symbols.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Make `report_topic` public. Add:
|
||
|
||
```cpp
|
||
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;
|
||
}
|
||
```
|
||
|
||
Keep the existing `make_topic_packet(uint8_t, uint16_t, const std::vector<std::string>&)` for now — Task 5 removes its callers.
|
||
|
||
- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaMqtt --output-on-failure`
|
||
|
||
---
|
||
|
||
## Task 5: single-id `subscribe` / `unsubscribe` + persistent set; `Config`-carrying `start`
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaMqttConnection.hpp`, `.cpp`, `tests/slic3rutils/test_orca_mqtt_connection.cpp`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
```cpp
|
||
bool start(const Config& config, MessageHandler on_message, StateHandler on_state);
|
||
bool subscribe(const std::string& dev_id); // adds report_topic(dev_id) to the set, SUBSCRIBEs if connected
|
||
bool unsubscribe(const std::string& dev_id);
|
||
int last_connack_rc() const; // 0 ok, 1..5 refusal, -1 none this attempt
|
||
```
|
||
The old `start(const std::string&, TokenProvider, ...)` and vector `subscribe`/`unsubscribe` are **removed** (no back-compat). `report_topic` set members replace the device-id set.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```cpp
|
||
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();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL)** — compile error: no `start(Config, ...)`, no `last_connack_rc`.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
- Replace the `endpoint_url` / `get_token` members with `Config current_config;` and `std::atomic<int> m_last_connack_rc{-1};`.
|
||
- Replace `std::set<std::string> subscriptions` semantics: it now holds full report-topic strings. Add `pending_subscriptions` / `pending_unsubscriptions` as topic strings too (rename in place).
|
||
- New `start`:
|
||
```cpp
|
||
bool OrcaMqttConnection::start(const Config& config, MessageHandler on_message, StateHandler on_state) {
|
||
stop();
|
||
{ std::lock_guard<std::mutex> l(mutex);
|
||
current_config = config; this->on_message = std::move(on_message); this->on_state = std::move(on_state);
|
||
initial_completed = false; initial_result = false; connected = false; m_last_connack_rc = -1; }
|
||
stopping.store(false);
|
||
worker = std::thread(&OrcaMqttConnection::run, this);
|
||
std::unique_lock<std::mutex> l(mutex);
|
||
initial_cv.wait_for(l, std::chrono::seconds(10), [this]{ return initial_completed; });
|
||
return initial_result;
|
||
}
|
||
```
|
||
- `subscribe(dev_id)` / `unsubscribe(dev_id)`:
|
||
```cpp
|
||
bool OrcaMqttConnection::subscribe(const std::string& dev_id) {
|
||
const std::string topic = report_topic(dev_id);
|
||
{ std::lock_guard<std::mutex> l(mutex);
|
||
subscriptions.insert(topic); pending_unsubscriptions.erase(topic);
|
||
pending_subscriptions.insert(topic); }
|
||
flush_subscription_change();
|
||
return true;
|
||
}
|
||
```
|
||
(mirror for `unsubscribe`).
|
||
- `connect_and_read()` / `send_current_subscriptions` / `send_pending_subscriptions`: iterate topic strings, build with `make_subscribe_packet(next_packet_id++, topic, 1)` / `make_unsubscribe_packet`. Delete `make_topic_packet` and its declaration.
|
||
- In `connect_and_read()` use `current_config`: `parse_endpoint(current_config.url, ep)`; branch TLS on `current_config.use_tls` (Task 7 completes the plaintext branch); build CONNECT via `make_connect_packet(current_config.client_id, current_config.username, current_config.password, current_config.keepalive_seconds)` — **delete the Task 3 TODO marker**; add the `Authorization: Bearer` upgrade header only when `current_config.bearer_provider` is set.
|
||
- On CONNACK: `m_last_connack_rc = <byte 3 of CONNACK>;`. If rc ∈ {4,5}: stop the worker, do not retry (terminal).
|
||
- Update `OrcaCloudServiceAgent.cpp::connect_server()` (its only caller) to the new signature — see Task 15; for now make it compile with a `Config` built from today's `wss://.../api/v1/printers/mqtt` URL and `bearer_provider = [this]{ return get_access_token(); }`.
|
||
|
||
- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaMqtt --output-on-failure` (the new test plus Tasks 2–4). Also `ctest -R 'printer_agent'` still green.
|
||
|
||
---
|
||
|
||
## Task 6: `send_request` publishes to `device/<id>/request`
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaMqttConnection.cpp`, `tests/slic3rutils/test_orca_mqtt_connection.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `make_publish_packet`, `request_topic`, `ws_write`/`send` (existing write path), `write_mutex`.
|
||
- Produces: `bool send_request(const std::string& dev_id, const std::string& payload)` — returns `false` without sending if `!is_connected()`.
|
||
|
||
- [ ] **Step 1: Write the failing test** (uses the mock broker from Task 8 — so this test is added but `[.]`-hidden until Task 9 wires it; for now assert the guard):
|
||
|
||
```cpp
|
||
TEST_CASE("OrcaMqtt send_request refuses when not connected", "[OrcaMqtt]") {
|
||
OrcaMqttConnection conn;
|
||
CHECK_FALSE(conn.send_request("abc", "{\"pushing\":{\"command\":\"pushall\",\"sequence_id\":\"20001\"}}"));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL)** — `send_request` currently returns something else / is unimplemented.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
```cpp
|
||
bool OrcaMqttConnection::send_request(const std::string& dev_id, const std::string& payload) {
|
||
if (!connected.load()) return false;
|
||
std::shared_ptr<Connection> conn;
|
||
{ std::lock_guard<std::mutex> l(connection_mutex); conn = active_connection; }
|
||
if (!conn) return false;
|
||
const auto packet = make_publish_packet(request_topic(dev_id), payload);
|
||
std::lock_guard<std::mutex> w(write_mutex);
|
||
try { send(conn->websocket, packet); } // existing write helper
|
||
catch (const std::exception& e) {
|
||
BOOST_LOG_TRIVIAL(warning) << "OrcaMqtt send_request failed: " << e.what();
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
```
|
||
|
||
(If Task 7 renamed `send`→`ws_write`, use that.)
|
||
|
||
- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaMqtt --output-on-failure`
|
||
|
||
---
|
||
|
||
## Task 7: plaintext (`ws://`) transport branch
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaMqttConnection.hpp`, `.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Config.use_tls`, `Config.bearer_provider`.
|
||
- Produces: `struct Connection` holds `std::optional<PlainWebSocket> ws;` and `std::optional<TlsWebSocket> wss;` with `PlainWebSocket = websocket::stream<beast::tcp_stream>` and `TlsWebSocket = websocket::stream<ssl::stream<beast::tcp_stream>>`. Internal helpers `ws_write(Connection&, const std::vector<uint8_t>&)`, `ws_read(Connection&, beast::flat_buffer&, error_code&)`, `ws_handshake(Connection&, const Config&, const Endpoint&)`, `ws_close(Connection&)` dispatch on which optional is engaged.
|
||
|
||
- [ ] **Step 1: Write the failing test** (needs the mock broker — mark `[.integration]`, un-hide in Task 9):
|
||
|
||
```cpp
|
||
TEST_CASE("OrcaMqtt connects over plaintext ws", "[OrcaMqtt][.integration]") {
|
||
orca_mqtt_test::MockBroker broker; // Task 8
|
||
OrcaMqttConnection conn;
|
||
OrcaMqttConnection::Config cfg; cfg.url = broker.ws_url(); cfg.use_tls = false;
|
||
std::atomic<bool> up{false};
|
||
REQUIRE(conn.start(cfg, [](auto,auto){}, [&](bool c, bool){ if (c) up = true; }));
|
||
CHECK(conn.is_connected());
|
||
conn.stop();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL to build / link)** — no `ws_handshake` etc.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
- `Connection` gets both optionals + the shared `beast::flat_buffer read_buffer;` and a `net::io_context io;` / `ssl::context tls{ssl::context::tlsv12_client};` as today.
|
||
- `ws_handshake`: resolve host/port; `beast::get_lowest_layer(stream).connect(results)`; if `use_tls`: set SNI (`SSL_set_tlsext_host_name`), `stream.next_layer().handshake(ssl::stream_base::client)`. Then set the upgrade decorator that adds `Authorization: Bearer <token>` when `config.bearer_provider` is set, and `Sec-WebSocket-Protocol: mqtt`. Then `stream.handshake(host, target)`.
|
||
- `ws_write` / `ws_read` / `ws_close`: `if (conn.wss) conn.wss->...; else conn.ws->...;`.
|
||
- `connect_and_read()`: replace direct `WebSocket` use with a `Connection` and the `ws_*` helpers; construct `conn->ws.emplace(conn->io)` or `conn->wss.emplace(conn->io, conn->tls)` based on `current_config.use_tls`.
|
||
- Delete the old `using WebSocket = websocket::stream<ssl::stream<...>>;` typedef and the `send(WebSocket&, ...)` signature (fold into `ws_write`).
|
||
|
||
> API note: exact Beast calls (`beast::get_lowest_layer`, `tcp_stream::connect`, decorator signature) vary by Boost version. Match the version already vendored in `deps/`; the existing TLS code in this file is the reference for the `wss` side.
|
||
|
||
- [ ] **Step 4: Verify green**
|
||
|
||
```bash
|
||
cmake --build build --target slic3rutils_tests -j
|
||
ctest --test-dir build -R 'OrcaMqtt' --output-on-failure # non-integration subset still green
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: in-process MQTT-over-WS mock broker
|
||
|
||
**Files:**
|
||
- Create: `tests/slic3rutils/orca_mqtt_mock_broker.hpp`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
```cpp
|
||
namespace orca_mqtt_test {
|
||
class MockBroker { // starts on ctor, stops on dtor
|
||
public:
|
||
MockBroker();
|
||
~MockBroker();
|
||
std::string ws_url() const; // "ws://127.0.0.1:<port>/mqtt"
|
||
void push_report(const std::string& dev_id, const std::string& payload); // server->client PUBLISH on device/<id>/report
|
||
std::vector<std::string> received_requests() const; // payloads PUBLISHed by the client to any device/<id>/request
|
||
int connect_count() const;
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1: Smoke test**
|
||
|
||
```cpp
|
||
#include "orca_mqtt_mock_broker.hpp"
|
||
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);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL)** — header missing.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Header-only. One `std::thread` running a `net::io_context`; `tcp::acceptor` on `{net::ip::make_address("127.0.0.1"), 0}` (port 0 → OS-assigned, read back via `acceptor.local_endpoint().port()`). On accept: `websocket::stream<beast::tcp_stream>`, accept the upgrade echoing `Sec-WebSocket-Protocol: mqtt`. Then a minimal MQTT read loop:
|
||
- `0x10` CONNECT → reply `0x20 0x02 0x00 0x00` (CONNACK accepted); `connect_count_++`.
|
||
- `0x82` SUBSCRIBE → reply `0x90` SUBACK with the echoed packet id and one granted-QoS byte `0x00`; remember the subscribed topic.
|
||
- `0x30` PUBLISH (QoS 0) → parse topic + payload; if topic ends `/request`, append payload to `received_requests_`.
|
||
- `0xC0` PINGREQ → reply `0xD0 0x00`.
|
||
- `0xE0` DISCONNECT / read error → close.
|
||
`push_report()` posts a `make`-style PUBLISH (`0x30`, topic `device/<id>/report`, payload) onto the connected client socket via `net::post(strand, ...)`.
|
||
|
||
Use only Boost already vendored. Guard all shared state (`received_requests_`, `connect_count_`) with a `std::mutex`.
|
||
|
||
- [ ] **Step 4: Verify green** — `ctest --test-dir build -R 'MockBroker' --output-on-failure` with `--allow-running-no-tests` off; run the hidden tag explicitly: `ctest --test-dir build -R OrcaMqtt -C RelWithDebInfo` then the test binary directly: `./build/tests/slic3rutils/slic3rutils_tests "[.integration]"`.
|
||
|
||
---
|
||
|
||
## Task 9: parametrized LAN/cloud integration test
|
||
|
||
**Files:**
|
||
- Modify: `tests/slic3rutils/test_orca_mqtt_connection.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MockBroker` (Task 8), `OrcaMqttConnection::start/subscribe/send_request` (Tasks 5–7).
|
||
|
||
- [ ] **Step 1: Write the test**
|
||
|
||
```cpp
|
||
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"; }
|
||
|
||
std::promise<std::pair<std::string,std::string>> got;
|
||
REQUIRE(conn.start(cfg,
|
||
[&](const std::string& id, const std::string& payload){ got.set_value({id, payload}); },
|
||
[](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"}})");
|
||
auto fut = got.get_future();
|
||
REQUIRE(fut.wait_for(std::chrono::seconds(3)) == std::future_status::ready);
|
||
auto [id, payload] = fut.get();
|
||
CHECK(id == "dev-1");
|
||
CHECK(payload.find("push_status") != std::string::npos);
|
||
|
||
// the client's command reached the broker on the request topic
|
||
CHECK(broker.received_requests().size() == 1);
|
||
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); }
|
||
```
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL then iterate)** — `./build/tests/slic3rutils/slic3rutils_tests "[.integration]"`.
|
||
|
||
- [ ] **Step 3: Fix defects** in `OrcaMqttConnection` until both cases pass with identical assertions. Un-hide the Task 6/7 integration tests (`[.integration]` → keep the tag; they run via explicit tag selection).
|
||
|
||
- [ ] **Step 4: Verify green** — `./build/tests/slic3rutils/slic3rutils_tests "[OrcaMqtt]"` (all, including `[.integration]`).
|
||
|
||
---
|
||
|
||
## Task 10: `OrcaPrinterAgent` — remove `read_loop`, wire per-connection inbound
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaPrinterAgent.hpp`, `.cpp`
|
||
- Create: `tests/slic3rutils/test_orca_printer_agent.cpp` (seed from `salvage/orcasonar-lan-agent-2026-09-03`)
|
||
- Modify: `tests/slic3rutils/CMakeLists.txt`
|
||
|
||
**Interfaces:**
|
||
- Produces: `void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload)` (private) — snapshots `on_message_fn` under `state_mutex`, calls it (marshalling via `queue_on_main_fn` when set). Used as the body of every connection's `MessageHandler`.
|
||
|
||
- [ ] **Step 1: Seed + write the failing test**
|
||
|
||
```bash
|
||
git show salvage/orcasonar-lan-agent-2026-09-03:tests/slic3rutils/test_orca_printer_agent.cpp \
|
||
> tests/slic3rutils/test_orca_printer_agent.cpp
|
||
```
|
||
Then replace its body with this design's tests. First test:
|
||
|
||
```cpp
|
||
#include <catch2/catch_test_macros.hpp>
|
||
#include "slic3r/Utils/OrcaPrinterAgent.hpp"
|
||
using Slic3r::OrcaPrinterAgent;
|
||
|
||
TEST_CASE("OrcaPrinterAgent forwards a status payload to on_message_fn", "[OrcaPrinterAgent]") {
|
||
OrcaPrinterAgent agent("/tmp");
|
||
std::string got_id, got_payload;
|
||
agent.set_on_message_fn([&](std::string id, std::string p){ got_id = id; got_payload = p; });
|
||
agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status"}})"); // test-only hook
|
||
CHECK(got_id == "dev-1");
|
||
CHECK(got_payload.find("push_status") != std::string::npos);
|
||
}
|
||
```
|
||
|
||
Add `test_orca_printer_agent.cpp` to `tests/slic3rutils/CMakeLists.txt`.
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL)** — `deliver_to_sink` missing; `read_loop` still present.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
- `OrcaPrinterAgent.hpp`: delete `read_loop`, `m_read_loop_thread`, `m_should_end`. Add `void deliver_to_sink(const std::string& dev_id, const std::string& payload);`.
|
||
- `OrcaPrinterAgent.cpp`: delete the `read_loop` definition and its ctor `std::thread(...)`/`detach()`. Delete the file-scope `std::mutex m_conn_type_mtx;`. Ctor body becomes empty (or just the log line). Add:
|
||
```cpp
|
||
void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload) {
|
||
OnMessageFn fn; QueueOnMainFn q;
|
||
{ std::lock_guard<std::mutex> l(state_mutex); fn = on_message_fn; q = queue_on_main_fn; }
|
||
if (!fn) return;
|
||
if (q) q([fn, dev_id, payload]{ fn(dev_id, payload); });
|
||
else fn(dev_id, payload);
|
||
}
|
||
```
|
||
- In `set_cloud_agent`, the existing `set_printer_status_callback` lambda body becomes `deliver_to_sink(std::move(dev_id), std::move(payload));`.
|
||
|
||
- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaPrinterAgent --output-on-failure`
|
||
|
||
---
|
||
|
||
## Task 11: `connect_printer` builds + starts the LAN connection
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaPrinterAgent.hpp`, `.cpp`, `tests/slic3rutils/test_orca_printer_agent.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `OrcaMqttConnection::Config`, `start`.
|
||
- Produces:
|
||
```cpp
|
||
static bool OrcaPrinterAgent::parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port); // "8280" default, "/mqtt" implied
|
||
static std::string OrcaPrinterAgent::make_lan_client_id(const std::string& dev_id);
|
||
```
|
||
New members: `std::atomic<uint64_t> m_lan_generation{0}`, `std::string m_lan_dev_id`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```cpp
|
||
TEST_CASE("OrcaPrinterAgent::parse_lan_endpoint", "[OrcaPrinterAgent]") {
|
||
std::string h, p;
|
||
REQUIRE(OrcaPrinterAgent::parse_lan_endpoint("192.168.1.9", h, p));
|
||
CHECK(h == "192.168.1.9"); CHECK(p == "8280");
|
||
REQUIRE(OrcaPrinterAgent::parse_lan_endpoint("http://host.local:9000/x", h, p));
|
||
CHECK(h == "host.local"); CHECK(p == "9000");
|
||
}
|
||
|
||
TEST_CASE("connect_printer stands up a LAN connection", "[OrcaPrinterAgent]") {
|
||
OrcaPrinterAgent agent("/tmp");
|
||
// 10.255.255.1 is unroutable → start() returns fast-ish; we only assert wiring
|
||
const int rc = agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false);
|
||
CHECK(rc == BAMBU_NETWORK_SUCCESS);
|
||
CHECK(agent.get_user_selected_machine().empty()); // LAN path does not set the cloud selection
|
||
agent.disconnect_printer();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL)** — helpers missing; `connect_printer` is a stub returning success without doing anything (make the wiring assertion fail by checking an observable — see Step 3 for the observable: a protected `lan_connection_url()` test hook).
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
- Add `parse_lan_endpoint` (scheme strip, `host[:port]`, default `8280`) and `make_lan_client_id` (`"orcaslicer-lan-" + dev_id + "-" + <8 hex, drawn once per process>`).
|
||
- `connect_printer`:
|
||
```cpp
|
||
int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip,
|
||
std::string username, std::string password, bool use_ssl) {
|
||
if (dev_id.empty() || dev_ip.empty()) return BAMBU_NETWORK_ERR_INVALID_HANDLE;
|
||
std::string host, port;
|
||
if (!parse_lan_endpoint(dev_ip, host, port)) return BAMBU_NETWORK_ERR_INVALID_HANDLE;
|
||
disconnect_printer();
|
||
const uint64_t gen = ++m_lan_generation;
|
||
OrcaMqttConnection::Config cfg;
|
||
cfg.url = "ws://" + host + ":" + port + "/mqtt";
|
||
cfg.use_tls = false; // OrcaSonar LAN is plaintext; use_ssl ignored
|
||
cfg.username = username.empty() ? "orcasonar" : username;
|
||
cfg.password = password;
|
||
cfg.client_id = make_lan_client_id(dev_id);
|
||
cfg.keepalive_seconds = 60;
|
||
{ std::lock_guard<std::mutex> l(state_mutex); m_lan_dev_id = dev_id; m_current_connection = LAN; }
|
||
lan_mqtt_connection = std::make_unique<OrcaMqttConnection>();
|
||
auto* conn = lan_mqtt_connection.get();
|
||
std::thread([this, conn, cfg, dev_id, gen] {
|
||
const bool ok = conn->start(cfg,
|
||
[this, gen](const std::string& id, const std::string& p){ if (gen == m_lan_generation.load()) deliver_to_sink(id, p); },
|
||
[this, gen, dev_id, conn](bool c, bool initial){ if (c && !initial && gen == m_lan_generation.load()) on_connected(dev_id, conn, gen); });
|
||
if (ok && gen == m_lan_generation.load()) on_connected(dev_id, conn, gen);
|
||
}).detach();
|
||
return BAMBU_NETWORK_SUCCESS;
|
||
}
|
||
```
|
||
- Add a protected test hook: `std::string lan_connection_target() const { return lan_mqtt_connection ? /* Config.url snapshot */ : ""; }` — store `cfg.url` in a `std::string m_lan_url;` member when building it, and return that. Assert it in the test.
|
||
|
||
- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaPrinterAgent --output-on-failure`
|
||
|
||
---
|
||
|
||
## Task 12: `on_connected` — the shared post-connect sequence
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaPrinterAgent.hpp`, `.cpp`, `tests/slic3rutils/test_orca_printer_agent.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `OrcaMqttConnection::subscribe`, `send_request`.
|
||
- Produces:
|
||
```cpp
|
||
void OrcaPrinterAgent::on_connected(const std::string& dev_id, OrcaMqttConnection* conn, uint64_t generation);
|
||
static std::string OrcaPrinterAgent::seq(int n); // "2000" + zero-padded band offset; returns a string in 20000..29999
|
||
static std::string OrcaPrinterAgent::build_pushing_start(const std::string& sequence_id);
|
||
static std::string OrcaPrinterAgent::build_pushall(const std::string& sequence_id);
|
||
static std::string OrcaPrinterAgent::build_get_version(const std::string& sequence_id);
|
||
static std::string OrcaPrinterAgent::build_get_capabilities(const std::string& sequence_id);
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing test** — a fake `OrcaMqttConnection` subclass is not available (non-virtual). Instead test the payload builders + call order via a seam: `on_connected` takes an `OrcaMqttConnection*`; make the four `send_request` payloads and the `subscribe` observable by having `on_connected` delegate to a `protected virtual void emit_connect_sequence(const std::string&, std::function<void(const std::string&)> subscribe, std::function<void(const std::string&)> request)` that the test overrides.
|
||
|
||
```cpp
|
||
TEST_CASE("post-connect sequence is subscribe then 4 requests in order", "[OrcaPrinterAgent]") {
|
||
struct Probe : 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"); // thin public shim calling emit_connect_sequence
|
||
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) // sequence_id band
|
||
if (auto pos = c.find("sequence_id"); pos != std::string::npos)
|
||
CHECK(c.substr(pos).find("\"2") != std::string::npos);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL)** — members missing.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
- Payload builders return exact JSON, e.g.:
|
||
```cpp
|
||
std::string OrcaPrinterAgent::build_pushall(const std::string& sid) {
|
||
return R"({"pushing":{"command":"pushall","sequence_id":")" + sid + R"(","version":1,"push_target":1}})";
|
||
}
|
||
std::string OrcaPrinterAgent::build_pushing_start(const std::string& sid) {
|
||
return R"({"pushing":{"command":"start","sequence_id":")" + sid + R"("}})";
|
||
}
|
||
std::string OrcaPrinterAgent::build_get_version(const std::string& sid) {
|
||
return R"({"info":{"command":"get_version","sequence_id":")" + sid + R"("}})";
|
||
}
|
||
std::string OrcaPrinterAgent::build_get_capabilities(const std::string& sid) {
|
||
return R"({"info":{"command":"get_capabilities","sequence_id":")" + sid + R"("}})";
|
||
}
|
||
```
|
||
- `seq(n)`: `return std::to_string(20000 + (n % 10000));`
|
||
- `emit_connect_sequence(dev_id, sub, req)`: `sub(dev_id); req(build_pushing_start(seq(1))); req(build_pushall(seq(2))); req(build_get_version(seq(3))); req(build_get_capabilities(seq(4)));`
|
||
- `on_connected(dev_id, conn, generation)`: if `generation != m_lan_generation.load()` **and** the cloud generation guard (Task 14) both mismatch, return. Else `emit_connect_sequence(dev_id, [conn](auto& id){ conn->subscribe(id); }, [conn, &dev_id](auto& body){ conn->send_request(dev_id, body); });`
|
||
- Add the tiny public shim `run_connect_sequence_for_test`.
|
||
|
||
- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaPrinterAgent --output-on-failure`
|
||
|
||
---
|
||
|
||
## Task 13: `disconnect_printer` + generation guard
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaPrinterAgent.cpp`, `tests/slic3rutils/test_orca_printer_agent.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `m_lan_generation`, `lan_mqtt_connection`, `OrcaMqttConnection::stop`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```cpp
|
||
TEST_CASE("a stale-generation inbound message is dropped", "[OrcaPrinterAgent]") {
|
||
struct Probe : OrcaPrinterAgent { using OrcaPrinterAgent::OrcaPrinterAgent;
|
||
using OrcaPrinterAgent::make_lan_message_handler; }; // expose for the test
|
||
Probe 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);
|
||
agent.bump_lan_generation_for_test(); // now current == 2
|
||
handler_gen1("dev-1", "{}"); // late callback from gen 1
|
||
CHECK(hits == 0);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL)** — helpers missing.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
- Factor the message-handler lambda into `std::function<void(const std::string&, const std::string&)> make_lan_message_handler(uint64_t generation)` returning `[this, generation](auto& id, auto& p){ if (generation == m_lan_generation.load()) deliver_to_sink(id, p); }`.
|
||
- `disconnect_printer`:
|
||
```cpp
|
||
int OrcaPrinterAgent::disconnect_printer() {
|
||
++m_lan_generation; // fence stale callbacks
|
||
std::unique_ptr<OrcaMqttConnection> doomed;
|
||
{ std::lock_guard<std::mutex> l(state_mutex);
|
||
doomed = std::move(lan_mqtt_connection);
|
||
m_lan_dev_id.clear();
|
||
if (m_current_connection == LAN) m_current_connection = NONE; }
|
||
if (doomed) doomed->stop(); // joins the worker, outside the lock
|
||
return BAMBU_NETWORK_SUCCESS;
|
||
}
|
||
```
|
||
- Add `bump_lan_generation_for_test()`.
|
||
|
||
- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaPrinterAgent --output-on-failure`
|
||
|
||
---
|
||
|
||
## Task 14: `set_user_selected_machine` drives the cloud per-printer connection
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaPrinterAgent.cpp`, `src/slic3r/Utils/OrcaCloudServiceAgent.hpp`, `.cpp`, `tests/slic3rutils/test_orca_printer_agent.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `OrcaCloudServiceAgent::get_mqtt_connection()`.
|
||
- Produces on `OrcaCloudServiceAgent`:
|
||
```cpp
|
||
int configure_selected_printer_mqtt(const std::string& dev_id); // (re)build Config{wss://<api>/api/v1/printers/{id}/mqtt, bearer}, start()
|
||
void teardown_selected_printer_mqtt(); // stop()
|
||
std::string selected_printer_mqtt_url() const; // test hook — "" when not configured
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```cpp
|
||
TEST_CASE("selecting a cloud printer configures the per-printer socket", "[OrcaPrinterAgent]") {
|
||
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");
|
||
CHECK(cloud->selected_printer_mqtt_url() == "wss://api.example.com/api/v1/printers/printer-uuid-1/mqtt");
|
||
|
||
agent.set_user_selected_machine("");
|
||
CHECK(cloud->selected_printer_mqtt_url().empty());
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL)** — methods missing; `set_user_selected_machine` still does the aggregate `add_subscribe` dance.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
- `OrcaCloudServiceAgent::configure_selected_printer_mqtt(dev_id)`:
|
||
```cpp
|
||
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;
|
||
m_selected_printer_mqtt_url = cfg.url;
|
||
return mqtt_connection->start(cfg,
|
||
[this](const std::string& id, const std::string& p){ /* Task 15 routes to printer_status_callback */ deliver_cloud_message(id, p); },
|
||
[this](bool, bool){ }) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
|
||
```
|
||
`teardown_selected_printer_mqtt()`: `mqtt_connection->stop(); m_selected_printer_mqtt_url.clear();`
|
||
- `OrcaPrinterAgent::set_user_selected_machine(dev_id)`:
|
||
```cpp
|
||
int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) {
|
||
auto* cloud = get_orca_cloud_agent();
|
||
std::string previous;
|
||
{ std::lock_guard<std::mutex> l(state_mutex);
|
||
if (dev_id == selected_machine) return BAMBU_NETWORK_SUCCESS;
|
||
previous = selected_machine; selected_machine = dev_id; }
|
||
if (!cloud) return BAMBU_NETWORK_SUCCESS;
|
||
auto* conn = cloud->get_mqtt_connection();
|
||
if (!previous.empty() && conn && conn->is_connected())
|
||
conn->send_request(previous, build_pushing_stop(seq(5)));
|
||
cloud->teardown_selected_printer_mqtt();
|
||
if (!dev_id.empty()) {
|
||
const uint64_t gen = ++m_lan_generation; // reuse the same fence for cloud
|
||
std::thread([this, cloud, dev_id, gen]{
|
||
if (cloud->configure_selected_printer_mqtt(dev_id) == BAMBU_NETWORK_SUCCESS
|
||
&& gen == m_lan_generation.load())
|
||
on_connected(dev_id, cloud->get_mqtt_connection(), gen);
|
||
}).detach();
|
||
{ std::lock_guard<std::mutex> l(state_mutex); m_current_connection = CLOUD; }
|
||
} else {
|
||
std::lock_guard<std::mutex> l(state_mutex); m_current_connection = NONE;
|
||
}
|
||
return BAMBU_NETWORK_SUCCESS;
|
||
}
|
||
```
|
||
Add `build_pushing_stop` next to `build_pushing_start` (`"command":"stop"`).
|
||
- Delete the old aggregate `add_subscribe` / `del_subscribe` / `send_message(pushall)` / `deliver_mock_get_version` body from `set_user_selected_machine` (the mock get_version is now redundant — `on_connected` sends a real `info.get_version`).
|
||
|
||
- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaPrinterAgent --output-on-failure`
|
||
|
||
---
|
||
|
||
## Task 15: `OrcaCloudServiceAgent::connect_server()` stops starting the aggregate socket
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaCloudServiceAgent.cpp`, `.hpp`, `tests/slic3rutils/test_orca_printer_agent.cpp` (or a new `test_orca_cloud_service_agent.cpp`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: existing REST `http_get(ORCA_HEALTH_PATH, ...)`.
|
||
- Produces: `connect_server()` returns success/failure from the health probe alone; `is_server_connected()` returns the last health result; `mqtt_connection` is untouched by `connect_server()`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```cpp
|
||
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"); // health probe will fail fast
|
||
cloud->connect_server();
|
||
REQUIRE(cloud->get_mqtt_connection() != nullptr);
|
||
CHECK_FALSE(cloud->get_mqtt_connection()->is_running());
|
||
CHECK(cloud->selected_printer_mqtt_url().empty());
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL)** — `connect_server()` currently builds `cfg.url = "wss://" + api_base_url + "/api/v1/printers/mqtt"` and calls `mqtt_connection->start(...)`.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
- Delete the whole `if (connected && mqtt_connection && !mqtt_connection->is_running()) { ... mqtt_connection->start(cfg, ...); }` block from `connect_server()` and the trailing "aggregate MQTT" logging.
|
||
- Keep the health check; set `is_connected = connected;` from it and call `invoke_server_connected_callback(connected ? 0 : -1, http_code);`.
|
||
- `refresh_connection()` still just calls `connect_server()`.
|
||
- The `printer_status_callback` / `set_printer_status_callback` machinery stays (Task 14's `configure_selected_printer_mqtt` message lambda calls `deliver_cloud_message` → the registered `printer_status_callback`). Add:
|
||
```cpp
|
||
void OrcaCloudServiceAgent::deliver_cloud_message(const std::string& id, const std::string& p) {
|
||
OnMessageFn cb;
|
||
{ std::lock_guard<std::mutex> l(callback_mutex); cb = printer_status_callback; }
|
||
if (cb) cb(id, p);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify green** — `ctest --test-dir build -R 'OrcaCloud|OrcaPrinterAgent' --output-on-failure`
|
||
|
||
---
|
||
|
||
## Task 16: collapse `send_message` / `send_message_to_printer`
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaPrinterAgent.cpp`, `tests/slic3rutils/test_orca_printer_agent.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `get_appropriate_mqtt_connection(bool)`, `OrcaMqttConnection::send_request`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```cpp
|
||
TEST_CASE("send_message* reject when 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);
|
||
}
|
||
|
||
TEST_CASE("send_message_to_printer publishes on the LAN connection", "[OrcaPrinterAgent][.integration]") {
|
||
orca_mqtt_test::MockBroker broker;
|
||
OrcaPrinterAgent agent("/tmp");
|
||
// point the LAN connection at the mock by connecting to its host:port
|
||
auto ep = broker.host_port(); // {"127.0.0.1", <port>}
|
||
agent.connect_printer("dev-1", ep.first + ":" + ep.second, "orcasonar", "code", false);
|
||
// wait for connect
|
||
for (int i = 0; i < 100 && broker.connect_count() == 0; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||
CHECK(agent.send_message_to_printer("dev-1", R"({"print":{"command":"pause","sequence_id":"20007"}})", 0, 0)
|
||
== BAMBU_NETWORK_SUCCESS);
|
||
for (int i = 0; i < 100 && broker.received_requests().empty(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||
REQUIRE(broker.received_requests().size() >= 1);
|
||
CHECK(broker.received_requests().back().find("pause") != std::string::npos);
|
||
agent.disconnect_printer();
|
||
}
|
||
```
|
||
|
||
> `MockBroker::host_port()` — add a small accessor returning `{"127.0.0.1", std::to_string(port_)}` in Task 8's header (fold this one-line addition here).
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL)** — `send_message` still spawns the old REST thread / `send_message_to_printer` is a bare `return SUCCESS`.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
```cpp
|
||
int OrcaPrinterAgent::send_message(std::string dev_id, std::string json_str, int, int) {
|
||
return route_send(false, dev_id, json_str);
|
||
}
|
||
int OrcaPrinterAgent::send_message_to_printer(std::string dev_id, std::string json_str, int, int) {
|
||
return route_send(true, dev_id, json_str);
|
||
}
|
||
int OrcaPrinterAgent::route_send(bool is_lan, const std::string& dev_id, const std::string& json_str) {
|
||
if (dev_id.empty()) return BAMBU_NETWORK_ERR_INVALID_HANDLE;
|
||
OrcaMqttConnection* conn = get_appropriate_mqtt_connection(is_lan);
|
||
if (!conn) return BAMBU_NETWORK_ERR_INVALID_HANDLE;
|
||
return conn->send_request(dev_id, json_str) ? BAMBU_NETWORK_SUCCESS
|
||
: BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
|
||
}
|
||
```
|
||
|
||
Guard `get_appropriate_mqtt_connection(false)` against a null cloud agent: `return get_orca_cloud_agent() ? get_orca_cloud_agent()->get_mqtt_connection() : nullptr;`
|
||
|
||
- [ ] **Step 4: Verify green** — `./build/tests/slic3rutils/slic3rutils_tests "[OrcaPrinterAgent]"`
|
||
|
||
---
|
||
|
||
## Task 17: destructor teardown — no detached threads touching `*this`
|
||
|
||
**Files:**
|
||
- Modify: `src/slic3r/Utils/OrcaPrinterAgent.cpp`, `tests/slic3rutils/test_orca_printer_agent.cpp`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```cpp
|
||
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", "10.255.255.1", "orcasonar", "code", false); // unroutable, still connecting
|
||
agent.reset(); // dtor must join/stop cleanly
|
||
}
|
||
SUCCEED();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it (expect FAIL / hang / TSAN error)** — the `connect_printer` detached thread may outlive `*this` and call `on_connected`/`deliver_to_sink`.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
- The detached connect thread already guards every callback with the generation check. Add: `~OrcaPrinterAgent()` bumps `m_lan_generation` first, then:
|
||
```cpp
|
||
OrcaPrinterAgent::~OrcaPrinterAgent() {
|
||
++m_lan_generation;
|
||
std::unique_ptr<OrcaMqttConnection> lan;
|
||
{ std::lock_guard<std::mutex> l(state_mutex); lan = std::move(lan_mqtt_connection); }
|
||
if (lan) lan->stop(); // joins the OrcaMqttConnection worker
|
||
if (auto* cloud = get_orca_cloud_agent()) cloud->teardown_selected_printer_mqtt();
|
||
}
|
||
```
|
||
- The remaining race is the *connect* `std::thread(...).detach()` still running `conn->start()` on a `conn` that `lan->stop()` then destroys. Fix: hold the connect thread as a member `std::thread m_lan_connect_thread;` (not detached) and `join()` it in the dtor **before** moving `lan_mqtt_connection`. Update Task 11 & Task 14 to assign `m_lan_connect_thread = std::thread(...)` (join any prior one first).
|
||
|
||
- [ ] **Step 4: Verify green** — run under sanitizers if the build has them: `ctest --test-dir build -R OrcaPrinterAgent --output-on-failure`
|
||
|
||
---
|
||
|
||
## Task 18: full build, full test run, manual smoke
|
||
|
||
**Files:**
|
||
- Modify: none (verification only)
|
||
|
||
- [ ] **Step 1: Full build**
|
||
|
||
```bash
|
||
cmake --build build --config RelWithDebInfo --target all -j
|
||
```
|
||
Expected: success on Linux. (CI covers Windows/macOS.)
|
||
|
||
- [ ] **Step 2: Full unit + integration suite**
|
||
|
||
```bash
|
||
ctest --test-dir build --output-on-failure
|
||
./build/tests/slic3rutils/slic3rutils_tests "[.integration]"
|
||
```
|
||
Expected: no regressions in `test_printer_agent`, `test_qidi_printer_agent`, `test_plugin_*`; all `[OrcaMqtt]` / `[OrcaPrinterAgent]` green.
|
||
|
||
- [ ] **Step 3: Manual smoke — LAN**
|
||
|
||
Run OrcaSlicer against a real OrcaSonar hub: it appears via discovery; select it; Device tab populates (version, temps); set nozzle temperature; confirm the result echo in the log (`OrcaMqtt` / `on_message` lines). Record a log excerpt or screenshot.
|
||
|
||
- [ ] **Step 4: Manual smoke — cloud**
|
||
|
||
Against OrcaCloud staging with a paired printer (requires CH-1 deployed there): select the printer; same checks. If CH-1 is not yet deployed, record that this step is blocked on the §5.2 hand-off and verify only that the socket opens and status is received (commands will 4403 until CH-1 ships).
|
||
|
||
- [ ] **Step 5: Record results**
|
||
|
||
Append a short "Verification" section to the spec doc: build result, `ctest` summary line, and the two smoke outcomes (or the cloud-blocked note).
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage**
|
||
|
||
| Spec section | Task(s) |
|
||
|---|---|
|
||
| §3.1 canonical core (topics, envelope) | T4 (topics), T3 (CONNECT), payload builders T12 |
|
||
| §3.2 command send = PUBLISH both transports | T6, T16 |
|
||
| §3.2 `sequence_id` band 20000–29999 | T12 (`seq`), asserted in T12 test |
|
||
| §3.2 capability via `info.get_capabilities` | T12 (`build_get_capabilities`, in the sequence) |
|
||
| §3.2 status gating (`pushing.start`/`stop`) | T12 (start), T14 (stop on deselect) |
|
||
| §3.2 exact-topic subscribe, no wildcard | T4/T5 (`report_topic(dev_id)`, single-id `subscribe`) |
|
||
| §3.2 auth: bearer or CONNECT creds, precedence | T3 (flags), T5/T7 (bearer header vs creds), T7 |
|
||
| §3.2 QoS ≤1, tolerate SUBACK QoS 0 | T4 (`make_subscribe_packet` qos arg), T8 mock grants 0, T9 round-trip |
|
||
| §3.2 keepalive from Config | T3, T5 |
|
||
| §4.1 `OrcaMqttConnection` own TU, Config, API | T1, T2, T5 |
|
||
| §4.1 reuse salvage pieces | T2/T3/T5 (parse_endpoint, connect packet, last_connack_rc) |
|
||
| §4.2 outbound collapse | T16 |
|
||
| §4.2 remove `read_loop`, per-connection MessageHandler | T10 |
|
||
| §4.2 `get_appropriate_mqtt_connection` kept | T16 (null-guarded) |
|
||
| §4.2 lifecycle table (connect/select/deselect) | T11, T13, T14 |
|
||
| §4.2 identical post-connect sequence | T12 |
|
||
| §4.2 generation guard | T11 (capture), T13 (test), T17 (dtor) |
|
||
| §4.2 CONNACK rc 4/5 terminal | T5 |
|
||
| §4.2 dtor stops both, no detached threads on `*this` | T17 |
|
||
| §4.3 one socket, per-printer cloud endpoint | T14 (`wss://.../printers/{id}/mqtt`) |
|
||
| §4.3 remove aggregate socket, health-only `is_server_connected` | T15 |
|
||
| §6.1 unit tests | T2–T6, T12 |
|
||
| §6.2 parametrized LAN/cloud integration | T9 |
|
||
| §6.2 reconnect re-sends subs + `pushing.start` | *gap → see below* |
|
||
| §6.2 auth-reject no retry storm | T5 (rc 4/5 terminal); add assertion in T5 |
|
||
| §6.5 gates: full build, no regressions, smoke | T18 |
|
||
|
||
**Gaps found & fixed inline:**
|
||
- §6.2 "reconnect re-sends subscriptions and re-issues `pushing.start`" had no task. **Added to Task 9 Step 3**: extend the round-trip test with a `broker.drop_client()` call, assert the client reconnects (`connect_count() == 2`), that the report subscription is re-established (a second `push_report` is delivered), and that the agent re-runs `on_connected` (observable via a second `pushall` in `received_requests()`). Requires `MockBroker::drop_client()` — fold into Task 8.
|
||
- §6.2 "auth reject → terminal, no retry storm" — **added assertion to Task 5 Step 1 test**: point `Config.username/password` at a mock that CONNACKs `0x05`; assert `last_connack_rc() == 5` and `is_running() == false` within 1 s (needs `MockBroker` ctor flag `refuse_auth` — fold into Task 8).
|
||
|
||
**Placeholder scan:** the Task 3 `// TODO(Task 6)` marker is intentional and explicitly removed in Task 5 Step 3 (corrected: the marker is added in T3, removed in T5 — not T6). No other TODO/TBD. All code steps carry real code.
|
||
|
||
**Type consistency:**
|
||
- `OrcaMqttConnection::start(const Config&, MessageHandler, StateHandler)` — defined T5, used T7/T9/T11/T14. ✓
|
||
- `subscribe(const std::string&)` / `send_request(const std::string&, const std::string&)` — defined T5/T6, used T9/T12/T16. ✓
|
||
- `on_connected(const std::string&, OrcaMqttConnection*, uint64_t)` — decl T11 interfaces, defined T12, called T11/T14. ✓
|
||
- `emit_connect_sequence` / `run_connect_sequence_for_test` — introduced T12, only used by T12's test. ✓
|
||
- `deliver_to_sink` — T10, used T11/T13. ✓
|
||
- `route_send` — T16 only. ✓
|
||
- `configure_selected_printer_mqtt` / `teardown_selected_printer_mqtt` / `selected_printer_mqtt_url` / `deliver_cloud_message` — T14/T15, used T14/T15/T17. ✓
|
||
- `MockBroker` API grows across T8 (`ws_url`, `push_report`, `received_requests`, `connect_count`), T16 (`host_port`), self-review (`drop_client`, `refuse_auth` ctor flag). All folded into Task 8's file; later tasks only call them. ✓
|
||
- `make_connect_packet` 4-arg — T3, caller updated T5. Old 0-arg removed T3. ✓
|
||
- `make_topic_packet` removed in T5; no later reference. ✓
|