zkk 08648da7a4 实现下位机版本检测升级功能以及优化断电续打
# Conflicts:
#	config/moonraker.conf   resolved by f5461563b8a7a5a25470a8763b8513779dbff253 version
#	scripts/sync_dependencies.py   resolved by f5461563b8a7a5a25470a8763b8513779dbff253 version
2025-05-10 16:36:55 +08:00

1202 lines
44 KiB
Python

#!/usr/bin/env python3
# Script to upload software via Katapult
#
# Copyright (C) 2022 Eric Callahan <arksine.code@gmail.com>
#
# This file may be distributed under the terms of the GNU GPLv3 license.
from __future__ import annotations
import sys
import os
import termios
import fcntl
import zlib
import json
import asyncio
import socket
import struct
import logging
import errno
import argparse
import hashlib
import pathlib
import shutil
import shlex
import contextlib
from typing import TYPE_CHECKING, Dict, List, Optional, Union, Any
if TYPE_CHECKING:
from ..server import Server
HAS_SERIAL = True
try:
from serial import Serial, SerialException
except ModuleNotFoundError:
HAS_SERIAL = False
SerialException = Exception
def output_line(msg: str) -> None:
sys.stdout.write(msg + "\n")
sys.stdout.flush()
logging.info(f"{msg}\n")
def output(msg: str) -> None:
sys.stdout.write(msg)
sys.stdout.flush()
# Standard crc16 ccitt, take from msgproto.py in Klipper
def crc16_ccitt(buf: Union[bytes, bytearray]) -> int:
crc = 0xffff
for data in buf:
data ^= crc & 0xff
data ^= (data & 0x0f) << 4
crc = ((data << 8) | (crc >> 8)) ^ (data >> 4) ^ (data << 3)
return crc & 0xFFFF
logging.basicConfig(level=logging.INFO)
CAN_FMT = "<IB3x8s"
CAN_READER_LIMIT = 1024 * 1024
# Katapult Defs
CMD_HEADER = b'\x01\x88'
CMD_TRAILER = b'\x99\x03'
BOOTLOADER_CMDS = {
'CONNECT': 0x11,
'SEND_BLOCK': 0x12,
'SEND_EOF': 0x13,
'REQUEST_BLOCK': 0x14,
'COMPLETE': 0x15,
'GET_CANBUS_ID': 0x16
}
ACK_SUCCESS = 0xa0
NACK = 0xf1
ACK_ERROR = 0xf2
ACK_BUSY = 0xf3
# Klipper Admin Defs (for jumping to bootloader)
KLIPPER_ADMIN_ID = 0x3f0
KLIPPER_SET_NODE_CMD = 0x01
KLIPPER_REBOOT_CMD = 0x02
# CAN Admin Defs
CANBUS_ID_ADMIN = 0x3f0
CANBUS_ID_ADMIN_RESP = 0x3f1
CANBUS_CMD_QUERY_UNASSIGNED = 0x00
CANBUS_CMD_SET_NODEID = 0x11
CANBUS_CMD_CLEAR_NODE_ID = 0x12
CANBUS_RESP_NEED_NODEID = 0x20
CANBUS_NODEID_OFFSET = 128
# USB IDs
KATAPULT_USB_ID = "1d50:6177"
KLIPPER_USB_ID = "1d50:614e"
GS_CAN_USB_ID = "1d50:606f"
SERIAL_BL_REQ = b"~ \x1c Request Serial Bootloader!! ~"
class FlashError(Exception):
pass
def get_usb_info(usb_path: pathlib.Path) -> Dict[str, Any]:
usb_info: Dict[str, Any] = {}
id_path = usb_path.joinpath("idVendor")
prod_id_path = usb_path.joinpath("idProduct")
mfr_path = usb_path.joinpath("manufacturer")
prod_path = usb_path.joinpath("product")
serial_path = usb_path.joinpath("serial")
usb_info["usb_id"] = ""
if id_path.is_file() and prod_id_path.is_file():
vid = id_path.read_text().strip().lower()
pid = prod_id_path.read_text().strip().lower()
usb_info["usb_id"] = f"{vid}:{pid}"
usb_info["manufacturer"] = "unknown"
usb_info["product"] = "unknown"
usb_info["serial_number"] = ""
if mfr_path.is_file():
usb_info["manufacturer"] = mfr_path.read_text().strip().lower()
if prod_path.is_file():
usb_info["product"] = prod_path.read_text().strip().lower()
if serial_path.is_file():
usb_info["serial_number"] = serial_path.read_text().strip().lower()
return usb_info
def get_usb_path(device: pathlib.Path) -> Optional[pathlib.Path]:
device_path = device.resolve()
if not device_path.exists():
return None
sys_dev_path = pathlib.Path("/sys/class/tty").joinpath(device_path.name)
if not sys_dev_path.exists():
return None
sys_dev_path = sys_dev_path.resolve()
for usb_path in sys_dev_path.parents:
dnum_file = usb_path.joinpath("devnum")
bnum_file = usb_path.joinpath("busnum")
if dnum_file.is_file() and bnum_file.is_file():
return usb_path
return None
def get_stable_usb_symlink(device: pathlib.Path) -> pathlib.Path:
device_path = device.resolve()
ser_by_path_dir = pathlib.Path("/dev/serial/by-path")
try:
if ser_by_path_dir.exists():
for item in ser_by_path_dir.iterdir():
if item.samefile(device_path):
return item
except OSError:
pass
return device_path
# Python Port of fasthash6
# Host URL: http://github.com/ztanml/fast-hash
# Original Copyright:
# Copyright (C) 2012 Zilong Tan (eric.zltan@gmail.com)
#
# MIT License
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use, copy,
# modify, merge, publish, distribute, sublicense, and/or sell copies
# of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
MASK_64 = 0xFFFFFFFFFFFFFFFF
def mix(val: int) -> int:
val ^= val >> 23
val = (val * 0x2127599bf4325c37) & MASK_64
val ^= val >> 47
return val
def fasthash64(buffer: bytes, seed: int) -> int:
m = 0x880355f21e6d1965
h = seed ^ ((len(buffer) * m) & MASK_64)
long_count = len(buffer) // 8
remaining: bytes = buffer
if long_count:
byte_count = long_count * 8
buf = struct.unpack(f"<{long_count}Q", buffer[:byte_count])
for v in buf:
h ^= mix(v)
h = (h * m) & MASK_64
remaining = buffer[byte_count:]
if remaining:
v = 0
print(remaining)
for i in range(len(remaining), 0, -1):
v ^= remaining[i -1] << (8 * (i - 1))
h ^= mix(v)
h = (h * m) & MASK_64
return mix(h)
def convert_usbsn_to_uuid(serial_number: str) -> int:
seed = 0xA16231A7
chip_id = bytes.fromhex(serial_number)
result = fasthash64(chip_id, seed)
swapped = 0
# The UUID returned by Klipper firmware is byteswapped
for i in range(6):
swapped |= ((result >> (i * 8)) & 0xFF) << ((5 - i) * 8)
return swapped
class CanFlasher:
def __init__(
self,
server: Server,
node: CanNode,
fw_file: pathlib.Path
) -> None:
self.server = server
self.node = node
self.firmware_path = fw_file
self.fw_sha = hashlib.sha1()
self.primed = False
self.file_size = 0
self.block_size = 64
self.block_count = 0
self.app_start_addr = 0
self.full_complete = False
self.klipper_dict: Optional[Dict[str, Any]] = None
self._check_binary()
def notify_update_response(
self, resp: Union[str, bytes], is_complete: bool = False
) -> None:
if self.firmware_path is None:
return
resp = resp.strip()
if isinstance(resp, bytes):
resp = resp.decode()
done = is_complete
done &= self.full_complete
notification = {
'message': resp,
'application': "firmware",
'complete': "done"}
self.server.send_event(
"update_manager:update_response", notification)
def _check_binary(self) -> None:
"""
Extract klipper.dict from binary
"""
fw_name = self.firmware_path.name.lower()
if fw_name != "klipper.bin" or not self.firmware_path.is_file():
return
bin_data = self.firmware_path.read_bytes()
klipper_dict: Dict[str, Any] = {}
for idx in range(len(bin_data)):
try:
uncmp_data = zlib.decompress(bin_data[idx:])
klipper_dict = json.loads(uncmp_data)
except (zlib.error, json.JSONDecodeError):
continue
if klipper_dict.get("app") == "Klipper":
break
if klipper_dict:
self.klipper_dict = klipper_dict
ver = klipper_dict.get("version", "")
bin_mcu = self.klipper_dict.get("config", {}).get("MCU", "")
output_line(
f"Detected Klipper binary version {ver}, MCU: {bin_mcu}"
)
def _build_command(self, cmd: int, payload: bytes) -> bytearray:
word_cnt = (len(payload) // 4) & 0xFF
out_cmd = bytearray(CMD_HEADER)
out_cmd.append(cmd)
out_cmd.append(word_cnt)
if payload:
out_cmd.extend(payload)
crc = crc16_ccitt(out_cmd[2:])
out_cmd.extend(struct.pack("<H", crc))
out_cmd.extend(CMD_TRAILER)
return out_cmd
def prime(self) -> None:
# Prime with an invalid command. This will generate an error
# and force double buffered USB devices to respond after the
# first command is sent.
msg = self._build_command(0x90, b"")
self.node.write(msg)
self.primed = True
async def connect_btl(self) -> None:
output_line("Attempting to connect to bootloader")
ret = await self.send_command('CONNECT')
pinfo = ret[:12]
mcu_info = ret[12:]
ver_bytes: bytes
ver_bytes, start_addr, self.block_size = struct.unpack("<4sII", pinfo)
self.app_start_addr = start_addr
self.software_version = "?"
self.proto_version = tuple([v for v in reversed(ver_bytes[:3])])
proto_version_str = ".".join([str(v) for v in self.proto_version])
if self.block_size not in [64, 128, 256, 512]:
raise FlashError("Invalid Block Size: %d" % (self.block_size,))
mcu_info = mcu_info.rstrip(b"\x00")
if self.proto_version >= (1, 1, 0):
build_info = mcu_info.split(b"\x00", maxsplit=1)
mcu_type = build_info[0].decode()
if len(build_info) == 2:
self.software_version = build_info[1].decode()
else:
output_line("Katapult build not reporting software version!")
else:
mcu_type = mcu_info.decode()
output_line(
f"Katapult Connected\n"
f"Software Version: {self.software_version}\n"
f"Protocol Version: {proto_version_str}\n"
f"Block Size: {self.block_size} bytes\n"
f"Application Start: 0x{self.app_start_addr:4X}\n"
f"MCU type: {mcu_type}"
)
if self.klipper_dict is not None:
bin_mcu = self.klipper_dict.get("config", {}).get("MCU", "")
if bin_mcu and bin_mcu != mcu_type:
raise FlashError(
"MCU returned by Katapult does not match MCU "
"identified in klipper.bin.\n"
f"Katapult MCU: {mcu_type}\n"
f"Klipper Binary MCU: {bin_mcu}"
)
async def verify_canbus_uuid(self, uuid):
output_line("Verifying canbus connection")
ret = await self.send_command('GET_CANBUS_ID')
mcu_uuid = sum([v << ((5 - i) * 8) for i, v in enumerate(ret[:6])])
if mcu_uuid != uuid:
raise FlashError("UUID mismatch (%s vs %s)" % (uuid, mcu_uuid))
async def send_command(
self,
cmdname: str,
payload: bytes = b"",
tries: int = 5
) -> bytearray:
cmd = BOOTLOADER_CMDS[cmdname]
out_cmd = self._build_command(cmd, payload)
last_err = Exception()
while tries:
data = bytearray()
recd_len = 0
try:
self.node.write(out_cmd)
read_done = False
while not read_done:
ret = await self.node.readuntil(CMD_TRAILER)
data.extend(ret)
while len(data) > 7:
if data[:2] != CMD_HEADER:
data = data[1:]
continue
recd_len = data[3] * 4
read_done = len(data) == recd_len + 8
break
if self.primed and read_done:
recd_len = 0
data.clear()
self.primed = False
read_done = False
except asyncio.CancelledError:
raise
except asyncio.TimeoutError:
logging.info(
f"Response for command {cmdname} timed out, "
f"{tries - 1} tries remaining"
)
except Exception as e:
if type(e) is type(last_err) and e.args == last_err.args:
last_err = e
logging.exception("Device Read Error")
else:
trailer = data[-2:]
recd_crc, = struct.unpack("<H", data[-4:-2])
calc_crc = crc16_ccitt(data[2:-4])
recd_ack = data[2]
cmd_response = 0
if recd_len:
cmd_response, = struct.unpack("<I", data[4:8])
if trailer != CMD_TRAILER:
logging.info(
f"Command '{cmdname}': Invalid Trailer Received "
f"0x{trailer.hex()}"
)
elif recd_crc != calc_crc:
logging.info(
f"Command '{cmdname}': Frame CRC Mismatch, expected: "
f"{calc_crc}, received {recd_crc}"
)
elif recd_ack == ACK_ERROR:
logging.info(f"Command '{cmdname}': Received Error Response")
elif recd_ack == ACK_BUSY:
logging.info(f"Command '{cmdname}': Received busy signal")
await asyncio.sleep(1.5)
elif recd_ack != ACK_SUCCESS:
logging.info(f"Command '{cmdname}': Received NACK")
elif cmd_response != cmd:
logging.info(
f"Command '{cmdname}': Acknowledged wrong command, "
f"expected: {cmd:2x}, received: {cmd_response:2x}"
)
else:
# Validation passed, return payload sans command
if recd_len <= 4:
return bytearray()
return data[8:recd_len + 4]
tries -= 1
# clear the read buffer
try:
ret = await self.node.read(1024, timeout=.25)
except asyncio.TimeoutError:
pass
else:
logging.info(f"Read Buffer Contents: {ret!r}")
await asyncio.sleep(.5)
raise FlashError("Error sending command [%s] to Device" % (cmdname))
def _get_mcu_from_path(self):
firmware:str = pathlib.Path(self.firmware_path).name
file_name = firmware.split('/')[-1]
mapping = {
"F446": "mcu",
"F072_L": "lift mcu",
"F072_R": "right mcu",
"F072": "tool mcu",
}
for key in mapping:
if file_name.startswith(key):
return mapping[key]
return None
async def send_file(self):
last_percent = 0
output_line("Flashing '%s'..." % (self.firmware_path))
output("\n[")
with open(self.firmware_path, 'rb') as f:
f.seek(0, os.SEEK_END)
self.file_size = f.tell()
f.seek(0)
flash_address = self.app_start_addr
recd_addr = 0
uinfo: Dict[str, Any] = {}
uinfo['busy'] = True
self.server.send_event("update_manager:update_refreshed", uinfo)
while True:
buf = f.read(self.block_size)
if not buf:
break
if len(buf) < self.block_size:
buf += b"\xFF" * (self.block_size - len(buf))
self.fw_sha.update(buf)
prefix = struct.pack("<I", flash_address)
for _ in range(3):
resp = await self.send_command('SEND_BLOCK', prefix + buf)
recd_addr, = struct.unpack("<I", resp)
if recd_addr == flash_address:
break
logging.info(
f"Block write mismatch: expected: {flash_address:4X}, "
f"received: {recd_addr:4X}"
)
await asyncio.sleep(.1)
else:
raise FlashError(
f"Flash write failed, block address 0x{recd_addr:4X}"
)
flash_address += self.block_size
self.block_count += 1
uploaded = self.block_count * self.block_size
pct = int(uploaded / float(self.file_size) * 100 + .5)
if pct >= last_percent + 2:
last_percent += 2.
output("#")
totals = (
f"{uploaded // 1024} KiB / "
f"{float(self.file_size) // 1024} KiB"
)
if pct == 100:
self.full_complete = True
self.notify_update_response(
f"Updataing {self._get_mcu_from_path()}: {totals} [{pct}%]")
resp = await self.send_command('SEND_EOF')
page_count, = struct.unpack("<I", resp)
output_line("]\n\nWrite complete: %d pages" % (page_count))
async def verify_file(self):
last_percent = 0
output_line("Verifying (block count = %d)..." % (self.block_count,))
output("\n[")
ver_sha = hashlib.sha1()
for i in range(self.block_count):
flash_address = i * self.block_size + self.app_start_addr
for _ in range(3):
payload = struct.pack("<I", flash_address)
resp = await self.send_command("REQUEST_BLOCK", payload)
recd_addr, = struct.unpack("<I", resp[:4])
if recd_addr == flash_address:
break
logging.info(
f"Block read mismatch: expected: 0x{flash_address:4X}, "
f"received: 0x{recd_addr}"
)
await asyncio.sleep(.1)
else:
output_line("Error")
raise FlashError("Block Request Error, block: %d" % (i,))
ver_sha.update(resp[4:])
pct = int(i * self.block_size / float(self.file_size) * 100 + .5)
if pct >= last_percent + 2:
last_percent += 2
output("#")
totals = (
f"{(i * self.block_size) // 1024} KiB / "
f"{float(self.file_size) // 1024} KiB"
)
self.notify_update_response(
f"Verify firmware: {totals} [{pct}%]")
ver_hex = ver_sha.hexdigest().upper()
fw_hex = self.fw_sha.hexdigest().upper()
if ver_hex != fw_hex:
raise FlashError("Checksum mismatch: Expected %s, Received %s"
% (fw_hex, ver_hex))
output_line("]\n\nVerification Complete: SHA = %s" % (ver_hex))
async def finish(self):
await self.send_command("COMPLETE")
class CanNode:
def __init__(self, node_id: int, cansocket: CanSocket | SerialSocket) -> None:
self.node_id = node_id
self._reader = asyncio.StreamReader(CAN_READER_LIMIT)
self._cansocket = cansocket
async def read(
self, n: int = -1, timeout: Optional[float] = 2.
) -> bytes:
return await asyncio.wait_for(self._reader.read(n), timeout)
async def readexactly(
self, n: int, timeout: Optional[float] = 2.
) -> bytes:
return await asyncio.wait_for(self._reader.readexactly(n), timeout)
async def readuntil(
self, sep: bytes = b"\x03", timeout: Optional[float] = 2.
) -> bytes:
return await asyncio.wait_for(self._reader.readuntil(sep), timeout)
def write(self, payload: Union[bytes, bytearray]) -> None:
if isinstance(payload, bytearray):
payload = bytes(payload)
self._cansocket.send(self.node_id, payload)
async def write_with_response(
self,
payload: Union[bytearray, bytes],
resp_length: int,
timeout: Optional[float] = 2.
) -> bytes:
self.write(payload)
return await self.readexactly(resp_length, timeout)
def feed_data(self, data: bytes) -> None:
self._reader.feed_data(data)
def close(self) -> None:
self._reader.feed_eof()
class BaseSocket:
def __init__(self, args: argparse.Namespace) -> None:
self._loop = asyncio.get_running_loop()
self._args = args
self._fw_path = pathlib.Path(args.firmware).expanduser().resolve()
@property
def is_flash_req(self) -> bool:
return not (
self.is_bootloader_req or self.is_status_req or self.is_query
)
@property
def is_bootloader_req(self) -> bool:
return self._args.request_bootloader
@property
def is_status_req(self) -> bool:
return self._args.status
@property
def is_query(self) -> bool:
return self._args.query
@property
def is_usb_can_bridge(self) -> bool:
return False
@property
def usb_serial_path(self) -> pathlib.Path:
raise NotImplementedError()
def _check_firmware(self) -> None:
if self.is_flash_req and not self._fw_path.is_file():
raise FlashError("Invalid firmware path '%s'" % (self._fw_path))
async def run(self) -> None:
raise NotImplementedError()
def close(self) -> None:
raise NotImplementedError()
class CanSocket(BaseSocket):
def __init__(self, server: Server, args: argparse.Namespace) -> None:
super().__init__(args)
self._uuid = 0
self.server = server
self._can_interface = args.interface
self._can_bridge_path: pathlib.Path | None = None
self._can_bridge_serial_path: pathlib.Path | None = None
if not self.is_query:
if args.uuid is None:
raise FlashError(
"The 'uuid' option must be specified to flash a CAN device"
)
else:
intf = self._can_interface
self._uuid = int(args.uuid, 16)
self._search_canbus_bridge()
output_line(f"Connecting to CAN UUID {args.uuid} on interface {intf}")
self.cansock = socket.socket(socket.PF_CAN, socket.SOCK_RAW,
socket.CAN_RAW)
self.admin_node = CanNode(CANBUS_ID_ADMIN, self)
self.nodes: Dict[int, CanNode] = {
CANBUS_ID_ADMIN_RESP: self.admin_node
}
self.input_buffer = b""
self.output_packets: List[bytes] = []
self.input_busy = False
self.output_busy = False
self.closed = True
@property
def is_usb_can_bridge(self) -> bool:
return self._can_bridge_path is not None
@property
def usb_serial_path(self) -> pathlib.Path:
if self._can_bridge_serial_path is not None:
return self._can_bridge_serial_path
# find tty path
assert self._can_bridge_path is not None
dir_name = self._can_bridge_path.name
ttys = list(self._can_bridge_path.glob(f"{dir_name}:*/tty/tty*"))
if not ttys:
raise FlashError("Failed to locate serial tty path")
tty_path = pathlib.Path("/dev").joinpath(ttys[0].name)
if not tty_path.exists():
raise FlashError("Detected tty path does not exist")
self._can_bridge_serial_path = tty_path
return tty_path
def _handle_can_response(self) -> None:
try:
data = self.cansock.recv(4096)
except socket.error as e:
# If bad file descriptor allow connection to be
# closed by the data check
if e.errno == errno.EBADF:
logging.exception("Can Socket Read Error, closing")
data = b''
else:
return
if not data:
# socket closed
self.close()
return
self.input_buffer += data
if self.input_busy:
return
self.input_busy = True
while len(self.input_buffer) >= 16:
packet = self.input_buffer[:16]
self._process_packet(packet)
self.input_buffer = self.input_buffer[16:]
self.input_busy = False
def _process_packet(self, packet: bytes) -> None:
can_id, length, data = struct.unpack(CAN_FMT, packet)
can_id &= socket.CAN_EFF_MASK
payload = data[:length]
node = self.nodes.get(can_id)
if node is not None:
node.feed_data(payload)
def send(self, can_id: int, payload: bytes = b"") -> None:
if can_id > 0x7FF:
can_id |= socket.CAN_EFF_FLAG
if not payload:
packet = struct.pack(CAN_FMT, can_id, 0, b"")
self.output_packets.append(packet)
else:
while payload:
length = min(len(payload), 8)
pkt_data = payload[:length]
payload = payload[length:]
packet = struct.pack(
CAN_FMT, can_id, length, pkt_data)
self.output_packets.append(packet)
if self.output_busy:
return
self.output_busy = True
asyncio.create_task(self._do_can_send())
async def _do_can_send(self):
while self.output_packets:
packet = self.output_packets.pop(0)
try:
await self._loop.sock_sendall(self.cansock, packet)
except socket.error:
logging.info("Socket Write Error, closing")
self.close()
break
self.output_busy = False
def _jump_to_bootloader(self, uuid: int):
output_line("Sending bootloader jump command...")
plist = [(uuid >> ((5 - i) * 8)) & 0xFF for i in range(6)]
plist.insert(0, KLIPPER_REBOOT_CMD)
self.send(KLIPPER_ADMIN_ID, bytes(plist))
async def _query_uuids(self) -> List[int]:
output_line("Checking for Katapult nodes...")
payload = bytes([CANBUS_CMD_QUERY_UNASSIGNED])
self.admin_node.write(payload)
curtime = self._loop.time()
endtime = curtime + 2.
self.uuids: List[int] = []
while curtime < endtime:
timeout = max(.1, endtime - curtime)
try:
resp = await self.admin_node.read(8, timeout)
except asyncio.TimeoutError:
continue
finally:
curtime = self._loop.time()
if len(resp) < 7 or resp[0] != CANBUS_RESP_NEED_NODEID:
continue
app_names = {
KLIPPER_SET_NODE_CMD: "Klipper",
CANBUS_CMD_SET_NODEID: "Katapult"
}
app = "Unknown"
if len(resp) > 7:
app = app_names.get(resp[7], "Unknown")
data = resp[1:7]
output_line(f"Detected UUID: {data.hex()}, Application: {app}")
uuid = sum([v << ((5 - i) * 8) for i, v in enumerate(data)])
if uuid not in self.uuids and app == "Katapult":
self.uuids.append(uuid)
return self.uuids
def _reset_nodes(self) -> None:
output_line("Resetting all bootloader node IDs...")
payload = bytes([CANBUS_CMD_CLEAR_NODE_ID])
self.admin_node.write(payload)
def _set_node_id(self, uuid: int) -> CanNode:
# Convert ID to a list
plist = [(uuid >> ((5 - i) * 8)) & 0xFF for i in range(6)]
plist.insert(0, CANBUS_CMD_SET_NODEID)
node_id = len(self.nodes) + CANBUS_NODEID_OFFSET
plist.append(node_id)
payload = bytes(plist)
self.admin_node.write(payload)
decoded_id = node_id * 2 + 0x100
node = CanNode(decoded_id, self)
self.nodes[decoded_id + 1] = node
return node
def _search_canbus_bridge(self) -> None:
can_intf = self._can_interface.lower()
sysfs_usb_path = pathlib.Path("/sys/bus/usb/devices")
if not sysfs_usb_path.is_dir():
return
for item in sysfs_usb_path.iterdir():
if not item.joinpath("bDeviceClass").is_file():
continue
usb_info = get_usb_info(item)
if (
usb_info["usb_id"] != GS_CAN_USB_ID or
usb_info["manufacturer"] != "klipper"
):
continue
if not list(item.glob(f"{item.name}:*/net/{can_intf}")):
continue
# Klipper GS USB Device matches
serial_no = usb_info["serial_number"]
logging.info(
f"Found Klipper USB-CAN bridge on {can_intf}, serial {serial_no}"
)
if serial_no:
try:
det_uuid = convert_usbsn_to_uuid(serial_no)
except Exception:
output_line(
f"Failed to convert can bridge serial number {serial_no} "
f"to a uuid for device {item.name}"
)
logging.exception("UUID conversion failed")
return
logging.info(
f"Detected UUID: {det_uuid:x}, provided UUID: {self._uuid:x}"
)
if det_uuid == self._uuid:
self._can_bridge_path = item
output_line(f"Canbus Bridge detected at {item}")
break
async def _wait_canbridge_reset(self) -> None:
if self._can_bridge_path is None:
return
output("Waiting for USB Reconnect.")
for _ in range(8):
await asyncio.sleep(.5)
output(".")
usb_info = get_usb_info(self._can_bridge_path)
mfr = usb_info.get("manufacturer")
usb_id = usb_info.get("usb_id", "")
product = usb_info.get("product")
if usb_id and usb_id != GS_CAN_USB_ID:
await asyncio.sleep(.5)
output_line("done")
output_line(f"Detected new USB Device: {usb_id} {mfr} {product}")
if mfr == "katapult" or usb_id == KATAPULT_USB_ID:
serial_path = self.usb_serial_path
output_line(f"Katapult detected at serial port {serial_path}")
else:
# Device is not Katapult, force exit
self._args.request_bootloader = True
output_line("Device is not Katapult, exiting...")
break
else:
output_line("timed out")
async def run(self) -> None:
self._check_firmware()
try:
self.cansock.bind((self._can_interface,))
except Exception:
raise FlashError(f"Unable to bind socket to {self._can_interface}")
self.closed = False
self.cansock.setblocking(False)
self._loop.add_reader(
self.cansock.fileno(), self._handle_can_response)
if self.is_flash_req or self.is_bootloader_req:
self._jump_to_bootloader(self._uuid)
if self.is_usb_can_bridge:
await self._wait_canbridge_reset()
return
else:
await asyncio.sleep(1.0)
if self.is_bootloader_req:
return
self._reset_nodes()
await asyncio.sleep(.5)
if self.is_query:
await self._query_uuids()
return
node = self._set_node_id(self._uuid)
flasher = CanFlasher(self.server, node, self._fw_path)
await asyncio.sleep(.5)
try:
await flasher.connect_btl()
await flasher.verify_canbus_uuid(self._uuid)
if not self.is_status_req:
await flasher.send_file()
await flasher.verify_file()
finally:
# always attempt to send the complete command. If
# there is an error it will exit the bootloader
# unless comms were broken
if self.is_flash_req:
await flasher.finish()
def close(self):
if self.closed:
return
self.closed = True
for node in self.nodes.values():
node.close()
self._loop.remove_reader(self.cansock.fileno())
self.cansock.close()
class SerialSocket(BaseSocket):
def __init__(self, server: Server, args: argparse.Namespace) -> None:
super().__init__(args)
self.server = server
self._device = args.device
self._baud = args.baud
if not HAS_SERIAL:
ser_inst_cmd = "pip3 install serial"
if shutil.which("apt") is not None:
ser_inst_cmd = "sudo apt install python3-serial"
raise FlashError(
"The pyserial python package was not found. To install "
"run the following command in a terminal: \n\n"
f" {ser_inst_cmd}\n\n"
)
if self._device is None:
raise FlashError(
"The 'device' option must be specified to flash a device"
)
output_line(f"Connecting to Serial Device {self._device}, baud {self._baud}")
self.serial: Optional[Serial] = None
self.node = CanNode(0, self)
@property
def is_query(self) -> bool:
return False
@property
def usb_serial_path(self) -> pathlib.Path:
return pathlib.Path(self._device)
def _handle_response(self) -> None:
assert self.serial is not None
try:
data = self.serial.read(4096)
except SerialException:
logging.exception("Error on serial read")
self.close()
else:
self.node.feed_data(data)
def send(self, can_id: int, payload: bytes = b"") -> None:
assert self.serial is not None
try:
self.serial.write(payload)
except SerialException:
logging.exception("Error on serial write")
self.close()
async def _lookup_proc_name(self, process_id: str) -> str:
has_sysctl = shutil.which("systemctl") is not None
if has_sysctl:
cmd = shlex.split(f"systemctl status {process_id}")
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, _ = await proc.communicate()
resp = stdout.strip().decode(errors="ignore")
if resp:
unit = resp.split(maxsplit=2)
if len(unit) == 3:
return f"Systemd Unit Name: {unit[1]}"
cmdline_file = pathlib.Path(f"/proc/{process_id}/cmdline")
if cmdline_file.exists():
res = cmdline_file.read_text().replace("\x00", " ").strip()
return f"Command Line: {res}"
exe_file = pathlib.Path(f"/proc/{process_id}/exe")
if exe_file.exists():
return f"Executable: {exe_file.resolve()})"
return "Name Unknown"
async def validate_device(self, dev_strpath: str) -> None:
dev_path = pathlib.Path(dev_strpath)
if not dev_path.exists():
raise FlashError(f"No Serial Device found at {dev_path}")
try:
dev_st = dev_path.stat()
except PermissionError as e:
raise FlashError(f"No permission to access device {dev_path}") from e
dev_id = (dev_st.st_dev, dev_st.st_ino)
for fd_dir in pathlib.Path("/proc").glob("*/fd"):
pid = fd_dir.parent.name
if not pid.isdigit():
continue
with contextlib.suppress(OSError):
for item in fd_dir.iterdir():
try:
item_st = item.stat()
except OSError:
continue
item_id = (item_st.st_dev, item_st.st_ino)
if item_id == dev_id:
proc_name = await self._lookup_proc_name(pid)
output_line(
f"Serial device {dev_path} in use by another program.\n"
f"Process ID: {pid}\n"
f"Process {proc_name}"
)
raise FlashError(f"Serial device {dev_path} in use")
async def _request_usb_bootloader(self, device: pathlib.Path) -> pathlib.Path:
output_line(f"Requesting USB bootloader for {device}...")
usb_dev_path = get_usb_path(device)
if usb_dev_path is None:
output_line(f"Device path {device} is not a usb device")
return device
stable_path = get_stable_usb_symlink(device)
usb_info = get_usb_info(usb_dev_path)
start_usb_id = usb_info["usb_id"]
fd: Optional[int] = None
with contextlib.suppress(OSError):
fd = os.open(str(device), os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
dtr_data = struct.pack('I', termios.TIOCM_DTR)
fcntl.ioctl(fd, termios.TIOCMBIS, dtr_data)
t = termios.tcgetattr(fd)
t[4] = t[5] = termios.B1200
termios.tcsetattr(fd, termios.TCSANOW, t)
fcntl.ioctl(fd, termios.TIOCMBIC, dtr_data)
if fd is not None:
os.close(fd)
output("Waiting for USB Reconnect.")
for _ in range(8):
await asyncio.sleep(.5)
output(".")
usb_info = get_usb_info(usb_dev_path)
mfr = usb_info.get("manufacturer")
product = usb_info.get("product")
usb_id = usb_info.get("usb_id", "")
if usb_id and usb_id != start_usb_id:
await asyncio.sleep(.5)
output_line("done")
output_line(f"Detected new USB Device: {usb_id} {mfr} {product}")
if mfr == "katapult" or usb_id == KATAPULT_USB_ID:
# prefer path resolved from sysfs usb path
path_match = list(
usb_dev_path.glob(f"{usb_dev_path.name}:*/tty/tty*")
)
if len(path_match) == 1:
det_path = pathlib.Path(f"/dev/{path_match[0].name}")
if det_path.exists():
stable_path = det_path
output_line(f"Katapult detected on {stable_path}")
else:
# Device is not Katapult, force exit
self._args.request_bootloader = True
output_line("Device is not Katapult, exiting...")
break
else:
output_line("timed out")
return stable_path
async def _request_serial_bootloader(self, device: str, baud: int) -> None:
output_line(f"Requesting serial bootloader for device {device}...")
self.serial = self._open_device(device, baud)
self.send(0, SERIAL_BL_REQ)
await asyncio.sleep(1.)
if self.serial is not None:
self.close()
def _open_device(self, device: str, baud: int) -> Serial:
try:
serial_dev = Serial( # type: ignore
baudrate=baud, timeout=0, exclusive=True
)
serial_dev.port = device
serial_dev.open()
except (OSError, IOError, SerialException) as e:
raise FlashError("Unable to open serial port: %s" % (e,))
return serial_dev
def _has_double_buffering(self, product: str) -> bool:
if product.startswith("stm32"):
variant = product[5:7]
return variant not in ("f2", "f4", "h7")
return False
async def run(self) -> None:
self._check_firmware()
device = self._device
await self.validate_device(device)
dev_path = pathlib.Path(device)
usb_dev_path = get_usb_path(dev_path)
dev_info: Dict[str, Any] = {}
if usb_dev_path is not None:
dev_info = get_usb_info(usb_dev_path)
usb_id = dev_info.get("usb_id")
usb_mfr = dev_info.get("manufacturer")
usb_prod: str = dev_info.get("product", "unknown")
if usb_mfr == "klipper" or usb_id == KLIPPER_USB_ID:
# Request usb bootloader, wait for katapult
output_line("Detected USB device running Klipper")
new_dpath = await self._request_usb_bootloader(dev_path)
device = str(new_dpath)
if self.is_bootloader_req:
return
elif usb_mfr == "katapult" or usb_id == KATAPULT_USB_ID:
output_line("Detected USB device running Katapult")
if self.is_bootloader_req:
return
elif self.is_bootloader_req:
# Request serial bootloader and exit
await self._request_serial_bootloader(device, self._baud)
return
else:
usb_prod = ""
self.serial = self._open_device(device, self._baud)
self._loop.add_reader(self.serial.fileno(), self._handle_response)
flasher = CanFlasher(self.server, self.node, self._fw_path)
try:
if self._has_double_buffering(usb_prod):
# Prime the USB Connection with a dummy command. This is
# necessary to get STM32 devices with usbfs double buffering
# to respond immediately to the connect command.
flasher.prime()
await flasher.connect_btl()
if not self.is_status_req:
await flasher.send_file()
await flasher.verify_file()
finally:
# always attempt to send the complete command. If
# there is an error it will exit the bootloader
# unless comms were broken
if self.is_flash_req:
await flasher.finish()
def close(self):
if self.serial is None:
return
self._loop.remove_reader(self.serial.fileno())
self.serial.close()
self.serial = None
class FlashTool:
def __init__(
self,
server: Server,
device=None,
baud=250000,
interface="can0",
firmware="~/klipper/custom_out/klipper.bin",
uuid=None,
query=False,
verbose=False,
request_bootloader=False,
status=False
):
self.server = server
self.args = argparse.Namespace(
device=device,
baud=baud,
interface=interface,
firmware=firmware,
uuid=uuid,
query=query,
verbose=verbose,
request_bootloader=request_bootloader,
status=status
)
async def run(self) -> int:
iscan = self.args.device is None
sock: CanSocket | SerialSocket | None = None
try:
if iscan:
sock = CanSocket(self.server, self.args)
else:
sock = SerialSocket(self.server, self.args)
await sock.run()
if sock.is_usb_can_bridge and not sock.is_bootloader_req:
self.args.device = str(sock.usb_serial_path)
sock.close()
sock = SerialSocket(self.server, self.args)
await sock.run()
except Exception:
logging.exception("Flash Tool Error")
return 1
finally:
if sock is not None:
sock.close()
if sock is None:
return 1
if sock.is_query:
output_line("CANBus UUID Query Complete")
elif sock.is_bootloader_req:
output_line("Bootloader Request Complete")
elif sock.is_status_req:
output_line("Status Request Complete")
else:
output_line("Programming Complete")
return 0