Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initial support for the AnkerMake M5C #145

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# AnkerMake M5 Protocol

Welcome! This repository contains `ankerctl`, a command-line interface and web UI for monitoring, controlling and interfacing with AnkerMake M5 3D printers.
Welcome! This repository contains `ankerctl`, a command-line interface and web UI for monitoring, controlling and interfacing with AnkerMake M5 and M5C 3D printers.

**NOTE:** This is our first major release and while we have tested thoroughly there may be bugs. If you encounter one please open a [Github Issue](https://github.com/Ankermgmt/ankermake-m5-protocol/issues/new/choose)

The `ankerctl` program uses [`libflagship`](documentation/developer-docs/libflagship.md), a library for communicating with the numerous different protocols required for connecting to an AnkerMake M5 printer. The `libflagship` library is also maintained in this repo, under [`libflagship/`](libflagship/).
The `ankerctl` program uses [`libflagship`](documentation/developer-docs/libflagship.md), a library for communicating with the numerous different protocols required for connecting to an AnkerMake M5 or M5C printer. The `libflagship` library is also maintained in this repo, under [`libflagship/`](libflagship/).

![Screenshot of ankerctl](/documentation/web-interface.png "Screenshot of ankerctl web interface")

Expand All @@ -14,15 +14,15 @@ The `ankerctl` program uses [`libflagship`](documentation/developer-docs/libflag

- Print directly from PrusaSlicer and its derivatives (SuperSlicer, Bamboo Studio, OrcaSlicer, etc.)

- Connect to AnkerMake M5 and AnkerMake APIs without using closed-source Anker software.
- Connect to AnkerMake M5/M5C and AnkerMake APIs without using closed-source Anker software.

- Send raw gcode commands to the printer (and see the response).

- Low-level access to MQTT, PPPP and HTTPS APIs.

- Send print jobs (gcode files) to the printer.

- Stream camera image/video to your computer.
- Stream camera image/video to your computer (AnkerMake M5 only).

- Easily monitor print status.

Expand Down Expand Up @@ -177,7 +177,7 @@ Some examples:

This project is **<u>NOT</u>** endorsed, affiliated with, or supported by AnkerMake. All information found herein is gathered entirely from reverse engineering using publicly available knowledge and resources.

The goal of this project is to make the AnkerMake M5 usable and accessible using only Free and Open Source Software (FOSS).
The goal of this project is to make the AnkerMake M5 and M5C usable and accessible using only Free and Open Source Software (FOSS).

This project is [licensed under the GNU GPLv3](LICENSE), and copyright © 2023 Christian Iversen.

Expand Down
47 changes: 35 additions & 12 deletions libflagship/mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class _MqttMsg:
size : u16le # length of packet, including header and checksum (minimum 65).
m3 : u8 # Magic constant: 5
m4 : u8 # Magic constant: 1
m5 : u8 # Magic constant: 2
m5 : u8 # Magic constant: 2 (M5) or 1 (M5C)
m6 : u8 # Magic constant: 5
m7 : u8 # Magic constant: 'F'
packet_type: MqttPktType # Packet type
Expand All @@ -103,9 +103,19 @@ def parse(cls, p):
m7, p = u8.parse(p)
packet_type, p = MqttPktType.parse(p)
packet_num, p = u16le.parse(p)
time, p = u32le.parse(p)
device_guid, p = String.parse(p, 37)
padding, p = Bytes.parse(p, 11)
if m5 == 2:
# AnkerMake M5
time, p = u32le.parse(p)
device_guid, p = String.parse(p, 37)
padding, p = Bytes.parse(p, 11)
elif m5 == 1:
# AnkerMake M5C
time = 0 # does not seem to be sent for M5C
device_guid = "none" # still present for M5C???
padding, p = Bytes.parse(p, 12) # first 6 bytes seem to change with each packet, rest is all zeros
else:
raise ValueError(f"Unsupported mqtt message format (expected 1 or 2, but found {m5})")

data, p = Tail.parse(p)
return cls(signature=signature, size=size, m3=m3, m4=m4, m5=m5, m6=m6, m7=m7, packet_type=packet_type, packet_num=packet_num, time=time, device_guid=device_guid, padding=padding, data=data), p

Expand All @@ -119,9 +129,14 @@ def pack(self):
p += u8.pack(self.m7)
p += MqttPktType.pack(self.packet_type)
p += u16le.pack(self.packet_num)
p += u32le.pack(self.time)
p += String.pack(self.device_guid, 37)
p += Bytes.pack(self.padding, 11)
if self.m5 == 2:
p += u32le.pack(self.time)
p += String.pack(self.device_guid, 37)
padding_len = 11
elif self.m5 == 1:
padding_len = 12
padding_missing = padding_len - len(self.padding)
p += Bytes.pack(self.padding + b"\x00" * padding_missing, padding_len)
p += Tail.pack(self.data)
return p

Expand All @@ -131,17 +146,25 @@ class MqttMsg(_MqttMsg):
@classmethod
def parse(cls, p, key):
p = mqtt_checksum_remove(p)
if p[6] != 2:
raise ValueError(f"Unsupported mqtt message format (expected 2, but found {p[6]})")
body, data = p[:64], mqtt_aes_decrypt(p[64:], key)
try:
body_len = {1:24, 2:64}[p[6]]
except KeyError:
raise ValueError("Unsupported mqtt message format " +
f"(expected 1 or 2, but found {p[6]})")
body, data = p[:body_len], mqtt_aes_decrypt(p[body_len:], key)
res = super().parse(body + data)
assert res[0].size == (len(p) + 1)
return res

def pack(self, key):
data = mqtt_aes_encrypt(self.data, key)
self.size = 64 + len(data) + 1
body = super().pack()[:64]
try:
body_len = {1:24, 2:64}[self.m5]
except KeyError:
raise ValueError("Unsupported mqtt message format " +
f"(expected 1 or 2, but found {self.m5})")
self.size = body_len + len(data) + 1
body = super().pack()[:body_len]
final = mqtt_checksum_add(body + data)
return final

Expand Down
5 changes: 3 additions & 2 deletions libflagship/mqttapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def _on_message(self, client, userdata, msg):
try:
pkt, tail = MqttMsg.parse(msg.payload, key=self._key)
except Exception as E:
hexStr =' '.join([f'0x{byte:02x}' for byte in msg.payload])
hexStr = ' '.join([f'0x{byte:02x}' for byte in msg.payload])
log.error(f"Failed to decode mqtt message\n Exception: {E}\n Message : {hexStr}")
return

Expand Down Expand Up @@ -111,10 +111,11 @@ def make_mqtt_pkt(guid, data, packet_type=MqttPktType.Single, packet_num=0):
m5=2,
m6=5,
m7=ord('F'),
packet_type=MqttPktType.Single,
packet_type=packet_type,
packet_num=0,
time=0,
device_guid=guid,
padding=b'', # fixed by .pack()
data=data,
)

Expand Down
21 changes: 17 additions & 4 deletions static/ankersrv.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ $(function () {
$("#progress").text("0%");
$("#nozzle-temp").text("0°C");
$("#set-nozzle-temp").attr("value", "0°C");
$("#bed-temp").text("$0°C");
$("#bed-temp").text("0°C");
$("#set-bed-temp").attr("value", "0°C");
$("#print-speed").text("0mm/s");
$("#print-layer").text("0 / 0");
Expand All @@ -223,7 +223,7 @@ $(function () {
sockets.video = new AutoWebSocket({
name: "Video socket",
url: `ws://${location.host}/ws/video`,
badge: "#badge-pppp",
badge: "#badge-video",
binary: true,

open: function () {
Expand Down Expand Up @@ -265,14 +265,27 @@ $(function () {
badge: "#badge-ctrl",
});

sockets.pppp_state = new AutoWebSocket({
name: "PPPP socket",
url: `ws://${location.host}/ws/pppp-state`,
badge: "#badge-pppp",
});

/* Only connect websockets if #player element exists in DOM (i.e., if we
* have a configuration). Otherwise we are constantly trying to make
* connections that will never succeed. */
if ($("#player").length) {
if ($("#badge-mqtt").length) {
sockets.mqtt.connect();
sockets.video.connect();
}
if ($("#badge-ctrl").length) {
sockets.ctrl.connect();
}
if ($("#badge-pppp").length) {
sockets.pppp_state.connect();
}
if ($("#player").length) {
sockets.video.connect();
}

/**
* On click of element with id "light-on", sends JSON data to wsctrl to turn light on
Expand Down
16 changes: 16 additions & 0 deletions static/tabs/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<div class="container">
<div class="row g-3">
<div class="col-md-12 col-lg-6 col-xl-8">
{% if video_supported %}
<!-- Video Container -->
<video
class="w-100 d-block rounded-3 bg-body-tertiary"
Expand All @@ -17,6 +18,16 @@
poster="{{ url_for('static', filename='img/load-screen.svg') }}"
>
</video>
{% else %}
<div class="card">
<div class="card-body text-center">
<p class="display-1">
{{ macro.bi_icon("camera-video-off") }}
</p>
<p class="display-6">Camera not available</p>
</div>
</div>
{% endif %}
</div>
<div class="col-md-12 col-lg-6 col-xl-4">
<div class="card">
Expand All @@ -30,9 +41,13 @@
</div>
<span id="badge-mqtt" class="badge">MQTT</span>
<span id="badge-pppp" class="badge">PPPP</span>
{% if video_supported %}
<span id="badge-video" class="badge">VIDEO</span>
{% endif %}
<span id="badge-ctrl" class="badge">CTRL</span>
</div>
{% endif %}
{% if video_supported %}
<div class="card-header fs-6">Video Controls</div>
<div class="card-body">
<div class="row g-3 mb-3">
Expand Down Expand Up @@ -60,6 +75,7 @@
</div>
</div>
</div>
{% endif %}
<div class="card-header fs-6">Temperature</div>
<div class="card-body">
<div class="row g-3">
Expand Down
42 changes: 38 additions & 4 deletions web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

Routes:
- /ws/mqtt: Handles receiving and sending messages on the 'mqttqueue' stream service through websocket
- /ws/pppp-state: Provides the status of the 'pppp' stream service through websocket
- /ws/video: Handles receiving and sending messages on the 'videoqueue' stream service through websocket
- /ws/ctrl: Handles controlling of light and video quality through websocket
- /video: Handles the video streaming/downloading feature in the Flask app
Expand Down Expand Up @@ -79,12 +80,37 @@ def video(sock):
"""
Handles receiving and sending messages on the 'videoqueue' stream service through websocket
"""
if not app.config["login"]:
if not app.config["login"] or not app.config["video_supported"]:
return
for msg in app.svc.stream("videoqueue"):
sock.send(msg.data)


@sock.route("/ws/pppp-state")
def pppp_state(sock):
"""
Handles a status request for the 'pppp' stream service through websocket
"""
if not app.config["login"]:
return

pppp_connected = False

# A timeout of 3 sec should be finr, as the printer continuously sends
# PktAlive messages every second on an established connnection.
for chan, msg in app.svc.stream("pppp", timeout=3.0):
if not pppp_connected:
with app.svc.borrow("pppp") as pppp:
if pppp.connected:
pppp_connected = True
# this is the only message ever sent on this connection
# to signal that the pppp connection is up
sock.send(json.dumps({"status": "connected"}))
log.info(f"PPPP connection established")

log.warning(f"PPPP connection lost")


@sock.route("/ws/ctrl")
def ctrl(sock):
"""
Expand Down Expand Up @@ -114,7 +140,7 @@ def video_download():
Handles the video streaming/downloading feature in the Flask app
"""
def generate():
if not app.config["login"]:
if not app.config["login"] or not app.config["video_supported"]:
return
for msg in app.svc.stream("videoqueue"):
yield msg.data
Expand Down Expand Up @@ -152,6 +178,7 @@ def app_root():
configure=app.config["login"],
login_file_path=web.platform.login_path(user_os),
anker_config=anker_config,
video_supported=app.config["video_supported"],
printer=printer
)

Expand Down Expand Up @@ -269,17 +296,24 @@ def webserver(config, printer_index, host, port, insecure=False, **kwargs):
- None
"""
with config.open() as cfg:
if cfg and printer_index >= len(cfg.printers):
video_supported = False
if cfg:
if printer_index < len(cfg.printers):
# no webcam in the AnkerMake M5C (Model "V8110")
video_supported = cfg.printers[printer_index].model != "V8110"
else:
log.critical(f"Printer number {printer_index} out of range, max printer number is {len(cfg.printers)-1} ")
app.config["config"] = config
app.config["login"] = bool(cfg)
app.config["printer_index"] = printer_index
app.config["video_supported"] = video_supported
app.config["port"] = port
app.config["host"] = host
app.config["insecure"] = insecure
app.config.update(kwargs)
app.svc.register("pppp", web.service.pppp.PPPPService())
app.svc.register("videoqueue", web.service.video.VideoQueue())
if video_supported:
app.svc.register("videoqueue", web.service.video.VideoQueue())
app.svc.register("mqttqueue", web.service.mqtt.MqttQueue())
app.svc.register("filetransfer", web.service.filetransfer.FileTransferService())
app.run(host=host, port=port)
7 changes: 4 additions & 3 deletions web/lib/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from threading import Thread, Event
from datetime import datetime, timedelta
from multiprocessing import Queue
from queue import Empty


class Holdoff:
Expand Down Expand Up @@ -352,13 +353,13 @@ def borrow(self, name: str):
finally:
self.put(name)

def stream(self, name: str):
def stream(self, name: str, timeout: float=None):
try:
with self.borrow(name) as svc:
queue = Queue()

with svc.tap(lambda data: queue.put(data)):
while svc.state == RunState.Running:
yield queue.get()
except (EOFError, OSError, ServiceStoppedError):
yield queue.get(timeout=timeout)
except (EOFError, OSError, ServiceStoppedError, Empty):
return
7 changes: 6 additions & 1 deletion web/service/pppp.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ def worker_run(self, timeout):
except ConnectionResetError:
raise ServiceRestartSignal()

if not msg or msg.type != Type.DRW:
if not msg:
return

if msg.type != Type.DRW:
# forward messages other than Type.DRW without further processing
self.notify((getattr(msg, "chan", None), msg))
return

ch = self._api.chans[msg.chan]
Expand Down