diff --git a/cassini.py b/cassini.py index 1b75040..052eebc 100755 --- a/cassini.py +++ b/cassini.py @@ -1,5 +1,12 @@ #!env python3 # -*- coding: utf-8 -*- +# +# Cassini +# +# Copyright (C) 2023 Vladimir Vukicevic +# License: MIT +# + import sys import socket import struct @@ -10,93 +17,14 @@ import logging import random from simple_mqtt_server import SimpleMQTTServer from simple_http_server import SimpleHTTPServer +from saturn_printer import SaturnPrinter logging.basicConfig( - level=logging.INFO, + level=logging.DEBUG, # .INFO format="%(asctime)s,%(msecs)d %(levelname)s: %(message)s", datefmt="%H:%M:%S", ) -SATURN_BROADCAST_PORT = 3000 - -SATURN_STATUS_PRINTING = 4 -SATURN_STATUS_COMPLETE = 16 # ?? - -SATURN_CMD_0 = 0 # null data -SATURN_CMD_1 = 1 # null data -SATURN_CMD_SET_MYSTERY_TIME_PERIOD = 512 # "TimePeriod": 5000 -SATURN_CMD_START_PRINTING = 128 # "Filename": "X", "StartLayer": 0 -SATURN_CMD_UPLOAD_FILE = 256 # "Check": 0, "CleanCache": 1, "Compress": 0, "FileSize": 3541068, "Filename": "_ResinXP2-ValidationMatrix_v2.goo", "MD5": "205abc8fab0762ad2b0ee1f6b63b1750", "URL": "http://${ipaddr}:58883/f60c0718c8144b0db48b7149d4d85390.goo" }, -SATURN_CMD_DISCONNECT = 64 # Maybe disconnect? - -PRINTERS = [] - -PRINTER_SEARCH_TIMEOUT = 1 - -def handle_exception(loop, context): - msg = context.get("exception", context["message"]) - name = context.get("future").get_coro().__name__ - logging.error(f"Caught exception from {name}: {msg}") - -class SaturnPrinter: - def __init__(self, addr, desc): - self.addr = addr - self.desc = desc - - # Class method: UDP broadcast search for all printers - def find_printers(timeout=1): - printers = [] - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - with sock: - sock.settimeout(PRINTER_SEARCH_TIMEOUT) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, timeout) - sock.sendto(b'M99999', ('', SATURN_BROADCAST_PORT)) - - now = time.time() - while True: - if time.time() - now > timeout: - break - try: - data, addr = sock.recvfrom(1024) - except socket.timeout: - continue - else: - print(f'Found printer at {addr}') - pdata = json.loads(data.decode('utf-8')) - printers.append(SaturnPrinter(addr, pdata)) - return printers - - # Tell this printer to connect to the given mqtt server - def connect(self, mqtt, http): - self.mqtt = mqtt - self.http = http - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - with sock: - sock.sendto(b'M66666 ' + str(mqtt.port).encode('utf-8'), self.addr) - - def describe(self): - attrs = self.desc['Data']['Attributes'] - return f"{attrs['Name']} ({attrs['MachineName']})" - - def send_command(self, cmdid, data=None): - # generate 16-byte random identifier as a hex string - hexstr = '%032x' % random.getrandbits(128) - timestamp = int(time.time() * 1000) - mainboard = self.desc['Data']['Attributes']['MainboardID'] - cmd_data = { - "Data": { - "Cmd": cmdid, - "Data": data, - "From": 0, - "MainboardID": mainboard, - "RequestID": hexstr, - "TimeStamp": timestamp - }, - "Id": self.desc['Id'] - } - print("SENDING REQUEST: " + json.dumps(cmd_data)) - self.mqtt.outgoing_messages.put_nowait({'topic': '/sdcp/request/' + mainboard, 'payload': json.dumps(cmd_data)}) - async def create_mqtt_server(): mqtt = SimpleMQTTServer('0.0.0.0', 0) await mqtt.start() @@ -127,6 +55,11 @@ async def main(): print("No printers found") return + if len(printers) > 1: + print("More than 1 printer found.") + print("Usage --printer argument to specify the ID. [TODO]") + return + if len(sys.argv) > 1: cmd = sys.argv[1] @@ -134,7 +67,7 @@ async def main(): print_printer_status(printers) return - # Spin up our private mqtt server + # Spin up our private servers mqtt, mqtt_port, mqtt_task = await create_mqtt_server() http, http_port, http_task = await create_http_server() diff --git a/saturn_printer.py b/saturn_printer.py new file mode 100644 index 0000000..00aebcf --- /dev/null +++ b/saturn_printer.py @@ -0,0 +1,115 @@ +# +# Cassini +# +# Copyright (C) 2023 Vladimir Vukicevic +# License: MIT +# + +import sys +import socket +import struct +import time +import json +import asyncio +import logging +import random + +SATURN_BROADCAST_PORT = 3000 + +SATURN_STATUS_EXPOSURE = 2 # TODO: double check tese +SATURN_STATUS_RETRACTING = 3 +SATURN_STATUS_LOWERING = 4 +SATURN_STATUS_COMPLETE = 16 # ?? + +STATUS_NAMES = { + SATURN_STATUS_EXPOSURE: "Exposure", + SATURN_STATUS_RETRACTING: "Retracting", + SATURN_STATUS_LOWERING: "Lowering", + SATURN_STATUS_COMPLETE: "Complete" +} + +SATURN_CMD_0 = 0 # null data +SATURN_CMD_1 = 1 # null data +SATURN_CMD_SET_MYSTERY_TIME_PERIOD = 512 # "TimePeriod": 5000 +SATURN_CMD_START_PRINTING = 128 # "Filename": "X", "StartLayer": 0 +SATURN_CMD_UPLOAD_FILE = 256 # "Check": 0, "CleanCache": 1, "Compress": 0, "FileSize": 3541068, "Filename": "_ResinXP2-ValidationMatrix_v2.goo", "MD5": "205abc8fab0762ad2b0ee1f6b63b1750", "URL": "http://${ipaddr}:58883/f60c0718c8144b0db48b7149d4d85390.goo" }, +SATURN_CMD_DISCONNECT = 64 # Maybe disconnect? + +class SaturnPrinter: + def __init__(self, addr, desc): + self.addr = addr + self.desc = desc + + # Class method: UDP broadcast search for all printers + def find_printers(timeout=1): + printers = [] + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + with sock: + sock.settimeout(timeout) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, timeout) + sock.sendto(b'M99999', ('', SATURN_BROADCAST_PORT)) + + now = time.time() + while True: + if time.time() - now > timeout: + break + try: + data, addr = sock.recvfrom(1024) + except socket.timeout: + continue + else: + logging.debug(f'Found printer at {addr}') + pdata = json.loads(data.decode('utf-8')) + printers.append(SaturnPrinter(addr, pdata)) + return printers + + # Tell this printer to connect to the given mqtt server + def connect(self, mqtt, http): + self.mqtt = mqtt + self.http = http + + mainboard = self.desc['Data']['Attributes']['MainboardID'] + mqtt.add_handler("/sdcp/saturn/" + mainboard, self.incoming_data) + mqtt.add_handler("/sdcp/response/" + mainboard, self.incoming_data) + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + with sock: + sock.sendto(b'M66666 ' + str(mqtt.port).encode('utf-8'), self.addr) + + def incoming_data(self, topic, payload): + if topic.startswith("/sdcp/status/"): + self.incoming_status(payload['Data']['Status']) + elif topic.startswith("/sdcp/attributes/"): + # don't think I care about attributes + pass + elif topic.startswith("/sdcp/response/"): + self.incoming_response(payload['Data']['RequestID'], payload['Data']['Cmd'], payload['Data']['Data']) + + def incoming_status(self, status): + logging.info(f"STATUS: {status}") + + def incoming_response(self, id, cmd, data): + logging.info(f"RESPONSE: {id} -- {cmd}: {data}") + + def describe(self): + attrs = self.desc['Data']['Attributes'] + return f"{attrs['Name']} ({attrs['MachineName']})" + + def send_command(self, cmdid, data=None): + # generate 16-byte random identifier as a hex string + hexstr = '%032x' % random.getrandbits(128) + timestamp = int(time.time() * 1000) + mainboard = self.desc['Data']['Attributes']['MainboardID'] + cmd_data = { + "Data": { + "Cmd": cmdid, + "Data": data, + "From": 0, + "MainboardID": mainboard, + "RequestID": hexstr, + "TimeStamp": timestamp + }, + "Id": self.desc['Id'] + } + print("SENDING REQUEST: " + json.dumps(cmd_data)) + self.mqtt.outgoing_messages.put_nowait({'topic': '/sdcp/request/' + mainboard, 'payload': json.dumps(cmd_data)}) diff --git a/simple_http_server.py b/simple_http_server.py index 59a6228..b7bf608 100644 --- a/simple_http_server.py +++ b/simple_http_server.py @@ -1,3 +1,10 @@ +# +# Cassini +# +# Copyright (C) 2023 Vladimir Vukicevic +# License: MIT +# + import asyncio import os import hashlib diff --git a/simple_mqtt_server.py b/simple_mqtt_server.py index 165e4de..f312cef 100644 --- a/simple_mqtt_server.py +++ b/simple_mqtt_server.py @@ -1,3 +1,11 @@ +# +# Cassini +# +# Copyright (C) 2023 Vladimir Vukicevic +# License: MIT +# + +import logging import asyncio import struct @@ -17,18 +25,24 @@ class SimpleMQTTServer: self.incoming_messages = asyncio.Queue() self.outgoing_messages = asyncio.Queue() self.next_pack_id_value = 1 + self.handlers = {} + + def add_handler(self, topic, handler): + if topic not in self.handlers: + self.handlers[topic] = [] + self.handlers[topic].append(handler) async def start(self): self.server = await asyncio.start_server(self.handle_client, self.host, self.port) self.port = self.server.sockets[0].getsockname()[1] - print(f'Listening on {self.server.sockets[0].getsockname()}') + logging.debug(f'Listening on {self.server.sockets[0].getsockname()}') async def serve_forever(self): await self.server.serve_forever() async def handle_client(self, reader, writer): addr = writer.get_extra_info('peername') - print(f'Socket connected from {addr}') + logging.debug(f'Socket connected from {addr}') data = b'' read_future = asyncio.ensure_future(reader.read(1024)) @@ -53,7 +67,7 @@ class SimpleMQTTServer: qos = subscribed_topics[topic] await self.send_msg(writer, MQTT_PUBLISH, payload=self.encode_publish(topic, payload, self.next_pack_id())) else: - print(f'SEND: NOT SUBSCRIBED {topic}: {payload}') + logging.debug(f'SEND: NOT SUBSCRIBED {topic}: {payload}') #msg = (MQTT_PUBLISH, 0, topic.encode('utf-8') + payload.encode('utf-8')) #await self.send_msg(writer, *msg) outgoing_messages_future = asyncio.ensure_future(self.outgoing_messages.get()) @@ -79,12 +93,12 @@ class SimpleMQTTServer: # TODO -- we could maybe not have enough bytes to decode the length, but assume # that won't happen msg_length, len_bytes_consumed = self.decode_length(data[1:]) - print(f" in msg_type: {msg_type} flags: {msg_flags} msg_length {msg_length} bytes_consumed for msg_length {len_bytes_consumed}") + logging.debug(f" in msg_type: {msg_type} flags: {msg_flags} msg_length {msg_length} bytes_consumed for msg_length {len_bytes_consumed}") # is there enough to process the message? head_len = len_bytes_consumed + 1 if msg_length + head_len > len(data): - print("Not enough") + logging.debug("Not enough") break # pull the message payload out, and move data to next packet @@ -93,12 +107,16 @@ class SimpleMQTTServer: if msg_type == MQTT_CONNECT: # ignore the contents of the message, should maybe check for 'MQTT' identifier at least - print(f"Client {addr} connected") + logging.info(f"Client {addr} connected") await self.send_msg(writer, MQTT_CONNACK, payload=b'\x00\x00') elif msg_type == MQTT_PUBLISH: qos = (msg_flags >> 1) & 0x3 topic, packid, content = self.parse_publish(message) - print(f"{topic}: {content}") + + logging.info(f"Got DATA on: {topic}") + if topic in self.handlers: + for handler in self.handlers[topic]: + handler(topic, content) if qos > 0: await self.send_msg(writer, MQTT_PUBACK, packet_ident=packid) elif msg_type == MQTT_SUBSCRIBE: @@ -106,11 +124,11 @@ class SimpleMQTTServer: packid = message[0] << 8 | message[1] message = message[2:] topic = self.parse_subscribe(message) - print(f"Client {addr} subscribed to topic '{topic}', QoS {qos}") + logging.info(f"Client {addr} subscribed to topic '{topic}', QoS {qos}") subscribed_topics[topic] = qos await self.send_msg(writer, MQTT_SUBACK, packet_ident=packid, payload=bytes([qos])) elif msg_type == MQTT_DISCONNECT: - print(f"Client {addr} disconnected") + logging.info(f"Client {addr} disconnected") writer.close() await writer.wait_closed() return @@ -124,7 +142,7 @@ class SimpleMQTTServer: if packet_ident > 0: head += bytes([packet_ident >> 8, packet_ident & 0xff]) data = head + payload - print(f" writing {len(data)} bytes: {data}") + logging.debug(f" writing {len(data)} bytes: {data}") writer.write(data) await writer.drain()