├── cli ├── __init__.py ├── mqtt.py ├── pppp.py ├── model.py ├── logfmt.py ├── util.py └── config.py ├── .editorconfig ├── .gitignore ├── .vscode └── settings.json ├── .gitmodules ├── static ├── img │ └── setup │ │ ├── prusaslicer-1.png │ │ ├── prusaslicer-2.png │ │ ├── prusaslicer-3.png │ │ └── prusaslicer-4.png ├── index.html └── vendor │ └── cash.min.js ├── requirements.txt ├── .gitattributes ├── Makefile ├── libflagship ├── util.py ├── logincache.py ├── seccode.py ├── httpapi.py ├── amtypes.py ├── mqttapi.py ├── mqtt.py ├── megajank.py ├── ppppapi.py └── pppp.py ├── examples ├── demo-pppp.py ├── parse-pppp.py ├── ankermake-mqtt.crt ├── extract-auth-token.py └── mqtt-connect.py ├── docker-compose.yaml ├── Dockerfile ├── documentation ├── example-file-usage │ ├── extract-auth-token-example-file-usage.md │ ├── mqtt-connect-example-file-usage.md │ └── example-file-prerequistes.md ├── libflagship.md └── mqtt-overview.md ├── templates ├── python │ ├── mqtt.py.tpl │ ├── amtypes.py.tpl │ └── pppp.py.tpl └── lib │ └── python.py ├── specification ├── mqtt.stf └── pppp.stf ├── README.md └── ankerctl.py /cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | insert_final_newline = true 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | __pycache__ 3 | .DS_Store 4 | /settings.json 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.insertFinalNewline": true 3 | } 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "transwarp"] 2 | path = transwarp 3 | url = https://github.com/chrivers/transwarp.git 4 | -------------------------------------------------------------------------------- /static/img/setup/prusaslicer-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therealmacjeezy/ankermake-m5-protocol/master/static/img/setup/prusaslicer-1.png -------------------------------------------------------------------------------- /static/img/setup/prusaslicer-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therealmacjeezy/ankermake-m5-protocol/master/static/img/setup/prusaslicer-2.png -------------------------------------------------------------------------------- /static/img/setup/prusaslicer-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therealmacjeezy/ankermake-m5-protocol/master/static/img/setup/prusaslicer-3.png -------------------------------------------------------------------------------- /static/img/setup/prusaslicer-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therealmacjeezy/ankermake-m5-protocol/master/static/img/setup/prusaslicer-4.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | paho_mqtt==1.6.1 2 | pycryptodomex==3.17 3 | rich==13.3.1 4 | requests==2.28.2 5 | click==8.1.3 6 | platformdirs==3.1.1 7 | git+https://github.com/chrivers/transwarp.git 8 | tinyec==0.4.0 9 | crcmod==1.7 10 | tqdm==4.65.0 11 | flask==2.2.0 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Project Specific Line endings 5 | *.crt binary 6 | *.stf text 7 | *.md text 8 | *.txt text 9 | *.tpl text 10 | *.py text diff=python 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | update: 2 | @transwarp -D specification -I templates/python/ -L templates/lib -O libflagship -u 3 | 4 | diff: 5 | @transwarp -D specification -I templates/python/ -L templates/lib -O libflagship -d 6 | 7 | install-tools: 8 | git submodule update --init 9 | pip install ./transwarp 10 | -------------------------------------------------------------------------------- /libflagship/util.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import crcmod 3 | import struct 4 | 5 | def enhex(s): 6 | return binascii.b2a_hex(s).decode() 7 | 8 | def unhex(s): 9 | return binascii.a2b_hex(s) 10 | 11 | def b64e(s): 12 | return binascii.b2a_base64(s).decode().strip() 13 | 14 | def b64d(s): 15 | return binascii.a2b_base64(s) 16 | 17 | def ppcs_crc16(data): 18 | crc16 = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0x0000, xorOut=0x0000) 19 | return struct.pack(" ...") 13 | exit() 14 | 15 | for hexinput in sys.argv[1:]: 16 | # binary message to parse 17 | input = unhex(hexinput) 18 | print(f"input: {input}") 19 | 20 | # parsing a message into structured data 21 | msg, tail = pppp.Message.parse(input) 22 | print(f"decoded: {msg}") 23 | 24 | # packing a structured message into binary output 25 | output = msg.pack() 26 | print(f"encoded: {output}") 27 | 28 | # the output must match our original input 29 | assert input == output 30 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | # You must manually run `ankerctl config import` atleast once 4 | # docker run \ 5 | # -v ankerctl_vol:/root/.config/ankerctl \ 6 | # -v "$HOME/Library/Application Support/AnkerMake/AnkerMake_64bit_fp/login.json:/tmp/login.json" \ 7 | # ankerctl config import /tmp/login.json 8 | services: 9 | ankerctl: 10 | image: ankerctl/ankerctl:latest 11 | container_name: ankerctl 12 | restart: unless-stopped 13 | build: . 14 | environment: 15 | - FLASK_PORT=4470 16 | - FLASK_HOST=0.0.0.0 17 | volumes: 18 | - ankerctl_vol:/root/.config/ankerctl 19 | ports: 20 | - 127.0.0.1:4470:4470 21 | entrypoint: "/app/ankerctl.py" 22 | command: ["webserver", "run"] 23 | 24 | volumes: 25 | ankerctl_vol: 26 | external: true 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # First stage: build environment 2 | FROM python:3.11-bullseye AS build-env 3 | 4 | # Set the working directory to /app 5 | WORKDIR /app 6 | 7 | # Copy the requirements file 8 | COPY requirements.txt . 9 | 10 | # Install the dependencies 11 | RUN pip install --no-cache-dir --upgrade pip && \ 12 | pip install --no-cache-dir -r requirements.txt 13 | 14 | # Second stage: runtime environment 15 | FROM python:3.11-slim 16 | 17 | # Set the working directory to /app 18 | WORKDIR /app 19 | 20 | RUN mkdir -p /root/.config/ 21 | 22 | # Copy the script and libraries 23 | COPY ankerctl.py /app/ 24 | COPY static /app/static/ 25 | COPY libflagship /app/libflagship/ 26 | COPY cli /app/cli/ 27 | 28 | # Copy the installed dependencies from the build environment 29 | COPY --from=build-env /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages 30 | 31 | ENTRYPOINT ["python", "/app/ankerctl.py"] 32 | -------------------------------------------------------------------------------- /libflagship/logincache.py: -------------------------------------------------------------------------------- 1 | from libflagship.util import unhex, b64d 2 | import Cryptodome.Cipher.AES 3 | import json 4 | 5 | cachekey = unhex("1b55f97793d58864571e1055838cac97") 6 | 7 | 8 | def guess_region(cc): 9 | us_regions = {"US", "CA", "MX", "BR", "AR", "CU", "BS", "AU", "NZ"} 10 | if cc in us_regions: 11 | return "us" 12 | else: 13 | return "eu" 14 | 15 | 16 | def decrypt(data, key=cachekey): 17 | raw = b64d(data) 18 | 19 | aes = Cryptodome.Cipher.AES.new(key=key, mode=Cryptodome.Cipher.AES.MODE_ECB) 20 | pmsg = aes.decrypt(raw) 21 | return pmsg.rstrip(b"\x00").decode() 22 | 23 | 24 | def load(data, key=cachekey): 25 | try: 26 | raw = decrypt(data, key) 27 | except: 28 | # older versions of the Ankermake slicer just save unencrypted login 29 | # credentials in login, so attempt to decode the file contents as-is 30 | raw = data 31 | 32 | return json.loads(raw.strip()) 33 | -------------------------------------------------------------------------------- /documentation/example-file-usage/extract-auth-token-example-file-usage.md: -------------------------------------------------------------------------------- 1 | # Extracting your Authentication Token 2 | 3 | **NOTE:** You need to have to have logged into the AnkerMake slicer in order for this to work, but it doesn't need to be open during this. 4 | 5 | 1. Navigate to wherever you cloned this repository to and into the `examples` folder in your terminal. 6 | 7 | 2. MacOS and Windows users, type in the following command: 8 | 9 | ```bash 10 | python3 extract-auth-token.py -a 11 | ``` 12 | 13 | For Linux users, it's a little more complicated since we can't assume where your `login.json` file is. Locate your `login.json` file in your system and use the following command, substituting the path to your file: 14 | 15 | ```bash 16 | python3 extract-auth-token.py -f /path/to/login.json 17 | ``` 18 | 19 | Here's and example of the output: 20 | 21 | ``` 22 | 2ab96390c7dbe3439de74d0c9b0b1767 23 | ``` 24 | 25 | That's your Authentication Token. 26 | -------------------------------------------------------------------------------- /cli/mqtt.py: -------------------------------------------------------------------------------- 1 | import json 2 | import click 3 | import logging as log 4 | 5 | import cli.util 6 | 7 | from libflagship.mqttapi import AnkerMQTTBaseClient 8 | 9 | servertable = { 10 | "eu": "make-mqtt-eu.ankermake.com", 11 | "us": "make-mqtt.ankermake.com", 12 | } 13 | 14 | def mqtt_open(env): 15 | with env.config.open() as cfg: 16 | printer = cfg.printers[0] 17 | acct = cfg.account 18 | server = servertable[acct.region] 19 | env.log.info(f"Connecting to {server}") 20 | client = AnkerMQTTBaseClient.login( 21 | printer.sn, 22 | acct.mqtt_username, 23 | acct.mqtt_password, 24 | printer.mqtt_key, 25 | ca_certs="examples/ankermake-mqtt.crt", 26 | verify=not env.insecure, 27 | ) 28 | client.connect(server) 29 | return client 30 | 31 | def mqtt_command(client, msg): 32 | client.command(msg) 33 | 34 | reply = client.await_response(msg["commandType"]) 35 | if reply: 36 | click.echo(cli.util.pretty_json(reply)) 37 | else: 38 | log.error("No response from printer") 39 | -------------------------------------------------------------------------------- /examples/ankermake-mqtt.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDwTCCAqmgAwIBAgIJAKrbZvWARI3BMA0GCSqGSIb3DQEBCwUAMHUxCzAJBgNV 3 | BAYTAkNOMREwDwYDVQQIDAhTaGVuemhlbjERMA8GA1UEBwwIU2hlbnpoZW4xEjAQ 4 | BgNVBAoMCWFua2VybWFrZTESMBAGA1UECwwJYW5rZXJtYWtlMRgwFgYDVQQDDA8q 5 | LmFua2VybWFrZS5jb20wIBcNMjIwNjE3MDMwNzU5WhgPMjEyMjA1MjQwMzA3NTla 6 | MHUxCzAJBgNVBAYTAkNOMREwDwYDVQQIDAhTaGVuemhlbjERMA8GA1UEBwwIU2hl 7 | bnpoZW4xEjAQBgNVBAoMCWFua2VybWFrZTESMBAGA1UECwwJYW5rZXJtYWtlMRgw 8 | FgYDVQQDDA8qLmFua2VybWFrZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw 9 | ggEKAoIBAQC8JWJzdVJFqrarK5oMCF8nI5QZ2nebs9df6CQHuSZCOmGCav5sDDFt 10 | 5IGhQ6G44++YNexC10kwxy10fOzIT6cZWnQrYQPBfS0y7G+yu/GPe9vXMWwkIcWv 11 | hg8xAO+/m5C/QAj4BOVTXVl5spuBGX644P3eErV+tUDwb1U2K6mMzmaJ7SZqkmiw 12 | QKfTK1KxH7oczcxjDtdbNdtpa1Rm3IUCCI2eAOQTlDHlKGGM2T+e6qQRCUQYqkiY 13 | jG+3ugTzHMe6FMzOB1EjG0bZDemQwgUdBJexLgxrJe4jsVcuP75DfrV0NL/Drrmt 14 | uJax3V4tu5Yx1RQCWqGTNPOahpS+qD+NAgMBAAGjUjBQMA4GA1UdDwEB/wQEAwID 15 | iDATBgNVHSUEDDAKBggrBgEFBQcDATApBgNVHREEIjAggg1hbmtlcm1ha2UuY29t 16 | gg8qLmFua2VybWFrZS5jb20wDQYJKoZIhvcNAQELBQADggEBALF/VDyZ21IdFejE 17 | awLriK+Xo78k1yqf2YKWYSDMEJPXXHfbkHZTU0IL+K9kToN19sObuWPA1oE2iyKp 18 | h4nKVDjy56Ntgt5lXeSTN08jlD0PzuuGfzPVxMrky8sp14pFT+Kw2HOEMLU6Hxj0 19 | WjpprKRbl1oI8JoksYNzCSelIItokA8CI3/p1j5FyWxok99sVvNUfjG9iaV74Nuh 20 | kY/1nm0T0aMPZKpcS0xS0JwA0tsySdDJP5t1KgmDa5D0hIhXuAJWGwUvg15vSyme 21 | bk3IO48Nh8QOG8PwGebPus1nnvKCbG6+iJaWp/PqSqNCzx/Nht+Tfi413dIc3exF 22 | LX0ZR20= 23 | -----END CERTIFICATE----- 24 | -------------------------------------------------------------------------------- /cli/pppp.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | import click 4 | import logging as log 5 | 6 | from tqdm import tqdm 7 | 8 | import cli.util 9 | 10 | from libflagship.pppp import PktLanSearch, Duid, P2PCmdType 11 | from libflagship.ppppapi import AnkerPPPPApi, FileTransfer 12 | 13 | 14 | def pppp_open(env): 15 | with env.config.open() as cfg: 16 | printer = cfg.printers[0] 17 | 18 | api = AnkerPPPPApi.open_lan(Duid.from_string(printer.p2p_duid), host=printer.ip_addr) 19 | log.info("Trying connect over pppp") 20 | api.daemon = True 21 | api.start() 22 | 23 | api.send(PktLanSearch()) 24 | 25 | while not api.rdy: 26 | time.sleep(0.1) 27 | 28 | log.info("Established pppp connection") 29 | return api 30 | 31 | 32 | def pppp_send_file(api, fui, data): 33 | log.info("Requesting file transfer..") 34 | api.send_xzyh(str(uuid.uuid4())[:16].encode(), cmd=P2PCmdType.P2P_SEND_FILE) 35 | 36 | log.info("Sending file metadata..") 37 | api.aabb_request(bytes(fui), frametype=FileTransfer.BEGIN) 38 | 39 | log.info("Sending file contents..") 40 | blocksize = 1024 * 32 41 | chunks = cli.util.split_chunks(data, blocksize) 42 | pos = 0 43 | 44 | with tqdm(unit="b", total=len(data), unit_scale=True, unit_divisor=1024) as bar: 45 | for chunk in chunks: 46 | api.aabb_request(chunk, frametype=FileTransfer.DATA, pos=pos) 47 | pos += len(chunk) 48 | bar.update(len(chunk)) 49 | -------------------------------------------------------------------------------- /cli/model.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import json 3 | from dataclasses import dataclass 4 | from libflagship.util import unhex, enhex 5 | 6 | class Serialize: 7 | 8 | @classmethod 9 | def from_dict(cls, data): 10 | res = {} 11 | for k, v in cls.__dataclass_fields__.items(): 12 | res[k] = data[k] 13 | if v.type == bytes: 14 | res[k] = unhex(res[k]) 15 | return cls(**res) 16 | 17 | def to_dict(self): 18 | res = {} 19 | for k, v in self.__dataclass_fields__.items(): 20 | res[k] = getattr(self, k) 21 | if v.type == bytes: 22 | res[k] = enhex(res[k]) 23 | return res 24 | 25 | @classmethod 26 | def from_json(cls, data): 27 | return cls.from_dict(json.loads(data)) 28 | 29 | def to_json(self): 30 | return json.dumps(self.to_dict()) 31 | 32 | @dataclass 33 | class Printer(Serialize): 34 | sn: str 35 | wifi_mac: str 36 | ip_addr: str 37 | mqtt_key: bytes 38 | api_hosts: str 39 | p2p_hosts: str 40 | p2p_duid: str 41 | p2p_key: str 42 | 43 | @dataclass 44 | class Account(Serialize): 45 | auth_token: str 46 | region: str 47 | user_id: str 48 | email: str 49 | 50 | @property 51 | def mqtt_username(self): 52 | return f"eufy_{self.user_id}" 53 | 54 | @property 55 | def mqtt_password(self): 56 | return self.email 57 | 58 | @dataclass 59 | class Config(Serialize): 60 | account: Account 61 | printers: [Printer] 62 | -------------------------------------------------------------------------------- /cli/logfmt.py: -------------------------------------------------------------------------------- 1 | import click 2 | import logging 3 | 4 | 5 | class ColorFormatter(logging.Formatter): 6 | 7 | def __init__(self, fmt): 8 | super().__init__(fmt) 9 | 10 | self._colors = { 11 | logging.CRITICAL: "red", 12 | logging.ERROR: "red", 13 | logging.WARNING: "yellow", 14 | logging.INFO: "green", 15 | logging.DEBUG: "magenta", 16 | } 17 | 18 | self._marks = { 19 | logging.CRITICAL: "!", 20 | logging.ERROR: "E", 21 | logging.WARNING: "W", 22 | logging.INFO: "*", 23 | logging.DEBUG: "D", 24 | } 25 | 26 | def format(self, rec): 27 | marks, colors = self._marks, self._colors 28 | return "".join([ 29 | click.style("[", fg="blue", bold=True), 30 | click.style(marks[rec.levelno], fg=colors[rec.levelno], bold=True), 31 | click.style("]", fg="blue", bold=True), 32 | " ", 33 | super().format(rec), 34 | ]) 35 | 36 | 37 | class ExitOnExceptionHandler(logging.StreamHandler): 38 | 39 | def emit(self, record): 40 | super().emit(record) 41 | if record.levelno == logging.CRITICAL: 42 | raise SystemExit(127) 43 | 44 | 45 | def setup_logging(level=logging.INFO): 46 | logging.basicConfig(handlers=[ExitOnExceptionHandler()]) 47 | log = logging.getLogger() 48 | log.setLevel(level) 49 | handler = log.handlers[0] 50 | handler.setFormatter(ColorFormatter("%(message)s")) 51 | return log 52 | -------------------------------------------------------------------------------- /examples/extract-auth-token.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | sys.path.append("..") 5 | 6 | from libflagship.util import b64d 7 | import libflagship.logincache 8 | 9 | from os import path 10 | import json 11 | import platform 12 | import argparse 13 | 14 | def print_login(fd): 15 | jsonj = libflagship.logincache.load(fd.read()) 16 | print(jsonj["data"]["auth_token"]) 17 | 18 | def parse_args(): 19 | def fmt(prog): 20 | return argparse.HelpFormatter(prog,max_help_position=42) 21 | 22 | parser = argparse.ArgumentParser( 23 | prog="extract-auth-token", 24 | description="Extract auth token from ankermake slicer login.json", 25 | formatter_class=fmt 26 | ) 27 | 28 | group = parser.add_mutually_exclusive_group(required=True) 29 | 30 | group.add_argument( 31 | "-a", "--auto", 32 | action="store_true", 33 | help="Attempt auto-detection of login.json location" 34 | ) 35 | 36 | group.add_argument( 37 | "-f", "--file", 38 | type=argparse.FileType("r"), 39 | metavar="", 40 | help="Specify location of login.json" 41 | ) 42 | 43 | return parser.parse_args() 44 | 45 | def main(): 46 | useros = platform.system() 47 | 48 | darfileloc = path.expanduser('~/Library/Application Support/AnkerMake/AnkerMake_64bit_fp/login.json') 49 | winfileloc = path.expandvars(r'%LOCALAPPDATA%\Ankermake\AnkerMake_64bit_fp\login.json') 50 | 51 | args = parse_args() 52 | 53 | if args.auto: 54 | 55 | if useros == 'Darwin': 56 | print_login(open(darfileloc)) 57 | elif useros == 'Windows': 58 | print_login(open(winfileloc)) 59 | else: 60 | exit("This platform does not support autodetection. Please specify file location with -f ") 61 | 62 | elif args.file: 63 | print_login(args.file) 64 | 65 | if __name__ == "__main__": 66 | exit(main()) 67 | -------------------------------------------------------------------------------- /libflagship/seccode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import hashlib 4 | import random 5 | from libflagship.util import enhex 6 | 7 | ## old v1 "check code" 8 | 9 | def calc_check_code(sn, mac): 10 | input = f"{sn}+{sn[-4:]}+{mac}" 11 | return hashlib.md5(input.encode()).hexdigest() 12 | 13 | ## new v2 "security code" 14 | 15 | def cal_hw_id_suffix(val): 16 | return sum(( 17 | int(chr(val[-1]), 16), 18 | int(chr(val[-2]), 16), 19 | int(chr(val[-3]), 16), 20 | int(chr(val[-4]), 16), 21 | )) 22 | 23 | 24 | def gen_base_code(sn, mac): 25 | last_digit = int(chr(sn[-1]), 16) 26 | 27 | offset = (last_digit + 10) % 10 28 | 29 | return sn[offset:] + str(cal_hw_id_suffix(mac)).encode() 30 | 31 | 32 | def gen_check_code_v1(base_code, seed): 33 | base = b"01" + base_code + seed 34 | 35 | sha = hashlib.sha256(base).digest() 36 | 37 | str = bytearray(sha + sha[10:12]) 38 | 39 | if (str[32] < 0x7d) or (str[33] < 0x7d): 40 | str[32] = (str[32] + str[33]) & 0xFF 41 | 42 | for x in range(0, 32, 2): 43 | if (str[x] < 0x7d) or (str[x+1] < 0x7d): 44 | str[x] = (str[x] + str[x+1]) & 0xFF 45 | 46 | if max(0x7d, str[x+1]) < str[x+2]: 47 | str[x+1] = str[x+2] - str[x+1] 48 | 49 | if (str[x+1] > 0x7d) and (str[x+1] > str[x+2]): 50 | str[x+1] = str[x+1] - str[x+2] 51 | 52 | return enhex(str[0x10:0x20]).upper() 53 | 54 | 55 | def gen_rand_seed(mac): 56 | rnd = random.randint(10000000,99999999) 57 | 58 | suffix = cal_hw_id_suffix(mac) 59 | txtbuf = str(1000 - suffix) + str(rnd) 60 | 61 | sec_ts = "01%d" % rnd 62 | sec_code = hashlib.md5(txtbuf.encode()).hexdigest().upper().encode() 63 | 64 | return sec_ts, sec_code 65 | 66 | 67 | def create_check_code_v1(sn, mac): 68 | base_code = gen_base_code(sn, mac) 69 | sec_ts, seed = gen_rand_seed(mac) 70 | sec_code = gen_check_code_v1(base_code, seed) 71 | return sec_ts, sec_code 72 | -------------------------------------------------------------------------------- /templates/python/mqtt.py.tpl: -------------------------------------------------------------------------------- 1 | <% import python %>\ 2 | ${python.header()} 3 | 4 | import enum 5 | from dataclasses import dataclass 6 | import json 7 | from .amtypes import * 8 | from .megajank import mqtt_checksum_add, mqtt_checksum_remove, mqtt_aes_encrypt, mqtt_aes_decrypt 9 | 10 | % for enum in _mqtt: 11 | % if enum.expr == "enum": 12 | class ${enum.name}(enum.IntEnum): 13 | % for const in enum.consts: 14 | ${const.aligned_name} = ${const.aligned_hex_value} # ${const.comment[0]} 15 | % endfor 16 | 17 | @classmethod 18 | def parse(cls, p): 19 | return cls(struct.unpack("B", p[:1])[0]), p[1:] 20 | 21 | def pack(self): 22 | return struct.pack("B", self) 23 | 24 | % endif 25 | % endfor 26 | % for struct in _mqtt: 27 | % if struct.expr == "struct": 28 | @dataclass 29 | class ${struct.name}: 30 | % for field in struct.fields: 31 | ${field.aligned_name}: ${python.typename(field)} # ${"".join(field.comment)} 32 | % endfor 33 | 34 | @classmethod 35 | def parse(cls, p): 36 | %for field in struct.fields: 37 | ${field.name}, p = ${python.typeparse(field, "p")} 38 | %endfor 39 | return cls(${", ".join(f"{f.name}={f.name}" for f in struct.fields)}), p 40 | 41 | def pack(self): 42 | p = ${python.typepack(struct.fields[0])} 43 | %for field in struct.fields[1:]: 44 | p += ${python.typepack(field)} 45 | %endfor 46 | return p 47 | 48 | % endif 49 | % endfor 50 | 51 | class MqttMsg(_MqttMsg): 52 | 53 | @classmethod 54 | def parse(cls, p, key): 55 | p = mqtt_checksum_remove(p) 56 | body, data = p[:64], mqtt_aes_decrypt(p[64:], key) 57 | res = super().parse(body + data) 58 | assert res[0].size == (len(p) + 1) 59 | return res 60 | 61 | def pack(self, key): 62 | data = mqtt_aes_encrypt(self.data, key) 63 | self.size = 64 + len(data) + 1 64 | body = super().pack()[:64] 65 | final = mqtt_checksum_add(body + data) 66 | return final 67 | 68 | def getjson(self): 69 | return json.loads(self.data.decode()) 70 | 71 | def setjson(self, val): 72 | self.data = json.dumps(val).encode() 73 | -------------------------------------------------------------------------------- /templates/lib/python.py: -------------------------------------------------------------------------------- 1 | def header(): 2 | return \ 3 | "## ------------------------------------------\n" \ 4 | "## Generated by Transwarp\n" \ 5 | "##\n" \ 6 | "## THIS FILE IS AUTOMATICALLY GENERATED.\n" \ 7 | "## DO NOT EDIT. ALL CHANGES WILL BE LOST.\n" \ 8 | "## ------------------------------------------" 9 | 10 | _parsetable = { 11 | "array": "Array", 12 | "string": "String", 13 | "zeroes": "Zeroes", 14 | "magic": "Magic", 15 | "tail": "Tail", 16 | "bytes": "Bytes", 17 | } 18 | 19 | def magic_default(tp): 20 | size = int(str(tp[0])) 21 | value = int(str(tp[1]), 16) 22 | hexval = f"%0{size * 2}x" % value 23 | return bytes.fromhex(hexval) 24 | 25 | def typename(field): 26 | tp = field.type 27 | 28 | if tp.name == "zeroes": 29 | return f"bytes = field(repr=False, kw_only=True, default='\\x00' * {tp[0]})" 30 | elif tp.name == "string": 31 | return "bytes" 32 | elif tp.name == "magic": 33 | return f"bytes = field(repr=False, kw_only=True, default={magic_default(tp)})" 34 | elif tp.name == "tail": 35 | return "bytes" 36 | elif tp.name == "array": 37 | if len(tp.args) in {1, 2}: 38 | return f"list[{tp[0]}]" 39 | else: 40 | raise ValueError(f"bad array type: {tp}") 41 | else: 42 | return tp.name 43 | 44 | def typeparse(field, p): 45 | tp = field.type 46 | name = _parsetable.get(tp.name, tp.name) 47 | args = [p] 48 | for i in range(len(tp)): 49 | if tp[i].name == "field": 50 | args.append(tp[i][0].name) 51 | else: 52 | args.append(tp[i].name) 53 | 54 | if tp.name == "magic": 55 | args[2] = repr(magic_default(tp)) 56 | 57 | return f"{name}.parse({', '.join(args)})" 58 | 59 | def typepack(field): 60 | tp = field.type 61 | name = tp.name 62 | 63 | args = [f"self.{field.name}"] 64 | for i in range(len(tp)): 65 | if tp[i].name == "field": 66 | args.append(f"self.{tp[i][0].name}") 67 | else: 68 | args.append(tp[i].name) 69 | 70 | if tp.name == "magic": 71 | args[2] = repr(magic_default(tp)) 72 | 73 | if name in _parsetable: 74 | name = _parsetable[name] 75 | return f"{name}.pack({', '.join(args)})" 76 | -------------------------------------------------------------------------------- /documentation/example-file-usage/mqtt-connect-example-file-usage.md: -------------------------------------------------------------------------------- 1 | # Connecting to a M5 over CLI (via MQTT) 2 | 3 | This example file connects to your machine over MQTT to view basic data. 4 | 5 | 1. Navigate to wherever you cloned this repository to. Open the "examples" folder and open a terminal window there, just like in the previous section. 6 | 7 | 2. In order to achieve a successful connection via `mqtt-connect.py`, it's important to understand each argument that is required to be input. Here's a valid command to run this script if your AnkerMake account was registered in the USA: 8 | 9 | ```bash 10 | python3 mqtt-connect.py -r us -A "YOUR_AUTH_TOKEN_HERE" 11 | ``` 12 | 13 | The first required argument is `-r` (or `--region`) for the region your AnkerMake account was registered in. Use either `eu` or `us`. 14 | 15 | The second required argument is `-A` (or `--auth`) and you should paste in your Authentication Token here. You can extract that from your `login.json` file by following [this guide](/documentation/example-file-usage/extract-auth-token-example-file-usage.md). 16 | 17 | 3. **[Optional Step]** If desired, you can save the contents of the output to a log file by adding `> output.log` to the end of the command in the previous step: 18 | 19 | ```bash 20 | python3 mqtt-connect.py -r us -A "YOUR_AUTH_TOKEN_HERE" > output.log 21 | ``` 22 | 23 | Now you should see a bunch of data updating in your terminal and this will update anytime a data point changes values. It should look something like this and will be quite a bit more colorful: 24 | 25 | ``` 26 | TOPIC [/phone/maker/YOUR_SERIAL_NUMBER_HERE/notice] 27 | 4d41c1000501020546c00100045b126436353163383633612d366266652d313033622d396234372d35623534393334336637356100000000000000000000000080d48d67e412394ccc2ef9d76b966687e047c862d36ca291d70a7c732aec8f28e7a315dc1dab0fc51eed678bee3959ae14af8ef3670553412e13cc90a0a6d2c4c0a949f072a716ef9153eed115eb7a7decf9c88bcb07922bae5cc925a96e954b1f70dfb55b079b696178f2c918c0af5c9e5861ae7809b97b80614cec6e948f86cc 28 | MqttMsg( 29 | size=193, 30 | m3=5, 31 | m4=1, 32 | m5=2, 33 | m6=5, 34 | m7=70, 35 | packet_type=, 36 | packet_num=1, 37 | time=1678924548, 38 | device_guid='SOME_DATA_HERE', 39 | data=b'[{"commandType":1003,"currentTemp":5263,"targetTemp":18000},{"commandType":1004,"currentTemp":3990,"targetTemp":6000}]' 40 | ) 41 | ``` 42 | 43 | Refer to the [MQTT documentation](https://github.com/Ankermgmt/ankermake-m5-research/tree/master/mqtt) for information on what values you're seeing in this output. For the general structure and abstract explanation of the AnkerMake MQTT communications, reference the documentation in this repository starting with the [MQTT Overview](../mqtt-overview.md). 44 | 45 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ankerctl 7 | 8 | 9 | 10 |
11 |
12 |

ankerctl

13 |

Congratulations on running ankerctl

14 |
15 |
16 |
17 |
18 |
19 |

Connecting PrusaSlicer/SuperSlicer

20 |
    21 |
  1. Go to "Printer settings" tab
  2. 22 |
  3. Click "Gear"⚙️ (Add/Edit Physical Printer)
  4. 23 |
  5. Fill in "Descriptive name for the printer" with whatever you like
  6. 24 |
  7. Select your Ankermake printer profile from the dropdown menu
  8. 25 |
  9. Under "Host type" select "OctoPrint"
  10. 26 |
  11. In "Hostname, IP or URL" fill in with: {{ configHost }}:{{ configPort }} 📋
  12. 27 |
  13. Leave "API Key / Password" blank
  14. 28 |
  15. Click "Test" to validate input
  16. 29 |
  17. Click "Ok"
  18. 30 |
  19. Go and slice yor files
  20. 31 |
  21. Press "G>" button to start direct upload
  22. 32 |
  23. Press "Upload and print" - the "Upload"-only is not supported
  24. 33 |
  25. Enjoy printing with ankerctl 😉
  26. 34 |
35 |
36 |
37 | step 1 38 | step 2 39 | step 3 40 | step 4 41 |
42 |
43 | {{ configData }} 44 |
45 | 46 | 47 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /templates/python/amtypes.py.tpl: -------------------------------------------------------------------------------- 1 | <% import python %>\ 2 | ${python.header()} 3 | 4 | import struct 5 | import enum 6 | from dataclasses import dataclass, field 7 | import socket 8 | 9 | def _assert_equal(value, expected): 10 | if value != expected: 11 | raise ValueError(f"expected {expected} but found {value}") 12 | 13 | class Zeroes: 14 | @classmethod 15 | def parse(cls, p, num): 16 | body = p[:num] 17 | _assert_equal(set(body) - {0}, set()) 18 | return body, p[num:] 19 | 20 | def pack(self, num): 21 | return b"\x00" * num 22 | 23 | class Bytes(bytes): 24 | @classmethod 25 | def parse(cls, p, size): 26 | return p[:size], p[size:] 27 | 28 | def pack(self, size): 29 | return self 30 | 31 | class String(Bytes): 32 | @classmethod 33 | def parse(cls, p, size): 34 | body, p = super().parse(p, size) 35 | _assert_equal(body[-1], 0) 36 | return body[:-1].decode(), p 37 | 38 | def pack(self, size): 39 | return self[:size-1].ljust(size, '\x00').encode() 40 | 41 | class Array: 42 | @classmethod 43 | def parse(cls, p, elem, num): 44 | res = [] 45 | for _ in range(num): 46 | item, p = elem.parse(p) 47 | res.append(item) 48 | return res, p 49 | 50 | def pack(self, cls, num): 51 | return b"".join(cls.pack(e) for e in self) 52 | 53 | class IPv4(str): 54 | @classmethod 55 | def parse(cls, p): 56 | addr = p[:4][::-1] 57 | return cls(socket.inet_ntoa(addr)), p[4:] 58 | 59 | def pack(self): 60 | return socket.inet_aton(self)[::-1] 61 | 62 | class Magic(bytes): 63 | @classmethod 64 | def parse(cls, p, size, expected): 65 | v, p = p[:size], p[size:] 66 | _assert_equal(v, expected) 67 | return cls(v), p 68 | 69 | def pack(self, size, expected): 70 | return self 71 | 72 | class Tail(bytes): 73 | @classmethod 74 | def parse(cls, p): 75 | return cls(p), b"" 76 | 77 | def pack(self): 78 | if isinstance(self, bytes): 79 | return self 80 | else: 81 | return self.pack() 82 | 83 | class IntType(int): 84 | pass 85 | 86 | %for name, desc, size in [ \ 87 | ("i8", "b", 1), \ 88 | ("u8", "B", 1), \ 89 | ("i16", "h", 2), \ 90 | ("u16", "H", 2), \ 91 | ("i32", "i", 4), \ 92 | ("u32", "I", 4), \ 93 | ]: 94 | class ${name}be(IntType): 95 | size = ${size} 96 | 97 | @classmethod 98 | def parse(cls, p): 99 | return cls(struct.unpack(">${desc}", p[:cls.size])[0]), p[cls.size:] 100 | 101 | def pack(self): 102 | return struct.pack(">${desc}", self) 103 | 104 | class ${name}le(IntType): 105 | size = ${size} 106 | 107 | @classmethod 108 | def parse(cls, p): 109 | return cls(struct.unpack("<${desc}", p[:cls.size])[0]), p[cls.size:] 110 | 111 | def pack(self): 112 | return struct.pack("<${desc}", self) 113 | 114 | ${name} = ${name}be 115 | 116 | %endfor 117 | -------------------------------------------------------------------------------- /cli/util.py: -------------------------------------------------------------------------------- 1 | import click 2 | import json 3 | from flask import make_response, abort 4 | 5 | 6 | def json_key_value(str): 7 | if "=" not in str: 8 | raise ValueError("Invalid 'key=value' argument") 9 | key, value = str.split("=", 1) 10 | try: 11 | return key, int(value) 12 | except ValueError: 13 | try: 14 | return key, float(value) 15 | except ValueError: 16 | return key, value 17 | 18 | 19 | class EnumType(click.ParamType): 20 | def __init__(self, enum): 21 | self.__enum = enum 22 | 23 | def get_missing_message(self, param): 24 | return "Choose number or name from:\n{choices}".format( 25 | choices="\n".join(f"{e.value:10}: {e.name}" for e in sorted(self.__enum)) 26 | ) 27 | 28 | def convert(self, value, param, ctx): 29 | try: 30 | return self.__enum(int(value)) 31 | except ValueError: 32 | try: 33 | return self.__enum[value] 34 | except KeyError: 35 | self.fail(self.get_missing_message(param), param, ctx) 36 | 37 | 38 | class FileSizeType(click.ParamType): 39 | 40 | name = "filesize" 41 | 42 | def convert(self, value, param, ctx): 43 | value = value.lower().rstrip("b") 44 | try: 45 | num = int(value[:-1]) 46 | if value.endswith("k"): 47 | return num * 1024**1 48 | elif value.endswith("m"): 49 | return num * 1024**2 50 | elif value.endswith("g"): 51 | return num * 1024**3 52 | elif value.endswith("t"): 53 | return num * 1024**4 54 | else: 55 | raise ValueError() 56 | except ValueError: 57 | self.fail("Invalid file size: use {kb,gb,mb,tb} suffix (examples: 1337kb, 42mb, 17gb)", param, ctx) 58 | 59 | 60 | def parse_json(msg): 61 | if isinstance(msg, dict): 62 | for key, value in msg.items(): 63 | msg[key] = parse_json(value) 64 | elif isinstance(msg, str): 65 | try: 66 | msg = parse_json(json.loads(msg)) 67 | except ValueError: 68 | pass 69 | 70 | return msg 71 | 72 | 73 | def pretty_json(msg): 74 | return json.dumps(parse_json(msg), indent=4) 75 | 76 | 77 | def pretty_mac(mac): 78 | parts = [] 79 | while mac: 80 | parts.append(mac[:2]) 81 | mac = mac[2:] 82 | return ":".join(parts) 83 | 84 | 85 | def pretty_size(size): 86 | for unit in ["", "KB", "MB", "GB", "TB"]: 87 | if size < 1024.0: 88 | break 89 | size /= 1024.0 90 | return f"{size:3.2f}{unit}" 91 | 92 | 93 | def split_chunks(data, chunksize): 94 | data = data[:] 95 | res = [] 96 | while data: 97 | res.append(data[:chunksize]) 98 | data = data[chunksize:] 99 | return res 100 | 101 | 102 | def parse_http_bool(str): 103 | if str in {"true", "True", "1"}: 104 | return True 105 | elif str in {"false", "False", "0"}: 106 | return False 107 | else: 108 | raise ValueError(f"Could not parse {str!r} as boolean") 109 | 110 | 111 | def http_abort(code, message): 112 | response = make_response(f"{message}") 113 | response.status_code = code 114 | abort(response) 115 | -------------------------------------------------------------------------------- /examples/mqtt-connect.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | sys.path.append("..") 5 | 6 | import argparse 7 | import json 8 | from rich import print 9 | 10 | from libflagship.util import enhex, unhex 11 | from libflagship.mqtt import MqttMsg 12 | from libflagship.mqttapi import AnkerMQTTBaseClient 13 | 14 | # inherit from AnkerMQTTBaseClient, and override event handling. 15 | class AnkerMQTTClient(AnkerMQTTBaseClient): 16 | 17 | def on_connect(self, client, userdata, flags): 18 | print("[*] Connected to mqtt") 19 | 20 | def on_message(self, client, userdata, msg, pkt, tail): 21 | print(f"TOPIC \[{msg.topic}]") 22 | sys.stdout.buffer.write(enhex(msg.payload[:]).encode() + b"\n") 23 | 24 | print(pkt) 25 | 26 | if tail: 27 | print(f"UNPARSED TAIL DATA: {tail}") 28 | 29 | def parse_args(): 30 | def fmt(prog): 31 | return argparse.HelpFormatter(prog,max_help_position=42) 32 | 33 | parser = argparse.ArgumentParser( 34 | prog="mqtt-connect", 35 | description="Connect to Ankermake M5 mqtt server", 36 | formatter_class=fmt 37 | ) 38 | 39 | parser.add_argument( 40 | "-r", "--region", 41 | choices=["eu", "us"], 42 | required=True, 43 | help="Select server region" 44 | ) 45 | 46 | parser.add_argument( 47 | "-P", "--printer", 48 | help="Use specified printer serial (instead of first available)" 49 | ) 50 | 51 | parser.add_argument( 52 | "-A", "--auth", 53 | help="Auth token" 54 | ) 55 | 56 | parser.add_argument( 57 | "-k", "--insecure", 58 | action="store_const", 59 | const=True, 60 | default=False, 61 | help="Disable TLS certificate validation", 62 | ) 63 | 64 | args = parser.parse_args() 65 | if args.auth and len(args.auth) != 48: 66 | print("ERROR: Auth token must be 48 characters") 67 | exit() 68 | 69 | return args 70 | 71 | def find_printer(devices, printer): 72 | for dev in devices: 73 | if not printer: 74 | return dev 75 | if dev["station_sn"] == printer: 76 | return dev 77 | 78 | def main(): 79 | import libflagship.httpapi 80 | 81 | servertable = { 82 | "eu": "make-mqtt-eu.ankermake.com", 83 | "us": "make-mqtt.ankermake.com", 84 | } 85 | 86 | # parse arguments 87 | args = parse_args() 88 | 89 | if args.insecure: 90 | import urllib3 91 | urllib3.disable_warnings() 92 | 93 | print("[*] Initializing API..") 94 | # create api instances 95 | appapi = libflagship.httpapi.AnkerHTTPAppApiV1(auth_token=args.auth, region=args.region, verify=not args.insecure) 96 | ppapi = libflagship.httpapi.AnkerHTTPPassportApiV1(auth_token=args.auth, region=args.region, verify=not args.insecure) 97 | 98 | # request profile and printer list 99 | print("[*] Requesting profile data..") 100 | profile = ppapi.profile() 101 | 102 | print("[*] Requesting printer list..") 103 | printers = appapi.query_fdm_list() 104 | 105 | # find printer to monitor 106 | printer = find_printer(printers, args.printer) 107 | 108 | if not printer: 109 | print(f"ERROR: could not find printer [{args.printer}]") 110 | if printers: 111 | print(f"Available printers:") 112 | for printer in printers: 113 | print(f" {printer['station_sn']}") 114 | exit() 115 | 116 | print("[*] Connecting to mqtt..") 117 | # collect mqtt arguments 118 | printer_sn = printer["station_sn"] 119 | mqtt_username = "eufy_" + profile["user_id"] 120 | mqtt_password = profile["email"] # yes, your mqtt password is your email address.. 121 | mqtt_key = unhex(printer["secret_key"]) 122 | 123 | # get up and running 124 | try: 125 | client = AnkerMQTTClient.login(printer_sn, mqtt_username, mqtt_password, mqtt_key, verify=not args.insecure) 126 | client.connect(server=servertable[args.region]) 127 | client.loop() 128 | except Exception as E: 129 | print(f"ERROR: {E}", file=sys.stderr) 130 | 131 | if __name__ == "__main__": 132 | main() 133 | -------------------------------------------------------------------------------- /documentation/libflagship.md: -------------------------------------------------------------------------------- 1 | Ankermake M5 protocols 2 | ====================== 3 | 4 | This repository tries to document and implement the various APIs associated with 5 | Ankermake M5 3D printers. 6 | 7 | For now, MQTT and PPPP have reasonable coverage. 8 | 9 | The protocol specifications themselves are written as [Simple Type 10 | Format](https://github.com/chrivers/transwarp#stf-specifications) files. 11 | 12 | Simple Type Format (`.stf`) files are lightly-structured, and can be used with 13 | project-specific template files, to generate any type of desired 14 | protocol-related output. 15 | 16 | In this repository, we use the 17 | [Transwarp](https://github.com/chrivers/transwarp#transwarp) compiler to compile 18 | the `.stf` files into python code files that implement the various protocols. 19 | 20 | In the future, we might also generate C header files, reference documentation, 21 | etc, from the same `.stf` source files: 22 | 23 | - [PPPP specification (stf)](specification/pppp.stf) 24 | - [MQTT specification (stf)](specification/mqtt.stf) 25 | 26 | libflagship 27 | =========== 28 | 29 | A python library (`libflagship`) is provided in this repository, which 30 | implements the `mqtt` and `pppp` protocols used by Ankermake M5. 31 | 32 | Let's take a look at [demo-pppp.py](demo-pppp.py), demonstrating basic usage of 33 | `libflagship` for working with PPPP data: 34 | 35 | ```python 36 | import libflagship.pppp as pppp 37 | from rich import print 38 | 39 | # binary message to parse 40 | input = b'\xf1C\x00,EUPRAKM\x00\x00\x0009ABCDE\x00\x00\x00\x00\x02iz(\x1e\x14\n\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 41 | print(f"input: {input}") 42 | 43 | # parsing a message into structured data 44 | msg, tail = pppp.Message.parse(input) 45 | print(f"decoded: {msg}") 46 | 47 | # packing a structured message into binary output 48 | output = msg.pack() 49 | print(f"encoded: {output}") 50 | 51 | # the output must match our original input 52 | assert input == output 53 | ``` 54 | 55 | The expected output from running this script is: 56 | 57 | ``` 58 | input: b'\xf1C\x00,EUPRAKM\x00\x00\x0009ABCDE\x00\x00\x00\x00\x02iz(\x1e\x14\n\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 59 | decoded: PktP2pRdyAck(duid=Duid(prefix='EUPRAKM', serial=12345, check='ABCDE'), host=Host(afam=2, port=31337, addr='10.20.30.40')) 60 | encoded: b'\xf1C\x00,EUPRAKM\x00\x00\x0009ABCDE\x00\x00\x00\x00\x02iz(\x1e\x14\n\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 61 | ``` 62 | 63 | First line shows the binary input data. The second line shows the structured 64 | data, which was parsed from the input data. 65 | 66 | Finally, the last line demonstrates the serialized form of the structured data, 67 | demonstrating that the output is identical to the input. 68 | 69 | Full PPPP packets can also be constructed entirely in python, like so: 70 | 71 | ```python 72 | # import all pppp names into top-level scope, 73 | # to avoid having to prefix everything with `pppp.` 74 | from libflagship.pppp import * 75 | 76 | # construct message from python. notice how the syntax is identical 77 | # to the printed output from the demo program above. 78 | pkt = PktP2pRdyAck( \ 79 | duid=Duid(prefix='EUPRAKM', serial=12345, check='ABCDE'), \ 80 | host=Host(afam=2, port=31337, addr='10.20.30.40')) 81 | 82 | # pack structured message back to binary data 83 | data = pkt.pack() 84 | ``` 85 | 86 | Development 87 | ----------- 88 | 89 | Some files in this library are auto-generated from the `.stf` sources and 90 | templates. These auto-generated files will have the following header: 91 | 92 | ```python 93 | ## ------------------------------------------ 94 | ## Generated by Transwarp 95 | ## 96 | ## THIS FILE IS AUTOMATICALLY GENERATED. 97 | ## DO NOT EDIT. ALL CHANGES WILL BE LOST. 98 | ## ------------------------------------------ 99 | ``` 100 | 101 | To maintain these files, edit the template sources in `templates/python/*.tpl` 102 | instead: 103 | 104 | ```sh 105 | # [first time only] install transwarp compiler tools 106 | make install-tools 107 | 108 | # edit input template 109 | $EDITOR templates/python/pppp.tpl 110 | 111 | # show diff between existing auto-generated file, and new result 112 | make diff 113 | 114 | # ...or, save new output 115 | make update 116 | ``` 117 | -------------------------------------------------------------------------------- /documentation/example-file-usage/example-file-prerequistes.md: -------------------------------------------------------------------------------- 1 | # Prerequisites for using the example scripts 2 | 3 | In order to run the example scripts, you'll need to install some supporting software onto your machine. 4 | 5 | You'll need Python 3, some pip packages, and the AnkerMake slicer. 6 | 7 | If you're on Linux, all these tools are natively supported. To install/run the AnkerMake slicer, you'll need some type of translation layer software such as [Wine](https://www.winehq.org/) or [Proton-Caller](https://github.com/caverym/proton-caller). 8 | 9 | We also include the [Transwarp Compiler](https://github.com/chrivers/transwarp) in our `requirements.txt` file for convenience should you want to generate new library files from templates. 10 | 11 | ## Installation 12 | 13 | ### Windows 14 | 15 | 1. Install the [AnkerMake slicer](https://www.ankermake.com/software). Make sure you open it and login. 16 | **NOTE:** The slicer app does not need to be open for the rest of these steps. 17 | 18 | 2. Using `git` or [GitHub Desktop](https://desktop.github.com/), clone this repository into a location of your choice and then navigate to that location in File Explorer. 19 | 20 | 3. Hold down the "Shift" key and right-click on some empty space in the repository top level folder. Select "Open in Terminal" from the context menu dropdown. (If the following commands do not work, try re-opening your terminal window as administrator and use the `cd` command to navigate to where you cloned this repository to) 21 | 22 | 4. Enter in the following command to install/check Python 3: 23 | 24 | ```powershell 25 | python3 26 | ``` 27 | 28 | Now, one of two things will happen: 29 | 30 | - The first possibility is that the Microsoft Store will open and prompt you to install Python 3.10. You can do that and that's a perfectly fine way to install Python 3 or you can go to the [Python website](https://www.python.org/downloads/) and install it from there. Either way works, do whichever you prefer. Then enter in the above command again after and you should see what's in the next bullet. 31 | 32 | - The other possibility is that you get a message similar to this: 33 | 34 | ``` 35 | Python 3.10.10 (tags/v3.10.10:aad5f6a, Feb 7 2023, 17:20:36) [MSC v.1929 64 bit (AMD64)] on win32 36 | Type "help", "copyright", "credits" or "license" for more information. 37 | >>> 38 | ``` 39 | 40 | This means you already have Python 3 installed and are good to go on to the next step. Enter in the following command to exit out of the Python 3 runtime environment. 41 | 42 | ```python 43 | quit() 44 | ``` 45 | 46 | 5. Enter in the following command to install the required pip packages and the Transwarp Complier: 47 | 48 | ```powershell 49 | pip3 install -r requirements.txt 50 | ``` 51 | 52 | If required, enter `Y` to all installation prompts. 53 | 54 | 55 | 56 | ### MacOS 57 | 58 | 1. Install the [AnkerMake slicer](https://www.ankermake.com/software). Make sure you open it and login. 59 | **NOTE:** The slicer app does not need to be open for the rest of these steps. 60 | 61 | 2. Using `git` or [GitHub Desktop](https://desktop.github.com/), clone this repository into a location of your choice and then navigate to that location in Finder. 62 | 63 | 3. Hold down the "Control" key and click the folder in the path bar, then choose "Open in Terminal". (If you don’t see the path bar at the bottom of the Finder window, choose View > Show Path Bar) 64 | 65 | 4. Install Python3 from the [Python website.](https://www.python.org/downloads/macos/) 66 | 67 | 5. Enter in the following command to install the required pip packages and the Transwarp Complier: 68 | 69 | ```bash 70 | pip3 install -r requirements.txt 71 | ``` 72 | 73 | If required, enter `Y` to all installation prompts. 74 | 75 | ### Linux 76 | 77 | 1. Install the [AnkerMake slicer](https://www.ankermake.com/software). Alternatively, you can install the slicer on a officially supported operating system that you have access to (Windows or MacOS) and use the `login.json` file from that machine. Either way you choose, make sure you open the slicer and login. 78 | 79 | **NOTE:** The slicer app does not need to be open for the rest of these steps. 80 | 81 | 2. Using `git`, clone this repository into a location of your choice and then navigate to that location in your terminal app of choice. 82 | 83 | 3. Install Python3 from whatever package manager your distro uses via the terminal. 84 | 85 | 4. Enter in the following command to install the required pip packages and the Transwarp Complier: 86 | 87 | ```bash 88 | pip3 install -r requirements.txt 89 | ``` 90 | 91 | If required, enter `Y` to all installation prompts. 92 | -------------------------------------------------------------------------------- /cli/config.py: -------------------------------------------------------------------------------- 1 | import logging as log 2 | import contextlib 3 | import json 4 | 5 | from pathlib import Path 6 | from platformdirs import PlatformDirs 7 | 8 | from libflagship.megajank import pppp_decode_initstring 9 | from libflagship.httpapi import AnkerHTTPAppApiV1, AnkerHTTPPassportApiV1 10 | from libflagship.util import unhex 11 | 12 | from .model import Serialize, Account, Printer, Config 13 | 14 | 15 | class BaseConfigManager: 16 | 17 | def __init__(self, dirs: PlatformDirs, classes=None): 18 | self._dirs = dirs 19 | if classes: 20 | self._classes = {t.__name__: t for t in classes} 21 | else: 22 | self._classes = [] 23 | dirs.user_config_path.mkdir(exist_ok=True, parents=True) 24 | 25 | @contextlib.contextmanager 26 | def _borrow(self, value, write, default=None): 27 | if not default: 28 | default = {} 29 | pr = self.load(value, default) 30 | yield pr 31 | if write: 32 | self.save(value, pr) 33 | 34 | @property 35 | def config_root(self): 36 | return self._dirs.user_config_path 37 | 38 | def config_path(self, name): 39 | return self.config_root / Path(f"{name}.json") 40 | 41 | def _load_json(self, val): 42 | if "__type__" not in val: 43 | return val 44 | 45 | typename = val["__type__"] 46 | if typename not in self._classes: 47 | return val 48 | 49 | return self._classes[typename].from_dict(val) 50 | 51 | @staticmethod 52 | def _save_json(val): 53 | if not isinstance(val, Serialize): 54 | return val 55 | 56 | data = val.to_dict() 57 | data["__type__"] = type(val).__name__ 58 | return data 59 | 60 | def load(self, name, default): 61 | path = self.config_path(name) 62 | if not path.exists(): 63 | return default 64 | 65 | return json.load(path.open(), object_hook=self._load_json) 66 | 67 | def save(self, name, value): 68 | path = self.config_path(name) 69 | path.write_text(json.dumps(value, default=self._save_json, indent=2) + "\n") 70 | 71 | 72 | class AnkerConfigManager(BaseConfigManager): 73 | 74 | def modify(self): 75 | return self._borrow("default", write=True) 76 | 77 | def open(self): 78 | return self._borrow("default", write=False) 79 | 80 | 81 | def configmgr(profile="default"): 82 | return AnkerConfigManager(PlatformDirs("ankerctl"), classes=(Config, Account, Printer)) 83 | 84 | 85 | def load_config_from_api(auth_token, region, insecure): 86 | log.info("Initializing API..") 87 | appapi = AnkerHTTPAppApiV1(auth_token=auth_token, region=region, verify=not insecure) 88 | ppapi = AnkerHTTPPassportApiV1(auth_token=auth_token, region=region, verify=not insecure) 89 | 90 | # request profile and printer list 91 | log.info("Requesting profile data..") 92 | profile = ppapi.profile() 93 | 94 | # create config object 95 | config = Config(account=Account( 96 | auth_token=auth_token, 97 | region=region, 98 | user_id=profile['user_id'], 99 | email=profile["email"], 100 | ), printers=[]) 101 | 102 | log.info("Requesting printer list..") 103 | printers = appapi.query_fdm_list() 104 | 105 | log.info("Requesting pppp keys..") 106 | sns = [pr["station_sn"] for pr in printers] 107 | dsks = {dsk["station_sn"]: dsk for dsk in appapi.equipment_get_dsk_keys(station_sns=sns)["dsk_keys"]} 108 | 109 | # populate config object with printer list 110 | for pr in printers: 111 | station_sn = pr["station_sn"] 112 | config.printers.append(Printer( 113 | sn=station_sn, 114 | mqtt_key=unhex(pr["secret_key"]), 115 | wifi_mac=pr["wifi_mac"], 116 | ip_addr=pr["ip_addr"], 117 | api_hosts=pppp_decode_initstring(pr["app_conn"]), 118 | p2p_hosts=pppp_decode_initstring(pr["p2p_conn"]), 119 | p2p_duid=pr["p2p_did"], 120 | p2p_key=dsks[pr["station_sn"]]["dsk_key"], 121 | )) 122 | log.info(f"Adding printer [{station_sn}]") 123 | 124 | return config 125 | 126 | 127 | def attempt_config_upgrade(config, profile, insecure): 128 | path = config.config_path("default") 129 | data = json.load(path.open()) 130 | cfg = load_config_from_api( 131 | data["account"]["auth_token"], 132 | data["account"]["region"], 133 | insecure 134 | ) 135 | 136 | # save config to json file named `ankerctl/default.json` 137 | config.save("default", cfg) 138 | log.info("Finished import") 139 | -------------------------------------------------------------------------------- /specification/mqtt.stf: -------------------------------------------------------------------------------- 1 | ## Anker MQTT protocol specification 2 | 3 | enum MqttPktType 4 | # Whole message in a single packet. No further packets in this stream 5 | Single = 0xc0 6 | 7 | # Reallocate buffer memory, *then* append to message. Unless this is used 8 | # for the first packet in a series, the result is a message buffer with 9 | # valid contents in the middle, but random data in the beginning. 10 | MultiBegin = 0xc1 11 | 12 | # Append to existing message buffer. 13 | MultiAppend = 0xc2 14 | 15 | # Append data, then handle complete message. 16 | MultiFinish = 0xc3 17 | 18 | struct _MqttMsg 19 | # Signature: 'MA' 20 | signature: magic<2, 0x4d41> 21 | 22 | # length of packet, including header and checksum (minimum 65). 23 | size: u16le 24 | 25 | # Magic constant: 5 26 | m3: u8 27 | 28 | # Magic constant: 1 29 | m4: u8 30 | 31 | # Magic constant: 2 32 | m5: u8 33 | 34 | # Magic constant: 5 35 | m6: u8 36 | 37 | # Magic constant: 'F' 38 | m7: u8 39 | 40 | # Packet type 41 | packet_type: MqttPktType 42 | 43 | # maybe for fragmented messages? 44 | # set to 1 for unfragmented messages. 45 | packet_num: u16le 46 | 47 | # `gettimeofday()` in whole seconds 48 | time: u32le 49 | 50 | # device guid, as hex string 51 | device_guid: string<37> 52 | 53 | # padding bytes, allways zero 54 | padding: zeroes<11> 55 | 56 | # payload data 57 | data: tail 58 | 59 | enum MqttMsgType 60 | 61 | # 62 | ZZ_MQTT_CMD_EVENT_NOTIFY = 0x3e8 63 | 64 | # 65 | ZZ_MQTT_CMD_PRINT_SCHEDULE = 0x3a9 66 | 67 | # Not implemented? 68 | ZZ_MQTT_CMD_FIRMWARE_VERSION = 0x3ea 69 | 70 | # Set nozzle temperature in units of 1/100th deg C (i.e.31337 is 313.37C) 71 | ZZ_MQTT_CMD_NOZZLE_TEMP = 0x3eb 72 | 73 | # Set hotbed temperature in units of 1/100th deg C (i.e. 1337 is 13.37C) 74 | ZZ_MQTT_CMD_HOTBED_TEMP = 0x3ec 75 | 76 | # Set fan speed 77 | ZZ_MQTT_CMD_FAN_SPEED = 0x3ed 78 | 79 | # ? Set print speed 80 | ZZ_MQTT_CMD_PRINT_SPEED = 0x3ee 81 | 82 | # (probably) Perform auto-levelling procedure 83 | ZZ_MQTT_CMD_AUTO_LEVELING = 0x3ef 84 | 85 | # 86 | ZZ_MQTT_CMD_PRINT_CONTROL = 0x3f0 87 | 88 | # Request on-board file list (value == 1) or usb file list (value != 1) 89 | ZZ_MQTT_CMD_FILE_LIST_REQUEST = 0x3f1 90 | 91 | # 92 | ZZ_MQTT_CMD_GCODE_FILE_REQUEST = 0x3f2 93 | 94 | # 95 | ZZ_MQTT_CMD_ALLOW_FIRMWARE_UPDATE = 0x3f3 96 | 97 | # 98 | ZZ_MQTT_CMD_GCODE_FILE_DOWNLOAD = 0x3fc 99 | 100 | # ? 101 | ZZ_MQTT_CMD_Z_AXIS_RECOUP = 0x3fd 102 | 103 | # (probably) run the extrusion stepper 104 | ZZ_MQTT_CMD_EXTRUSION_STEP = 0x3fe 105 | 106 | # maybe related to filament change? 107 | ZZ_MQTT_CMD_ENTER_OR_QUIT_MATERIEL = 0x3ff 108 | 109 | # 110 | ZZ_MQTT_CMD_MOVE_STEP = 0x400 111 | 112 | # 113 | ZZ_MQTT_CMD_MOVE_DIRECTION = 0x401 114 | 115 | # (probably) Move to home position 116 | ZZ_MQTT_CMD_MOVE_ZERO = 0x402 117 | 118 | # 119 | ZZ_MQTT_CMD_APP_QUERY_STATUS = 0x403 120 | 121 | # 122 | ZZ_MQTT_CMD_ONLINE_NOTIFY = 0x404 123 | 124 | # 125 | ZZ_MQTT_CMD_APP_RECOVER_FACTORY = 0x405 126 | 127 | # (probably) Enable/disable Bluetooth Low Energy ("ble") radio 128 | ZZ_MQTT_CMD_BLE_ONOFF = 0x407 129 | 130 | # (probably) Delete specified gcode file 131 | ZZ_MQTT_CMD_DELETE_GCODE_FILE = 0x408 132 | 133 | # ? 134 | ZZ_MQTT_CMD_RESET_GCODE_PARAM = 0x409 135 | 136 | # 137 | ZZ_MQTT_CMD_DEVICE_NAME_SET = 0x40a 138 | 139 | # 140 | ZZ_MQTT_CMD_DEVICE_LOG_UPLOAD = 0x40b 141 | 142 | # ? 143 | ZZ_MQTT_CMD_ONOFF_MODAL = 0x40c 144 | 145 | # ? 146 | ZZ_MQTT_CMD_MOTOR_LOCK = 0x40d 147 | 148 | # ? 149 | ZZ_MQTT_CMD_PREHEAT_CONFIG = 0x40e 150 | 151 | # 152 | ZZ_MQTT_CMD_BREAK_POINT = 0x40f 153 | 154 | # 155 | ZZ_MQTT_CMD_AI_CALIB = 0x410 156 | 157 | # ? 158 | ZZ_MQTT_CMD_VIDEO_ONOFF = 0x411 159 | 160 | # ? 161 | ZZ_MQTT_CMD_ADVANCED_PARAMETERS = 0x412 162 | 163 | # Run custom GCode command 164 | ZZ_MQTT_CMD_GCODE_COMMAND = 0x413 165 | 166 | # 167 | ZZ_MQTT_CMD_PREVIEW_IMAGE_URL = 0x414 168 | 169 | # ? 170 | ZZ_MQTT_CMD_SYSTEM_CHECK = 0x419 171 | 172 | # ? 173 | ZZ_MQTT_CMD_AI_SWITCH = 0x41a 174 | 175 | # ? 176 | ZZ_STEST_CMD_GCODE_TRANSPOR = 0x7e2 177 | 178 | # 179 | ZZ_MQTT_CMD_ALEXA_MSG = 0xbb8 180 | -------------------------------------------------------------------------------- /libflagship/httpapi.py: -------------------------------------------------------------------------------- 1 | import logging as log 2 | import requests 3 | import functools 4 | import json 5 | import hashlib 6 | 7 | 8 | class APIError(Exception): 9 | pass 10 | 11 | 12 | def require_auth_token(func): 13 | 14 | @functools.wraps(func) 15 | def wrapper(self, *args, **kwargs): 16 | if not self._auth: 17 | raise APIError("Missing auth token") 18 | return func(self, *args, **kwargs) 19 | 20 | return wrapper 21 | 22 | 23 | def unwrap_api(func): 24 | 25 | @functools.wraps(func) 26 | def wrapper(self, *args, **kwargs): 27 | if not self.scope: 28 | raise APIError("scope undefined") 29 | data = func(self, *args, **kwargs) 30 | if data.ok: 31 | jsn = data.json() 32 | if jsn["code"] == 0: 33 | data = jsn.get("data") 34 | log.debug(f"JSON result: {json.dumps(jsn, indent=4)}") 35 | return data 36 | else: 37 | raise APIError("API error", json) 38 | else: 39 | raise APIError(f"API request failed: {data.status_code} {data.reason}") 40 | 41 | return wrapper 42 | 43 | 44 | class AnkerHTTPApi: 45 | 46 | scope = None 47 | 48 | def __init__(self, auth_token=None, verify=True, region=None, base_url=None): 49 | self._auth = auth_token 50 | self._verify = verify 51 | if base_url: 52 | self._base = base_url 53 | else: 54 | if region == "eu": 55 | self._base = "https://make-app-eu.ankermake.com" 56 | elif region == "us": 57 | self._base = "https://make-app.ankermake.com" 58 | else: 59 | raise APIError("must specify either base_url or region {'eu', 'us'}") 60 | 61 | @unwrap_api 62 | def _get(self, url, headers=None): 63 | return requests.get(f"{self._base}{self.scope}{url}", headers=headers, verify=self._verify) 64 | 65 | @unwrap_api 66 | def _post(self, url, headers=None, data=None): 67 | return requests.post(f"{self._base}{self.scope}{url}", headers=headers, verify=self._verify, json=data) 68 | 69 | 70 | class AnkerHTTPAppApiV1(AnkerHTTPApi): 71 | 72 | scope = "/v1/app" 73 | 74 | def get_app_version(self, app_name="Ankermake_Windows", app_version=1, model="-"): 75 | return self._post("/ota/get_app_version", data={ 76 | "app_name": app_name, 77 | "app_version": app_version, 78 | "model": model 79 | }) 80 | 81 | @require_auth_token 82 | def query_fdm_list(self): 83 | return self._post("/query_fdm_list", headers={"X-Auth-Token": self._auth}) 84 | 85 | @require_auth_token 86 | def equipment_get_dsk_keys(self, station_sns, invalid_dsks={}): 87 | return self._post("/equipment/get_dsk_keys", headers={"X-Auth-Token": self._auth}, data={ 88 | "invalid_dsks": invalid_dsks, 89 | "station_sns": station_sns, 90 | }) 91 | 92 | 93 | class AnkerHTTPPassportApiV1(AnkerHTTPApi): 94 | 95 | scope = "/v1/passport" 96 | 97 | @require_auth_token 98 | def profile(self): 99 | return self._get("/profile", headers={"X-Auth-Token": self._auth}) 100 | 101 | 102 | class AnkerHTTPHubApiV1(AnkerHTTPApi): 103 | 104 | scope = "/v1/hub" 105 | 106 | def query_device_info(self, station_sn, check_code): 107 | return self._post("/query_device_info", data={ 108 | "station_sn": station_sn, 109 | "check_code": check_code, 110 | }) 111 | 112 | def ota_get_rom_version(self, printer_sn, check_code, device_type="V8111_Model", current_version_name="V1.0.5"): 113 | return self._post("/ota/get_rom_version", data={ 114 | "sn": printer_sn, 115 | "check_code": check_code, 116 | "device_type": device_type, 117 | "current_version_name": current_version_name, 118 | }) 119 | 120 | 121 | class AnkerHTTPHubApiV2(AnkerHTTPApi): 122 | 123 | scope = "/v2/hub" 124 | 125 | def query_device_info(self, station_sn, sec_code, sec_ts): 126 | return self._post("/query_device_info", data={ 127 | "station_sn": station_sn, 128 | "sec_code": sec_code, 129 | "sec_ts": sec_ts, 130 | }) 131 | 132 | def ota_get_rom_version(self, printer_sn, sec_code, sec_ts, device_type="V8111", current_version_name="V1"): 133 | return self._post("/ota/get_rom_version", data={ 134 | "sn": printer_sn, 135 | "sec_code": sec_code, 136 | "sec_ts": sec_ts, 137 | "device_type": device_type, 138 | "current_version_name": current_version_name, 139 | }) 140 | 141 | def get_p2p_connectinfo(self, printer_sn, sec_code, sec_ts): 142 | return self._post("/get_p2p_connectinfo", data={ 143 | "station_sn": printer_sn, 144 | "sec_code": sec_code, 145 | "sec_ts": sec_ts, 146 | }) 147 | -------------------------------------------------------------------------------- /templates/python/pppp.py.tpl: -------------------------------------------------------------------------------- 1 | <% import python %>\ 2 | ${python.header()} 3 | 4 | <%def name="encrypt(struct)">\ 5 | %if struct.const("@crypto_type", 0) == 1: 6 | p = simple_encrypt_string(p)\ 7 | %elif struct.const("@crypto_type", 0) == 2: 8 | p = crypto_curse_string(p)\ 9 | %else: 10 | # not encrypted\ 11 | %endif 12 | \ 13 | ## 14 | ## 15 | <%def name="decrypt(struct)">\ 16 | %if struct.const("@crypto_type", 0) == 1: 17 | p = simple_decrypt_string(p)\ 18 | %elif struct.const("@crypto_type", 0) == 2: 19 | p = crypto_decurse_string(p)\ 20 | %else: 21 | # not encrypted\ 22 | %endif 23 | \ 24 | ## 25 | ## 26 | <%def name="pack_fields(struct)">\ 27 | %if len(struct.fields) > 0: 28 | p = ${python.typepack(struct.fields[0])} 29 | %for field in struct.fields[1:]: 30 | p += ${python.typepack(field)} 31 | %endfor 32 | %else: 33 | p = b"" 34 | %endif 35 | \ 36 | ## 37 | ## 38 | <%def name="unpack_fields(struct)">\ 39 | %for field in struct.fields: 40 | ${field.name}, p = ${python.typeparse(field, "p")} 41 | %endfor 42 | \ 43 | ## 44 | ## 45 | <%def name="declare_fields(struct)">\ 46 | %for field in struct.fields: 47 | ${field.aligned_name} : ${python.typename(field)} # ${"".join(field.comment or "unknown")} 48 | %endfor 49 | \ 50 | ## 51 | ## 52 | import struct 53 | import enum 54 | from dataclasses import dataclass, field 55 | from .amtypes import * 56 | from .amtypes import _assert_equal 57 | from .megajank import crypto_curse_string, crypto_decurse_string, simple_encrypt_string, simple_decrypt_string 58 | from .util import ppcs_crc16 59 | 60 | %for enum in _pppp: 61 | %if enum.expr == "enum": 62 | class ${enum.name}(enum.IntEnum): 63 | %for const in enum.consts: 64 | ${const.aligned_name} = ${const.aligned_hex_value} # ${"".join(const.comment or "unknown")} 65 | %endfor 66 | 67 | @classmethod 68 | def parse(cls, p): 69 | return cls(struct.unpack("B", p[:1])[0]), p[1:] 70 | 71 | def pack(self): 72 | return struct.pack("B", self) 73 | 74 | %endif 75 | %endfor 76 | 77 | @dataclass 78 | class Message: 79 | 80 | type: Type = field(repr=False, init=False) 81 | 82 | @classmethod 83 | def parse(cls, m): 84 | magic, type, size = struct.unpack(">BBH", m[:4]) 85 | assert magic == 0xF1 86 | type = Type(type) 87 | p = m[4:4+size] 88 | if type in MessageTypeTable: 89 | return MessageTypeTable[type].parse(p) 90 | else: 91 | raise ValueError(f"unknown message type {type:02x}") 92 | 93 | def pack(self, p): 94 | return struct.pack(">BBH", 0xF1, self.type, len(p)) + p 95 | 96 | class _Host: 97 | pass 98 | 99 | class _Duid: 100 | 101 | @classmethod 102 | def from_string(cls, str): 103 | prefix, serial, check = str.split("-") 104 | return cls(prefix, int(serial), check) 105 | 106 | def __str__(self): 107 | return f"{self.prefix}-{self.serial:06}-{self.check}" 108 | 109 | class _Xzyh: 110 | pass 111 | 112 | class _Aabb: 113 | 114 | @classmethod 115 | def parse_with_crc(cls, m): 116 | head, m = m[:12], m[12:] 117 | header = cls.parse(head)[0] 118 | data, m = m[:header.len], m[header.len:] 119 | crc1, m = m[:2], m[2:] 120 | crc2 = ppcs_crc16(head[2:] + data) 121 | _assert_equal(crc1, crc2) 122 | return header, data, m 123 | 124 | def pack_with_crc(self, data): 125 | header = self.pack() 126 | 127 | return header + data + ppcs_crc16(header[2:] + data) 128 | 129 | class _Dsk: 130 | pass 131 | 132 | class _Version: 133 | pass 134 | 135 | ## output all "struct" blocks 136 | %for struct in _pppp.without("Message"): 137 | %if struct.expr == "struct": 138 | @dataclass 139 | class ${struct.name}(_${struct.name}): 140 | ${declare_fields(struct)} 141 | @classmethod 142 | def parse(cls, p): 143 | ${decrypt(struct)} 144 | ${unpack_fields(struct)} 145 | return cls(${", ".join(f"{f.name}={f.name}" for f in struct.fields)}), p 146 | 147 | def pack(self): 148 | ${pack_fields(struct)} 149 | ${encrypt(struct)} 150 | return p 151 | 152 | %endif 153 | %endfor 154 | 155 | ## output all "packet" blocks 156 | %for struct in _pppp.without("Message"): 157 | %if struct.expr == "packet": 158 | @dataclass 159 | class ${struct.name}(Message): 160 | %for f in _pppp.get("MessageType").fields: 161 | %if f.type.name == struct.name: 162 | type = Type.${f.name} 163 | %endif 164 | %endfor 165 | ${declare_fields(struct)} 166 | @classmethod 167 | def parse(cls, p): 168 | ${decrypt(struct)} 169 | ${unpack_fields(struct)} 170 | return cls(${", ".join(f"{f.name}={f.name}" for f in struct.fields)}), p 171 | 172 | def pack(self): 173 | ${pack_fields(struct)} 174 | ${encrypt(struct)} 175 | return super().pack(p) 176 | 177 | %endif 178 | %endfor 179 | 180 | %for struct in _pppp: 181 | %if struct.expr == "parser": 182 | ${struct.name}Table = { 183 | %for field in struct.fields: 184 | ${struct.field("@type").type}.${field.aligned_name} : ${field.type}, 185 | %endfor 186 | } 187 | 188 | %endif 189 | %endfor 190 | -------------------------------------------------------------------------------- /libflagship/amtypes.py: -------------------------------------------------------------------------------- 1 | ## ------------------------------------------ 2 | ## Generated by Transwarp 3 | ## 4 | ## THIS FILE IS AUTOMATICALLY GENERATED. 5 | ## DO NOT EDIT. ALL CHANGES WILL BE LOST. 6 | ## ------------------------------------------ 7 | 8 | import struct 9 | import enum 10 | from dataclasses import dataclass, field 11 | import socket 12 | 13 | def _assert_equal(value, expected): 14 | if value != expected: 15 | raise ValueError(f"expected {expected} but found {value}") 16 | 17 | class Zeroes: 18 | @classmethod 19 | def parse(cls, p, num): 20 | body = p[:num] 21 | _assert_equal(set(body) - {0}, set()) 22 | return body, p[num:] 23 | 24 | def pack(self, num): 25 | return b"\x00" * num 26 | 27 | class Bytes(bytes): 28 | @classmethod 29 | def parse(cls, p, size): 30 | return p[:size], p[size:] 31 | 32 | def pack(self, size): 33 | return self 34 | 35 | class String(Bytes): 36 | @classmethod 37 | def parse(cls, p, size): 38 | body, p = super().parse(p, size) 39 | _assert_equal(body[-1], 0) 40 | return body[:-1].decode(), p 41 | 42 | def pack(self, size): 43 | return self[:size-1].ljust(size, '\x00').encode() 44 | 45 | class Array: 46 | @classmethod 47 | def parse(cls, p, elem, num): 48 | res = [] 49 | for _ in range(num): 50 | item, p = elem.parse(p) 51 | res.append(item) 52 | return res, p 53 | 54 | def pack(self, cls, num): 55 | return b"".join(cls.pack(e) for e in self) 56 | 57 | class IPv4(str): 58 | @classmethod 59 | def parse(cls, p): 60 | addr = p[:4][::-1] 61 | return cls(socket.inet_ntoa(addr)), p[4:] 62 | 63 | def pack(self): 64 | return socket.inet_aton(self)[::-1] 65 | 66 | class Magic(bytes): 67 | @classmethod 68 | def parse(cls, p, size, expected): 69 | v, p = p[:size], p[size:] 70 | _assert_equal(v, expected) 71 | return cls(v), p 72 | 73 | def pack(self, size, expected): 74 | return self 75 | 76 | class Tail(bytes): 77 | @classmethod 78 | def parse(cls, p): 79 | return cls(p), b"" 80 | 81 | def pack(self): 82 | if isinstance(self, bytes): 83 | return self 84 | else: 85 | return self.pack() 86 | 87 | class IntType(int): 88 | pass 89 | 90 | class i8be(IntType): 91 | size = 1 92 | 93 | @classmethod 94 | def parse(cls, p): 95 | return cls(struct.unpack(">b", p[:cls.size])[0]), p[cls.size:] 96 | 97 | def pack(self): 98 | return struct.pack(">b", self) 99 | 100 | class i8le(IntType): 101 | size = 1 102 | 103 | @classmethod 104 | def parse(cls, p): 105 | return cls(struct.unpack("B", p[:cls.size])[0]), p[cls.size:] 118 | 119 | def pack(self): 120 | return struct.pack(">B", self) 121 | 122 | class u8le(IntType): 123 | size = 1 124 | 125 | @classmethod 126 | def parse(cls, p): 127 | return cls(struct.unpack("h", p[:cls.size])[0]), p[cls.size:] 140 | 141 | def pack(self): 142 | return struct.pack(">h", self) 143 | 144 | class i16le(IntType): 145 | size = 2 146 | 147 | @classmethod 148 | def parse(cls, p): 149 | return cls(struct.unpack("H", p[:cls.size])[0]), p[cls.size:] 162 | 163 | def pack(self): 164 | return struct.pack(">H", self) 165 | 166 | class u16le(IntType): 167 | size = 2 168 | 169 | @classmethod 170 | def parse(cls, p): 171 | return cls(struct.unpack("i", p[:cls.size])[0]), p[cls.size:] 184 | 185 | def pack(self): 186 | return struct.pack(">i", self) 187 | 188 | class i32le(IntType): 189 | size = 4 190 | 191 | @classmethod 192 | def parse(cls, p): 193 | return cls(struct.unpack("I", p[:cls.size])[0]), p[cls.size:] 206 | 207 | def pack(self): 208 | return struct.pack(">I", self) 209 | 210 | class u32le(IntType): 211 | size = 4 212 | 213 | @classmethod 214 | def parse(cls, p): 215 | return cls(struct.unpack(" For example, consider modern chat/messaging applications such as iMessage and Discord. Most of these applications have implemented a feature that shows all the participants of a chat that another participant is typing a response. The topic for this feature may look something like `chat-app/conversation/participant-a/is-typing` and have a boolean value set to "false" by default. 14 | > 15 | > When Participant A starts to type in the compose field, their client would publish an update to this topic that sets the value to "true". Each participant would have a corresponding topic that represents that they are typing and the other participant's clients would be subscribed to these topics. When the value of these topics changes to "true", each participant's client would present an animation to communicate this to the user. 16 | 17 | The Broker is responsible for forwarding messages sent by the originating client to other subscribed clients. Messages are sent over a transport connection between the Client and the Broker. This connection can be unencrypted or use TLS encryption, and will always be initiated from the Client side. 18 | 19 | The Clients have the ability to subscribe to Topics in the Broker's directory to pull down any published updates from that point forward. They also have the ability to publish updates to any Topic the Broker gives them access to. 20 | 21 | The messages consists of two core parts: the Topic and the **Payload**. The Payload can be anything from a single byte to a JSON file. What exactly is in the Payload is determined by the application. 22 | 23 | > Here's an example of a simple MQTT connection that consists of two Clients (Client A and Client B), a Broker, and a single Topic of "network/application/clientA/parameter". 24 | > 25 | > This example depicts a simple remote monitoring scenario for a parameter where Client A acts as a publisher and Client be acts as a subscriber. 26 | > 27 | > ```mermaid 28 | > graph 29 | > A("[Broker] Topic: network/application/clientA/parameter") <-- "Publish: network/application/clientA/parameter" --> B(("Client A")) 30 | > A <-- "Subscribe: network/application/clientA/parameter" --> C(("Client B")) 31 | > 32 | > ``` 33 | > 34 | 35 | 36 | 37 | ## M5 Specifics 38 | 39 | ### MQTT Deployment 40 | 41 | AnkerMake has likely deployed MQTT via [EMQX](https://www.emqx.com/en) (most likely it's [EMQX Cloud](https://www.emqx.com/en/cloud) since the servers are hosted by [AWS](https://aws.amazon.com/)). You can infer this by typing the following command into a terminal: 42 | 43 | ``` 44 | nslookup make-mqtt.ankermake.com 45 | ``` 46 | 47 | That returns something similar to: 48 | 49 | ``` 50 | Non-authoritative answer: 51 | Name: ankermake-emqx-nlb-60dd062d3b52769a.elb.us-east-2.amazonaws.com 52 | Addresses: 3.22.247.85 53 | 3.134.119.140 54 | 18.189.81.10 55 | Aliases: make-mqtt.ankermake.com 56 | ``` 57 | 58 | Look at the the `Name:` field. The hostname suggests it's an EMQX instance (but this is unconfirmed) running on an AWS server. 59 | 60 | ### Update Behavior 61 | 62 | As long as a M5 is powered on, it publishes updates to the broker servers under the `/phone/maker/YOUR_SERIAL_NUMBER_HERE/notice` topic. It only publishes an update when a value changes. 63 | 64 | The M5 also subscribes to other topics, where it will react to events, and treat them as commands to perform various actions. In this way, the M5 can be partially remote-controlled over MQTT. 65 | 66 | Another client device (your phone or computer) subscribes to topics associated with the target printer and can publish commands for execution on the target printer. 67 | 68 | The messages are exchanged via an encrypted tunnel (TLS) per the MQTT specification. What's unusual about AnkerMake MQTT messages, is that part of the payload is also encrypted before transmission using standard AES-256-CBC. In order for the communications to be successful, the messages must be encrypted and decrypted at either end. The AES encryption of the payload is required for any program interfacing with an M5 to function. 69 | 70 | In order to encrypt and decrypt the payload, a printer-specific encryption key is required. The AnkerMake HTTPS API allows a client to pull down the MQTT login info and MQTT-only encryption key needed to setup a new client device connection instance and handle payload encryption. 71 | 72 | Your **Authentication Token** (auth_token) is needed to authenticate a client's identity with the AnkerMake HTTPS servers. Then those MQTT credentials are presented to the MQTT server to subscribe/publish to the topics in the broker. Finally the MQTT-only key is used to decrypt incoming messages and encrypt outgoing messages. 73 | 74 | The basic relationships for AnkerMake MQTT communications can be illustrated as such: 75 | 76 | ```mermaid 77 | graph 78 | A("[Broker] Ankermake MQTT Servers (Job Queue and Data)") <-- "MQTT Data (TLS + Encrypted Payload)" --> B(("M5 Printers")) 79 | A <-- "MQTT Data (TLS + Encrypted Payload)" --> C(("Client Devices")) 80 | D("AnkerMake HTTPS Servers (API)") -- "MQTT Login Info + Key (HTTPS)" --> C 81 | C -- "Authentication Token (HTTPS)" --> D 82 | ``` 83 | 84 | ### Payload Structure 85 | 86 | As detailed in the previous section, part of the payload is encrypted. The payload is split into two parts: an unencrypted header and an encrypted JSON body. A MQTT-only key is used to decrypt incoming messages and encrypt outgoing messages using AES-256-CBC. This parsing and cryptography can be accomplished in your programs by using the `libflagship` library in this repository. 87 | 88 | In the `specifications` folder, you'll find a file named `mqtt.stf` that maps out the contents of the message payload. 89 | -------------------------------------------------------------------------------- /libflagship/mqtt.py: -------------------------------------------------------------------------------- 1 | ## ------------------------------------------ 2 | ## Generated by Transwarp 3 | ## 4 | ## THIS FILE IS AUTOMATICALLY GENERATED. 5 | ## DO NOT EDIT. ALL CHANGES WILL BE LOST. 6 | ## ------------------------------------------ 7 | 8 | import enum 9 | from dataclasses import dataclass 10 | import json 11 | from .amtypes import * 12 | from .megajank import mqtt_checksum_add, mqtt_checksum_remove, mqtt_aes_encrypt, mqtt_aes_decrypt 13 | 14 | class MqttPktType(enum.IntEnum): 15 | Single = 0xc0 # Whole message in a single packet. No further packets in this stream 16 | MultiBegin = 0xc1 # Reallocate buffer memory, *then* append to message. Unless this is used 17 | MultiAppend = 0xc2 # Append to existing message buffer. 18 | MultiFinish = 0xc3 # Append data, then handle complete message. 19 | 20 | @classmethod 21 | def parse(cls, p): 22 | return cls(struct.unpack("B", p[:1])[0]), p[1:] 23 | 24 | def pack(self): 25 | return struct.pack("B", self) 26 | 27 | class MqttMsgType(enum.IntEnum): 28 | ZZ_MQTT_CMD_EVENT_NOTIFY = 0x03e8 # 29 | ZZ_MQTT_CMD_PRINT_SCHEDULE = 0x03a9 # 30 | ZZ_MQTT_CMD_FIRMWARE_VERSION = 0x03ea # Not implemented? 31 | ZZ_MQTT_CMD_NOZZLE_TEMP = 0x03eb # Set nozzle temperature in units of 1/100th deg C (i.e.31337 is 313.37C) 32 | ZZ_MQTT_CMD_HOTBED_TEMP = 0x03ec # Set hotbed temperature in units of 1/100th deg C (i.e. 1337 is 13.37C) 33 | ZZ_MQTT_CMD_FAN_SPEED = 0x03ed # Set fan speed 34 | ZZ_MQTT_CMD_PRINT_SPEED = 0x03ee # ? Set print speed 35 | ZZ_MQTT_CMD_AUTO_LEVELING = 0x03ef # (probably) Perform auto-levelling procedure 36 | ZZ_MQTT_CMD_PRINT_CONTROL = 0x03f0 # 37 | ZZ_MQTT_CMD_FILE_LIST_REQUEST = 0x03f1 # Request on-board file list (value == 1) or usb file list (value != 1) 38 | ZZ_MQTT_CMD_GCODE_FILE_REQUEST = 0x03f2 # 39 | ZZ_MQTT_CMD_ALLOW_FIRMWARE_UPDATE = 0x03f3 # 40 | ZZ_MQTT_CMD_GCODE_FILE_DOWNLOAD = 0x03fc # 41 | ZZ_MQTT_CMD_Z_AXIS_RECOUP = 0x03fd # ? 42 | ZZ_MQTT_CMD_EXTRUSION_STEP = 0x03fe # (probably) run the extrusion stepper 43 | ZZ_MQTT_CMD_ENTER_OR_QUIT_MATERIEL = 0x03ff # maybe related to filament change? 44 | ZZ_MQTT_CMD_MOVE_STEP = 0x0400 # 45 | ZZ_MQTT_CMD_MOVE_DIRECTION = 0x0401 # 46 | ZZ_MQTT_CMD_MOVE_ZERO = 0x0402 # (probably) Move to home position 47 | ZZ_MQTT_CMD_APP_QUERY_STATUS = 0x0403 # 48 | ZZ_MQTT_CMD_ONLINE_NOTIFY = 0x0404 # 49 | ZZ_MQTT_CMD_APP_RECOVER_FACTORY = 0x0405 # 50 | ZZ_MQTT_CMD_BLE_ONOFF = 0x0407 # (probably) Enable/disable Bluetooth Low Energy ("ble") radio 51 | ZZ_MQTT_CMD_DELETE_GCODE_FILE = 0x0408 # (probably) Delete specified gcode file 52 | ZZ_MQTT_CMD_RESET_GCODE_PARAM = 0x0409 # ? 53 | ZZ_MQTT_CMD_DEVICE_NAME_SET = 0x040a # 54 | ZZ_MQTT_CMD_DEVICE_LOG_UPLOAD = 0x040b # 55 | ZZ_MQTT_CMD_ONOFF_MODAL = 0x040c # ? 56 | ZZ_MQTT_CMD_MOTOR_LOCK = 0x040d # ? 57 | ZZ_MQTT_CMD_PREHEAT_CONFIG = 0x040e # ? 58 | ZZ_MQTT_CMD_BREAK_POINT = 0x040f # 59 | ZZ_MQTT_CMD_AI_CALIB = 0x0410 # 60 | ZZ_MQTT_CMD_VIDEO_ONOFF = 0x0411 # ? 61 | ZZ_MQTT_CMD_ADVANCED_PARAMETERS = 0x0412 # ? 62 | ZZ_MQTT_CMD_GCODE_COMMAND = 0x0413 # Run custom GCode command 63 | ZZ_MQTT_CMD_PREVIEW_IMAGE_URL = 0x0414 # 64 | ZZ_MQTT_CMD_SYSTEM_CHECK = 0x0419 # ? 65 | ZZ_MQTT_CMD_AI_SWITCH = 0x041a # ? 66 | ZZ_STEST_CMD_GCODE_TRANSPOR = 0x07e2 # ? 67 | ZZ_MQTT_CMD_ALEXA_MSG = 0x0bb8 # 68 | 69 | @classmethod 70 | def parse(cls, p): 71 | return cls(struct.unpack("B", p[:1])[0]), p[1:] 72 | 73 | def pack(self): 74 | return struct.pack("B", self) 75 | 76 | @dataclass 77 | class _MqttMsg: 78 | signature : bytes = field(repr=False, kw_only=True, default=b'MA') # Signature: 'MA' 79 | size : u16le # length of packet, including header and checksum (minimum 65). 80 | m3 : u8 # Magic constant: 5 81 | m4 : u8 # Magic constant: 1 82 | m5 : u8 # Magic constant: 2 83 | m6 : u8 # Magic constant: 5 84 | m7 : u8 # Magic constant: 'F' 85 | packet_type: MqttPktType # Packet type 86 | packet_num : u16le # maybe for fragmented messages?set to 1 for unfragmented messages. 87 | time : u32le # `gettimeofday()` in whole seconds 88 | device_guid: bytes # device guid, as hex string 89 | padding : bytes = field(repr=False, kw_only=True, default='\x00' * 11) # padding bytes, allways zero 90 | data : bytes # payload data 91 | 92 | @classmethod 93 | def parse(cls, p): 94 | signature, p = Magic.parse(p, 2, b'MA') 95 | size, p = u16le.parse(p) 96 | m3, p = u8.parse(p) 97 | m4, p = u8.parse(p) 98 | m5, p = u8.parse(p) 99 | m6, p = u8.parse(p) 100 | m7, p = u8.parse(p) 101 | packet_type, p = MqttPktType.parse(p) 102 | packet_num, p = u16le.parse(p) 103 | time, p = u32le.parse(p) 104 | device_guid, p = String.parse(p, 37) 105 | padding, p = Zeroes.parse(p, 11) 106 | data, p = Tail.parse(p) 107 | 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 108 | 109 | def pack(self): 110 | p = Magic.pack(self.signature, 2, b'MA') 111 | p += u16le.pack(self.size) 112 | p += u8.pack(self.m3) 113 | p += u8.pack(self.m4) 114 | p += u8.pack(self.m5) 115 | p += u8.pack(self.m6) 116 | p += u8.pack(self.m7) 117 | p += MqttPktType.pack(self.packet_type) 118 | p += u16le.pack(self.packet_num) 119 | p += u32le.pack(self.time) 120 | p += String.pack(self.device_guid, 37) 121 | p += Zeroes.pack(self.padding, 11) 122 | p += Tail.pack(self.data) 123 | return p 124 | 125 | 126 | class MqttMsg(_MqttMsg): 127 | 128 | @classmethod 129 | def parse(cls, p, key): 130 | p = mqtt_checksum_remove(p) 131 | body, data = p[:64], mqtt_aes_decrypt(p[64:], key) 132 | res = super().parse(body + data) 133 | assert res[0].size == (len(p) + 1) 134 | return res 135 | 136 | def pack(self, key): 137 | data = mqtt_aes_encrypt(self.data, key) 138 | self.size = 64 + len(data) + 1 139 | body = super().pack()[:64] 140 | final = mqtt_checksum_add(body + data) 141 | return final 142 | 143 | def getjson(self): 144 | return json.loads(self.data.decode()) 145 | 146 | def setjson(self, val): 147 | self.data = json.dumps(val).encode() 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ankermake M5 Protocol 2 | 3 | Welcome! This repository contains `ankerctl.py`, a command-line interface for 4 | monitoring, controlling and interfacing with Ankermake M5 3D printers. 5 | 6 | NOTE: This software is in early stages, so expect sharp edges and occasional errors! 7 | 8 | The `ankerctl` program uses [`libflagship`](documentation/libflagship.md), a 9 | library for communicating with the numerous different protocols required for 10 | connecting to an Ankermake M5 printer. The `libflagship` library is also maintained 11 | in this repo, under [`libflagship/`](libflagship/). 12 | 13 | ## Current features 14 | 15 | - Connect to Ankermake M5 and Ankermake APIs without using closed-source Anker 16 | software. 17 | 18 | - Send raw gcode commands directly to the printer (and see the response) 19 | 20 | - Low-level access to MQTT, PPPP and HTTPS APIs 21 | 22 | - Send print jobs to the printer 23 | 24 | - Stream camera image/video to your computer 25 | 26 | ## Upcoming and planned features 27 | 28 | - Easily monitor print status 29 | 30 | - Integration into other software. Home Assistant? Prusa Slicer? 31 | 32 | Pull requests always welcome :-) 33 | 34 | ## Installation instructions 35 | 36 | First, please follow the [installation 37 | instructions](documentation/example-file-usage/example-file-prerequistes.md) for 38 | your platform. 39 | 40 | Verify that you can start `ankerctl.py`, and get the help screen: 41 | 42 | ```powershell 43 | ## For Windows, use: 44 | python3 ankerctl.py -h 45 | ``` 46 | 47 | ```sh 48 | ## Linux and MacOS: 49 | ./ankerctl.py -h 50 | ``` 51 | 52 | You should see this output: 53 | ``` 54 | Usage: ankerctl.py [OPTIONS] COMMAND [ARGS]... 55 | 56 | Options: 57 | -k, --insecure Disable TLS certificate validation 58 | -v, --verbose Increase verbosity 59 | -q, --quiet Decrease verbosity 60 | -h, --help Show this message and exit. 61 | 62 | Commands: 63 | config View and update configuration 64 | http Low-level http api access 65 | mqtt Low-level mqtt api access 66 | pppp Low-level pppp api access 67 | ``` 68 | 69 | Before you can use ankerctl, you need to import the configuration. 70 | 71 | Support for logging in with username and password is not yet supported, but an 72 | `auth_token` can be imported from the saved credentials found in `login.json` in 73 | Ankermake Slicer. See `ankerctl.py config import -h` for details: 74 | 75 | ``` 76 | Usage: ankerctl.py config import [OPTIONS] path/to/login.json 77 | 78 | Import printer and account information from login.json 79 | 80 | When run without filename, attempt to auto-detect login.json in default 81 | install location 82 | 83 | Options: 84 | -h, --help Show this message and exit. 85 | ``` 86 | 87 | On Windows and MacOS, the default location of `login.json` will be tried if no 88 | filename is specified. Otherwise, you can specify the file path for 89 | `login.json`. Example for linux: 90 | 91 | ```sh 92 | ankerctl.py config import ~/.wine/drive_c/users/username/AppData/Local/AnkerMake/AnkerMake_64bit_fp/login.json 93 | ``` 94 | 95 | The expected output is similar to this: 96 | ``` 97 | [*] Loading cache.. 98 | [*] Initializing API.. 99 | [*] Requesting profile data.. 100 | [*] Requesting printer list.. 101 | [*] Requesting pppp keys.. 102 | [*] Adding printer [AK7ABC0123401234] 103 | [*] Finished import 104 | ``` 105 | 106 | At this point, your config is saved to a configuration file managed by 107 | `ankerctl.py`. To see an overview of the stored data, use `config show`: 108 | 109 | ```sh 110 | ./ankerctl.py config show 111 | [*] Account: 112 | user_id: 01234567890abcdef012... 113 | email: bob@example.org 114 | region: eu 115 | 116 | [*] Printers: 117 | sn: AK7ABC0123401234 118 | duid: EUPRAKM-001234-ABCDE 119 | ``` 120 | 121 | NOTE: The cached login info contains sensitive details. In particular, the 122 | `user_id` field is used when connecting to MQTT servers, and essentially works 123 | as a password. Thus, the end of the value is redacted when printed to screen, to avoid 124 | accidentally disclosing sensitive information. 125 | 126 | Now that the printer information is known to `ankerctl`, the tool is ready to use. 127 | 128 | Some examples: 129 | 130 | ```sh 131 | # attempt to detect printers on local network 132 | ./ankerctl.py pppp lan-search 133 | 134 | # monitor mqtt events 135 | ./ankerctl.py mqtt monitor 136 | 137 | # start gcode prompt 138 | ./ankerctl.py mqtt gcode 139 | 140 | # set printer name 141 | ./ankerctl.py mqtt rename-printer BoatyMcBoatFace 142 | 143 | # print boaty.gcode 144 | ./ankerctl.py pppp print-file boaty.gcode 145 | 146 | # capture 4mb of video from camera 147 | ./ankerctl.py pppp capture-video -m 4mb output.h264 148 | ``` 149 | 150 | ## Webserver 151 | 152 | ankerctl can also be used as a webserver to allow slicers like prusaslicer to print directly to the printer. 153 | 154 | 155 | ![Screenshot of prusa slicer](https://user-images.githubusercontent.com/242382/229643454-eef1088c-c8b7-493d-851e-c5ef7bd26a35.png) 156 | 157 | To start the webserver run the following command, then navigate to [http://localhost:4470](http://localhost:4470) 158 | 159 | ```sh 160 | ./ankerctl.py webserver run 161 | ``` 162 | 163 | You can alternativly use docker compose to start the webserver running behind nginx 164 | 165 | ```sh 166 | docker compose up 167 | ``` 168 | 169 | 170 | 171 | ## Docker 172 | 173 | While running the python script is generally prefered, there may be situations where you want a more portable solution. For this, a docker image is provided. 174 | 175 | ```sh 176 | docker build -t ankerctl . 177 | ``` 178 | 179 | Example usage (no peristent storage) 180 | ```bash 181 | docker run \ 182 | -v "$HOME/Library/Application Support/AnkerMake/AnkerMake_64bit_fp/login.json:/tmp/login.json" \ 183 | ankerctl config decode /tmp/login.json 184 | ``` 185 | 186 | Example usage (with peristent storage) 187 | ```bash 188 | # create volume where we can store configs 189 | docker volume create ankerctl_vol 190 | 191 | # generate /root/.config/ankerctl/default.json which is mounted to the docker volume 192 | docker run \ 193 | -v ankerctl_vol:/root/.config/ankerctl \ 194 | -v "$HOME/Library/Application Support/AnkerMake/AnkerMake_64bit_fp/login.json:/tmp/login.json" \ 195 | ankerctl config import /tmp/login.json 196 | 197 | # Now that there is a /root/.config/ankerctl/default.json file that persists in the docker volume 198 | # we can run ankerctl without having to specify the login.json file 199 | docker run \ 200 | -v ankerctl_vol:/root/.config/ankerctl \ 201 | ankerctl config show 202 | ``` 203 | 204 | 205 | ## Legal 206 | 207 | This project is in ABSOLUTELY NO WAY endorsed, affiliated with, or supported by 208 | Anker. All information found herein is gathered entirely from reverse 209 | engineering. 210 | 211 | The goal of this project is to make the Ankermake M5 usable and accessible using 212 | only Free and Open Source Software. 213 | 214 | This project is [licensed under the GNU GPLv3](LICENSE), and copyright © 2023 215 | Christian Iversen. 216 | -------------------------------------------------------------------------------- /libflagship/megajank.py: -------------------------------------------------------------------------------- 1 | import Cryptodome.Util.Padding 2 | import Cryptodome.Cipher.AES 3 | import tinyec.registry 4 | import tinyec.ec 5 | 6 | from libflagship.util import b64e 7 | 8 | 9 | # mqtt aes handling 10 | 11 | def aes_cbc_encrypt(msg, key, iv): 12 | aes = Cryptodome.Cipher.AES.new(key=key, iv=iv, mode=Cryptodome.Cipher.AES.MODE_CBC) 13 | pmsg = Cryptodome.Util.Padding.pad(msg, block_size=16) 14 | cmsg = aes.encrypt(pmsg) 15 | return cmsg 16 | 17 | 18 | def aes_cbc_decrypt(cmsg, key, iv): 19 | aes = Cryptodome.Cipher.AES.new(key=key, iv=iv, mode=Cryptodome.Cipher.AES.MODE_CBC) 20 | pmsg = aes.decrypt(cmsg) 21 | msg = Cryptodome.Util.Padding.unpad(pmsg, block_size=16) 22 | return msg 23 | 24 | 25 | def mqtt_aes_encrypt(msg, key, iv=b"3DPrintAnkerMake"): 26 | return aes_cbc_encrypt(msg, key, iv) 27 | 28 | 29 | def mqtt_aes_decrypt(cmsg, key, iv=b"3DPrintAnkerMake"): 30 | return aes_cbc_decrypt(cmsg, key, iv) 31 | 32 | 33 | # mqtt checksum handling 34 | 35 | def mqtt_checksum_remove(payload): 36 | if xor_bytes(payload) != 0: 37 | # raise ... 38 | print(f"MALFORMED MESSAGE: {payload}") 39 | return payload[:-1] 40 | 41 | 42 | def mqtt_checksum_add(msg): 43 | return msg + bytes([xor_bytes(msg)]) 44 | 45 | 46 | def xor_bytes(data): 47 | s = 0 48 | for x in data: 49 | s ^= x 50 | return s 51 | 52 | 53 | # Elliptic-Curve Diffie-Hellman (ECDH) for password exchange in login api 54 | 55 | anker_ec_v1_curve = tinyec.registry.get_curve("secp256r1") 56 | 57 | anker_ec_v1_public_key = tinyec.ec.Keypair(anker_ec_v1_curve, pub=tinyec.ec.Point( 58 | anker_ec_v1_curve, 59 | 0xC5C00C4F8D1197CC7C3167C52BF7ACB054D722F0EF08DCD7E0883236E0D72A38, 60 | 0x68D9750CB47FA4619248F3D83F0F662671DADC6E2D31C2F41DB0161651C7C076 61 | )) 62 | 63 | 64 | def ec_pubkey_export(key): 65 | return f"04{key.x:32x}{key.y:32x}" 66 | 67 | 68 | def ecdh_encrypt_login_password(password, partner=anker_ec_v1_public_key): 69 | # make fresh EC key 70 | eckey = tinyec.ec.make_keypair(anker_ec_v1_curve) 71 | 72 | # Create ECDH instance for new key 73 | ecdh = tinyec.ec.ECDH(eckey) 74 | 75 | # Perform ECDH with partner key 76 | secret = ecdh.get_secret(partner) 77 | 78 | # Extract result as hex, use as AES key 79 | key = bytes.fromhex(hex(secret.x)[2:].zfill(64)) 80 | 81 | # AES IV is just the first half of the key 82 | iv = key[:16] 83 | 84 | # Encrypt password with AES, return base64 encoding 85 | return ec_pubkey_export(eckey.pub), b64e(aes_cbc_encrypt(password, key, iv)) 86 | 87 | 88 | # pppp init string decoder 89 | 90 | def pppp_decode_initstring_raw(input): 91 | shuffle = [ 0x49, 0x59, 0x43, 0x3d, 0xb5, 0xbf, 0x6d, 0xa3, 0x47, 0x53, 92 | 0x4f, 0x61, 0x65, 0xe3, 0x71, 0xe9, 0x67, 0x7f, 0x02, 0x03, 93 | 0x0b, 0xad, 0xb3, 0x89, 0x2b, 0x2f, 0x35, 0xc1, 0x6b, 0x8b, 94 | 0x95, 0x97, 0x11, 0xe5, 0xa7, 0x0d, 0xef, 0xf1, 0x05, 0x07, 95 | 0x83, 0xfb, 0x9d, 0x3b, 0xc5, 0xc7, 0x13, 0x17, 0x1d, 0x1f, 96 | 0x25, 0x29, 0xd3, 0xdf ] 97 | 98 | olen = len(input) >> 1 99 | 100 | q = 0 101 | output = [0] * olen 102 | 103 | for q in range(olen): 104 | xor = 0x39 ^ shuffle[q % 0x36] 105 | 106 | for p in range(q+1): 107 | xor ^= output[p] 108 | 109 | l = input[q*2+1] - 0x41 110 | h = input[q*2+0] - 0x41 111 | output[q] = xor ^ (l + (h << 4)) 112 | 113 | return bytes(output) 114 | 115 | 116 | def pppp_decode_initstring(input): 117 | res = pppp_decode_initstring_raw(input.encode()) 118 | return res.decode().rstrip(",").split(",") 119 | 120 | 121 | # pppp crypto curses 122 | 123 | PPPP_SEED = "EUPRAKM" 124 | 125 | PPPP_SHUFFLE = [ 126 | [ 0x95, 0xe5, 0x61, 0x97, 0x83, 0x0d, 0xa7, 0xf1, ], 127 | [ 0xd3, 0x05, 0x95, 0x8b, 0xdf, 0x13, 0x6d, 0xef, ], 128 | [ 0x07, 0x61, 0x0d, 0x6d, 0x7f, 0x67, 0x17, 0x2b, ], 129 | [ 0xc1, 0xb5, 0x13, 0x0b, 0xdf, 0x8b, 0x49, 0x3b, ], 130 | [ 0x7f, 0x07, 0xd3, 0x02, 0x6d, 0x2f, 0x13, 0xc5, ], 131 | [ 0x6d, 0x3d, 0xfb, 0x0d, 0x0b, 0x29, 0xe9, 0x4f, ], 132 | [ 0x89, 0x2f, 0xe3, 0xe9, 0x0d, 0x83, 0x6d, 0xe5, ], 133 | [ 0x07, 0x53, 0x8b, 0x25, 0x95, 0x47, 0x1f, 0x29, ], 134 | ] 135 | 136 | 137 | def crypto_decurse(input, key, shuffle): 138 | 139 | a, b, c, d = (1, 3, 5, 7) 140 | 141 | for q in key: 142 | q = ord(q) 143 | a, b, c, d = [ 144 | shuffle[b + (q % a) & 7][q + (c % d) & 7], 145 | shuffle[c + (q % b) & 7][q + (d % a) & 7], 146 | shuffle[d + (q % c) & 7][q + (a % b) & 7], 147 | shuffle[a + (q % d) & 7][q + (b % c) & 7], 148 | ] 149 | 150 | output = [0] * len(input) 151 | for p, x in enumerate(input): 152 | output[p] = x ^ (a^b^c^d) 153 | 154 | a, b, c, d = [ 155 | shuffle[b + (x % a) & 7][x + (c % d) & 7], 156 | shuffle[c + (x % b) & 7][x + (d % a) & 7], 157 | shuffle[d + (x % c) & 7][x + (a % b) & 7], 158 | shuffle[a + (x % d) & 7][x + (b % c) & 7], 159 | ] 160 | 161 | return output 162 | 163 | 164 | def crypto_curse(input, key, shuffle): 165 | 166 | a, b, c, d = (1, 3, 5, 7) 167 | 168 | for q in key: 169 | q = ord(q) 170 | a, b, c, d = [ 171 | shuffle[b + (q % a) & 7][q + (c % d) & 7], 172 | shuffle[c + (q % b) & 7][q + (d % a) & 7], 173 | shuffle[d + (q % c) & 7][q + (a % b) & 7], 174 | shuffle[a + (q % d) & 7][q + (b % c) & 7], 175 | ] 176 | 177 | output = [0] * (len(input) + 4) 178 | for p, x in enumerate(input): 179 | x = output[p] = x ^ (a^b^c^d) 180 | 181 | a, b, c, d = [ 182 | shuffle[b + (x % a) & 7][x + (c % d) & 7], 183 | shuffle[c + (x % b) & 7][x + (d % a) & 7], 184 | shuffle[d + (x % c) & 7][x + (a % b) & 7], 185 | shuffle[a + (x % d) & 7][x + (b % c) & 7], 186 | ] 187 | 188 | for p in range(len(input), len(input)+4): 189 | x = output[p] = a ^ b ^ c ^ d ^ 0x43; 190 | 191 | a, b, c, d = [ 192 | shuffle[b + (x % a) & 7][x + (c % d) & 7], 193 | shuffle[c + (x % b) & 7][x + (d % a) & 7], 194 | shuffle[d + (x % c) & 7][x + (a % b) & 7], 195 | shuffle[a + (x % d) & 7][x + (b % c) & 7], 196 | ] 197 | 198 | return output 199 | 200 | 201 | def crypto_decurse_string(input): 202 | 203 | output = crypto_decurse(input, key=PPPP_SEED, shuffle=PPPP_SHUFFLE) 204 | 205 | if output[-4:] != [0x43, 0x43, 0x43, 0x43]: 206 | raise ValueError("Invalid decode") 207 | 208 | return bytes(output[:-4]) 209 | 210 | 211 | def crypto_curse_string(input): 212 | 213 | output = crypto_curse(input, key=PPPP_SEED, shuffle=PPPP_SHUFFLE) 214 | 215 | return bytes(output) 216 | 217 | 218 | # pppp crypto curse, older(?) version 219 | # 220 | # the simple_* functions have been adapted from https://github.com/fbertone/lib32100/issues/7 221 | # 222 | 223 | PPPP_SIMPLE_SEED = b"SSD@cs2-network." 224 | 225 | PPPP_SIMPLE_SHUFFLE = [ 226 | 0x7C, 0x9C, 0xE8, 0x4A, 0x13, 0xDE, 0xDC, 0xB2, 0x2F, 0x21, 0x23, 0xE4, 0x30, 0x7B, 0x3D, 0x8C, 227 | 0xBC, 0x0B, 0x27, 0x0C, 0x3C, 0xF7, 0x9A, 0xE7, 0x08, 0x71, 0x96, 0x00, 0x97, 0x85, 0xEF, 0xC1, 228 | 0x1F, 0xC4, 0xDB, 0xA1, 0xC2, 0xEB, 0xD9, 0x01, 0xFA, 0xBA, 0x3B, 0x05, 0xB8, 0x15, 0x87, 0x83, 229 | 0x28, 0x72, 0xD1, 0x8B, 0x5A, 0xD6, 0xDA, 0x93, 0x58, 0xFE, 0xAA, 0xCC, 0x6E, 0x1B, 0xF0, 0xA3, 230 | 0x88, 0xAB, 0x43, 0xC0, 0x0D, 0xB5, 0x45, 0x38, 0x4F, 0x50, 0x22, 0x66, 0x20, 0x7F, 0x07, 0x5B, 231 | 0x14, 0x98, 0x1D, 0x9B, 0xA7, 0x2A, 0xB9, 0xA8, 0xCB, 0xF1, 0xFC, 0x49, 0x47, 0x06, 0x3E, 0xB1, 232 | 0x0E, 0x04, 0x3A, 0x94, 0x5E, 0xEE, 0x54, 0x11, 0x34, 0xDD, 0x4D, 0xF9, 0xEC, 0xC7, 0xC9, 0xE3, 233 | 0x78, 0x1A, 0x6F, 0x70, 0x6B, 0xA4, 0xBD, 0xA9, 0x5D, 0xD5, 0xF8, 0xE5, 0xBB, 0x26, 0xAF, 0x42, 234 | 0x37, 0xD8, 0xE1, 0x02, 0x0A, 0xAE, 0x5F, 0x1C, 0xC5, 0x73, 0x09, 0x4E, 0x69, 0x24, 0x90, 0x6D, 235 | 0x12, 0xB3, 0x19, 0xAD, 0x74, 0x8A, 0x29, 0x40, 0xF5, 0x2D, 0xBE, 0xA5, 0x59, 0xE0, 0xF4, 0x79, 236 | 0xD2, 0x4B, 0xCE, 0x89, 0x82, 0x48, 0x84, 0x25, 0xC6, 0x91, 0x2B, 0xA2, 0xFB, 0x8F, 0xE9, 0xA6, 237 | 0xB0, 0x9E, 0x3F, 0x65, 0xF6, 0x03, 0x31, 0x2E, 0xAC, 0x0F, 0x95, 0x2C, 0x5C, 0xED, 0x39, 0xB7, 238 | 0x33, 0x6C, 0x56, 0x7E, 0xB4, 0xA0, 0xFD, 0x7A, 0x81, 0x53, 0x51, 0x86, 0x8D, 0x9F, 0x77, 0xFF, 239 | 0x6A, 0x80, 0xDF, 0xE2, 0xBF, 0x10, 0xD7, 0x75, 0x64, 0x57, 0x76, 0xF3, 0x55, 0xCD, 0xD0, 0xC8, 240 | 0x18, 0xE6, 0x36, 0x41, 0x62, 0xCF, 0x99, 0xF2, 0x32, 0x4C, 0x67, 0x60, 0x61, 0x92, 0xCA, 0xD3, 241 | 0xEA, 0x63, 0x7D, 0x16, 0xB6, 0x8E, 0xD4, 0x68, 0x35, 0xC3, 0x52, 0x9D, 0x46, 0x44, 0x1E, 0x17, 242 | ] 243 | 244 | 245 | def simple_hash(seed): 246 | hash = [0] * 4 247 | 248 | for i in range(len(seed)): 249 | hash[0] = hash[0] ^ seed[i] 250 | hash[1] = hash[1] + seed[i] // 3 251 | hash[2] = hash[2] - seed[i] 252 | hash[3] = hash[3] + seed[i] 253 | 254 | return hash[::-1] 255 | 256 | 257 | def _lookup(hash, b): 258 | index = hash[b & 0x3] + b 259 | return PPPP_SIMPLE_SHUFFLE[index % len(PPPP_SIMPLE_SHUFFLE)] 260 | 261 | 262 | def simple_decrypt(seed, input): 263 | hash = simple_hash(seed) 264 | output = [0] * len(input) 265 | 266 | output[0] = input[0] ^ _lookup(hash, 0) 267 | for i in range(1, len(input)): 268 | output[i] = input[i] ^ _lookup(hash, input[i-1]) 269 | 270 | return bytes(output) 271 | 272 | 273 | def simple_encrypt(seed, input): 274 | hash = simple_hash(seed) 275 | output = [0] * len(input) 276 | 277 | output[0] = input[0] ^ _lookup(hash, 0) 278 | for i in range(1, len(input)): 279 | output[i] = input[i] ^ _lookup(hash, output[i-1]) 280 | 281 | return bytes(output) 282 | 283 | 284 | def simple_decrypt_string(input): 285 | return simple_decrypt(PPPP_SIMPLE_SEED, input) 286 | 287 | 288 | def simple_encrypt_string(input): 289 | return simple_encrypt(PPPP_SIMPLE_SEED, input) 290 | 291 | 292 | if __name__ == "__main__": 293 | print(simple_encrypt_string(b"foo")) 294 | print(simple_decrypt_string(simple_encrypt_string(b"foo"))) 295 | -------------------------------------------------------------------------------- /libflagship/ppppapi.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | import string 4 | import hashlib 5 | import logging as log 6 | 7 | from multiprocessing import Pipe 8 | from datetime import datetime, timedelta 9 | from threading import Thread, Event 10 | from socket import AF_INET 11 | from dataclasses import dataclass 12 | 13 | from libflagship.pppp import * 14 | 15 | PPPP_LAN_PORT = 32108 16 | PPPP_WAN_PORT = 32100 17 | 18 | 19 | class PPPPError(Exception): 20 | 21 | def __init__(self, err, message): 22 | self.err = err 23 | super().__init__(message) 24 | 25 | 26 | @dataclass 27 | class FileUploadInfo: 28 | name: str 29 | size: str 30 | md5: str 31 | user_name: str 32 | user_id: str 33 | machine_id: str 34 | type: int = 0 35 | 36 | @staticmethod 37 | def sanitize_filename(str): 38 | whitelist = string.ascii_letters + string.digits + "._-" 39 | 40 | def sanitize(c): 41 | if c in whitelist: 42 | return c 43 | else: 44 | return "_" 45 | 46 | cleaned = "".join(sanitize(c) for c in str) 47 | return cleaned.lstrip(".").replace("..", ".") 48 | 49 | @classmethod 50 | def from_file(cls, filename, user_name, user_id, machine_id, type=0): 51 | data = open(filename, "rb").read() 52 | return cls(data, filename, user_name, user_id, machine_id, type=0) 53 | 54 | @classmethod 55 | def from_data(cls, data, filename, user_name, user_id, machine_id, type=0): 56 | return cls( 57 | name=cls.sanitize_filename(os.path.basename(filename)), 58 | size=len(data), 59 | md5=hashlib.md5(data).hexdigest(), 60 | user_name=user_name, 61 | user_id=user_id, 62 | machine_id=machine_id, 63 | type=type 64 | ) 65 | 66 | def __str__(self): 67 | return f"{self.type},{self.name},{self.size},{self.md5},{self.user_name},{self.user_id},{self.machine_id}" 68 | 69 | def __bytes__(self): 70 | return str(self).encode() + b"\x00" 71 | 72 | 73 | class Wire: 74 | 75 | def __init__(self): 76 | self.buf = [] 77 | self.rx, self.tx = Pipe(False) 78 | 79 | def read(self, size): 80 | while len(self.buf) < size: 81 | self.buf.extend(self.rx.recv()) 82 | res, self.buf = self.buf[:size], self.buf[size:] 83 | return bytes(res) 84 | 85 | def write(self, data): 86 | self.tx.send(data) 87 | 88 | 89 | class Channel: 90 | 91 | def __init__(self, index, max_in_flight=64): 92 | self.index = index 93 | self.rxqueue = {} 94 | self.txqueue = [] 95 | self.backlog = [] 96 | self.rx_ctr = 0 97 | self.tx_ctr = 0 98 | self.tx_ack = 0 99 | self.rx = Wire() 100 | self.tx = Wire() 101 | self.timeout = timedelta(seconds=0.5) 102 | self.acks = set() 103 | self.event = Event() 104 | self.max_in_flight = max_in_flight 105 | 106 | def rx_ack(self, acks): 107 | # remove all ACKed packets from transmission queue 108 | self.txqueue = [tx for tx in self.txqueue if tx[1] not in acks] 109 | 110 | # record any ACKs that are not yet confirmed 111 | for ack in acks: 112 | if ack >= self.tx_ack: 113 | self.acks.add(ack) 114 | 115 | # update tx_ack step by step 116 | while self.tx_ack in self.acks: 117 | self.acks.remove(self.tx_ack) 118 | self.tx_ack += 1 119 | 120 | def rx_drw(self, index, data): 121 | # drop any packets we have already recieved 122 | if self.rx_ctr > index: 123 | if self.rx_ctr - index > 100: 124 | log.warn(f"Dropping old packet: index {index} while expecting {self.rx_ctr}.") 125 | return 126 | 127 | # record packet in queue 128 | self.rxqueue[index] = data 129 | 130 | # recombine data from queue 131 | while self.rx_ctr in self.rxqueue: 132 | del self.rxqueue[self.rx_ctr] 133 | self.rx_ctr = (self.rx_ctr + 1) & 0xFFFF 134 | self.rx.write(data) 135 | 136 | def poll(self): 137 | # signal event to make blocking reads check status again 138 | self.event.set() 139 | 140 | txq = self.txqueue 141 | 142 | if self.backlog and len(txq) < self.max_in_flight: 143 | while self.backlog and len(txq) < self.max_in_flight: 144 | txq.append(self.backlog.pop(0)) 145 | 146 | # sort list to make sure oldest deadline is first 147 | txq.sort() 148 | 149 | res = [] 150 | now = datetime.now() 151 | 152 | while txq and txq[0][0] < now: 153 | deadline, index, pkt = txq.pop(0) 154 | res.append(PktDrw(chan=self.index, index=index, data=pkt)) 155 | txq.append((deadline + self.timeout, index, pkt)) 156 | 157 | # the returned chunks will be (re)transmitted 158 | return res 159 | 160 | def wait(self): 161 | self.event.wait() 162 | self.event.clear() 163 | 164 | def read(self, nbytes): 165 | return self.rx.read(nbytes) 166 | 167 | def write(self, payload, block=True): 168 | pdata = payload[:] 169 | 170 | tx_ctr_start = self.tx_ctr 171 | 172 | # schedule all packets, starting from current time 173 | deadline = datetime.now() 174 | while pdata: 175 | # schedule transmission in 1kb chunks 176 | data, pdata = pdata[:1024], pdata[1024:] 177 | self.backlog.append((deadline, self.tx_ctr, data)) 178 | self.tx_ctr = (self.tx_ctr + 1) & 0xFFFF 179 | 180 | tx_ctr_done = self.tx_ctr 181 | 182 | while block: 183 | # if doing a blocking write, loop on self.event until we have 184 | # received acknowledgment of our data 185 | self.wait() 186 | 187 | if self.tx_ack >= tx_ctr_done: 188 | break 189 | 190 | return (tx_ctr_start, tx_ctr_done) 191 | 192 | 193 | class AnkerPPPPApi(Thread): 194 | 195 | def __init__(self, sock, duid, addr=None): 196 | super().__init__() 197 | self.sock = sock 198 | self.duid = duid 199 | self.addr = addr 200 | 201 | self.new = True 202 | self.rdy = False 203 | self.chans = [Channel(n) for n in range(8)] 204 | 205 | self.running = True 206 | self.stopped = Event() 207 | 208 | @classmethod 209 | def open(cls, duid, host, port): 210 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 211 | return cls(sock, duid, addr=(host, port)) 212 | 213 | @classmethod 214 | def open_lan(cls, duid, host): 215 | return cls.open(duid, host, PPPP_LAN_PORT) 216 | 217 | @classmethod 218 | def open_wan(cls, duid, host): 219 | return cls.open(duid, host, PPPP_WAN_PORT) 220 | 221 | @classmethod 222 | def open_broadcast(cls): 223 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 224 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 225 | addr = ("255.255.255.255", PPPP_LAN_PORT) 226 | return cls(sock, duid=None, addr=addr) 227 | 228 | def stop(self): 229 | self.running = False 230 | self.stopped.wait() 231 | 232 | def run(self): 233 | log.debug("Started pppp thread") 234 | while self.running: 235 | try: 236 | msg = self.recv(timeout=0.05) 237 | self.process(msg) 238 | except TimeoutError: 239 | pass 240 | except StopIteration: 241 | break 242 | 243 | for idx, ch in enumerate(self.chans): 244 | for pkt in ch.poll(): 245 | self.send(pkt) 246 | 247 | self.send(PktClose()) 248 | 249 | self.stopped.set() 250 | 251 | @property 252 | def host(self): 253 | return Host(afam=AF_INET, addr=self.addr[0], port=self.addr[1]) 254 | 255 | def process(self, msg): 256 | 257 | if msg.type == Type.CLOSE: 258 | log.error("CLOSE") 259 | raise StopIteration 260 | 261 | elif msg.type == Type.REPORT_SESSION_READY: 262 | pkt = PktSessionReady( 263 | duid=self.duid, 264 | handle=-3, 265 | max_handles=5, 266 | active_handles=1, 267 | startup_ticks=0, 268 | b1=1, b2=0, b3=1, b4=0, 269 | addr_local=Host(afam=AF_INET, addr="0.0.0.0", port=0), 270 | addr_wan=Host(afam=AF_INET, addr="0.0.0.0", port=0), 271 | addr_relay=Host(afam=AF_INET, addr="0.0.0.0", port=0) 272 | ) 273 | 274 | # self.send(pkt) 275 | 276 | elif msg.type == Type.ALIVE: 277 | self.send(PktAliveAck()) 278 | 279 | elif msg.type == Type.DRW: 280 | self.send(PktDrwAck(chan=msg.chan, count=1, acks=[msg.index])) 281 | self.chans[msg.chan].rx_drw(msg.index, msg.data) 282 | 283 | elif msg.type == Type.DRW_ACK: 284 | self.chans[msg.chan].rx_ack(msg.acks) 285 | 286 | elif msg.type == Type.DEV_LGN_CRC: 287 | self.send(PktDevLgnAckCrc()) 288 | 289 | elif msg.type == Type.HELLO: 290 | self.send(PktHelloAck(host=self.host)) 291 | 292 | elif msg.type == Type.ALIVE_ACK: 293 | pass 294 | 295 | elif msg.type == Type.P2P_RDY: 296 | self.send(PktP2pRdyAck(duid=self.duid, host=self.host)) 297 | 298 | self.new = False 299 | self.rdy = True 300 | 301 | elif msg.type == Type.PUNCH_PKT: 302 | if self.new: 303 | self.send(PktClose()) 304 | self.send(PktP2pRdy(self.duid)) 305 | 306 | def recv(self, timeout=None): 307 | self.sock.settimeout(timeout) 308 | data, self.addr = self.sock.recvfrom(4096) 309 | msg = Message.parse(data)[0] 310 | log.debug(f"RX <-- {msg}") 311 | return msg 312 | 313 | def send(self, pkt, addr=None): 314 | resp = pkt.pack() 315 | msg = Message.parse(resp)[0] 316 | log.debug(f"TX --> {msg}") 317 | self.sock.sendto(resp, addr or self.addr) 318 | 319 | def send_xzyh(self, data, cmd, chan=0, unk0=0, unk1=0, sign_code=0, unk3=0, dev_type=0, block=True): 320 | xzyh = Xzyh( 321 | cmd=cmd, 322 | len=len(data), 323 | data=data, 324 | chan=chan, 325 | unk0=unk0, 326 | unk1=unk1, 327 | sign_code=sign_code, 328 | unk3=unk3, 329 | dev_type=dev_type 330 | ) 331 | 332 | return self.chans[chan].write(xzyh.pack(), block=block) 333 | 334 | def send_aabb(self, data, sn=0, pos=0, frametype=0, chan=1, block=True): 335 | aabb = Aabb( 336 | frametype=frametype, 337 | sn=sn, 338 | pos=pos, 339 | len=len(data) 340 | ) 341 | 342 | return self.chans[chan].write(aabb.pack_with_crc(data), block=block) 343 | 344 | def recv_xzyh(self, chan=1): 345 | fd = self.chans[chan] 346 | 347 | xzyh = Xzyh.parse(fd.read(16))[0] 348 | xzyh.data = fd.read(xzyh.len) 349 | return xzyh 350 | 351 | def recv_aabb(self, chan=1): 352 | fd = self.chans[chan] 353 | 354 | data = fd.read(12) 355 | aabb = Aabb.parse(data)[0] 356 | p = data + fd.read(aabb.len + 2) 357 | aabb, data = Aabb.parse_with_crc(p)[:2] 358 | return aabb, data 359 | 360 | def recv_aabb_reply(self, chan=1, check=True): 361 | aabb, data = self.recv_aabb(chan=chan) 362 | if len(data) != 1: 363 | raise ValueError(f"Unexpected reply from aabb request: {data}") 364 | 365 | res = FileTransferReply(data[0]) 366 | if check and res != FileTransferReply.OK: 367 | raise PPPPError(res, f"Aabb request failed: {res.name}") 368 | 369 | return res 370 | 371 | def aabb_request(self, data, frametype, pos=0, chan=1, check=True): 372 | self.send_aabb(data=data, frametype=frametype, chan=chan, pos=pos) 373 | return self.recv_aabb_reply(chan, check) 374 | -------------------------------------------------------------------------------- /specification/pppp.stf: -------------------------------------------------------------------------------- 1 | enum Type 2 | HELLO = 0x00 3 | HELLO_ACK = 0x01 4 | HELLO_TO = 0x02 5 | HELLO_TO_ACK = 0x03 6 | QUERY_DID = 0x08 7 | QUERY_DID_ACK = 0x09 8 | DEV_LGN = 0x10 9 | DEV_LGN_ACK = 0x11 10 | DEV_LGN_CRC = 0x12 11 | DEV_LGN_ACK_CRC = 0x13 12 | DEV_LGN_KEY = 0x14 13 | DEV_LGN_ACK_KEY = 0x15 14 | DEV_LGN_DSK = 0x16 15 | DEV_ONLINE_REQ = 0x18 16 | DEV_ONLINE_REQ_ACK = 0x19 17 | P2P_REQ = 0x20 18 | P2P_REQ_ACK = 0x21 19 | P2P_REQ_DSK = 0x26 20 | LAN_SEARCH = 0x30 21 | LAN_NOTIFY = 0x31 22 | LAN_NOTIFY_ACK = 0x32 23 | PUNCH_TO = 0x40 24 | PUNCH_PKT = 0x41 25 | PUNCH_PKT_EX = 0x41 26 | P2P_RDY = 0x42 27 | P2P_RDY_EX = 0x42 28 | P2P_RDY_ACK = 0x43 29 | RS_LGN = 0x60 30 | RS_LGN_ACK = 0x61 31 | RS_LGN1 = 0x62 32 | RS_LGN1_ACK = 0x63 33 | LIST_REQ1 = 0x67 34 | LIST_REQ = 0x68 35 | LIST_REQ_ACK = 0x69 36 | LIST_REQ_DSK = 0x6A 37 | RLY_HELLO = 0x70 38 | RLY_HELLO_ACK = 0x71 39 | RLY_PORT = 0x72 40 | RLY_PORT_ACK = 0x73 41 | RLY_PORT_KEY = 0x74 42 | RLY_PORT_ACK_KEY = 0x75 43 | RLY_BYTE_COUNT = 0x78 44 | RLY_REQ = 0x80 45 | RLY_REQ_ACK = 0x81 46 | RLY_TO = 0x82 47 | RLY_PKT = 0x83 48 | RLY_RDY = 0x84 49 | RLY_TO_ACK = 0x85 50 | RLY_SERVER_REQ = 0x87 51 | RLY_SERVER_REQ_ACK = 0x87 52 | SDEV_RUN = 0x90 53 | SDEV_LGN = 0x91 54 | SDEV_LGN_ACK = 0x91 55 | SDEV_LGN_CRC = 0x92 56 | SDEV_LGN_ACK_CRC = 0x92 57 | SDEV_REPORT = 0x94 58 | CONNECT_REPORT = 0xA0 59 | REPORT_REQ = 0xA1 60 | REPORT = 0xA2 61 | DRW = 0xD0 62 | DRW_ACK = 0xD1 63 | PSR = 0xD8 64 | ALIVE = 0xE0 65 | ALIVE_ACK = 0xE1 66 | CLOSE = 0xF0 67 | MGM_DUMP_LOGIN_DID = 0xF4 68 | MGM_DUMP_LOGIN_DID_DETAIL = 0xF5 69 | MGM_DUMP_LOGIN_DID_1 = 0xF6 70 | MGM_LOG_CONTROL = 0xF7 71 | MGM_REMOTE_MANAGEMENT = 0xF8 72 | REPORT_SESSION_READY = 0xF9 73 | 74 | INVALID = 0xFF 75 | 76 | enum P2PCmdType 77 | P2P_JSON_CMD = 0x6a4 78 | P2P_SEND_FILE = 0x3a98 79 | 80 | enum P2PSubCmdType 81 | START_LIVE = 0x03e8 82 | CLOSE_LIVE = 0x03e9 83 | VIDEO_RECORD_SWITCH = 0x03ea 84 | LIGHT_STATE_SWITCH = 0x03ab 85 | LIGHT_STATE_GET = 0x03ec 86 | LIVE_MODE_SET = 0x03ed 87 | LIVE_MODE_GET = 0x03ee 88 | 89 | enum FileTransfer 90 | # Begin file transfer (sent with metadata) 91 | BEGIN = 0x00 92 | 93 | # File content 94 | DATA = 0x01 95 | 96 | # Complete file transfer (start printing) 97 | END = 0x02 98 | 99 | # Abort file transfer (delete file) 100 | ABORT = 0x03 101 | 102 | # Reply from printer 103 | REPLY = 0x80 104 | 105 | enum FileTransferReply 106 | # Success 107 | OK = 0x00 108 | 109 | # Timeout during transfer 110 | ERR_TIMEOUT = 0xfc 111 | 112 | # Frame type error 113 | ERR_FRAME_TYPE = 0xfd 114 | 115 | # Checksum did not match 116 | ERR_WRONG_MD5 = 0xfe 117 | 118 | # Printer was not ready to receive 119 | ERR_BUSY = 0xff 120 | 121 | struct Host 122 | pad0: zeroes<1> 123 | 124 | # Adress family. Set to AF_INET (2) 125 | afam: u8le 126 | 127 | # Port number 128 | port: u16le 129 | 130 | # IP address 131 | addr: IPv4 132 | 133 | pad1: zeroes<8> 134 | 135 | struct Duid 136 | # duid "prefix", 7 chars + NULL terminator 137 | prefix: string<8> 138 | ## prefix: array 139 | 140 | # device serial number 141 | serial: u32 142 | 143 | # checkcode relating to prefix+serial 144 | check: string<6> 145 | ## check: array 146 | 147 | # padding 148 | pad0: zeroes<2> 149 | 150 | struct Xzyh 151 | magic: magic<4, 0x585a5948> 152 | 153 | # Command field (P2PCmdType) 154 | cmd: u16le 155 | 156 | # Payload length 157 | len: u32le 158 | unk0: u8 159 | unk1: u8 160 | chan: u8 161 | sign_code: u8 162 | unk3: u8 163 | dev_type: u8 164 | data: bytes> 165 | 166 | struct Aabb 167 | # Signature bytes. Must be 0xAABB 168 | signature: magic<2, 0xAABB> 169 | 170 | # Frame type (file transfer control) 171 | frametype: FileTransfer 172 | 173 | # Session id 174 | sn: u8 175 | 176 | # File offset to write to 177 | pos: u32le 178 | 179 | # Length field 180 | len: u32le 181 | 182 | struct Dsk 183 | @size = 24 184 | key: bytes<20> 185 | pad: zeroes<4> 186 | 187 | struct Version 188 | @size = 3 189 | major: u8 190 | minor: u8 191 | patch: u8 192 | 193 | # Base message class 194 | struct Message 195 | # Signature byte. Must be 0xF1 196 | magic: u8 197 | 198 | # Packet type 199 | type: Type 200 | 201 | # Packet length 202 | len: u16 203 | 204 | packet PktDrw 205 | # Signature byte. Must be 0xD1 206 | signature: magic<1, 0xD1> 207 | 208 | # Channel index 209 | chan: u8 210 | 211 | # Packet index 212 | index: u16 213 | 214 | # Payload 215 | data: tail 216 | 217 | packet PktDrwAck 218 | # Signature byte. Must be 0xD1 219 | signature: magic<1, 0xD1> 220 | 221 | # Channel index 222 | chan: u8 223 | 224 | # Number of acks following 225 | count: u16 226 | 227 | # Array of acknowledged DRW packet 228 | acks: array> 229 | 230 | packet PktPunchTo 231 | host: Host 232 | 233 | packet PktHello 234 | @size = 0 235 | 236 | packet PktLanSearch 237 | @size = 0 238 | 239 | packet PktRlyHello 240 | @size = 0 241 | 242 | packet PktRlyHelloAck 243 | @size = 0 244 | 245 | packet PktRlyPort 246 | @size = 0 247 | 248 | packet PktRlyPortAck 249 | @size = 0 250 | mark: u32 251 | port: u16 252 | pad: zeroes<2> 253 | 254 | packet PktRlyReq 255 | @size = 0 256 | duid: Duid 257 | host: Host 258 | mark: u32 259 | 260 | packet PktRlyReqAck 261 | @size = 0 262 | mark: u32 263 | 264 | packet PktAlive 265 | @size = 0 266 | 267 | packet PktAliveAck 268 | @size = 0 269 | 270 | packet PktClose 271 | @size = 0 272 | 273 | packet PktHelloAck 274 | host: Host 275 | 276 | packet PktPunchPkt 277 | duid: Duid 278 | 279 | packet PktP2pRdy 280 | duid: Duid 281 | 282 | packet PktP2pReq 283 | duid: Duid 284 | host: Host 285 | 286 | packet PktP2pReqAck 287 | mark: u32 288 | 289 | packet PktP2pReqDsk 290 | duid: Duid 291 | host: Host 292 | nat_type: u8 293 | version: Version 294 | dsk: Dsk 295 | 296 | packet PktP2pRdyAck 297 | duid: Duid 298 | host: Host 299 | pad: zeroes<8> 300 | 301 | packet PktListReqDsk 302 | # Device id 303 | duid: Duid 304 | 305 | # Device secret key 306 | dsk: Dsk 307 | 308 | packet PktListReqAck 309 | # Number of relays 310 | numr: u8 311 | 312 | # Padding 313 | pad: zeroes<3> 314 | 315 | # Available relay hosts 316 | relays: array> 317 | 318 | packet PktDevLgnCrc 319 | @crypto_type = 2 320 | 321 | duid: Duid 322 | nat_type: u8 323 | version: Version 324 | host: Host 325 | 326 | packet PktRlyTo 327 | host: Host 328 | mark: u32 329 | 330 | packet PktRlyPkt 331 | mark: u32 332 | duid: Duid 333 | unk: u32 334 | 335 | packet PktRlyRdy 336 | duid: Duid 337 | 338 | packet PktDevLgnAckCrc 339 | @crypto_type = 2 340 | 341 | pad0: zeroes<4> 342 | 343 | packet PktSessionReady 344 | @size = 84 345 | @crypto_type = 1 346 | 347 | duid: Duid 348 | 349 | handle: i32 350 | max_handles: u16 351 | active_handles: u16 352 | startup_ticks: u16 353 | b1: u8 354 | b2: u8 355 | b3: u8 356 | b4: u8 357 | pad0: zeroes<2> 358 | addr_local: Host 359 | addr_wan: Host 360 | addr_relay: Host 361 | 362 | parser MessageType 363 | @type: Type 364 | 365 | HELLO : PktHello 366 | HELLO_ACK : PktHelloAck 367 | ## HELLO_TO = 0x02 368 | ## HELLO_TO_ACK = 0x03 369 | ## QUERY_DID = 0x08 370 | ## QUERY_DID_ACK = 0x09 371 | ## DEV_LGN = 0x10 372 | ## DEV_LGN_ACK = 0x11 373 | DEV_LGN_CRC : PktDevLgnCrc 374 | DEV_LGN_ACK_CRC : PktDevLgnAckCrc 375 | ## DEV_LGN_KEY = 0x14 376 | ## DEV_LGN_ACK_KEY = 0x15 377 | ## DEV_LGN_DSK = 0x16 378 | ## DEV_ONLINE_REQ = 0x18 379 | ## DEV_ONLINE_REQ_ACK = 0x19 380 | P2P_REQ : PktP2pReq 381 | P2P_REQ_ACK : PktP2pReqAck 382 | P2P_REQ_DSK : PktP2pReqDsk 383 | LAN_SEARCH : PktLanSearch 384 | ## LAN_NOTIFY = 0x31 385 | ## LAN_NOTIFY_ACK = 0x32 386 | PUNCH_TO : PktPunchTo 387 | PUNCH_PKT : PktPunchPkt 388 | ## PUNCH_PKT_EX = 0x41 389 | P2P_RDY : PktP2pRdy 390 | ## P2P_RDY_EX = 0x42 391 | P2P_RDY_ACK : PktP2pRdyAck 392 | ## RS_LGN = 0x60 393 | ## RS_LGN_ACK = 0x61 394 | ## RS_LGN1 = 0x62 395 | ## RS_LGN1_ACK = 0x63 396 | ## LIST_REQ1 = 0x67 397 | ## LIST_REQ = 0x68 398 | LIST_REQ_ACK : PktListReqAck 399 | LIST_REQ_DSK : PktListReqDsk 400 | RLY_HELLO : PktRlyHello 401 | RLY_HELLO_ACK : PktRlyHelloAck 402 | RLY_PORT : PktRlyPort 403 | RLY_PORT_ACK : PktRlyPortAck 404 | ## RLY_PORT_KEY = 0x74 405 | ## RLY_PORT_ACK_KEY = 0x75 406 | ## RLY_BYTE_COUNT = 0x78 407 | RLY_REQ : PktRlyReq 408 | RLY_REQ_ACK : PktRlyReqAck 409 | RLY_TO : PktRlyTo 410 | RLY_PKT : PktRlyPkt 411 | RLY_RDY : PktRlyRdy 412 | ## RLY_TO_ACK = 0x85 413 | ## RLY_SERVER_REQ = 0x87 414 | ## RLY_SERVER_REQ_ACK = 0x87 415 | ## SDEV_RUN = 0x90 416 | ## SDEV_LGN = 0x91 417 | ## SDEV_LGN_ACK = 0x91 418 | ## SDEV_LGN_CRC = 0x92 419 | ## SDEV_LGN_ACK_CRC = 0x92 420 | ## SDEV_REPORT = 0x94 421 | ## CONNECT_REPORT = 0xA0 422 | ## REPORT_REQ = 0xA1 423 | ## REPORT = 0xA2 424 | DRW : PktDrw 425 | DRW_ACK : PktDrwAck 426 | ## PSR = 0xD8 427 | ALIVE : PktAlive 428 | ALIVE_ACK : PktAliveAck 429 | CLOSE : PktClose 430 | ## MGM_DUMP_LOGIN_DID = 0xF4 431 | ## MGM_DUMP_LOGIN_DID_DETAIL = 0xF5 432 | ## MGM_DUMP_LOGIN_DID_1 = 0xF6 433 | ## MGM_LOG_CONTROL = 0xF7 434 | ## MGM_REMOTE_MANAGEMENT = 0xF8 435 | REPORT_SESSION_READY : PktSessionReady 436 | -------------------------------------------------------------------------------- /static/vendor/cash.min.js: -------------------------------------------------------------------------------- 1 | (function(){"use strict";var C=document,M=window,st=C.documentElement,L=C.createElement.bind(C),ft=L("div"),q=L("table"),Mt=L("tbody"),ot=L("tr"),H=Array.isArray,S=Array.prototype,Bt=S.concat,U=S.filter,at=S.indexOf,ct=S.map,Dt=S.push,ht=S.slice,z=S.some,_t=S.splice,Pt=/^#(?:[\w-]|\\.|[^\x00-\xa0])*$/,Ht=/^\.(?:[\w-]|\\.|[^\x00-\xa0])*$/,It=/<.+>/,$t=/^\w+$/;function J(t,n){var r=jt(n);return!t||!r&&!D(n)&&!c(n)?[]:!r&&Ht.test(t)?n.getElementsByClassName(t.slice(1).replace(/\\/g,"")):!r&&$t.test(t)?n.getElementsByTagName(t):n.querySelectorAll(t)}var dt=function(){function t(n,r){if(n){if(Y(n))return n;var i=n;if(g(n)){var u=(Y(r)?r[0]:r)||C;if(i=Pt.test(n)&&"getElementById"in u?u.getElementById(n.slice(1).replace(/\\/g,"")):It.test(n)?yt(n):J(n,u),!i)return}else if(A(n))return this.ready(n);(i.nodeType||i===M)&&(i=[i]),this.length=i.length;for(var s=0,f=this.length;s]*>/,Gt=/^<(\w+)\s*\/?>(?:<\/\1>)?$/,mt={"*":ft,tr:Mt,td:ot,th:ot,thead:q,tbody:q,tfoot:q};function yt(t){if(!g(t))return[];if(Gt.test(t))return[L(RegExp.$1)];var n=Yt.test(t)&&RegExp.$1,r=mt[n]||mt["*"];return r.innerHTML=t,o(r.childNodes).detach().get()}o.parseHTML=yt,e.has=function(t){var n=g(t)?function(r,i){return J(t,i).length}:function(r,i){return i.contains(t)};return this.filter(n)},e.not=function(t){var n=j(t);return this.filter(function(r,i){return(!g(t)||c(i))&&!n.call(i,r,i)})};function x(t,n,r,i){for(var u=[],s=A(n),f=i&&j(i),a=0,y=t.length;a=0},!0):r.checked=u.indexOf(r.value)>=0}else r.value=v(t)||P(t)?"":t}):this[0]&&bt(this[0])}e.val=Xt,e.is=function(t){var n=j(t);return z.call(this,function(r,i){return n.call(r,i,r)})},o.guid=1;function w(t){return t.length>1?U.call(t,function(n,r,i){return at.call(i,n)===r}):t}o.unique=w,e.add=function(t,n){return o(w(this.get().concat(o(t,n).get())))},e.children=function(t){return R(o(w(x(this,function(n){return n.children}))),t)},e.parent=function(t){return R(o(w(x(this,"parentNode"))),t)},e.index=function(t){var n=t?o(t)[0]:this[0],r=t?this:o(n).parent().children();return at.call(r,n)},e.closest=function(t){var n=this.filter(t);if(n.length)return n;var r=this.parent();return r.length?r.closest(t):n},e.siblings=function(t){return R(o(w(x(this,function(n){return o(n).parent().children().not(n)}))),t)},e.find=function(t){return o(w(x(this,function(n){return J(t,n)})))};var Kt=/^\s*\s*$/g,Qt=/^$|^module$|\/(java|ecma)script/i,Vt=["type","src","nonce","noModule"];function Zt(t,n){var r=o(t);r.filter("script").add(r.find("script")).each(function(i,u){if(Qt.test(u.type)&&st.contains(u)){var s=L("script");s.text=u.textContent.replace(Kt,""),d(Vt,function(f,a){u[a]&&(s[a]=u[a])}),n.head.insertBefore(s,null),n.head.removeChild(s)}})}function kt(t,n,r,i,u){i?t.insertBefore(n,r?t.firstChild:null):t.nodeName==="HTML"?t.parentNode.replaceChild(n,t):t.parentNode.insertBefore(n,r?t:t.nextSibling),u&&Zt(n,t.ownerDocument)}function N(t,n,r,i,u,s,f,a){return d(t,function(y,h){d(o(h),function(p,O){d(o(n),function(b,W){var rt=r?O:W,it=r?W:O,m=r?p:b;kt(rt,m?it.cloneNode(!0):it,i,u,!m)},a)},f)},s),n}e.after=function(){return N(arguments,this,!1,!1,!1,!0,!0)},e.append=function(){return N(arguments,this,!1,!1,!0)};function tn(t){if(!arguments.length)return this[0]&&this[0].innerHTML;if(v(t))return this;var n=/]/.test(t);return this.each(function(r,i){c(i)&&(n?o(i).empty().append(t):i.innerHTML=t)})}e.html=tn,e.appendTo=function(t){return N(arguments,this,!0,!1,!0)},e.wrapInner=function(t){return this.each(function(n,r){var i=o(r),u=i.contents();u.length?u.wrapAll(t):i.append(t)})},e.before=function(){return N(arguments,this,!1,!0)},e.wrapAll=function(t){for(var n=o(t),r=n[0];r.children.length;)r=r.firstElementChild;return this.first().before(n),this.appendTo(r)},e.wrap=function(t){return this.each(function(n,r){var i=o(t)[0];o(r).wrapAll(n?i.cloneNode(!0):i)})},e.insertAfter=function(t){return N(arguments,this,!0,!1,!1,!1,!1,!0)},e.insertBefore=function(t){return N(arguments,this,!0,!0)},e.prepend=function(){return N(arguments,this,!1,!0,!0,!0,!0)},e.prependTo=function(t){return N(arguments,this,!0,!0,!0,!1,!1,!0)},e.contents=function(){return o(w(x(this,function(t){return t.tagName==="IFRAME"?[t.contentDocument]:t.tagName==="TEMPLATE"?t.content.childNodes:t.childNodes})))},e.next=function(t,n,r){return R(o(w(x(this,"nextElementSibling",n,r))),t)},e.nextAll=function(t){return this.next(t,!0)},e.nextUntil=function(t,n){return this.next(n,!0,t)},e.parents=function(t,n){return R(o(w(x(this,"parentElement",!0,n))),t)},e.parentsUntil=function(t,n){return this.parents(n,t)},e.prev=function(t,n,r){return R(o(w(x(this,"previousElementSibling",n,r))),t)},e.prevAll=function(t){return this.prev(t,!0)},e.prevUntil=function(t,n){return this.prev(n,!0,t)},e.map=function(t){return o(Bt.apply([],ct.call(this,function(n,r){return t.call(n,r,n)})))},e.clone=function(){return this.map(function(t,n){return n.cloneNode(!0)})},e.offsetParent=function(){return this.map(function(t,n){for(var r=n.offsetParent;r&&T(r,"position")==="static";)r=r.offsetParent;return r||st})},e.slice=function(t,n){return o(ht.call(this,t,n))};var nn=/-([a-z])/g;function K(t){return t.replace(nn,function(n,r){return r.toUpperCase()})}e.ready=function(t){var n=function(){return setTimeout(t,0,o)};return C.readyState!=="loading"?n():C.addEventListener("DOMContentLoaded",n),this},e.unwrap=function(){return this.parent().each(function(t,n){if(n.tagName!=="BODY"){var r=o(n);r.replaceWith(r.children())}}),this},e.offset=function(){var t=this[0];if(t){var n=t.getBoundingClientRect();return{top:n.top+M.pageYOffset,left:n.left+M.pageXOffset}}},e.position=function(){var t=this[0];if(t){var n=T(t,"position")==="fixed",r=n?t.getBoundingClientRect():this.offset();if(!n){for(var i=t.ownerDocument,u=t.offsetParent||i.documentElement;(u===i.body||u===i.documentElement)&&T(u,"position")==="static";)u=u.parentNode;if(u!==t&&c(u)){var s=o(u).offset();r.top-=s.top+E(u,"borderTopWidth"),r.left-=s.left+E(u,"borderLeftWidth")}}return{top:r.top-E(t,"marginTop"),left:r.left-E(t,"marginLeft")}}};var Et={class:"className",contenteditable:"contentEditable",for:"htmlFor",readonly:"readOnly",maxlength:"maxLength",tabindex:"tabIndex",colspan:"colSpan",rowspan:"rowSpan",usemap:"useMap"};e.prop=function(t,n){if(t){if(g(t))return t=Et[t]||t,arguments.length<2?this[0]&&this[0][t]:this.each(function(i,u){u[t]=n});for(var r in t)this.prop(r,t[r]);return this}},e.removeProp=function(t){return this.each(function(n,r){delete r[Et[t]||t]})};var rn=/^--/;function Q(t){return rn.test(t)}var V={},en=ft.style,un=["webkit","moz","ms"];function sn(t,n){if(n===void 0&&(n=Q(t)),n)return t;if(!V[t]){var r=K(t),i="".concat(r[0].toUpperCase()).concat(r.slice(1)),u="".concat(r," ").concat(un.join("".concat(i," "))).concat(i).split(" ");d(u,function(s,f){if(f in en)return V[t]=f,!1})}return V[t]}var fn={animationIterationCount:!0,columnCount:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0};function wt(t,n,r){return r===void 0&&(r=Q(t)),!r&&!fn[t]&<(n)?"".concat(n,"px"):n}function on(t,n){if(g(t)){var r=Q(t);return t=sn(t,r),arguments.length<2?this[0]&&T(this[0],t,r):t?(n=wt(t,n,r),this.each(function(u,s){c(s)&&(r?s.style.setProperty(t,n):s.style[t]=n)})):this}for(var i in t)this.css(i,t[i]);return this}e.css=on;function Ct(t,n){try{return t(n)}catch{return n}}var an=/^\s+|\s+$/;function St(t,n){var r=t.dataset[n]||t.dataset[K(n)];return an.test(r)?r:Ct(JSON.parse,r)}function cn(t,n,r){r=Ct(JSON.stringify,r),t.dataset[K(n)]=r}function hn(t,n){if(!t){if(!this[0])return;var r={};for(var i in this[0].dataset)r[i]=St(this[0],i);return r}if(g(t))return arguments.length<2?this[0]&&St(this[0],t):v(n)?this:this.each(function(u,s){cn(s,t,n)});for(var i in t)this.data(i,t[i]);return this}e.data=hn;function Tt(t,n){var r=t.documentElement;return Math.max(t.body["scroll".concat(n)],r["scroll".concat(n)],t.body["offset".concat(n)],r["offset".concat(n)],r["client".concat(n)])}d([!0,!1],function(t,n){d(["Width","Height"],function(r,i){var u="".concat(n?"outer":"inner").concat(i);e[u]=function(s){if(this[0])return B(this[0])?n?this[0]["inner".concat(i)]:this[0].document.documentElement["client".concat(i)]:D(this[0])?Tt(this[0],i):this[0]["".concat(n?"offset":"client").concat(i)]+(s&&n?E(this[0],"margin".concat(r?"Top":"Left"))+E(this[0],"margin".concat(r?"Bottom":"Right")):0)}})}),d(["Width","Height"],function(t,n){var r=n.toLowerCase();e[r]=function(i){if(!this[0])return v(i)?void 0:this;if(!arguments.length)return B(this[0])?this[0].document.documentElement["client".concat(n)]:D(this[0])?Tt(this[0],n):this[0].getBoundingClientRect()[r]-gt(this[0],!t);var u=parseInt(i,10);return this.each(function(s,f){if(c(f)){var a=T(f,"boxSizing");f.style[r]=wt(r,u+(a==="border-box"?gt(f,!t):0))}})}});var xt="___cd";e.toggle=function(t){return this.each(function(n,r){if(c(r)){var i=v(t)?vt(r):t;i?(r.style.display=r[xt]||"",vt(r)&&(r.style.display=Jt(r.tagName))):(r[xt]=T(r,"display"),r.style.display="none")}})},e.hide=function(){return this.toggle(!1)},e.show=function(){return this.toggle(!0)};var Rt="___ce",Z=".",k={focus:"focusin",blur:"focusout"},Nt={mouseenter:"mouseover",mouseleave:"mouseout"},dn=/^(mouse|pointer|contextmenu|drag|drop|click|dblclick)/i;function tt(t){return Nt[t]||k[t]||t}function nt(t){var n=t.split(Z);return[n[0],n.slice(1).sort()]}e.trigger=function(t,n){if(g(t)){var r=nt(t),i=r[0],u=r[1],s=tt(i);if(!s)return this;var f=dn.test(s)?"MouseEvents":"HTMLEvents";t=C.createEvent(f),t.initEvent(s,!0,!0),t.namespace=u.join(Z),t.___ot=i}t.___td=n;var a=t.___ot in k;return this.each(function(y,h){a&&A(h[t.___ot])&&(h["___i".concat(t.type)]=!0,h[t.___ot](),h["___i".concat(t.type)]=!1),h.dispatchEvent(t)})};function Lt(t){return t[Rt]=t[Rt]||{}}function ln(t,n,r,i,u){var s=Lt(t);s[n]=s[n]||[],s[n].push([r,i,u]),t.addEventListener(n,u)}function At(t,n){return!n||!z.call(n,function(r){return t.indexOf(r)<0})}function F(t,n,r,i,u){var s=Lt(t);if(n)s[n]&&(s[n]=s[n].filter(function(f){var a=f[0],y=f[1],h=f[2];if(u&&h.guid!==u.guid||!At(a,r)||i&&i!==y)return!0;t.removeEventListener(n,h)}));else for(n in s)F(t,n,r,i,u)}e.off=function(t,n,r){var i=this;if(v(t))this.each(function(s,f){!c(f)&&!D(f)&&!B(f)||F(f)});else if(g(t))A(n)&&(r=n,n=""),d($(t),function(s,f){var a=nt(f),y=a[0],h=a[1],p=tt(y);i.each(function(O,b){!c(b)&&!D(b)&&!B(b)||F(b,p,h,n,r)})});else for(var u in t)this.off(u,t[u]);return this},e.remove=function(t){return R(this,t).detach().off(),this},e.replaceWith=function(t){return this.before(t).remove()},e.replaceAll=function(t){return o(t).replaceWith(this),this};function gn(t,n,r,i,u){var s=this;if(!g(t)){for(var f in t)this.on(f,n,r,t[f],u);return this}return g(n)||(v(n)||P(n)?n="":v(r)?(r=n,n=""):(i=r,r=n,n="")),A(i)||(i=r,r=void 0),i?(d($(t),function(a,y){var h=nt(y),p=h[0],O=h[1],b=tt(p),W=p in Nt,rt=p in k;b&&s.each(function(it,m){if(!(!c(m)&&!D(m)&&!B(m))){var et=function(l){if(l.target["___i".concat(l.type)])return l.stopImmediatePropagation();if(!(l.namespace&&!At(O,l.namespace.split(Z)))&&!(!n&&(rt&&(l.target!==m||l.___ot===b)||W&&l.relatedTarget&&m.contains(l.relatedTarget)))){var ut=m;if(n){for(var _=l.target;!pt(_,n);)if(_===m||(_=_.parentNode,!_))return;ut=_}Object.defineProperty(l,"currentTarget",{configurable:!0,get:function(){return ut}}),Object.defineProperty(l,"delegateTarget",{configurable:!0,get:function(){return m}}),Object.defineProperty(l,"data",{configurable:!0,get:function(){return r}});var bn=i.call(ut,l,l.___td);u&&F(m,b,O,n,et),bn===!1&&(l.preventDefault(),l.stopPropagation())}};et.guid=i.guid=i.guid||o.guid++,ln(m,b,O,n,et)}})}),this):this}e.on=gn;function vn(t,n,r,i){return this.on(t,n,r,i,!0)}e.one=vn;var pn=/\r?\n/g;function mn(t,n){return"&".concat(encodeURIComponent(t),"=").concat(encodeURIComponent(n.replace(pn,`\r 2 | `)))}var yn=/file|reset|submit|button|image/i,Ot=/radio|checkbox/i;e.serialize=function(){var t="";return this.each(function(n,r){d(r.elements||[r],function(i,u){if(!(u.disabled||!u.name||u.tagName==="FIELDSET"||yn.test(u.type)||Ot.test(u.type)&&!u.checked)){var s=bt(u);if(!v(s)){var f=H(s)?s:[s];d(f,function(a,y){t+=mn(u.name,y)})}}})}),t.slice(1)},typeof exports<"u"?module.exports=o:M.cash=M.$=o})(); 3 | -------------------------------------------------------------------------------- /ankerctl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import click 5 | import logging 6 | import platform 7 | from os import path 8 | from rich import print 9 | from tqdm import tqdm 10 | from flask import Flask, request, render_template 11 | 12 | import cli.config 13 | import cli.model 14 | import cli.logfmt 15 | import cli.mqtt 16 | import cli.util 17 | import cli.pppp 18 | 19 | import libflagship.httpapi 20 | import libflagship.logincache 21 | import libflagship.seccode 22 | 23 | from libflagship.util import enhex 24 | from libflagship.mqtt import MqttMsgType 25 | from libflagship.pppp import PktLanSearch, FileTransferReply, P2PCmdType, P2PSubCmdType, FileTransfer 26 | from libflagship.ppppapi import AnkerPPPPApi, FileUploadInfo, PPPPError 27 | 28 | 29 | class Environment: 30 | def __init__(self): 31 | pass 32 | 33 | def require_config(self): 34 | with self.config.open() as config: 35 | if not getattr(config, 'printers', False): 36 | log.critical("No printers found in config. Please import configuration using 'config import'") 37 | 38 | def upgrade_config_if_needed(self): 39 | try: 40 | with self.config.open(): 41 | pass 42 | except (KeyError, TypeError): 43 | log.warning("Outdated found. Attempting to refresh...") 44 | try: 45 | cli.config.attempt_config_upgrade(self.config, "default", self.insecure) 46 | except Exception as E: 47 | log.critical(f"Failed to refresh config. Please import configuration using 'config import' ({E})") 48 | 49 | 50 | pass_env = click.make_pass_decorator(Environment) 51 | 52 | 53 | @click.group(context_settings=dict(help_option_names=["-h", "--help"])) 54 | @click.option("--insecure", "-k", is_flag=True, help="Disable TLS certificate validation") 55 | @click.option("--verbose", "-v", count=True, help="Increase verbosity") 56 | @click.option("--quiet", "-q", count=True, help="Decrease verbosity") 57 | @click.pass_context 58 | def main(ctx, verbose, quiet, insecure): 59 | ctx.ensure_object(Environment) 60 | env = ctx.obj 61 | 62 | levels = { 63 | -3: logging.CRITICAL, 64 | -2: logging.ERROR, 65 | -1: logging.WARNING, 66 | 0: logging.INFO, 67 | 1: logging.DEBUG, 68 | } 69 | env.config = cli.config.configmgr() 70 | env.insecure = insecure 71 | env.level = max(-3, min(verbose - quiet, 1)) 72 | env.log = cli.logfmt.setup_logging(levels[env.level]) 73 | 74 | global log 75 | log = env.log 76 | 77 | if insecure: 78 | import urllib3 79 | urllib3.disable_warnings() 80 | 81 | env.upgrade_config_if_needed() 82 | 83 | 84 | @main.group("mqtt", help="Low-level mqtt api access") 85 | @pass_env 86 | def mqtt(env): 87 | env.require_config() 88 | 89 | 90 | @mqtt.command("monitor") 91 | @pass_env 92 | def mqtt_monitor(env): 93 | """ 94 | Connect to mqtt broker, and show low-level events in realtime. 95 | """ 96 | 97 | client = cli.mqtt.mqtt_open(env) 98 | 99 | for msg, body in client.fetchloop(): 100 | log.info(f"TOPIC [{msg.topic}]") 101 | log.debug(enhex(msg.payload[:])) 102 | 103 | for obj in body: 104 | try: 105 | cmdtype = obj["commandType"] 106 | name = MqttMsgType(cmdtype).name 107 | if name.startswith("ZZ_MQTT_CMD_"): 108 | name = name[len("ZZ_MQTT_CMD_"):].lower() 109 | 110 | del obj["commandType"] 111 | print(f" [{cmdtype:4}] {name:20} {obj}") 112 | except: 113 | print(f" {obj}") 114 | 115 | 116 | @mqtt.command("send") 117 | @click.argument("command-type", type=cli.util.EnumType(MqttMsgType), required=True, metavar="") 118 | @click.argument("args", type=cli.util.json_key_value, nargs=-1, metavar="[key=value] ...") 119 | @click.option("--force", "-f", default=False, is_flag=True, help="Allow dangerous commands") 120 | @pass_env 121 | def mqtt_send(env, command_type, args, force): 122 | """ 123 | Send raw command to printer via mqtt. 124 | 125 | BEWARE: This is intended for developers and experts only. Sending a 126 | malformed command can crash your printer, or have other unintended side 127 | effects. 128 | 129 | To see a list of known command types, run this command without arguments. 130 | """ 131 | 132 | cmd = { 133 | "commandType": command_type, 134 | **{key: value for (key, value) in args}, 135 | } 136 | 137 | if not force: 138 | if command_type == MqttMsgType.ZZ_MQTT_CMD_APP_RECOVER_FACTORY.value: 139 | log.fatal("Refusing to perform factory reset (override with --force)") 140 | return 141 | 142 | if command_type == MqttMsgType.ZZ_MQTT_CMD_DEVICE_NAME_SET and "devName" not in cmd: 143 | log.fatal("Sending DEVICE_NAME_SET without devName= will crash printer (override with --force)") 144 | return 145 | 146 | client = cli.mqtt.mqtt_open(env) 147 | cli.mqtt.mqtt_command(client, cmd) 148 | 149 | 150 | @mqtt.command("rename-printer") 151 | @click.argument("newname", type=str, required=True, metavar="") 152 | @pass_env 153 | def mqtt_rename_printer(env, newname): 154 | """ 155 | Set a new nickname for your printer 156 | """ 157 | 158 | client = cli.mqtt.mqtt_open(env) 159 | 160 | cmd = { 161 | "commandType": MqttMsgType.ZZ_MQTT_CMD_DEVICE_NAME_SET, 162 | "devName": newname 163 | } 164 | 165 | cli.mqtt.mqtt_command(client, cmd) 166 | 167 | 168 | @mqtt.command("gcode") 169 | @pass_env 170 | def mqtt_gcode(env): 171 | """ 172 | Interactive gcode command line. Send gcode command to the printer, and print the 173 | response. 174 | 175 | Press Ctrl-C to exit. (or Ctrl-D to close connection, except on Windows) 176 | """ 177 | client = cli.mqtt.mqtt_open(env) 178 | 179 | while True: 180 | gcode = click.prompt("gcode", prompt_suffix="> ") 181 | 182 | if not gcode: 183 | break 184 | 185 | cmd = { 186 | "commandType": MqttMsgType.ZZ_MQTT_CMD_GCODE_COMMAND.value, 187 | "cmdData": gcode, 188 | "cmdLen": len(gcode), 189 | } 190 | 191 | client.command(cmd) 192 | msg = client.await_response(MqttMsgType.ZZ_MQTT_CMD_GCODE_COMMAND) 193 | if msg: 194 | click.echo(msg["resData"]) 195 | else: 196 | log.error("No response from printer") 197 | 198 | 199 | @main.group("pppp", help="Low-level pppp api access") 200 | def pppp(): pass 201 | 202 | 203 | @pppp.command("lan-search") 204 | @pass_env 205 | def pppp_lan_search(env): 206 | """ 207 | Attempt to find available printers on local LAN. 208 | 209 | Works by broadcasting a LAN_SEARCH packet, and waiting for a reply. 210 | """ 211 | api = AnkerPPPPApi.open_broadcast() 212 | try: 213 | api.send(PktLanSearch()) 214 | resp = api.recv(timeout=1.0) 215 | except TimeoutError: 216 | log.error("No printers responded within timeout. Are you connected to the same network as the printer?") 217 | else: 218 | if isinstance(resp, libflagship.pppp.PktPunchPkt): 219 | log.info(f"Printer [{str(resp.duid)}] is online") 220 | 221 | 222 | @pppp.command("print-file") 223 | @click.argument("file", required=True, type=click.File("rb"), metavar="") 224 | @click.option("--no-act", "-n", is_flag=True, help="Test upload only (do not print)") 225 | @pass_env 226 | def pppp_print_file(env, file, no_act): 227 | """ 228 | Transfer print job to printer, and start printing. 229 | 230 | The --no-act flag performs the upload, but will not make the printer start 231 | executing the print job. NOTE: the printer only ever stores ONE uploaded 232 | file, so anytime a file is uploaded, the old one is deleted. 233 | """ 234 | env.require_config() 235 | api = cli.pppp.pppp_open(env) 236 | 237 | data = file.read() 238 | fui = FileUploadInfo.from_file(file.name, user_name="ankerctl", user_id="-", machine_id="-") 239 | log.info(f"Going to upload {fui.size} bytes as {fui.name!r}") 240 | try: 241 | cli.pppp.pppp_send_file(api, fui, data) 242 | if no_act: 243 | log.info("File upload complete") 244 | else: 245 | log.info("File upload complete. Requesting print start of job.") 246 | api.aabb_request(b"", frametype=FileTransfer.END) 247 | except PPPPError as E: 248 | log.error(f"Could not send print job: {E}") 249 | else: 250 | if not no_act: 251 | log.info("Successfully sent print job") 252 | finally: 253 | api.stop() 254 | 255 | 256 | @pppp.command("capture-video") 257 | @click.argument("file", required=True, type=click.File("wb"), metavar="") 258 | @click.option("--max-size", "-m", required=True, type=cli.util.FileSizeType(), help="Stop capture at this size (kb, mb, gb, etc)") 259 | @pass_env 260 | def pppp_capture_video(env, file, max_size): 261 | """ 262 | Capture video stream from printer camera. 263 | 264 | The output is in h264 ES (Elementary Stream) format. It can be played with 265 | "ffplay" from the ffmpeg program suite. 266 | """ 267 | env.require_config() 268 | api = cli.pppp.pppp_open(env) 269 | 270 | cmd = {"commandType": P2PSubCmdType.START_LIVE, "data": {"encryptkey": "x", "accountId": "y"}} 271 | api.send_xzyh(json.dumps(cmd).encode(), cmd=P2PCmdType.P2P_JSON_CMD) 272 | try: 273 | with tqdm(unit="b", total=max_size, unit_scale=True, unit_divisor=1024) as bar: 274 | size = 0 275 | while True: 276 | d = api.recv_xzyh(chan=1) 277 | size += len(d.data) 278 | file.write(d.data) 279 | bar.set_postfix(size=cli.util.pretty_size(size), refresh=False) 280 | bar.update(len(d.data)) 281 | if size >= max_size: 282 | break 283 | finally: 284 | cmd = {"commandType": P2PSubCmdType.CLOSE_LIVE} 285 | api.send_xzyh(json.dumps(cmd).encode(), cmd=P2PCmdType.P2P_JSON_CMD) 286 | 287 | log.info(f"Successfully captured {cli.util.pretty_size(size)} video stream into {file.name}") 288 | 289 | 290 | @main.group("http", help="Low-level http api access") 291 | def http(): pass 292 | 293 | 294 | @http.command("calc-check-code") 295 | @click.argument("duid", required=True) 296 | @click.argument("mac", required=True) 297 | def http_calc_check_code(duid, mac): 298 | """ 299 | Calculate printer 'check code' for http api version 1 300 | 301 | duid: Printer serial number (looks like EUPRAKM-012345-ABCDEF) 302 | 303 | mac: Printer mac address (looks like 11:22:33:44:55:66) 304 | """ 305 | 306 | check_code = libflagship.seccode.calc_check_code(duid, mac.replace(":", "")) 307 | print(f"check_code: {check_code}") 308 | 309 | 310 | @http.command("calc-sec-code") 311 | @click.argument("duid", required=True) 312 | @click.argument("mac", required=True) 313 | def http_calc_sec_code(duid, mac): 314 | """ 315 | Calculate printer 'security code' for http api version 2 316 | 317 | duid: Printer serial number (looks like EUPRAKM-012345-ABCDEF) 318 | 319 | mac: Printer mac address (looks like 11:22:33:44:55:66) 320 | """ 321 | 322 | sec_ts, sec_code = libflagship.seccode.create_check_code_v1(duid.encode(), mac.replace(":", "").encode()) 323 | print(f"sec_ts: {sec_ts}") 324 | print(f"sec_code: {sec_code}") 325 | 326 | 327 | @main.group("config", help="View and update configuration") 328 | def config(): pass 329 | 330 | 331 | @config.command("decode") 332 | @click.argument("fd", required=False, type=click.File("r"), metavar="path/to/login.json") 333 | @pass_env 334 | def config_decode(env, fd): 335 | """ 336 | Decode a `login.json` file and print its contents. 337 | """ 338 | 339 | if fd is None: 340 | useros = platform.system() 341 | 342 | darfileloc = path.expanduser('~/Library/Application Support/AnkerMake/AnkerMake_64bit_fp/login.json') 343 | winfileloc1 = path.expandvars(r'%LOCALAPPDATA%\Ankermake\AnkerMake_64bit_fp\login.json') 344 | winfileloc2 = path.expandvars(r'%LOCALAPPDATA%\Ankermake\login.json') 345 | 346 | try: 347 | if useros == 'Darwin': 348 | fd = open(darfileloc, 'r') 349 | elif useros == 'Windows': 350 | if path.isfile(winfileloc1): 351 | fd = open(winfileloc1, 'r') 352 | else: 353 | fd = open(winfileloc2, 'r') 354 | else: 355 | log.critical("This platform does not support autodetection. Please specify file location") 356 | except FileNotFoundError: 357 | log.critical("Failed to import file - check if you are logged into Ankerslicer") 358 | 359 | log.info("Loading file..") 360 | 361 | cache = libflagship.logincache.load(fd.read())["data"] 362 | print(json.dumps(cache, indent=4)) 363 | 364 | 365 | @config.command("import") 366 | @click.argument("fd", required=False, type=click.File("r"), metavar="path/to/login.json") 367 | @pass_env 368 | def config_import(env, fd): 369 | """ 370 | Import printer and account information from login.json 371 | 372 | When run without filename, attempt to auto-detect login.json in default 373 | install location 374 | """ 375 | 376 | if fd is None: 377 | useros = platform.system() 378 | 379 | darfileloc = path.expanduser('~/Library/Application Support/AnkerMake/AnkerMake_64bit_fp/login.json') 380 | winfileloc1 = path.expandvars(r'%LOCALAPPDATA%\Ankermake\AnkerMake_64bit_fp\login.json') 381 | winfileloc2 = path.expandvars(r'%LOCALAPPDATA%\Ankermake\login.json') 382 | 383 | try: 384 | if useros == 'Darwin': 385 | fd = open(darfileloc, 'r') 386 | elif useros == 'Windows': 387 | if path.isfile(winfileloc1): 388 | fd = open(winfileloc1, 'r') 389 | else: 390 | fd = open(winfileloc2, 'r') 391 | else: 392 | log.critical("This platform does not support autodetection. Please specify file location") 393 | except FileNotFoundError: 394 | log.critical("Failed to import file - check if you are logged into Ankerslicer") 395 | 396 | log.info("Loading cache..") 397 | 398 | # extract auth token 399 | cache = libflagship.logincache.load(fd.read())["data"] 400 | auth_token = cache["auth_token"] 401 | 402 | # extract account region 403 | region = libflagship.logincache.guess_region(cache["ab_code"]) 404 | 405 | try: 406 | config = cli.config.load_config_from_api(auth_token, region, env.insecure) 407 | except libflagship.httpapi.APIError as E: 408 | log.critical(f"Config import failed: {E} (auth token might be expired: make sure Ankermake Slicer can connect, then try again") 409 | except Exception as E: 410 | log.critical(f"Config import failed: {E}") 411 | 412 | # save config to json file named `ankerctl/default.json` 413 | env.config.save("default", config) 414 | 415 | log.info("Finished import") 416 | 417 | 418 | @config.command("show") 419 | @pass_env 420 | def config_show(env): 421 | """Show current config""" 422 | 423 | log.info(f"Loading config from {env.config.config_path('default')}") 424 | print() 425 | 426 | # read config from json file named `ankerctl/default.json` 427 | with env.config.open() as cfg: 428 | if not cfg: 429 | log.error("No printers configured. Run 'config import' to populate.") 430 | return 431 | 432 | log.info("Account:") 433 | print(f" user_id: {cfg.account.user_id[:20]}...") 434 | print(f" auth_token: {cfg.account.auth_token[:20]}...") 435 | print(f" email: {cfg.account.email}") 436 | print(f" region: {cfg.account.region.upper()}") 437 | print() 438 | 439 | log.info("Printers:") 440 | for p in cfg.printers: 441 | print(f" duid: {p.p2p_duid}") # Printer Serial Number 442 | print(f" sn: {p.sn}") 443 | print(f" ip: {p.ip_addr}") 444 | print(f" wifi_mac: {cli.util.pretty_mac(p.wifi_mac)}") 445 | print(f" api_hosts: {', '.join(p.api_hosts)}") 446 | print(f" p2p_hosts: {', '.join(p.p2p_hosts)}") 447 | 448 | 449 | @main.group("webserver", help="Built-in webserver support") 450 | @pass_env 451 | def webserver(env): 452 | env.require_config() 453 | 454 | 455 | app = Flask(__name__, template_folder='./static') 456 | # app.config['TEMPLATES_AUTO_RELOAD'] = True 457 | 458 | @app.get("/") 459 | def app_root(): 460 | return render_template("index.html", configPort = app.config["port"], configHost = app.config["host"]) 461 | 462 | @app.get("/api/version") 463 | def app_api_version(): 464 | return { 465 | "api": "0.1", 466 | "server": "1.9.0", 467 | "text": "OctoPrint 1.9.0" 468 | } 469 | 470 | 471 | @app.post("/api/files/local") 472 | def app_api_files_local(): 473 | env = app.config["env"] 474 | 475 | user_name = request.headers.get("User-Agent", "ankerctl").split("/")[0] 476 | 477 | no_act = not cli.util.parse_http_bool(request.form["print"]) 478 | 479 | if no_act: 480 | cli.util.http_abort(409, "Upload-only not supported by Ankermake M5") 481 | 482 | fd = request.files["file"] 483 | 484 | api = cli.pppp.pppp_open(env) 485 | 486 | data = fd.read() 487 | fui = FileUploadInfo.from_data(data, fd.filename, user_name=user_name, user_id="-", machine_id="-") 488 | log.info(f"Going to upload {fui.size} bytes as {fui.name!r}") 489 | try: 490 | cli.pppp.pppp_send_file(api, fui, data) 491 | log.info("File upload complete. Requesting print start of job.") 492 | api.aabb_request(b"", frametype=FileTransfer.END) 493 | except PPPPError as E: 494 | log.error(f"Could not send print job: {E}") 495 | else: 496 | log.info("Successfully sent print job") 497 | finally: 498 | api.stop() 499 | 500 | return {} 501 | 502 | 503 | @webserver.command("run", help="Run ankerctl webserver") 504 | @click.option("--host", default='127.0.0.1', envvar="FLASK_HOST", help="Network interface to bind to") 505 | @click.option("--port", default=4470, envvar="FLASK_PORT", help="Port to bind to") 506 | @pass_env 507 | def webserver(env, host, port): 508 | env.require_config() 509 | app.config["env"] = env 510 | app.config["port"] = port 511 | app.config["host"] = host 512 | app.run(host=host,port=port) 513 | 514 | 515 | if __name__ == "__main__": 516 | main() 517 | -------------------------------------------------------------------------------- /libflagship/pppp.py: -------------------------------------------------------------------------------- 1 | ## ------------------------------------------ 2 | ## Generated by Transwarp 3 | ## 4 | ## THIS FILE IS AUTOMATICALLY GENERATED. 5 | ## DO NOT EDIT. ALL CHANGES WILL BE LOST. 6 | ## ------------------------------------------ 7 | 8 | import struct 9 | import enum 10 | from dataclasses import dataclass, field 11 | from .amtypes import * 12 | from .amtypes import _assert_equal 13 | from .megajank import crypto_curse_string, crypto_decurse_string, simple_encrypt_string, simple_decrypt_string 14 | from .util import ppcs_crc16 15 | 16 | class Type(enum.IntEnum): 17 | HELLO = 0x00 # unknown 18 | HELLO_ACK = 0x01 # unknown 19 | HELLO_TO = 0x02 # unknown 20 | HELLO_TO_ACK = 0x03 # unknown 21 | QUERY_DID = 0x08 # unknown 22 | QUERY_DID_ACK = 0x09 # unknown 23 | DEV_LGN = 0x10 # unknown 24 | DEV_LGN_ACK = 0x11 # unknown 25 | DEV_LGN_CRC = 0x12 # unknown 26 | DEV_LGN_ACK_CRC = 0x13 # unknown 27 | DEV_LGN_KEY = 0x14 # unknown 28 | DEV_LGN_ACK_KEY = 0x15 # unknown 29 | DEV_LGN_DSK = 0x16 # unknown 30 | DEV_ONLINE_REQ = 0x18 # unknown 31 | DEV_ONLINE_REQ_ACK = 0x19 # unknown 32 | P2P_REQ = 0x20 # unknown 33 | P2P_REQ_ACK = 0x21 # unknown 34 | P2P_REQ_DSK = 0x26 # unknown 35 | LAN_SEARCH = 0x30 # unknown 36 | LAN_NOTIFY = 0x31 # unknown 37 | LAN_NOTIFY_ACK = 0x32 # unknown 38 | PUNCH_TO = 0x40 # unknown 39 | PUNCH_PKT = 0x41 # unknown 40 | PUNCH_PKT_EX = 0x41 # unknown 41 | P2P_RDY = 0x42 # unknown 42 | P2P_RDY_EX = 0x42 # unknown 43 | P2P_RDY_ACK = 0x43 # unknown 44 | RS_LGN = 0x60 # unknown 45 | RS_LGN_ACK = 0x61 # unknown 46 | RS_LGN1 = 0x62 # unknown 47 | RS_LGN1_ACK = 0x63 # unknown 48 | LIST_REQ1 = 0x67 # unknown 49 | LIST_REQ = 0x68 # unknown 50 | LIST_REQ_ACK = 0x69 # unknown 51 | LIST_REQ_DSK = 0x6a # unknown 52 | RLY_HELLO = 0x70 # unknown 53 | RLY_HELLO_ACK = 0x71 # unknown 54 | RLY_PORT = 0x72 # unknown 55 | RLY_PORT_ACK = 0x73 # unknown 56 | RLY_PORT_KEY = 0x74 # unknown 57 | RLY_PORT_ACK_KEY = 0x75 # unknown 58 | RLY_BYTE_COUNT = 0x78 # unknown 59 | RLY_REQ = 0x80 # unknown 60 | RLY_REQ_ACK = 0x81 # unknown 61 | RLY_TO = 0x82 # unknown 62 | RLY_PKT = 0x83 # unknown 63 | RLY_RDY = 0x84 # unknown 64 | RLY_TO_ACK = 0x85 # unknown 65 | RLY_SERVER_REQ = 0x87 # unknown 66 | RLY_SERVER_REQ_ACK = 0x87 # unknown 67 | SDEV_RUN = 0x90 # unknown 68 | SDEV_LGN = 0x91 # unknown 69 | SDEV_LGN_ACK = 0x91 # unknown 70 | SDEV_LGN_CRC = 0x92 # unknown 71 | SDEV_LGN_ACK_CRC = 0x92 # unknown 72 | SDEV_REPORT = 0x94 # unknown 73 | CONNECT_REPORT = 0xa0 # unknown 74 | REPORT_REQ = 0xa1 # unknown 75 | REPORT = 0xa2 # unknown 76 | DRW = 0xd0 # unknown 77 | DRW_ACK = 0xd1 # unknown 78 | PSR = 0xd8 # unknown 79 | ALIVE = 0xe0 # unknown 80 | ALIVE_ACK = 0xe1 # unknown 81 | CLOSE = 0xf0 # unknown 82 | MGM_DUMP_LOGIN_DID = 0xf4 # unknown 83 | MGM_DUMP_LOGIN_DID_DETAIL = 0xf5 # unknown 84 | MGM_DUMP_LOGIN_DID_1 = 0xf6 # unknown 85 | MGM_LOG_CONTROL = 0xf7 # unknown 86 | MGM_REMOTE_MANAGEMENT = 0xf8 # unknown 87 | REPORT_SESSION_READY = 0xf9 # unknown 88 | INVALID = 0xff # unknown 89 | 90 | @classmethod 91 | def parse(cls, p): 92 | return cls(struct.unpack("B", p[:1])[0]), p[1:] 93 | 94 | def pack(self): 95 | return struct.pack("B", self) 96 | 97 | class P2PCmdType(enum.IntEnum): 98 | P2P_JSON_CMD = 0x06a4 # unknown 99 | P2P_SEND_FILE = 0x3a98 # unknown 100 | 101 | @classmethod 102 | def parse(cls, p): 103 | return cls(struct.unpack("B", p[:1])[0]), p[1:] 104 | 105 | def pack(self): 106 | return struct.pack("B", self) 107 | 108 | class P2PSubCmdType(enum.IntEnum): 109 | START_LIVE = 0x03e8 # unknown 110 | CLOSE_LIVE = 0x03e9 # unknown 111 | VIDEO_RECORD_SWITCH = 0x03ea # unknown 112 | LIGHT_STATE_SWITCH = 0x03ab # unknown 113 | LIGHT_STATE_GET = 0x03ec # unknown 114 | LIVE_MODE_SET = 0x03ed # unknown 115 | LIVE_MODE_GET = 0x03ee # unknown 116 | 117 | @classmethod 118 | def parse(cls, p): 119 | return cls(struct.unpack("B", p[:1])[0]), p[1:] 120 | 121 | def pack(self): 122 | return struct.pack("B", self) 123 | 124 | class FileTransfer(enum.IntEnum): 125 | BEGIN = 0x00 # Begin file transfer (sent with metadata) 126 | DATA = 0x01 # File content 127 | END = 0x02 # Complete file transfer (start printing) 128 | ABORT = 0x03 # Abort file transfer (delete file) 129 | REPLY = 0x80 # Reply from printer 130 | 131 | @classmethod 132 | def parse(cls, p): 133 | return cls(struct.unpack("B", p[:1])[0]), p[1:] 134 | 135 | def pack(self): 136 | return struct.pack("B", self) 137 | 138 | class FileTransferReply(enum.IntEnum): 139 | OK = 0x00 # Success 140 | ERR_TIMEOUT = 0xfc # Timeout during transfer 141 | ERR_FRAME_TYPE = 0xfd # Frame type error 142 | ERR_WRONG_MD5 = 0xfe # Checksum did not match 143 | ERR_BUSY = 0xff # Printer was not ready to receive 144 | 145 | @classmethod 146 | def parse(cls, p): 147 | return cls(struct.unpack("B", p[:1])[0]), p[1:] 148 | 149 | def pack(self): 150 | return struct.pack("B", self) 151 | 152 | 153 | @dataclass 154 | class Message: 155 | 156 | type: Type = field(repr=False, init=False) 157 | 158 | @classmethod 159 | def parse(cls, m): 160 | magic, type, size = struct.unpack(">BBH", m[:4]) 161 | assert magic == 0xF1 162 | type = Type(type) 163 | p = m[4:4+size] 164 | if type in MessageTypeTable: 165 | return MessageTypeTable[type].parse(p) 166 | else: 167 | raise ValueError(f"unknown message type {type:02x}") 168 | 169 | def pack(self, p): 170 | return struct.pack(">BBH", 0xF1, self.type, len(p)) + p 171 | 172 | class _Host: 173 | pass 174 | 175 | class _Duid: 176 | 177 | @classmethod 178 | def from_string(cls, str): 179 | prefix, serial, check = str.split("-") 180 | return cls(prefix, int(serial), check) 181 | 182 | def __str__(self): 183 | return f"{self.prefix}-{self.serial:06}-{self.check}" 184 | 185 | class _Xzyh: 186 | pass 187 | 188 | class _Aabb: 189 | 190 | @classmethod 191 | def parse_with_crc(cls, m): 192 | head, m = m[:12], m[12:] 193 | header = cls.parse(head)[0] 194 | data, m = m[:header.len], m[header.len:] 195 | crc1, m = m[:2], m[2:] 196 | crc2 = ppcs_crc16(head[2:] + data) 197 | _assert_equal(crc1, crc2) 198 | return header, data, m 199 | 200 | def pack_with_crc(self, data): 201 | header = self.pack() 202 | 203 | return header + data + ppcs_crc16(header[2:] + data) 204 | 205 | class _Dsk: 206 | pass 207 | 208 | class _Version: 209 | pass 210 | 211 | @dataclass 212 | class Host(_Host): 213 | pad0 : bytes = field(repr=False, kw_only=True, default='\x00' * 1) # unknown 214 | afam : u8le # Adress family. Set to AF_INET (2) 215 | port : u16le # Port number 216 | addr : IPv4 # IP address 217 | pad1 : bytes = field(repr=False, kw_only=True, default='\x00' * 8) # unknown 218 | 219 | @classmethod 220 | def parse(cls, p): 221 | # not encrypted 222 | pad0, p = Zeroes.parse(p, 1) 223 | afam, p = u8le.parse(p) 224 | port, p = u16le.parse(p) 225 | addr, p = IPv4.parse(p) 226 | pad1, p = Zeroes.parse(p, 8) 227 | 228 | return cls(pad0=pad0, afam=afam, port=port, addr=addr, pad1=pad1), p 229 | 230 | def pack(self): 231 | p = Zeroes.pack(self.pad0, 1) 232 | p += u8le.pack(self.afam) 233 | p += u16le.pack(self.port) 234 | p += IPv4.pack(self.addr) 235 | p += Zeroes.pack(self.pad1, 8) 236 | 237 | # not encrypted 238 | return p 239 | 240 | @dataclass 241 | class Duid(_Duid): 242 | prefix : bytes # duid "prefix", 7 chars + NULL terminator 243 | serial : u32 # device serial number 244 | check : bytes # checkcode relating to prefix+serial 245 | pad0 : bytes = field(repr=False, kw_only=True, default='\x00' * 2) # padding 246 | 247 | @classmethod 248 | def parse(cls, p): 249 | # not encrypted 250 | prefix, p = String.parse(p, 8) 251 | serial, p = u32.parse(p) 252 | check, p = String.parse(p, 6) 253 | pad0, p = Zeroes.parse(p, 2) 254 | 255 | return cls(prefix=prefix, serial=serial, check=check, pad0=pad0), p 256 | 257 | def pack(self): 258 | p = String.pack(self.prefix, 8) 259 | p += u32.pack(self.serial) 260 | p += String.pack(self.check, 6) 261 | p += Zeroes.pack(self.pad0, 2) 262 | 263 | # not encrypted 264 | return p 265 | 266 | @dataclass 267 | class Xzyh(_Xzyh): 268 | magic : bytes = field(repr=False, kw_only=True, default=b'XZYH') # unknown 269 | cmd : u16le # Command field (P2PCmdType) 270 | len : u32le # Payload length 271 | unk0 : u8 # unknown 272 | unk1 : u8 # unknown 273 | chan : u8 # unknown 274 | sign_code : u8 # unknown 275 | unk3 : u8 # unknown 276 | dev_type : u8 # unknown 277 | data : bytes # unknown 278 | 279 | @classmethod 280 | def parse(cls, p): 281 | # not encrypted 282 | magic, p = Magic.parse(p, 4, b'XZYH') 283 | cmd, p = u16le.parse(p) 284 | len, p = u32le.parse(p) 285 | unk0, p = u8.parse(p) 286 | unk1, p = u8.parse(p) 287 | chan, p = u8.parse(p) 288 | sign_code, p = u8.parse(p) 289 | unk3, p = u8.parse(p) 290 | dev_type, p = u8.parse(p) 291 | data, p = Bytes.parse(p, len) 292 | 293 | return cls(magic=magic, cmd=cmd, len=len, unk0=unk0, unk1=unk1, chan=chan, sign_code=sign_code, unk3=unk3, dev_type=dev_type, data=data), p 294 | 295 | def pack(self): 296 | p = Magic.pack(self.magic, 4, b'XZYH') 297 | p += u16le.pack(self.cmd) 298 | p += u32le.pack(self.len) 299 | p += u8.pack(self.unk0) 300 | p += u8.pack(self.unk1) 301 | p += u8.pack(self.chan) 302 | p += u8.pack(self.sign_code) 303 | p += u8.pack(self.unk3) 304 | p += u8.pack(self.dev_type) 305 | p += Bytes.pack(self.data, self.len) 306 | 307 | # not encrypted 308 | return p 309 | 310 | @dataclass 311 | class Aabb(_Aabb): 312 | signature : bytes = field(repr=False, kw_only=True, default=b'\xaa\xbb') # Signature bytes. Must be 0xAABB 313 | frametype : FileTransfer # Frame type (file transfer control) 314 | sn : u8 # Session id 315 | pos : u32le # File offset to write to 316 | len : u32le # Length field 317 | 318 | @classmethod 319 | def parse(cls, p): 320 | # not encrypted 321 | signature, p = Magic.parse(p, 2, b'\xaa\xbb') 322 | frametype, p = FileTransfer.parse(p) 323 | sn, p = u8.parse(p) 324 | pos, p = u32le.parse(p) 325 | len, p = u32le.parse(p) 326 | 327 | return cls(signature=signature, frametype=frametype, sn=sn, pos=pos, len=len), p 328 | 329 | def pack(self): 330 | p = Magic.pack(self.signature, 2, b'\xaa\xbb') 331 | p += FileTransfer.pack(self.frametype) 332 | p += u8.pack(self.sn) 333 | p += u32le.pack(self.pos) 334 | p += u32le.pack(self.len) 335 | 336 | # not encrypted 337 | return p 338 | 339 | @dataclass 340 | class Dsk(_Dsk): 341 | key : bytes # unknown 342 | pad : bytes = field(repr=False, kw_only=True, default='\x00' * 4) # unknown 343 | 344 | @classmethod 345 | def parse(cls, p): 346 | # not encrypted 347 | key, p = Bytes.parse(p, 20) 348 | pad, p = Zeroes.parse(p, 4) 349 | 350 | return cls(key=key, pad=pad), p 351 | 352 | def pack(self): 353 | p = Bytes.pack(self.key, 20) 354 | p += Zeroes.pack(self.pad, 4) 355 | 356 | # not encrypted 357 | return p 358 | 359 | @dataclass 360 | class Version(_Version): 361 | major : u8 # unknown 362 | minor : u8 # unknown 363 | patch : u8 # unknown 364 | 365 | @classmethod 366 | def parse(cls, p): 367 | # not encrypted 368 | major, p = u8.parse(p) 369 | minor, p = u8.parse(p) 370 | patch, p = u8.parse(p) 371 | 372 | return cls(major=major, minor=minor, patch=patch), p 373 | 374 | def pack(self): 375 | p = u8.pack(self.major) 376 | p += u8.pack(self.minor) 377 | p += u8.pack(self.patch) 378 | 379 | # not encrypted 380 | return p 381 | 382 | 383 | @dataclass 384 | class PktDrw(Message): 385 | type = Type.DRW 386 | signature : bytes = field(repr=False, kw_only=True, default=b'\xd1') # Signature byte. Must be 0xD1 387 | chan : u8 # Channel index 388 | index : u16 # Packet index 389 | data : bytes # Payload 390 | 391 | @classmethod 392 | def parse(cls, p): 393 | # not encrypted 394 | signature, p = Magic.parse(p, 1, b'\xd1') 395 | chan, p = u8.parse(p) 396 | index, p = u16.parse(p) 397 | data, p = Tail.parse(p) 398 | 399 | return cls(signature=signature, chan=chan, index=index, data=data), p 400 | 401 | def pack(self): 402 | p = Magic.pack(self.signature, 1, b'\xd1') 403 | p += u8.pack(self.chan) 404 | p += u16.pack(self.index) 405 | p += Tail.pack(self.data) 406 | 407 | # not encrypted 408 | return super().pack(p) 409 | 410 | @dataclass 411 | class PktDrwAck(Message): 412 | type = Type.DRW_ACK 413 | signature : bytes = field(repr=False, kw_only=True, default=b'\xd1') # Signature byte. Must be 0xD1 414 | chan : u8 # Channel index 415 | count : u16 # Number of acks following 416 | acks : list[u16] # Array of acknowledged DRW packet 417 | 418 | @classmethod 419 | def parse(cls, p): 420 | # not encrypted 421 | signature, p = Magic.parse(p, 1, b'\xd1') 422 | chan, p = u8.parse(p) 423 | count, p = u16.parse(p) 424 | acks, p = Array.parse(p, u16, count) 425 | 426 | return cls(signature=signature, chan=chan, count=count, acks=acks), p 427 | 428 | def pack(self): 429 | p = Magic.pack(self.signature, 1, b'\xd1') 430 | p += u8.pack(self.chan) 431 | p += u16.pack(self.count) 432 | p += Array.pack(self.acks, u16, self.count) 433 | 434 | # not encrypted 435 | return super().pack(p) 436 | 437 | @dataclass 438 | class PktPunchTo(Message): 439 | type = Type.PUNCH_TO 440 | host : Host # unknown 441 | 442 | @classmethod 443 | def parse(cls, p): 444 | # not encrypted 445 | host, p = Host.parse(p) 446 | 447 | return cls(host=host), p 448 | 449 | def pack(self): 450 | p = Host.pack(self.host) 451 | 452 | # not encrypted 453 | return super().pack(p) 454 | 455 | @dataclass 456 | class PktHello(Message): 457 | type = Type.HELLO 458 | 459 | @classmethod 460 | def parse(cls, p): 461 | # not encrypted 462 | 463 | return cls(), p 464 | 465 | def pack(self): 466 | p = b"" 467 | 468 | # not encrypted 469 | return super().pack(p) 470 | 471 | @dataclass 472 | class PktLanSearch(Message): 473 | type = Type.LAN_SEARCH 474 | 475 | @classmethod 476 | def parse(cls, p): 477 | # not encrypted 478 | 479 | return cls(), p 480 | 481 | def pack(self): 482 | p = b"" 483 | 484 | # not encrypted 485 | return super().pack(p) 486 | 487 | @dataclass 488 | class PktRlyHello(Message): 489 | type = Type.RLY_HELLO 490 | 491 | @classmethod 492 | def parse(cls, p): 493 | # not encrypted 494 | 495 | return cls(), p 496 | 497 | def pack(self): 498 | p = b"" 499 | 500 | # not encrypted 501 | return super().pack(p) 502 | 503 | @dataclass 504 | class PktRlyHelloAck(Message): 505 | type = Type.RLY_HELLO_ACK 506 | 507 | @classmethod 508 | def parse(cls, p): 509 | # not encrypted 510 | 511 | return cls(), p 512 | 513 | def pack(self): 514 | p = b"" 515 | 516 | # not encrypted 517 | return super().pack(p) 518 | 519 | @dataclass 520 | class PktRlyPort(Message): 521 | type = Type.RLY_PORT 522 | 523 | @classmethod 524 | def parse(cls, p): 525 | # not encrypted 526 | 527 | return cls(), p 528 | 529 | def pack(self): 530 | p = b"" 531 | 532 | # not encrypted 533 | return super().pack(p) 534 | 535 | @dataclass 536 | class PktRlyPortAck(Message): 537 | type = Type.RLY_PORT_ACK 538 | mark : u32 # unknown 539 | port : u16 # unknown 540 | pad : bytes = field(repr=False, kw_only=True, default='\x00' * 2) # unknown 541 | 542 | @classmethod 543 | def parse(cls, p): 544 | # not encrypted 545 | mark, p = u32.parse(p) 546 | port, p = u16.parse(p) 547 | pad, p = Zeroes.parse(p, 2) 548 | 549 | return cls(mark=mark, port=port, pad=pad), p 550 | 551 | def pack(self): 552 | p = u32.pack(self.mark) 553 | p += u16.pack(self.port) 554 | p += Zeroes.pack(self.pad, 2) 555 | 556 | # not encrypted 557 | return super().pack(p) 558 | 559 | @dataclass 560 | class PktRlyReq(Message): 561 | type = Type.RLY_REQ 562 | duid : Duid # unknown 563 | host : Host # unknown 564 | mark : u32 # unknown 565 | 566 | @classmethod 567 | def parse(cls, p): 568 | # not encrypted 569 | duid, p = Duid.parse(p) 570 | host, p = Host.parse(p) 571 | mark, p = u32.parse(p) 572 | 573 | return cls(duid=duid, host=host, mark=mark), p 574 | 575 | def pack(self): 576 | p = Duid.pack(self.duid) 577 | p += Host.pack(self.host) 578 | p += u32.pack(self.mark) 579 | 580 | # not encrypted 581 | return super().pack(p) 582 | 583 | @dataclass 584 | class PktRlyReqAck(Message): 585 | type = Type.RLY_REQ_ACK 586 | mark : u32 # unknown 587 | 588 | @classmethod 589 | def parse(cls, p): 590 | # not encrypted 591 | mark, p = u32.parse(p) 592 | 593 | return cls(mark=mark), p 594 | 595 | def pack(self): 596 | p = u32.pack(self.mark) 597 | 598 | # not encrypted 599 | return super().pack(p) 600 | 601 | @dataclass 602 | class PktAlive(Message): 603 | type = Type.ALIVE 604 | 605 | @classmethod 606 | def parse(cls, p): 607 | # not encrypted 608 | 609 | return cls(), p 610 | 611 | def pack(self): 612 | p = b"" 613 | 614 | # not encrypted 615 | return super().pack(p) 616 | 617 | @dataclass 618 | class PktAliveAck(Message): 619 | type = Type.ALIVE_ACK 620 | 621 | @classmethod 622 | def parse(cls, p): 623 | # not encrypted 624 | 625 | return cls(), p 626 | 627 | def pack(self): 628 | p = b"" 629 | 630 | # not encrypted 631 | return super().pack(p) 632 | 633 | @dataclass 634 | class PktClose(Message): 635 | type = Type.CLOSE 636 | 637 | @classmethod 638 | def parse(cls, p): 639 | # not encrypted 640 | 641 | return cls(), p 642 | 643 | def pack(self): 644 | p = b"" 645 | 646 | # not encrypted 647 | return super().pack(p) 648 | 649 | @dataclass 650 | class PktHelloAck(Message): 651 | type = Type.HELLO_ACK 652 | host : Host # unknown 653 | 654 | @classmethod 655 | def parse(cls, p): 656 | # not encrypted 657 | host, p = Host.parse(p) 658 | 659 | return cls(host=host), p 660 | 661 | def pack(self): 662 | p = Host.pack(self.host) 663 | 664 | # not encrypted 665 | return super().pack(p) 666 | 667 | @dataclass 668 | class PktPunchPkt(Message): 669 | type = Type.PUNCH_PKT 670 | duid : Duid # unknown 671 | 672 | @classmethod 673 | def parse(cls, p): 674 | # not encrypted 675 | duid, p = Duid.parse(p) 676 | 677 | return cls(duid=duid), p 678 | 679 | def pack(self): 680 | p = Duid.pack(self.duid) 681 | 682 | # not encrypted 683 | return super().pack(p) 684 | 685 | @dataclass 686 | class PktP2pRdy(Message): 687 | type = Type.P2P_RDY 688 | duid : Duid # unknown 689 | 690 | @classmethod 691 | def parse(cls, p): 692 | # not encrypted 693 | duid, p = Duid.parse(p) 694 | 695 | return cls(duid=duid), p 696 | 697 | def pack(self): 698 | p = Duid.pack(self.duid) 699 | 700 | # not encrypted 701 | return super().pack(p) 702 | 703 | @dataclass 704 | class PktP2pReq(Message): 705 | type = Type.P2P_REQ 706 | duid : Duid # unknown 707 | host : Host # unknown 708 | 709 | @classmethod 710 | def parse(cls, p): 711 | # not encrypted 712 | duid, p = Duid.parse(p) 713 | host, p = Host.parse(p) 714 | 715 | return cls(duid=duid, host=host), p 716 | 717 | def pack(self): 718 | p = Duid.pack(self.duid) 719 | p += Host.pack(self.host) 720 | 721 | # not encrypted 722 | return super().pack(p) 723 | 724 | @dataclass 725 | class PktP2pReqAck(Message): 726 | type = Type.P2P_REQ_ACK 727 | mark : u32 # unknown 728 | 729 | @classmethod 730 | def parse(cls, p): 731 | # not encrypted 732 | mark, p = u32.parse(p) 733 | 734 | return cls(mark=mark), p 735 | 736 | def pack(self): 737 | p = u32.pack(self.mark) 738 | 739 | # not encrypted 740 | return super().pack(p) 741 | 742 | @dataclass 743 | class PktP2pReqDsk(Message): 744 | type = Type.P2P_REQ_DSK 745 | duid : Duid # unknown 746 | host : Host # unknown 747 | nat_type : u8 # unknown 748 | version : Version # unknown 749 | dsk : Dsk # unknown 750 | 751 | @classmethod 752 | def parse(cls, p): 753 | # not encrypted 754 | duid, p = Duid.parse(p) 755 | host, p = Host.parse(p) 756 | nat_type, p = u8.parse(p) 757 | version, p = Version.parse(p) 758 | dsk, p = Dsk.parse(p) 759 | 760 | return cls(duid=duid, host=host, nat_type=nat_type, version=version, dsk=dsk), p 761 | 762 | def pack(self): 763 | p = Duid.pack(self.duid) 764 | p += Host.pack(self.host) 765 | p += u8.pack(self.nat_type) 766 | p += Version.pack(self.version) 767 | p += Dsk.pack(self.dsk) 768 | 769 | # not encrypted 770 | return super().pack(p) 771 | 772 | @dataclass 773 | class PktP2pRdyAck(Message): 774 | type = Type.P2P_RDY_ACK 775 | duid : Duid # unknown 776 | host : Host # unknown 777 | pad : bytes = field(repr=False, kw_only=True, default='\x00' * 8) # unknown 778 | 779 | @classmethod 780 | def parse(cls, p): 781 | # not encrypted 782 | duid, p = Duid.parse(p) 783 | host, p = Host.parse(p) 784 | pad, p = Zeroes.parse(p, 8) 785 | 786 | return cls(duid=duid, host=host, pad=pad), p 787 | 788 | def pack(self): 789 | p = Duid.pack(self.duid) 790 | p += Host.pack(self.host) 791 | p += Zeroes.pack(self.pad, 8) 792 | 793 | # not encrypted 794 | return super().pack(p) 795 | 796 | @dataclass 797 | class PktListReqDsk(Message): 798 | type = Type.LIST_REQ_DSK 799 | duid : Duid # Device id 800 | dsk : Dsk # Device secret key 801 | 802 | @classmethod 803 | def parse(cls, p): 804 | # not encrypted 805 | duid, p = Duid.parse(p) 806 | dsk, p = Dsk.parse(p) 807 | 808 | return cls(duid=duid, dsk=dsk), p 809 | 810 | def pack(self): 811 | p = Duid.pack(self.duid) 812 | p += Dsk.pack(self.dsk) 813 | 814 | # not encrypted 815 | return super().pack(p) 816 | 817 | @dataclass 818 | class PktListReqAck(Message): 819 | type = Type.LIST_REQ_ACK 820 | numr : u8 # Number of relays 821 | pad : bytes = field(repr=False, kw_only=True, default='\x00' * 3) # Padding 822 | relays : list[Host] # Available relay hosts 823 | 824 | @classmethod 825 | def parse(cls, p): 826 | # not encrypted 827 | numr, p = u8.parse(p) 828 | pad, p = Zeroes.parse(p, 3) 829 | relays, p = Array.parse(p, Host, numr) 830 | 831 | return cls(numr=numr, pad=pad, relays=relays), p 832 | 833 | def pack(self): 834 | p = u8.pack(self.numr) 835 | p += Zeroes.pack(self.pad, 3) 836 | p += Array.pack(self.relays, Host, self.numr) 837 | 838 | # not encrypted 839 | return super().pack(p) 840 | 841 | @dataclass 842 | class PktDevLgnCrc(Message): 843 | type = Type.DEV_LGN_CRC 844 | duid : Duid # unknown 845 | nat_type : u8 # unknown 846 | version : Version # unknown 847 | host : Host # unknown 848 | 849 | @classmethod 850 | def parse(cls, p): 851 | p = crypto_decurse_string(p) 852 | duid, p = Duid.parse(p) 853 | nat_type, p = u8.parse(p) 854 | version, p = Version.parse(p) 855 | host, p = Host.parse(p) 856 | 857 | return cls(duid=duid, nat_type=nat_type, version=version, host=host), p 858 | 859 | def pack(self): 860 | p = Duid.pack(self.duid) 861 | p += u8.pack(self.nat_type) 862 | p += Version.pack(self.version) 863 | p += Host.pack(self.host) 864 | 865 | p = crypto_curse_string(p) 866 | return super().pack(p) 867 | 868 | @dataclass 869 | class PktRlyTo(Message): 870 | type = Type.RLY_TO 871 | host : Host # unknown 872 | mark : u32 # unknown 873 | 874 | @classmethod 875 | def parse(cls, p): 876 | # not encrypted 877 | host, p = Host.parse(p) 878 | mark, p = u32.parse(p) 879 | 880 | return cls(host=host, mark=mark), p 881 | 882 | def pack(self): 883 | p = Host.pack(self.host) 884 | p += u32.pack(self.mark) 885 | 886 | # not encrypted 887 | return super().pack(p) 888 | 889 | @dataclass 890 | class PktRlyPkt(Message): 891 | type = Type.RLY_PKT 892 | mark : u32 # unknown 893 | duid : Duid # unknown 894 | unk : u32 # unknown 895 | 896 | @classmethod 897 | def parse(cls, p): 898 | # not encrypted 899 | mark, p = u32.parse(p) 900 | duid, p = Duid.parse(p) 901 | unk, p = u32.parse(p) 902 | 903 | return cls(mark=mark, duid=duid, unk=unk), p 904 | 905 | def pack(self): 906 | p = u32.pack(self.mark) 907 | p += Duid.pack(self.duid) 908 | p += u32.pack(self.unk) 909 | 910 | # not encrypted 911 | return super().pack(p) 912 | 913 | @dataclass 914 | class PktRlyRdy(Message): 915 | type = Type.RLY_RDY 916 | duid : Duid # unknown 917 | 918 | @classmethod 919 | def parse(cls, p): 920 | # not encrypted 921 | duid, p = Duid.parse(p) 922 | 923 | return cls(duid=duid), p 924 | 925 | def pack(self): 926 | p = Duid.pack(self.duid) 927 | 928 | # not encrypted 929 | return super().pack(p) 930 | 931 | @dataclass 932 | class PktDevLgnAckCrc(Message): 933 | type = Type.DEV_LGN_ACK_CRC 934 | pad0 : bytes = field(repr=False, kw_only=True, default='\x00' * 4) # unknown 935 | 936 | @classmethod 937 | def parse(cls, p): 938 | p = crypto_decurse_string(p) 939 | pad0, p = Zeroes.parse(p, 4) 940 | 941 | return cls(pad0=pad0), p 942 | 943 | def pack(self): 944 | p = Zeroes.pack(self.pad0, 4) 945 | 946 | p = crypto_curse_string(p) 947 | return super().pack(p) 948 | 949 | @dataclass 950 | class PktSessionReady(Message): 951 | type = Type.REPORT_SESSION_READY 952 | duid : Duid # unknown 953 | handle : i32 # unknown 954 | max_handles : u16 # unknown 955 | active_handles : u16 # unknown 956 | startup_ticks : u16 # unknown 957 | b1 : u8 # unknown 958 | b2 : u8 # unknown 959 | b3 : u8 # unknown 960 | b4 : u8 # unknown 961 | pad0 : bytes = field(repr=False, kw_only=True, default='\x00' * 2) # unknown 962 | addr_local : Host # unknown 963 | addr_wan : Host # unknown 964 | addr_relay : Host # unknown 965 | 966 | @classmethod 967 | def parse(cls, p): 968 | p = simple_decrypt_string(p) 969 | duid, p = Duid.parse(p) 970 | handle, p = i32.parse(p) 971 | max_handles, p = u16.parse(p) 972 | active_handles, p = u16.parse(p) 973 | startup_ticks, p = u16.parse(p) 974 | b1, p = u8.parse(p) 975 | b2, p = u8.parse(p) 976 | b3, p = u8.parse(p) 977 | b4, p = u8.parse(p) 978 | pad0, p = Zeroes.parse(p, 2) 979 | addr_local, p = Host.parse(p) 980 | addr_wan, p = Host.parse(p) 981 | addr_relay, p = Host.parse(p) 982 | 983 | return cls(duid=duid, handle=handle, max_handles=max_handles, active_handles=active_handles, startup_ticks=startup_ticks, b1=b1, b2=b2, b3=b3, b4=b4, pad0=pad0, addr_local=addr_local, addr_wan=addr_wan, addr_relay=addr_relay), p 984 | 985 | def pack(self): 986 | p = Duid.pack(self.duid) 987 | p += i32.pack(self.handle) 988 | p += u16.pack(self.max_handles) 989 | p += u16.pack(self.active_handles) 990 | p += u16.pack(self.startup_ticks) 991 | p += u8.pack(self.b1) 992 | p += u8.pack(self.b2) 993 | p += u8.pack(self.b3) 994 | p += u8.pack(self.b4) 995 | p += Zeroes.pack(self.pad0, 2) 996 | p += Host.pack(self.addr_local) 997 | p += Host.pack(self.addr_wan) 998 | p += Host.pack(self.addr_relay) 999 | 1000 | p = simple_encrypt_string(p) 1001 | return super().pack(p) 1002 | 1003 | 1004 | MessageTypeTable = { 1005 | Type.HELLO : PktHello, 1006 | Type.HELLO_ACK : PktHelloAck, 1007 | Type.DEV_LGN_CRC : PktDevLgnCrc, 1008 | Type.DEV_LGN_ACK_CRC : PktDevLgnAckCrc, 1009 | Type.P2P_REQ : PktP2pReq, 1010 | Type.P2P_REQ_ACK : PktP2pReqAck, 1011 | Type.P2P_REQ_DSK : PktP2pReqDsk, 1012 | Type.LAN_SEARCH : PktLanSearch, 1013 | Type.PUNCH_TO : PktPunchTo, 1014 | Type.PUNCH_PKT : PktPunchPkt, 1015 | Type.P2P_RDY : PktP2pRdy, 1016 | Type.P2P_RDY_ACK : PktP2pRdyAck, 1017 | Type.LIST_REQ_ACK : PktListReqAck, 1018 | Type.LIST_REQ_DSK : PktListReqDsk, 1019 | Type.RLY_HELLO : PktRlyHello, 1020 | Type.RLY_HELLO_ACK : PktRlyHelloAck, 1021 | Type.RLY_PORT : PktRlyPort, 1022 | Type.RLY_PORT_ACK : PktRlyPortAck, 1023 | Type.RLY_REQ : PktRlyReq, 1024 | Type.RLY_REQ_ACK : PktRlyReqAck, 1025 | Type.RLY_TO : PktRlyTo, 1026 | Type.RLY_PKT : PktRlyPkt, 1027 | Type.RLY_RDY : PktRlyRdy, 1028 | Type.DRW : PktDrw, 1029 | Type.DRW_ACK : PktDrwAck, 1030 | Type.ALIVE : PktAlive, 1031 | Type.ALIVE_ACK : PktAliveAck, 1032 | Type.CLOSE : PktClose, 1033 | Type.REPORT_SESSION_READY : PktSessionReady, 1034 | } 1035 | 1036 | --------------------------------------------------------------------------------