Show Snapmaker U1 camera in Device tab

The U1 exposes no /server/webcams/list entry;
its camera only captures after an explicit
camera.start_monitor RPC, which the Moonraker
websocket executes unauthenticated but only
answers over MQTT - so the call is fire and
forget.

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

Frames land in monitor.jpg as still JPEGs
(~2 fps at interval 0), so the webview loads
a local HTML wrapper that repolls with a
cache buster.
This commit is contained in:
Andrew
2026-07-30 14:21:25 +08:00
committed by Ian Chua
parent cdb662d4b2
commit 79fd33d49a
11 changed files with 206 additions and 2 deletions

View File

@@ -1792,6 +1792,14 @@ int MachineObject::command_ams_refresh_rfid2(int ams_id, int slot_id)
return this->publish_json(j);
}
int MachineObject::command_start_camera()
{
if (!m_agent) return -1;
// why: this fires from the camera view's renew timer, so a refusal must stay silent -
// show_unsupported_dlg() here would pop a dialog every ~5 min on every other printer.
return m_agent->command_start_camera(get_dev_id());
}
int MachineObject::command_ams_select_tray(std::string tray_id)
{

View File

@@ -812,6 +812,7 @@ public:
int command_ams_select_tray(std::string tray_id);
int command_ams_refresh_rfid(std::string tray_id);
int command_ams_refresh_rfid2(int ams_id, int slot_id);
int command_start_camera();
int command_ams_control(std::string action);
int command_ams_drying_stop();
int command_start_extrusion_cali(int tray_index, int nozzle_temp, int bed_temp, float max_volumetric_speed, std::string setting_id = "");

View File

@@ -2328,9 +2328,19 @@ void StatusPanel::update_camera_state(MachineObject* obj)
m_media_ctrl->Hide();
m_media_play_ctrl->Hide();
}
// why: printers like the U1 capture only while asked and retire the capture task ~362 s
// after each start, so the open camera view has to renew ahead of that. 300 s matches
// Snapmaker's own client. Agents that do not need it refuse the call silently.
const auto now = std::chrono::steady_clock::now();
if (m_camera_start_sent == std::chrono::steady_clock::time_point{} ||
now - m_camera_start_sent >= std::chrono::seconds(300)) {
obj->command_start_camera();
m_camera_start_sent = now;
}
} else if (!m_printer_webcam_url.empty()) {
handle_camera_source_change();
m_printer_webcam_url.clear();
m_camera_start_sent = std::chrono::steady_clock::time_point{};
}
//sdcard

View File

@@ -666,6 +666,8 @@ protected:
int m_last_extrusion = -1;
int m_last_vcamera = -1;
std::string m_printer_webcam_url;
// note: zero = not started; see update_camera_state() for the renew interval.
std::chrono::steady_clock::time_point m_camera_start_sent{};
int m_model_mall_request_count = 0;
bool m_is_load_with_temp = false;
json m_rating_result;

View File

@@ -93,6 +93,11 @@ public:
{ return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; }
virtual int command_ams_select_tray(std::string, std::string, int, bool)
{ return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; }
// why: some printers emit camera frames only while explicitly asked, and retire the
// capture task on their own - the camera view starts it and renews it. Printers with an
// always-on stream need nothing here, hence the honest refusal by default.
virtual int command_start_camera(std::string)
{ return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; }
/**
* Establish a direct LAN connection to a printer.

View File

@@ -1442,8 +1442,99 @@ bool MoonrakerPrinterAgent::query_printer_status(const std::string& base_url,
return true;
}
bool MoonrakerPrinterAgent::send_ws_rpc(const std::string& method, const nlohmann::json& params)
{
std::string base_url;
std::string api_key;
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
base_url = device_info.base_url;
api_key = device_info.api_key;
}
WsEndpoint endpoint;
if (!parse_ws_endpoint(base_url, endpoint) || endpoint.secure) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: send_ws_rpc has no usable websocket for base_url="
<< base_url;
return false;
}
nlohmann::json request;
request["jsonrpc"] = "2.0";
request["method"] = method;
if (!params.is_null()) {
request["params"] = params;
}
request["id"] = next_jsonrpc_id++;
const std::string body = request.dump();
// why: the configured address and Moonraker's own API port are both plausible websocket
// homes - the U1 answers on 7125 while its base_url points at port 80. Try what the user
// configured first, then the default API port.
std::vector<std::string> ports{endpoint.port};
if (endpoint.port != "7125") {
ports.emplace_back("7125");
}
for (const auto& port : ports) {
try {
net::io_context ioc;
tcp::resolver resolver{ioc};
beast::tcp_stream stream{ioc};
stream.expires_after(std::chrono::seconds(5));
stream.connect(resolver.resolve(endpoint.host, port));
websocket::stream<beast::tcp_stream> ws{std::move(stream)};
ws.set_option(websocket::stream_base::decorator([&](websocket::request_type& req) {
req.set(http::field::user_agent, "OrcaSlicer");
if (!api_key.empty()) {
req.set("X-Api-Key", api_key);
}
}));
std::string host_header = endpoint.host;
if (!port.empty() && port != "80") {
host_header += ":" + port;
}
ws.handshake(host_header, endpoint.target);
ws.text(true);
ws.write(net::buffer(body));
// why: some RPCs are answered on another transport entirely, so a reply may never
// come - read once with a short deadline purely to let the printer act on the
// request before we close, then drop the socket. A timeout here is the normal path.
ws.next_layer().expires_after(std::chrono::seconds(2));
beast::flat_buffer buffer;
beast::error_code read_ec;
ws.read(buffer, read_ec);
beast::error_code close_ec;
ws.close(websocket::close_code::normal, close_ec);
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: sent " << method << " over ws to "
<< endpoint.host << ":" << port;
return true;
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: " << method << " over ws to " << endpoint.host
<< ":" << port << " failed: " << e.what();
}
}
return false;
}
bool MoonrakerPrinterAgent::fetch_webcam_info(const std::string& base_url, const std::string& api_key, uint64_t generation)
{
if (const std::string override_url = webcam_stream_override(base_url); !override_url.empty()) {
{
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
if (generation != connect_generation.load()) {
return false;
}
webcam_stream_url = override_url;
}
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: using printer-specific webcam URL " << override_url;
return true;
}
std::string stream_url;
std::string webcam_name;
std::string error;
@@ -2763,7 +2854,7 @@ std::string MoonrakerPrinterAgent::join_url(const std::string& base_url, const s
// Sanitize filename to prevent path traversal attacks
// Extracts only the basename, removing any path components
std::string MoonrakerPrinterAgent::sanitize_filename(const std::string& filename)
std::string MoonrakerPrinterAgent::sanitize_filename(const std::string& filename) const
{
if (filename.empty()) {
return "print.gcode";

View File

@@ -117,7 +117,7 @@ protected:
// Helpers
bool is_numeric(const std::string& value);
std::string normalize_base_url(std::string host, const std::string& port);
std::string sanitize_filename(const std::string& filename);
std::string sanitize_filename(const std::string& filename) const;
std::string join_url(const std::string& base_url, const std::string& path) const;
// Trim whitespace and convert to uppercase
@@ -134,6 +134,14 @@ protected:
bool post_print_action(const std::string& action,
const std::string& base_url, const std::string& api_key) const;
// Send one JSON-RPC call over a short-lived Moonraker websocket. Returns true when the
// request was written; it never waits for a reply.
bool send_ws_rpc(const std::string& method, const nlohmann::json& params);
// why: a printer with no /server/webcams/list entry can still name its stream directly;
// returning empty (the default) keeps the normal Moonraker discovery path.
virtual std::string webcam_stream_override(const std::string& base_url) const { return {}; }
private:
int handle_request(const std::string& dev_id, const std::string& json_str);
int send_version_info(const std::string& dev_id);

View File

@@ -788,6 +788,13 @@ int NetworkAgent::command_ams_select_tray(std::string dev_id, std::string tray_i
return -1;
}
int NetworkAgent::command_start_camera(std::string dev_id)
{
if (m_printer_agent)
return m_printer_agent->command_start_camera(dev_id);
return -1;
}
int NetworkAgent::connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl)
{
if (m_printer_agent)

View File

@@ -145,6 +145,7 @@ public:
int command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode);
int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode);
int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode);
int command_start_camera(std::string dev_id);
int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl);
int disconnect_printer();
int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag);

View File

@@ -1,10 +1,14 @@
#include "SnapmakerPrinterAgent.hpp"
#include "Http.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "nlohmann/json.hpp"
#include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp>
#include <boost/nowide/fstream.hpp>
#include <thread>
namespace Slic3r {
@@ -67,6 +71,69 @@ std::string find_closest_color_preset_by_vendor_and_type(const PresetCollection&
SnapmakerPrinterAgent::SnapmakerPrinterAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
int SnapmakerPrinterAgent::command_start_camera(std::string dev_id)
{
(void) dev_id;
// why: the printer executes this over the websocket but answers only over MQTT, and the
// call itself blocks on socket I/O - it fires from the camera view's renew timer on the UI
// thread, so run it detached rather than block the caller on a reply that never comes.
// note: interval is dead time in SECONDS on top of a ~0.455 s capture, so 0 is the 2.15 fps
// ceiling (1 measures 0.63 fps), and it cannot be changed while a capture task is running.
std::thread([this] {
send_ws_rpc("camera.start_monitor",
{{"domain", "lan"}, {"interval", 0}, {"expect_pw", false}});
}).detach();
return BAMBU_NETWORK_SUCCESS;
}
std::string SnapmakerPrinterAgent::webcam_stream_override(const std::string& base_url) const
{
const std::string snapshot_url = join_url(base_url, "/server/files/camera/monitor.jpg");
// why: one wrapper file per printer - two U1s would otherwise overwrite each other's URL.
const boost::filesystem::path page = boost::filesystem::path(data_dir()) / "cache" /
("snapmaker_camera_" + sanitize_filename(device_info.dev_ip) + ".html");
// why: the printer writes a still JPEG at ~2 fps, so the page polls it with a cache buster
// instead of consuming a stream. Chaining the next request off onload (never a bare
// setInterval) keeps requests from piling up when the printer is slow to answer.
const std::string html =
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Camera</title><style>"
"html,body{margin:0;height:100%;background:#000;overflow:hidden}"
"img{width:100%;height:100%;object-fit:contain;display:block}</style></head>"
"<body><img id=\"frame\" alt=\"\"><script>\n"
"var src=\"" + snapshot_url + "\";\n"
"var img=document.getElementById(\"frame\");\n"
"function next(){img.src=src+\"?_nocache=\"+Date.now()+\"_\"+Math.floor(Math.random()*10000);}\n"
"img.onload=function(){setTimeout(next,250);};\n"
"img.onerror=function(){setTimeout(next,1000);};\n"
"next();\n"
"</script></body></html>\n";
std::string write_error;
try {
boost::filesystem::create_directories(page.parent_path());
boost::nowide::ofstream out(page.string().c_str(), std::ios::binary | std::ios::trunc);
out << html;
out.close();
// note: an ofstream reports a failed write in its state, not by throwing.
if (!out) {
write_error = "write failed";
}
} catch (const std::exception& e) {
write_error = e.what();
}
if (!write_error.empty()) {
// why: no wrapper means no camera - a raw monitor.jpg URL would render one frozen frame
// and read as a broken feed, so fall back to showing nothing and say why in the log.
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent: could not write camera page " << page.string()
<< ": " << write_error;
return {};
}
return "file://" + page.generic_string();
}
AgentInfo SnapmakerPrinterAgent::get_agent_info_static()
{
return AgentInfo{"snapmaker", "Snapmaker", SNAPMAKER_AGENT_VERSION, "Snapmaker printer agent"};

View File

@@ -16,6 +16,10 @@ public:
AgentInfo get_agent_info() override { return get_agent_info_static(); }
bool fetch_filament_info(std::string dev_id) override;
int command_start_camera(std::string dev_id) override;
protected:
std::string webcam_stream_override(const std::string& base_url) const override;
private:
// Combine filament_type + filament_sub_type into a unified type string