diff --git a/.vscode/launch.json b/.vscode/launch.json index adb136e..12200c5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -35,6 +35,21 @@ "{\"LocalServerAddress\":\"\", \"LogLevel\":\"DEBUG\"}" ] }, + { + "name": "Elegoo Connect", + "type": "debugpy", + "request": "launch", + "module": "elegoo_octoeverywhere", + "justMyCode": false, + "args": [ + // These args reflect the correct setup for a pi installed with the Bambu Connect version of the plugin. These args target the first instance. + // + // { "ServiceName": "octoeverywhere-elegoo", "VirtualEnvPath":"/home/pi/octoeverywhere-env", "RepoRootFolder":"/home/pi/octoeverywhere/", "LogFolder":"/home/pi/.octoeverywhere-elegoo/logs", "LocalFileStoragePath":"/home/pi/.octoeverywhere-elegoo/octoeverywhere-store", "ConfigFolder":"/home/pi/.octoeverywhere-elegoo/", "CompanionInstanceIdStr":"1" } + "eyAiU2VydmljZU5hbWUiOiAib2N0b2V2ZXJ5d2hlcmUtZWxlZ29vIiwgIlZpcnR1YWxFbnZQYXRoIjoiL2hvbWUvcGkvb2N0b2V2ZXJ5d2hlcmUtZW52IiwgIlJlcG9Sb290Rm9sZGVyIjoiL2hvbWUvcGkvb2N0b2V2ZXJ5d2hlcmUvIiwgIkxvZ0ZvbGRlciI6Ii9ob21lL3BpLy5vY3RvZXZlcnl3aGVyZS1lbGVnb28vbG9ncyIsICJMb2NhbEZpbGVTdG9yYWdlUGF0aCI6Ii9ob21lL3BpLy5vY3RvZXZlcnl3aGVyZS1lbGVnb28vb2N0b2V2ZXJ5d2hlcmUtc3RvcmUiLCAiQ29uZmlnRm9sZGVyIjoiL2hvbWUvcGkvLm9jdG9ldmVyeXdoZXJlLWVsZWdvby8iLCAiQ29tcGFuaW9uSW5zdGFuY2VJZFN0ciI6IjEiIH0=", + // We can optionally pass a dev config json object, which has dev specific overwrites we can make. + "{\"LocalServerAddress\":\"\", \"LogLevel\":\"DEBUG\"}" + ] + }, { "name": "Bambu Connect - X1C", "type": "debugpy", diff --git a/.vscode/settings.json b/.vscode/settings.json index 6138161..03ccec7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -57,6 +57,7 @@ "didnt", "dnspython", "dnstest", + "elegoo", "esac", "faststart", "Fernet", diff --git a/elegoo_octoeverywhere/__init__.py b/elegoo_octoeverywhere/__init__.py new file mode 100644 index 0000000..564091d --- /dev/null +++ b/elegoo_octoeverywhere/__init__.py @@ -0,0 +1 @@ +# Need to make this a module diff --git a/elegoo_octoeverywhere/__main__.py b/elegoo_octoeverywhere/__main__.py new file mode 100644 index 0000000..c2a8c1f --- /dev/null +++ b/elegoo_octoeverywhere/__main__.py @@ -0,0 +1,45 @@ +import sys + +from linux_host.startup import Startup +from linux_host.startup import ConfigDataTypes + +from .elegoohost import ElegooHost + +if __name__ == '__main__': + + # This is a helper class, to keep the startup logic common. + s = Startup() + + # Try to parse the config + jsonConfigStr = None + try: + # Get the json from the process args. + jsonConfig = s.GetJsonFromArgs(sys.argv) + + # + # Parse the common, required args. + # + ServiceName = s.GetConfigVarAndValidate(jsonConfig, "ServiceName", ConfigDataTypes.String) + VirtualEnvPath = s.GetConfigVarAndValidate(jsonConfig, "VirtualEnvPath", ConfigDataTypes.Path) + RepoRootFolder = s.GetConfigVarAndValidate(jsonConfig, "RepoRootFolder", ConfigDataTypes.Path) + LocalFileStoragePath = s.GetConfigVarAndValidate(jsonConfig, "LocalFileStoragePath", ConfigDataTypes.Path) + LogFolder = s.GetConfigVarAndValidate(jsonConfig, "LogFolder", ConfigDataTypes.Path) + ConfigFolder = s.GetConfigVarAndValidate(jsonConfig, "ConfigFolder", ConfigDataTypes.Path) + InstanceStr = s.GetConfigVarAndValidate(jsonConfig, "CompanionInstanceIdStr", ConfigDataTypes.String) + + except Exception as e: + s.PrintErrorAndExit(f"Exception while loading json config. Error:{str(e)}, Config: {jsonConfigStr}") + + # For debugging, we also allow an optional dev object to be passed. + devConfig_CanBeNone = s.GetDevConfigIfAvailable(sys.argv) + + # Run! + try: + # Create and run the main host! + host = ElegooHost(ConfigFolder, LogFolder, devConfig_CanBeNone) + host.RunBlocking(ConfigFolder, LocalFileStoragePath, RepoRootFolder, devConfig_CanBeNone) + except Exception as e: + s.PrintErrorAndExit(f"Exception leaked from main elegoo host class. Error:{str(e)}") + + # If we exit here, it's due to an error, since RunBlocking should be blocked forever. + sys.exit(1) diff --git a/elegoo_octoeverywhere/bambuclient.py b/elegoo_octoeverywhere/bambuclient.py new file mode 100644 index 0000000..3a1851e --- /dev/null +++ b/elegoo_octoeverywhere/bambuclient.py @@ -0,0 +1,494 @@ +import logging +import ssl +import time +import json +import socket +import threading +from typing import List + +import paho.mqtt.client as mqtt + +from octoeverywhere.sentry import Sentry + +from linux_host.config import Config +from linux_host.networksearch import NetworkSearch + +from .bambucloud import BambuCloud, LoginStatus +from .bambumodels import BambuState, BambuVersion + + +class ConnectionContext: + def __init__(self, isCloud:bool, ipOrHostname:str, userName:str, accessToken:str): + self.IsCloud = isCloud + self.IpOrHostname = ipOrHostname + self.UserName = userName + self.AccessToken = accessToken + + +# Responsible for connecting to and maintaining a connection to the Bambu Printer. +# Also responsible for dispatching out MQTT update messages. +class BambuClient: + + _Instance = None + + # Useful for debugging. + _PrintMQTTMessages = False + + @staticmethod + def Init(logger:logging.Logger, config:Config, stateTranslator): + BambuClient._Instance = BambuClient(logger, config, stateTranslator) + + + @staticmethod + def Get(): + return BambuClient._Instance + + + def __init__(self, logger:logging.Logger, config:Config, stateTranslator) -> None: + self.Logger = logger + self.StateTranslator = stateTranslator # BambuStateTranslator + + # Used to keep track of the printer state + # None means we are disconnected. + self.State:BambuState = None + self.Version:BambuVersion = None + self.HasDoneFirstFullStateSync = False + self.ReportSubscribeMid = None + self.IsPendingSubscribe = False + self._CleanupStateOnDisconnect() + + # Get the required args. + self.Config = config + self.PortStr = config.GetStr(Config.SectionCompanion, Config.CompanionKeyPort, None) + self.LanAccessCode = config.GetStr(Config.SectionBambu, Config.BambuAccessToken, None) + self.PrinterSn = config.GetStr(Config.SectionBambu, Config.BambuPrinterSn, None) + # The port and SN are required, but the Access Code isn't, since sometimes it's not there for cloud connections. + if self.PortStr is None or self.PrinterSn is None: + raise Exception("Missing required args from the config") + + # We use this var to keep track of consecutively failed connections + self.ConsecutivelyFailedConnectionAttempts = 0 + + # Start a thread to setup and maintain the connection. + self.Client:mqtt.Client = None + t = threading.Thread(target=self._ClientWorker) + t.start() + + + # Returns the current local State object which is kept in sync with the printer. + # Returns None if the printer is not connected and the state is unknown. + def GetState(self) -> BambuState: + return self.State + + + # Returns the current local Version object which is kept in sync with the printer. + # Returns None if the printer is not connected and the state is unknown. + def GetVersion(self) -> BambuVersion: + return self.Version + + + # Sends the pause command, returns is the send was successful or not. + def SendPause(self) -> bool: + return self._Publish({"print": {"sequence_id": "0", "command": "pause"}}) + + + # Sends the resume command, returns is the send was successful or not. + def SendResume(self) -> bool: + return self._Publish({"print": {"sequence_id": "0", "command": "resume"}}) + + + # Sends the cancel (stop) command, returns is the send was successful or not. + def SendCancel(self) -> bool: + return self._Publish({"print": {"sequence_id": "0", "command": "stop"}}) + + + # Sets up, runs, and maintains the MQTT connection. + def _ClientWorker(self): + localBackoffCounter = 0 + while True: + ipOrHostname = None + try: + # Before we try to connect, ensure we tell the state translator that we are starting a new connection. + self.StateTranslator.ResetForNewConnection() + + # We always connect locally. We use encryption, but the printer doesn't have a trusted + # cert root, so we have to disable the cert root checks. + self.Client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + + # Since we are local, we can do more aggressive reconnect logic. + # The default is min=1 max=120 seconds. + self.Client.reconnect_delay_set(min_delay=1, max_delay=5) + + # Setup the callback functions. + self.Client.on_connect = self._OnConnect + self.Client.on_message = self._OnMessage + self.Client.on_disconnect = self._OnDisconnect + self.Client.on_subscribe = self._OnSubscribe + self.Client.on_log = self._OnLog + + # Get the IP to try on this connect + connectionContext = self._GetConnectionContextToTry() + if connectionContext.IsCloud: + self.Logger.info("Trying to connect to printer via Bambu Cloud...") + # We are connecting to Bambu Cloud, setup MQTT for it. + self.Client.tls_set(tls_version=ssl.PROTOCOL_TLS) + else: + # We are trying to connect to the printer locally, so configure mqtt for a local connection. + self.Logger.info("Trying to connect to printer via local connection...") + self.Client.tls_set(tls_version=ssl.PROTOCOL_TLS, cert_reqs=ssl.CERT_NONE) + self.Client.tls_insecure_set(True) + + # Set the username and access token. + self.Client.username_pw_set(connectionContext.UserName, connectionContext.AccessToken) + + # Connect to the server + # This will throw if it fails, but after that, the loop_forever will handle reconnecting. + localBackoffCounter += 1 + self.Client.connect(connectionContext.IpOrHostname, int(self.PortStr), keepalive=5) + + # Note that self.Client.connect will not throw if there's no MQTT server, but not if auth is wrong. + # So if it didn't throw, we know there's a server there, but it might not be the right server + localBackoffCounter = 0 + + # This will run forever, including handling reconnects and such. + self.Client.loop_forever() + except Exception as e: + if isinstance(e, ConnectionRefusedError): + # This means there was no open socket at the given IP and port. + # This happens when the printer is offline, so we only need to log sometimes. + self.Logger.warning(f"Failed to connect to the Bambu printer {ipOrHostname}:{self.PortStr}, we will retry in a bit. "+str(e)) + elif isinstance(e, TimeoutError): + # This means there was no open socket at the given IP and port. + self.Logger.warning(f"Failed to connect to the Bambu printer {ipOrHostname}:{self.PortStr}, we will retry in a bit. "+str(e)) + elif isinstance(e, OSError) and ("Network is unreachable" in str(e) or "No route to host" in str(e)): + # This means the IP doesn't route to a device. + self.Logger.warning(f"Failed to connect to the Bambu printer {ipOrHostname}:{self.PortStr}, we will retry in a bit. "+str(e)) + elif isinstance(e, socket.timeout) and "timed out" in str(e): + # This means the IP doesn't route to a device. + self.Logger.warning(f"Failed to connect to the Bambu printer {ipOrHostname}:{self.PortStr} due to a timeout, we will retry in a bit. "+str(e)) + else: + # Random other errors. + Sentry.Exception(f"Failed to connect to the Bambu printer {ipOrHostname}:{self.PortStr}. We will retry in a bit.", e) + + # Sleep for a bit between tries. + # The main consideration here is to not log too much when the printer is off. But we do still want to connect quickly, when it's back on. + # Note that the system might also do a printer scan after many failed attempts, which can be CPU intensive. + # Right now we allow it to ramp up to 30 seconds between retries. + localBackoffCounter = min(localBackoffCounter, 6) + time.sleep(5 * localBackoffCounter) + + + # Since MQTT sends a full state and then partial updates, we sometimes need to force a full state sync, like on connect. + # This must be done async for most callers, since it blocks until the publish is acked. If this blocked on the main mqtt thread, it would + # dead lock. + # If this fails, it will disconnect the client. + def _ForceStateSyncAsync(self) -> bool: + def _FullSyncWorker(): + try: + self.Logger.info("Starting full state sync.") + # It's important to request the hardware version first, so we have it parsed before we get the first full sync. + getInfo = {"info": {"sequence_id": "0", "command": "get_version"}} + if not self._Publish(getInfo): + raise Exception("Failed to publish get_version") + pushAll = { "pushing": {"sequence_id": "0", "command": "pushall"}} + if not self._Publish(pushAll): + raise Exception("Failed to publish full sync") + except Exception as e: + # Report and disconnect since we are in an unknown state. + Sentry.Exception("BambuClient _ForceStateSyncAsync exception.", e) + self.Client.disconnect() + t = threading.Thread(target=_FullSyncWorker) + t.start() + + + # Fired whenever the client is disconnected, we need to clean up the state since it's now unknown. + def _CleanupStateOnDisconnect(self): + self.State = None + self.Version = None + self.HasDoneFirstFullStateSync = False + self.ReportSubscribeMid = None + self.IsPendingSubscribe = False + # For some reason, the Bambu Cloud MQTT server will fire a disconnect message but doesn't actually disconnect. + # So we always call disconnect to ensure we force it, to ensure our connection loop closes. + try: + c = self.Client + if c is not None: + c.disconnect() + except Exception as e: + self.Logger.debug(f"_CleanupStateOnDisconnect exception on mqtt disconnect during cleanup. {e}") + + + # Fired when the MQTT connection is made. + def _OnConnect(self, client:mqtt.Client, userdata, flags, reason_code, properties): + self.Logger.info("Connection to the Bambu printer established! - Subscribing to the report subscription.") + # After connect, we try to subscribe to the report feed. + # We must do this before anything else, otherwise we won't get responses for things like + # the full state sync. The result of the subscribe will be reported to _OnSubscribe + # Note that at least for my P1P, if the SN is incorrect, the MQTT connection is closed with no _OnSubscribe callback. + # Thus we set the self.IsPendingSubscribe flag, so we can give the user a better error message. + self.IsPendingSubscribe = True + (result, self.ReportSubscribeMid) = self.Client.subscribe(f"device/{self.PrinterSn}/report") + if result != mqtt.MQTT_ERR_SUCCESS or self.ReportSubscribeMid is None: + # If we can't sub, disconnect, since we can't do anything. + self.Logger.warn(f"Failed to subscribe to the MQTT subscription using the serial number '{self.PrinterSn}'. Result: {result}. Disconnecting.") + self.Client.disconnect() + + + # Fired when the MQTT connection is lost + def _OnDisconnect(self, client, userdata, disconnect_flags, reason_code, properties): + # If the serial number is wrong in the subscribe call, instead of returning an error the Bambu Lab printers just disconnect. + # So if we were pending a subscribe call, give the user a better error message so they know the likely cause. + if self.IsPendingSubscribe: + self.Logger.error("Bambu printer mqtt connection lost when trying to sub for events.") + self.Logger.error(f"THIS USUALLY MEANS THE PRINTER SERIAL NUMBER IS WRONG. We tried to use the serial number '{self.PrinterSn}'. Double check the SN is correct.") + else: + self.Logger.warn("Bambu printer connection lost. We will try to reconnect in a few seconds.") + # Clear the state since we lost the connection and won't stay synced. + self._CleanupStateOnDisconnect() + + + # Fired when the MQTT connection has something to log. + def _OnLog(self, client, userdata, level:mqtt.LOGGING_LEVEL, msg:str): + if level == mqtt.MQTT_LOG_ERR: + # If the string is something like "Caught exception in on_connect: ..." + # It's a leaked exception from us. + if "exception" in msg: + Sentry.Exception("MQTT leaked exception.", Exception(msg)) + else: + self.Logger.error(f"MQTT log error: {msg}") + elif level == mqtt.MQTT_LOG_WARNING: + # Report warnings. + self.Logger.error(f"MQTT log warn: {msg}") + # else: + # # Report everything else if debug is enabled. + # if self.Logger.isEnabledFor(logging.DEBUG): + # self.Logger.debug(f"MQTT log: {msg}") + + + # Fried when the MQTT subscribe result has come back. + def _OnSubscribe(self, client, userdata, mid, reason_code_list:List[mqtt.ReasonCode], properties): + # We only want to listen for the result of the report subscribe. + if self.ReportSubscribeMid is not None and self.ReportSubscribeMid == mid: + # Ensure the sub was successful. + for r in reason_code_list: + if r.is_failure: + # On any failure, report it and disconnect. + self.Logger.error(f"Sub response for the report subscription reports failure. {r}") + self.Client.disconnect() + return + + # At this point, we know the connection was successful, the access code is correct, and the SN is correct. + self.ConsecutivelyFailedConnectionAttempts = 0 + + # Sub success! Force a full state sync. + self._ForceStateSyncAsync() + + + # Fired when there's an incoming MQTT message. + def _OnMessage(self, client, userdata, mqttMsg:mqtt.MQTTMessage): + try: + # Try to deserialize the message. + msg = json.loads(mqttMsg.payload) + if msg is None: + raise Exception("Parsed json MQTT message returned None") + + # Print for debugging if desired. + if BambuClient._PrintMQTTMessages and self.Logger.isEnabledFor(logging.DEBUG): + self.Logger.debug("Incoming Bambu Message:\r\n"+json.dumps(msg, indent=3)) + + # Since we keep a track of the state locally from the partial updates, we need to feed all updates to our state object. + isFirstFullSyncResponse = False + if "print" in msg: + printMsg = msg["print"] + try: + if self.State is None: + # Build the object before we set it. + s = BambuState() + s.OnUpdate(printMsg) + self.State = s + else: + self.State.OnUpdate(printMsg) + except Exception as e: + Sentry.Exception("Exception calling BambuState.OnUpdate", e) + + # Try to detect if this is the response to the first full sync request. + if self.HasDoneFirstFullStateSync is False: + # First make sure the command is the push status. + cmd = printMsg.get("command", None) + if cmd is not None and cmd == "push_status": + # We dont have a 100% great way to know if this is a fully sync message. + # For now, we use this stat. The message we get from a P1P has 59 members in the root, so we use 40 as mark. + # Note we use this same value in NetworkSearch.ValidateConnection_Bambu + if len(printMsg) > 40: + isFirstFullSyncResponse = True + self.HasDoneFirstFullStateSync = True + + # Update the version info if sent. + if "info" in msg: + try: + if self.Version is None: + # Build the object before we set it. + s = BambuVersion(self.Logger) + s.OnUpdate(msg["info"]) + self.Version = s + else: + self.Version.OnUpdate(msg["info"]) + except Exception as e: + Sentry.Exception("Exception calling BambuVersion.OnUpdate", e) + + # Send all messages to the state translator + # This must happen AFTER we update the State object, so it's current. + try: + # Only send the message along if there's a state. This can happen if a push_status isn't the first message we receive. + if self.State is not None: + self.StateTranslator.OnMqttMessage(msg, self.State, isFirstFullSyncResponse) + except Exception as e: + Sentry.Exception("Exception calling StateTranslator.OnMqttMessage", e) + + except Exception as e: + Sentry.Exception(f"Failed to handle incoming mqtt message. {mqttMsg.payload}", e) + + + # Publishes a message and blocks until it knows if the message send was successful or not. + def _Publish(self, msg:dict) -> bool: + try: + # Print for debugging if desired. + if self.Logger.isEnabledFor(logging.DEBUG): + self.Logger.debug("Incoming Bambu Message:\r\n"+json.dumps(msg, indent=3)) + + # Ensure we are connected. + if self.Client is None or not self.Client.is_connected(): + self.Logger.info("Failed to publish command because we aren't connected.") + return False + + # Try to publish. + state = self.Client.publish(f"device/{self.PrinterSn}/request", json.dumps(msg)) + + # Wait for the message publish to be acked. + # This will throw if the publish fails. + state.wait_for_publish(20) + return True + except Exception as e: + Sentry.Exception("Failed to publish message to bambu printer.", e) + return False + + + # Returns a connection context object we should try to for this connection attempt. + # The connection context can indicate we are trying to connect to the Bambu Cloud or the local printer, + # depending on the plugin config and what's available. + def _GetConnectionContextToTry(self) -> ConnectionContext: + # Increment and reset if it's too high. + # This will restart the process of trying cloud connect and falling back. + doPrinterSearch = False + self.ConsecutivelyFailedConnectionAttempts += 1 + if self.ConsecutivelyFailedConnectionAttempts > 6: + self.ConsecutivelyFailedConnectionAttempts = 0 + doPrinterSearch = True + + # Get the connection mode set by the user. This defaults to local, but the user can explicitly set it to either. + connectionMode = self.Config.GetStr(Config.SectionBambu, Config.BambuConnectionMode, Config.BambuConnectionModeDefault) + if connectionMode == Config.BambuConnectionModeValueCloud: + # If the mode is set to cloud, try to connect via it. + # If a context can't be created, there's something wrong with the account info + # or a Bambu service issue. Since we have the local info, we can try it as well. + cloudContext = self._TryToGetCloudConnectContext() + if cloudContext is not None: + return cloudContext + self.Logger.warning("We tried to connect via Bambu Cloud, but failed. We will try a local connection.") + + # On the first few attempts, use the expected IP or the cloud config. + # Every time we reset the count, we will try a network scan to see if we can find the printer guessing it's IP might have changed. + # The IP can be empty, like if the docker container is used, in which case we should always search for the printer. + configIpOrHostname = self.Config.GetStr(Config.SectionCompanion, Config.CompanionKeyIpOrHostname, None) + if doPrinterSearch is False: + # If we aren't using a cloud connection or it failed, return the local hostname + if configIpOrHostname is not None and len(configIpOrHostname) > 0: + return self._GetLocalConnectionContext(configIpOrHostname) + + # If we fail too many times, try to scan for the printer on the local subnet, the IP could have changed. + # Since we 100% identify the printer by the access token and printer SN, we can try to scan for it. + # Note we don't want to do this too often since it's CPU intensive and the printer might just be off. + # We use a lower thread count and delay before each action to reduce the required load. + # Using this config, it takes about 30 seconds to scan for the printer. + self.Logger.info(f"Searching for your Bambu Lab printer {self.PrinterSn}") + ips = NetworkSearch.ScanForInstances_Bambu(self.Logger, self.LanAccessCode, self.PrinterSn, threadCount=25, delaySec=0.2) + + # If we get an IP back, it is the printer. + # The scan above will only return an IP if the printer was successfully connected to, logged into, and fully authorized with the Access Token and Printer SN. + if len(ips) == 1: + # Since we know this is the IP, we will update it in the config. This mean in the future we will use this IP directly + # And everything else trying to connect to the printer (webcam and ftp) will use the correct IP. + ip = ips[0] + self.Logger.info(f"We found a new IP for this printer. [{configIpOrHostname} -> {ip}] Updating the config and using it to connect.") + self.Config.SetStr(Config.SectionCompanion, Config.CompanionKeyIpOrHostname, ip) + return self._GetLocalConnectionContext(ip) + + # If we don't find anything, just use the config IP. + return self._GetLocalConnectionContext(configIpOrHostname) + + + def _GetLocalConnectionContext(self, ipOrHostname) -> ConnectionContext: + # The username is always the same, we use the local LAN access token. + return ConnectionContext(False, ipOrHostname, "bblp", self.LanAccessCode) + + + # Returns a Bambu Cloud based connection context if it can be made, otherwise None + def _TryToGetCloudConnectContext(self) -> ConnectionContext: + bCloud = BambuCloud.Get() + if bCloud.HasContext() is False: + return None + + # Try to login and get the access token. + # Force the login to ensure the access token is current. + accessTokenResult = BambuCloud.Get().GetAccessToken(forceLogin=True) + + # If we failed, make sure to log the reason, so it's obvious for the user. + if accessTokenResult.Status != LoginStatus.Success: + self.Logger.error("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + self.Logger.error(" Failed To Log Into Bambu Cloud") + if accessTokenResult.Status == LoginStatus.BadUserNameOrPassword: + self.Logger.error("The email address or password is wrong. Re-run the Bambu Connect installer or use the docker files to update your email address and password.") + elif accessTokenResult.Status == LoginStatus.TwoFactorAuthEnabled: + self.Logger.error("Two factor auth is enabled on this account. Bambu Lab doesn't allow us to support two factor auth, so it must be disabled on your account or the local connection mode.") + elif accessTokenResult.Status == LoginStatus.EmailCodeRequired: + self.Logger.error("This account requires an email code to login. Bambu Lab doesn't allow us to support this, so you must use the local connection mode.") + else: + self.Logger.error("Unknown error, we will try again later.") + self.Logger.error("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + + # We do a delay here, so we don't pound on the service. If we can't login for one of these reasons, we probably can't recover. + time.sleep(600.0 * self.ConsecutivelyFailedConnectionAttempts) + return None + + # Return the connection object. + accessToken = accessTokenResult.AccessToken + return ConnectionContext(True, bCloud.GetMqttHostname(), bCloud.GetUserNameFromAccessToken(accessToken), accessToken) + + +# A class returned as the result of all commands. +class BambuCommandResult: + + def __init__(self, result:dict = None, connected:bool = True, timeout:bool = False, otherError:str = None, exception:Exception = None) -> None: + self.Connected = connected + self.Timeout = timeout + self.OtherError = otherError + self.Ex = exception + self.Result = result + + + def HasError(self) -> bool: + return self.Ex is not None or self.OtherError is not None or self.Result is None or self.Connected is False or self.Timeout is True + + + def GetLoggingErrorStr(self) -> str: + if self.Ex is not None: + return str(self.Ex) + if self.OtherError is not None: + return self.OtherError + if self.Connected is False: + return "MQTT not connected." + if self.Timeout: + return "Command timeout." + if self.Result is None: + return "No response." + return "No Error" diff --git a/elegoo_octoeverywhere/bambucloud.py b/elegoo_octoeverywhere/bambucloud.py new file mode 100644 index 0000000..43ab47f --- /dev/null +++ b/elegoo_octoeverywhere/bambucloud.py @@ -0,0 +1,304 @@ +import json +import codecs +import base64 +import logging +import threading +from enum import Enum + +import requests + +from linux_host.config import Config + +from octoeverywhere.sentry import Sentry + + +# The result of a login request. +class LoginStatus(Enum): + Success = 0 # This is the only successful value + TwoFactorAuthEnabled = 1 + BadUserNameOrPassword = 2 + EmailCodeRequired = 3 + UnknownError = 4 + + +# The result of a get access token request. +# If the token is None, the Status will indicate why. +class AccessTokenResult(): + def __init__(self, status:LoginStatus, token:str = None) -> None: + self.Status = status + self.AccessToken = token + + +# A class that interacts with the Bambu Cloud. +# This github has a community made API docs: +# https://github.com/Doridian/OpenBambuAPI/blob/main/cloud-http.md +class BambuCloud: + + _Instance = None + + + @staticmethod + def Init(logger:logging.Logger, config:Config): + BambuCloud._Instance = BambuCloud(logger, config) + + + @staticmethod + def Get(): + return BambuCloud._Instance + + + def __init__(self, logger:logging.Logger, config:Config) -> None: + self.Logger = logger + self.Config = config + self.AccessToken = None + + + # Logs in given the user name and password. This doesn't support two factor auth at this time. + # Returns true if the login was successful, otherwise false. + def Login(self) -> LoginStatus: + try: + # Some notes on login. We were going to originally going to cache the access token and refresh token, so we didn't have to store the user name and password. + # However, the refresh token has an expiration on it, so eventually the user would have to re-enter their password, which isn't ideal. + # We also don't gain anything by storing the access token, since we need to hit an API anyways to make sure it's still valid and working. + self.Logger.info("Logging into Bambu Cloud...") + + # Get the correct URL. + url = self._GetBambuCloudApi("/v1/user-service/user/login") + + # Get the context. + email, password = self.GetContext() + if email is None or password is None: + self.Logger.error("Login Bambu Cloud failed to get context from the config.") + return LoginStatus.BadUserNameOrPassword + + # Make the request. + response = requests.post(url, json={'account': email, 'password': password}, timeout=30) + + # Check the response. + if response.status_code != 200: + body = "" + try: + body = json.dumps(response.json()) + except Exception: + pass + if response.status_code == 400: + self.Logger.error(f"Login Bambu Cloud failed with status code: 400 bad request. The user name or password are probably wrong or has changed. Response: {body}") + return LoginStatus.BadUserNameOrPassword + self.Logger.error(f"Login Bambu Cloud failed with status code: {response.status_code}, Response: {body}") + return LoginStatus.UnknownError + + # If the user has two factor auth enabled, this will still return 200, but there will be a tfaKey field with a string. + j = response.json() + tfaKey = j.get('tfaKey', None) + if tfaKey is not None and len(tfaKey) > 0: + self.Logger.error("Login Bambu Cloud failed because two factor auth is enabled. Bambu Lab's APIs don't allow us to support two factor at this time.") + return LoginStatus.TwoFactorAuthEnabled + + # Try to get the access token + accessToken = j.get('accessToken', None) + if accessToken is None or len(accessToken) == 0: + self.Logger.error("Login Bambu Cloud failed because access token was not found in the response.") + return LoginStatus.UnknownError + self.AccessToken = accessToken + + # The token expiration is usually 1 year, we just check it for now. + expiresIn = int(j.get('expiresIn', 0)) + if expiresIn / 60 / 60 / 24 < 300: + self.Logger.warn(f"Login Bambu Cloud access token expires in {expiresIn} seconds") + + # Every time we login in, we also want to ensure the printer's cloud info is synced locally. + # Right now this can only sync the access code, but this is important, because things like the webcam streaming need to know the access code. + self.SyncBambuCloudInfoAsync() + + # Success + return LoginStatus.Success + + except Exception as e: + Sentry.Exception("Bambu Cloud login exception", e) + return LoginStatus.UnknownError + + + # Returns the access token. + # If there's no valid access token, this will try a blocking login. + def GetAccessToken(self, forceLogin = False) -> AccessTokenResult: + # If we already have the access token, we are good. + if forceLogin is False and self.AccessToken is not None and len(self.AccessToken) > 0: + return AccessTokenResult(LoginStatus.Success, self.AccessToken) + + # Else, try a login. + status = self.Login() + return AccessTokenResult(status, self.AccessToken) + + + # Used to clear the access token if there's a failure using it. + def _ResetAccessToken(self): + self.AccessToken = None + + + # A helper to decode the access token and get the Bambu Cloud username. + # Returns None on failure. + def GetUserNameFromAccessToken(self, accessToken: str) -> str: + try: + # The Access Token is a JWT, we need the second part to decode. + accountInfoBase64 = accessToken.split(".")[1] + # The string len must be a multiple of 4, padded with "=" + while (len(accountInfoBase64)) % 4 != 0: + accountInfoBase64 += "=" + # Decode and parse as json. + jsonAuthToken = json.loads(base64.b64decode(accountInfoBase64)) + return jsonAuthToken["username"] + except Exception as e: + Sentry.Exception("Bambu Cloud GetUserNameFromAccessToken exception", e) + return None + + + # Returns a list of the user's devices. + # Returns None on failure. + # Special Note: This function is used as a access token validation check. So if this fails due to the access token being invalid, the access token should be cleared so we try to login again. + def GetDeviceList(self) -> dict: + tokenResult = self.GetAccessToken() + if tokenResult.Status != LoginStatus.Success: + return None + + # Get the API + url = self._GetBambuCloudApi("/v1/iot-service/api/user/bind") + + # Make the request. + headers = {'Authorization': 'Bearer ' + tokenResult.AccessToken} + response = requests.get(url, headers=headers, timeout=10) + if response.status_code != 200: + self.Logger.error(f"Bambu Cloud GetDeviceList failed with status code: {response.status_code}") + # On failure reset the access token. + self._ResetAccessToken() + return None + self.Logger.debug(f"Bambu Cloud Device List: {response.json()}") + devices = response.json().get('devices', None) + if devices is None: + self.Logger.error("Bambu Cloud GetDeviceList failed, the devices object was missing.") + return None + return response.json()['devices'] + + + # Returns this device info from the Bambu Cloud API by matching the SN + def GetThisDeviceInfo(self) -> dict: + devices = self.GetDeviceList() + localSn = self.Config.GetStr(Config.SectionBambu, Config.BambuPrinterSn, None) + if localSn is None: + self.Logger.error("Bambu Cloud GetThisDeviceInfo has no local printer SN to match.") + return None + for d in devices: + sn = d.get('dev_id', None) + self.Logger.debug(f"Bambu Cloud Printer Info. SN:{sn} Name:{(d.get('name', None))}") + if sn == localSn: + return d + self.Logger.error("Bambu Cloud failed to find a matching printer SN on the user account.") + return None + + + # Get's the known device info from the Bambu API and ensures it's synced with our config settings. + def SyncBambuCloudInfoAsync(self) -> bool: + threading.Thread(target=self.SyncBambuCloudInfo, daemon=True).start() + + + def SyncBambuCloudInfo(self) -> bool: + try: + info = self.GetThisDeviceInfo() + if info is None: + self.Logger.error("Bambu Cloud SyncBambuCloudInfo didn't find printer info.") + return False + accessCode = info.get('dev_access_code', None) + if accessCode is None: + self.Logger.error("Bambu Cloud SyncBambuCloudInfo didn't find an access code.") + return False + # It turns out that sometimes the Access Code from the service wrong, so we only update + # it if there's no access token set, so the user can override it in the config. + if self.Config.GetStr(Config.SectionBambu, Config.BambuAccessToken, None) is None: + self.Config.SetStr(Config.SectionBambu, Config.BambuAccessToken, accessCode) + self.Logger.info("Bambu Cloud SyncBambuCloudInfo updated the access code.") + return True + except Exception as e: + Sentry.Exception("SyncBambuCloudInfo exception", e) + return False + + + def _IsRegionChina(self) -> bool: + region = self.Config.GetStr(Config.SectionBambu, Config.BambuCloudRegion, None) + if region is None: + self.Logger.warn("Bambu Cloud region not set, assuming world wide.") + region = "world" + return region == "china" + + + # Returns the correct MQTT hostname depending on the region. + def GetMqttHostname(self): + if self._IsRegionChina(): + return "cn.mqtt.bambulab.com" + return "us.mqtt.bambulab.com" + + + # Returns the correct full API URL based on the region. + def _GetBambuCloudApi(self, urlPathAndParams:str): + if self._IsRegionChina(): + return "https://api.bambulab.cn" + urlPathAndParams + return "https://api.bambulab.com" + urlPathAndParams + + + # Sets the user's context into the config file. + def SetContext(self, email:str, p:str) -> bool: + try: + # This isn't ideal, but there's nothing we can do better locally on the device. + # So at least it's not just plain text. + data = {"email":email, "p":p} + j = json.dumps(data) + # In the past we used the crypo lib to actually do crypto with a static key here in the code. + # But the crypo lib had a lot of native lib requirements and it caused install issues. + # Since we were using a static key anyways, we will just do this custom obfuscation function. + token = self._ObfuscateString(j) + self.Config.SetStr(Config.SectionBambu, Config.BambuCloudContext, token) + return True + except Exception as e: + Sentry.Exception("Bambu Cloud set email exception", e) + return False + + + # Returns if there's a user context in the config file. + # This doesn't check if the user context is valid, just that it's there. + def HasContext(self) -> bool: + (e, p) = self.GetContext() + return e is not None and p is not None + + + # Sets the user's context from the config file. + def GetContext(self, expectContextToExist = True): + try: + token = self.Config.GetStr(Config.SectionBambu, Config.BambuCloudContext, None) + if token is None: + if expectContextToExist: + self.Logger.error("No Bambu Cloud context found in the config file.") + return (None, None) + jsonStr = self._UnobfuscateString(token) + data = json.loads(jsonStr) + e = data.get("email", None) + p = data.get("p", None) + if e is None or p is None: + self.Logger.error("No Bambu Cloud context was missing required data.") + return (None, None) + return (e, p) + except Exception as e: + Sentry.Exception("Bambu Cloud login exception", e) + return (None, None) + + + # The goal here is just to obfuscate the string with a unique algo, so the email and password aren't just plain text in the config file. + def _ObfuscateString(self, s:str) -> str: + # First, base64 encode the string. + base64Str = base64.b64encode(s.encode(encoding="utf-8")) + # First, next, rotate it. + return codecs.encode(base64Str.decode(encoding="utf-8"), 'rot13') + + + def _UnobfuscateString(self, s:str) -> str: + # Un-rotate + base64String = codecs.decode(s, 'rot13') + # Un-base64 encode + return base64.b64decode(base64String).decode(encoding="utf-8") diff --git a/elegoo_octoeverywhere/bambumodels.py b/elegoo_octoeverywhere/bambumodels.py new file mode 100644 index 0000000..d3f83a5 --- /dev/null +++ b/elegoo_octoeverywhere/bambumodels.py @@ -0,0 +1,266 @@ +import time +import logging +from enum import Enum + +from octoeverywhere.sentry import Sentry + +# Known printer error types. +# Note that the print state doesn't have to be ERROR to have an error, during a print it's "PAUSED" but the print_error value is not 0. +# Here's the full list https://e.bambulab.com/query.php?lang=en +class BambuPrintErrors(Enum): + Unknown = 1 # This will be most errors, since most of them aren't mapped + FilamentRunOut = 2 + + +# Since MQTT syncs a full state and then sends partial updates, we keep track of the full state +# and then apply updates on top of it. We basically keep a locally cached version of the state around. +class BambuState: + + def __init__(self) -> None: + # We only parse out what we currently use. + # We use the same naming as the json in the msg + self.stg_cur:int = None + self.gcode_state:str = None + self.layer_num:int = None + self.total_layer_num:int = None + self.subtask_name:str = None + self.mc_percent:int = None + self.nozzle_temper:float = None + self.nozzle_target_temper:float = None + self.bed_temper:float = None + self.bed_target_temper:float = None + self.mc_remaining_time:int = None + self.project_id:str = None + self.print_error:int = None + # On the X1, this is empty is LAN viewing of off + # It's a URL if streaming is enabled + # On other printers, this doesn't exist, so it's None + self.rtsp_url:str = None + # Custom fields + self.LastTimeRemainingWallClock:float = None + + + # Called when there's a new print message from the printer. + def OnUpdate(self, msg:dict) -> None: + # Get a new value or keep the current. + # Remember that most of these are partial updates and will only have some values. + self.stg_cur = msg.get("stg_cur", self.stg_cur) + self.gcode_state = msg.get("gcode_state", self.gcode_state) + self.layer_num = msg.get("layer_num", self.layer_num) + self.total_layer_num = msg.get("total_layer_num", self.total_layer_num) + self.subtask_name = msg.get("subtask_name", self.subtask_name) + self.project_id = msg.get("project_id", self.project_id) + self.mc_percent = msg.get("mc_percent", self.mc_percent) + self.nozzle_temper = msg.get("nozzle_temper", self.nozzle_temper) + self.nozzle_target_temper = msg.get("nozzle_target_temper", self.nozzle_target_temper) + self.bed_temper = msg.get("bed_temper", self.bed_temper) + self.bed_target_temper = msg.get("bed_target_temper", self.bed_target_temper) + self.print_error = msg.get("print_error", self.print_error) + ipCam = msg.get("ipcam", None) + if ipCam is not None: + self.rtsp_url = ipCam.get("rtsp_url", self.rtsp_url) + + # Time remaining has some custom logic, so as it's queried each time it keep counting down in seconds, since Bambu only gives us minutes. + old_mc_remaining_time = self.mc_remaining_time + self.mc_remaining_time = msg.get("mc_remaining_time", self.mc_remaining_time) + if old_mc_remaining_time != self.mc_remaining_time: + self.LastTimeRemainingWallClock = time.time() + + + # Returns a time reaming value that counts down in seconds, not just minutes. + # Returns null if the time is unknown. + def GetContinuousTimeRemainingSec(self) -> int: + if self.mc_remaining_time is None or self.LastTimeRemainingWallClock is None: + return None + # The slicer holds a constant time while in preparing, so we don't want to fake our countdown either. + if self.IsPrepareOrSlicing(): + # Reset the last wall clock time to now, so when we transition to running, we don't snap to a strange offset. + self.LastTimeRemainingWallClock = time.time() + return int(self.mc_remaining_time * 60) + # Compute the time based on when the value last updated. + return int(max(0, (self.mc_remaining_time * 60) - (time.time() - self.LastTimeRemainingWallClock))) + + + # Since there's a lot to consider to figure out if a print is running, this one function acts as common logic across the plugin. + def IsPrinting(self, includePausedAsPrinting:bool) -> bool: + return BambuState.IsPrintingState(self.gcode_state, includePausedAsPrinting) + + + # We use this common method since "is this a printing state?" is complicated and we can to keep all of the logic common in the plugin + @staticmethod + def IsPrintingState(state:str, includePausedAsPrinting:bool) -> bool: + if state is None: + return False + if state == "PAUSE" and includePausedAsPrinting: + return True + # Do we need to consider some of the stg_cur states? + return state == "RUNNING" or BambuState.IsPrepareOrSlicingState(state) + + + # We use this common method to keep all of the logic common in the plugin + def IsPrepareOrSlicing(self) -> bool: + return BambuState.IsPrepareOrSlicingState(self.gcode_state) + + + # We use this common method to keep all of the logic common in the plugin + @staticmethod + def IsPrepareOrSlicingState(state:str) -> bool: + if state is None: + return False + return state == "SLICING" or state == "PREPARE" + + + # This one function acts as common logic across the plugin. + def IsPaused(self) -> bool: + if self.gcode_state is None: + return False + return self.gcode_state == "PAUSE" + + + # If there is a file name, this returns it without the final . + def GetFileNameWithNoExtension(self): + if self.subtask_name is None: + return None + pos = self.subtask_name.rfind(".") + if pos == -1: + return self.subtask_name + return self.subtask_name[:pos] + + + # Returns a unique string for this print. + # This string should be as unique as possible, but always the same for the same print. + # See details in NotificationHandler._RecoverOrRestForNewPrint + def GetPrintCookie(self) -> str: + # From testing, the project_id is always unique for cloud based prints, but is 0 for local prints. + # The file name changes most of the time, so the combination of both makes a good pair. + return f"{self.project_id}-{self.GetFileNameWithNoExtension()}" + + + # If the printer is in an error state, this tries to return the type, if known. + # If the printer is not in an error state, None is returned. + def GetPrinterError(self) -> BambuPrintErrors: + # If there is a printer error, this is not 0 + if self.print_error is None or self.print_error == 0: + return None + + # Oddly there are some errors that aren't errors? And the printer might sit in them while printing. + # We ignore these. We also use the direct int values, so we don't have to build the hex string all of the time. + # These error codes are in https://e.bambulab.com/query.php?lang=en, but have empty strings. + # Hex: 05008030, 03008012, 0500C011 + if self.print_error == 83918896 or self.print_error == 50364434 or self.print_error == 83935249: + return None + + # This state is when the user is loading filament, and the printer is asking them to push it in. + # This isn't an error. + if self.print_error == 134184967: + return None + + # There's a full list of errors here, we only care about some of them + # https://e.bambulab.com/query.php?lang=en + # We format the error into a hex the same way the are on the page, to make it easier. + # NOTE SOME ERRORS HAVE MULTIPLE VALUES, SO GET THEM ALL! + # They have different values for the different AMS slots + h = hex(self.print_error)[2:].rjust(8, '0') + errorMap = { + "07008011": BambuPrintErrors.FilamentRunOut, + "07018011": BambuPrintErrors.FilamentRunOut, + "07028011": BambuPrintErrors.FilamentRunOut, + "07038011": BambuPrintErrors.FilamentRunOut, + "07FF8011": BambuPrintErrors.FilamentRunOut, + } + return errorMap.get(h, BambuPrintErrors.Unknown) + + +# Different types of hardware. +class BambuPrinters(Enum): + Unknown = 1 + X1C = 2 + X1E = 3 + P1P = 10 + P1S = 11 + A1 = 20 + A1Mini = 21 + + +class BambuCPUs(Enum): + Unknown = 1 + ESP32 = 2 # Lower powered CPU used on the A1 and P1P + RV1126= 3 # High powered CPU used on the X1 line + + +# Tracks the version info. +class BambuVersion: + + def __init__(self, logger:logging.Logger) -> None: + self.Logger = logger + self.HasLoggedPrinterVersion = False + # We only parse out what we currently use. + self.SoftwareVersion:str = None + self.HardwareVersion:str = None + self.SerialNumber:str = None + self.ProjectName:str = None + self.Cpu:BambuCPUs = None + self.PrinterName:BambuPrinters = None + + + # Called when there's a new print message from the printer. + def OnUpdate(self, msg:dict) -> None: + module = msg.get("module", None) + if module is None: + return + for m in module: + name = m.get("name", None) + if name is None: + continue + if name == "ota": + self.SoftwareVersion = m.get("sw_ver", self.SoftwareVersion) + elif name == "mc": + self.SerialNumber = m.get("sn", self.SerialNumber) + elif name == "esp32": + self.HardwareVersion = m.get("hw_ver", self.HardwareVersion) + self.ProjectName = m.get("project_name", self.ProjectName) + self.Cpu = BambuCPUs.ESP32 + elif name == "rv1126": + self.HardwareVersion = m.get("hw_ver", self.HardwareVersion) + self.ProjectName = m.get("project_name", self.ProjectName) + self.Cpu = BambuCPUs.RV1126 + + # If we didn't find a hardware, it's unknown. + if self.Cpu is None: + self.Cpu = BambuCPUs.Unknown + + # Now that we have info, map the printer type. + if self.Cpu is not BambuCPUs.Unknown and self.HardwareVersion is not None: + if self.Cpu is BambuCPUs.RV1126: + # Map for RV1126 CPU + rv1126_map = { + "AP05": BambuPrinters.X1C, + "AP02": BambuPrinters.X1E, + # Add more mappings here as needed + } + self.PrinterName = rv1126_map.get(self.HardwareVersion, BambuPrinters.Unknown) + + elif self.Cpu is BambuCPUs.ESP32 and self.ProjectName is not None: + # Map for ESP32 CPU + esp32_map = { + ("AP04", "C11"): BambuPrinters.P1P, + ("AP04", "C12"): BambuPrinters.P1S, + ("AP05", "N1"): BambuPrinters.A1Mini, + ("AP05", "N2S"): BambuPrinters.A1, + ("AP07", "N1"): BambuPrinters.A1Mini, + # Add more mappings here as needed + } + self.PrinterName = esp32_map.get((self.HardwareVersion, self.ProjectName), BambuPrinters.Unknown) + + if self.PrinterName is None or self.PrinterName is BambuPrinters.Unknown: + Sentry.LogError(f"Unknown printer type. CPU:{self.Cpu}, Project Name: {self.ProjectName}, Hardware Version: {self.HardwareVersion}",{ + "CPU": str(self.Cpu), + "ProjectName": str(self.ProjectName), + "HardwareVersion": str(self.HardwareVersion), + "SoftwareVersion": str(self.SoftwareVersion), + }) + self.PrinterName = BambuPrinters.Unknown + + if self.HasLoggedPrinterVersion is False: + self.HasLoggedPrinterVersion = True + self.Logger.info(f"Printer Version: {self.PrinterName}, CPU: {self.Cpu}, Project: {self.ProjectName} Hardware: {self.HardwareVersion}, Software: {self.SoftwareVersion}, Serial: {self.SerialNumber}") diff --git a/elegoo_octoeverywhere/bambustatetranslater.py b/elegoo_octoeverywhere/bambustatetranslater.py new file mode 100644 index 0000000..01d92b7 --- /dev/null +++ b/elegoo_octoeverywhere/bambustatetranslater.py @@ -0,0 +1,273 @@ +import time + +from octoeverywhere.notificationshandler import NotificationsHandler +from octoeverywhere.printinfo import PrintInfoManager + +from .bambuclient import BambuClient +from .bambumodels import BambuState, BambuPrintErrors + +# This class is responsible for listening to the mqtt messages to fire off notifications +# and to act as the printer state interface for Bambu printers. +class BambuStateTranslator: + + def __init__(self, logger) -> None: + self.Logger = logger + self.NotificationsHandler:NotificationsHandler = None + self.LastState:str = None + + + def SetNotificationHandler(self, notificationHandler:NotificationsHandler): + self.NotificationsHandler = notificationHandler + + + # Called by the client just before it tires to make a new connection. + # This is used to let us know that we are in an unknown state again, until we can re-sync. + def ResetForNewConnection(self): + # Reset the last state to indicate that we don't know what it is. + self.LastState = None + + + # Fired when any mqtt message comes in. + # State will always be NOT NONE, since it's going to be created before this call. + # The isFirstFullSyncResponse flag indicates if this is the first full state sync of a new connection. + def OnMqttMessage(self, msg:dict, bambuState:BambuState, isFirstFullSyncResponse:bool): + + # First, if we have a new connection and we just synced, make sure the notification handler is in sync. + if isFirstFullSyncResponse: + self.NotificationsHandler.OnRestorePrintIfNeeded(bambuState.IsPrinting(False), bambuState.IsPaused(), bambuState.GetPrintCookie()) + + # Bambu does send some commands when actions happen, but they don't always get sent for all state changes. + # For example, if a user issues a pause command, we see the command. But if the print goes into an error an pauses, we don't get a pause command. + # Thus, we have to rely on keeping track of that state and knowing when it changes. + # Note we check state for all messages, not just push_status, but it doesn't matter because it will only change on push_status anyways. + # Here's a list of all states: https://github.com/greghesp/ha-bambulab/blob/e72e343acd3279c9bccba510f94bf0e291fe5aaa/custom_components/bambu_lab/pybambu/const.py#L83C1-L83C21 + if self.LastState != bambuState.gcode_state: + # We know the state changed. + self.Logger.debug(f"Bambu state change: {self.LastState} -> {bambuState.gcode_state}") + if self.LastState is None: + # If the last state is None, this is mostly likely the first time we've seen a state. + # All we want to do here is update last state to the new state. + pass + # Check if we are now in a printing state we use the common function so the definition of "printing" stays common. + elif bambuState.IsPrinting(False): + if self.LastState == "PAUSE": + self.BambuOnResume(bambuState) + else: + # We know the state changed and the state is now a printing state. + # If the last state was also a printing state, we don't want to fire this, since we already did. + if BambuState.IsPrintingState(self.LastState, False) is False: + self.BambuOnStart(bambuState) + # Check for the paused state + elif bambuState.IsPaused(): + # If the error is temporary, like a filament run out, the printer goes into a paused state + # with the printer_error set. + self.BambuOnPauseOrTempError(bambuState) + # Check for the print ending in failure (like if the user stops it by command) + elif bambuState.gcode_state == "FAILED": + self.BambuOnFailed(bambuState) + # Check for a successful print ending. + elif bambuState.gcode_state == "FINISH": + self.BambuOnComplete(bambuState) + + # Always capture the new state. + self.LastState = bambuState.gcode_state + + # + # Next - Handle the progress update. + # + # These are harder to get right, because the printer will send full state objects sometimes when IDLE or PRINTING. + # Thus if we respond to them, it might not be the correct time. For example, the full sync will always include mc_percent, but we + # don't want to fire BambuOnPrintProgress if we aren't printing. + # + # We only want to consider firing these events if we know this isn't the first time sync from a new connection + # and we are currently tacking a print. + if not isFirstFullSyncResponse and self.NotificationsHandler.IsTrackingPrint(): + # Percentage progress update + printMsg = msg.get("print", None) + if printMsg is not None and "mc_percent" in printMsg: + # On the X1, the progress doesn't get reset from the last print when the printer switches into prepare or slicing for the next print. + # So we will not send any progress updates in these states, until the state is "RUNNING" and the progress should reset to 0. + if bambuState.IsPrepareOrSlicing() is False: + self.BambuOnPrintProgress(bambuState) + + # Since bambu doesn't tell us a print duration, we need to figure out when it ends ourselves. + # This is different from the state changes above, because if we are ever not printing for any reason, + # We want to finalize any current print. + if bambuState.IsPrinting(True) is False: + # See if there's a print info for the last print. + pi = PrintInfoManager.Get().GetPrintInfo(bambuState.GetPrintCookie()) + if pi is not None: + # Check if the print info has a final duration set yet or not. + if pi.GetFinalPrintDurationSec() is None: + # We know we aren't printing, so regardless of the non-printing state, set the final duration. + pi.SetFinalPrintDurationSec(int(time.time()-pi.GetLocalPrintStartTimeSec())) + + + def BambuOnStart(self, bambuState:BambuState): + # We must pass the unique cookie name for this print and any other details we can. + self.NotificationsHandler.OnStarted(bambuState.GetPrintCookie(), bambuState.GetFileNameWithNoExtension()) + + + def BambuOnComplete(self, bambuState:BambuState): + # We can only get the file name from Bambu. + self.NotificationsHandler.OnDone(bambuState.GetFileNameWithNoExtension(), None) + + + def BambuOnPauseOrTempError(self, bambuState:BambuState): + # For errors that are user fixable, like filament run outs, the printer will go into a paused state with + # a printer error message. In this case we want to fire different things. + err = bambuState.GetPrinterError() + if err is None: + # If error is none, this is a user pause + self.NotificationsHandler.OnPaused(bambuState.GetFileNameWithNoExtension()) + return + # Otherwise, try to match the error. + if err == BambuPrintErrors.FilamentRunOut: + self.NotificationsHandler.OnFilamentChange() + return + + # Send a generic error. + self.NotificationsHandler.OnUserInteractionNeeded() + + + def BambuOnResume(self, bambuState:BambuState): + self.NotificationsHandler.OnResume(bambuState.GetFileNameWithNoExtension()) + + + def BambuOnFailed(self, bambuState:BambuState): + # TODO - Right now this is only called by what we think are use requested cancels. + # How can we add this for print stopping errors as well? + self.NotificationsHandler.OnFailed(bambuState.GetFileNameWithNoExtension(), None, "cancelled") + + + def BambuOnPrintProgress(self, bambuState:BambuState): + # We use the "moonrakerProgressFloat" because it's really means a progress that's + # 100% correct and there's no estimations needed. + self.NotificationsHandler.OnPrintProgress(None, float(bambuState.mc_percent)) + + # TODO - Handlers + # # Fired when OctoPrint or the printer hits an error. + # def OnError(self, error): + + + # + # + # Printer State Interface + # + # + + # ! Interface Function ! The entire interface must change if the function is changed. + # This function will get the estimated time remaining for the current print. + # Returns -1 if the estimate is unknown. + def GetPrintTimeRemainingEstimateInSeconds(self): + # Get the current state. + state = BambuClient.Get().GetState() + if state is None: + return -1 + # We use our special logic function that will return a almost perfect seconds based countdown + # instead of the just minutes based countdown from bambu. + timeRemainingSec = state.GetContinuousTimeRemainingSec() + if timeRemainingSec is None: + return -1 + return timeRemainingSec + + + # ! Interface Function ! The entire interface must change if the function is changed. + # If the printer is warming up, this value would be -1. The First Layer Notification logic depends upon this or GetCurrentLayerInfo! + # Returns the current zoffset if known, otherwise -1. + def GetCurrentZOffset(self): + # This is only used for the first layer logic, but only if GetCurrentLayerInfo fails. + # Since our GetCurrentLayerInfo shouldn't always work, this shouldn't really matter. + # We can't get this value, but since it doesn't really matter, we can estimate it. + (currentLayer, _) = self.GetCurrentLayerInfo() + if currentLayer is None: + return -1 + + # Since the standard layer height is 0.20mm, we just use that for a guess. + return currentLayer * 0.2 + + + # ! Interface Function ! The entire interface must change if the function is changed. + # If this platform DOESN'T support getting the layer info from the system, this returns (None, None) + # If the platform does support it... + # If the current value is unknown, (0,0) is returned. + # If the values are known, (currentLayer(int), totalLayers(int)) is returned. + # Note that total layers will always be > 0, but current layer can be 0! + def GetCurrentLayerInfo(self): + state = BambuClient.Get().GetState() + if state is None: + # If we dont have a state yet, return 0,0, which means we can get layer info but we don't know yet. + return (0, 0) + if state.IsPrepareOrSlicing(): + # The printer doesn't clear these values when a new print is starting and it's in a prepare or slicing state. + # So if we are in that state, return 0,0, to represent we don't know the layer info yet. + return (0, 0) + # We can get accurate and 100% correct layers from Bambu, awesome! + currentLayer = None + totalLayers = None + if state.layer_num is not None: + currentLayer = int(state.layer_num) + if state.total_layer_num is not None: + totalLayers = int(state.total_layer_num) + return (currentLayer, totalLayers) + + + # ! Interface Function ! The entire interface must change if the function is changed. + # Returns True if the printing timers (notifications and gadget) should be running, which is only the printing state. (not even paused) + # False if the printer state is anything else, which means they should stop. + def ShouldPrintingTimersBeRunning(self): + state = BambuClient.Get().GetState() + if state is None: + return False + + gcodeState = state.gcode_state + if gcodeState is None: + return False + + # See the logic in GetCurrentJobStatus for a full description + # Since we don't know 100% of the states, we will fail open. + # Here's a possible list: https://github.com/greghesp/ha-bambulab/blob/e72e343acd3279c9bccba510f94bf0e291fe5aaa/custom_components/bambu_lab/pybambu/const.py#L83C1-L83C21 + if gcodeState == "IDLE" or gcodeState == "FINISH" or gcodeState == "FAILED": + self.Logger.warn("ShouldPrintingTimersBeRunning is not in a printing state: "+str(gcodeState)) + return False + return True + + + # ! Interface Function ! The entire interface must change if the function is changed. + # If called while the print state is "Printing", returns True if the print is currently in the warm-up phase. Otherwise False + def IsPrintWarmingUp(self): + state = BambuClient.Get().GetState() + if state is None: + return False + + # Check if the print timers should be running + # This will weed out any gcode_states where we know we aren't running. + # We have seen stg_cur not get reset in the past when the state transitions to an error. + if not self.ShouldPrintingTimersBeRunning(): + return False + + gcodeState = state.gcode_state + if gcodeState is not None: + # See the logic in GetCurrentJobStatus for a full description + # Here's a possible list: https://github.com/greghesp/ha-bambulab/blob/e72e343acd3279c9bccba510f94bf0e291fe5aaa/custom_components/bambu_lab/pybambu/const.py#L83C1-L83C21 + if gcodeState == "PREPARE" or gcodeState == "SLICING": + return True + + if state.stg_cur is None: + return False + # See the logic in GetCurrentJobStatus for a full description + # Here's a full list: https://github.com/davglass/bambu-cli/blob/398c24057c71fc6bcc5dbd818bdcacc20833f61c/lib/const.js#L104 + if state.stg_cur == 1 or state.stg_cur == 2 or state.stg_cur == 7 or state.stg_cur == 9 or state.stg_cur == 11 or state.stg_cur == 14: + return True + return False + + + # ! Interface Function ! The entire interface must change if the function is changed. + # Returns the current hotend temp and bed temp as a float in celsius if they are available, otherwise None. + def GetTemps(self): + state = BambuClient.Get().GetState() + if state is None: + return (None, None) + + # These will be None if they are unknown. + return (state.nozzle_temper, state.bed_temper) diff --git a/elegoo_octoeverywhere/elegoocommandhandler.py b/elegoo_octoeverywhere/elegoocommandhandler.py new file mode 100644 index 0000000..5c6bc19 --- /dev/null +++ b/elegoo_octoeverywhere/elegoocommandhandler.py @@ -0,0 +1,251 @@ +from octoeverywhere.commandhandler import CommandResponse +# from octoeverywhere.printinfo import PrintInfoManager + +# from .bambuclient import BambuClient +# from .bambumodels import BambuPrintErrors + +# This class implements the Platform Command Handler Interface +class ElegooCommandHandler: + + def __init__(self, logger) -> None: + self.Logger = logger + + + # This map contains UI ready strings that map to a subset of sub-stages we can send which are more specific than the state. + # These need to be UI ready, since they will be shown directly. + # Some known stages are excluded, because we don't want to show them. + # Here's a full list: https://github.com/davglass/bambu-cli/blob/398c24057c71fc6bcc5dbd818bdcacc20833f61c/lib/const.js#L104 + SubStageMap = { + 1: "Auto Bed Leveling", + 2: "Bed Preheating", + 3: "Sweeping XY Mech Mode", + 4: "Changing Filament", + 5: "M400 Pause", + 6: "Filament Runout", + 7: "Heating Hotend", + 8: "Calibrating Extrusion", + 9: "Scanning Bed Surface", + 10: "Inspecting First Layer", + 11: "Identifying Build Plate", + 12: "Calibrating Micro Lidar", + 13: "Homing Toolhead", + 14: "Cleaning Nozzle", + 15: "Checking Temperature", + 16: "Paused By User", + 17: "Front Cover Falling", + 18: "Calibrating Micro Lidar", + 19: "Calibrating Extrusion Flow", + 20: "Nozzle Temperature Malfunction", + 21: "Bed Temperature Malfunction", + 22: "Filament Unloading", + 23: "Skip Step Pause", + 24: "Filament Loading", + 25: "Motor Noise Calibration", + 26: "AMS lost", + 27: "Low Speed Of Heat Break Fan", + 28: "Chamber Temperature Control Error", + 29: "Cooling Chamber", + 30: "Paused By Gcode", + 31: "Motor Noise Showoff", + 32: "Nozzle Filament Covered Detected Pause", + 33: "Cutter Error", + 34: "First Layer Error", + 35: "Nozzle Clogged" + } + + + # !! Platform Command Handler Interface Function !! + # + # This must return the common "JobStatus" dict or None on failure. + # The format of this must stay consistent with OctoPrint and the service. + # Returning None send back the NoHostConnected error, assuming that the plugin isn't connected to the host or the host isn't + # connected to the printer's firmware. + # + # See the JobStatusV2 class in the service for the object definition. + # + # Returning None will result in the "Printer not connected" state. + def GetCurrentJobStatus(self): + return None + # # Try to get the current state. + # bambuState = BambuClient.Get().GetState() + + # # If the state is None, we are disconnected. + # if bambuState is None: + # # Returning None will be a "connection lost" state. + # return None + + # # Map the state + # # Possible states: https://github.com/greghesp/ha-bambulab/blob/e72e343acd3279c9bccba510f94bf0e291fe5aaa/custom_components/bambu_lab/pybambu/const.py#L83C1-L83C21 + # state = "idle" + # errorStr_CanBeNone = None + + # # Before checking the state, see if the print is in an error state. + # # This error state can be common among other states, like "IDLE" or "PAUSE" + # printError = bambuState.GetPrinterError() + # if printError is not None: + # # Always set the state to error. + # # If we can match a known state, return a good string that can be shown for the user. + # state = "error" + # if printError == BambuPrintErrors.FilamentRunOut: + # errorStr_CanBeNone = "Filament Run Out" + # # If we aren't in error, use the state + # elif bambuState.gcode_state is not None: + # gcodeState = bambuState.gcode_state + # if gcodeState == "IDLE" or gcodeState == "INIT" or gcodeState == "OFFLINE" or gcodeState == "UNKNOWN": + # state = "idle" + # elif gcodeState == "RUNNING" or gcodeState == "SLICING": + # # Only check stg_cur in the known printing state, because sometimes it doesn't get reset to idle when transitioning to an error. + # stg = bambuState.stg_cur + # if stg == 2 or stg == 7: + # state = "warmingup" + # else: + # # These are all a subset of printing states. + # state = "printing" + # elif gcodeState == "PAUSE": + # state = "paused" + # elif gcodeState == "FINISH": + # # When the X1C first starts and does the first time user calibration, the state is FINISH + # # but there's really nothing done. This might happen after other calibrations, so if the total layers is 0, we are idle. + # if bambuState.total_layer_num is not None and bambuState.total_layer_num == 0: + # state = "idle" + # else: + # state = "complete" + # elif gcodeState == "FAILED": + # state = "cancelled" + # elif gcodeState == "PREPARE": + # state = "warmingup" + # else: + # self.Logger.warn(f"Unknown gcode_state state in print state: {gcodeState}") + + # # If we have a mapped sub state, set it. + # subState_CanBeNone = None + # if bambuState.stg_cur is not None: + # if bambuState.stg_cur in BambuCommandHandler.SubStageMap: + # subState_CanBeNone = BambuCommandHandler.SubStageMap[bambuState.stg_cur] + + # # Get current layer info + # # None = The platform doesn't provide it. + # # 0 = The platform provider it, but there's no info yet. + # # # = The values + # currentLayerInt = None + # totalLayersInt = None + # if bambuState.layer_num is not None: + # currentLayerInt = int(bambuState.layer_num) + # if bambuState.total_layer_num is not None: + # totalLayersInt = int(bambuState.total_layer_num) + + # # Get the filename. + # fileName = bambuState.GetFileNameWithNoExtension() + # if fileName is None: + # fileName = "" + + # # For Bambu, the printer doesn't report the duration or the print start time. + # # Thus we have to track it ourselves in our print info. + # # When the print is over, a final print duration is set, so this doesn't keep going from print start. + # durationSec = 0 + # pi = PrintInfoManager.Get().GetPrintInfo(bambuState.GetPrintCookie()) + # if pi is not None: + # durationSec = pi.GetPrintDurationSec() + + # # If we have a file name, try to get the current filament usage. + # filamentUsageMm = 0 + # # if fileName is not None and len(fileName) > 0: + # # filamentUsageMm = FileMetadataCache.Get().GetEstimatedFilamentUsageMm(fileName) + + # # Get the progress + # progress = 0.0 + # if bambuState.mc_percent is not None: + # progress = float(bambuState.mc_percent) + + # # We have special logic to handle the time left count down, since bambu only gives us minutes + # # and we want seconds. We can estimate it pretty well by counting down from the last time it changed. + # timeLeftSec = bambuState.GetContinuousTimeRemainingSec() + # if timeLeftSec is None: + # timeLeftSec = 0 + + # # Get the current temps if possible. + # hotendActual = 0.0 + # hotendTarget = 0.0 + # bedTarget = 0.0 + # bedActual = 0.0 + # if bambuState.nozzle_temper is not None: + # hotendActual = round(float(bambuState.nozzle_temper), 2) + # if bambuState.nozzle_target_temper is not None: + # hotendTarget = round(float(bambuState.nozzle_target_temper), 2) + # if bambuState.bed_temper is not None: + # bedActual = round(float(bambuState.bed_temper), 2) + # if bambuState.bed_target_temper is not None: + # bedTarget = round(float(bambuState.bed_target_temper), 2) + + # # Build the object and return. + # return { + # "State": state, + # "SubState": subState_CanBeNone, + # "Error": errorStr_CanBeNone, + # "CurrentPrint": + # { + # "Progress" : progress, + # "DurationSec" : durationSec, + # # In some system buggy cases, the time left can be super high and won't fit into a int32, so we cap it. + # "TimeLeftSec" : min(timeLeftSec, 2147483600), + # "FileName" : fileName, + # "EstTotalFilUsedMm" : filamentUsageMm, + # "CurrentLayer": currentLayerInt, + # "TotalLayers": totalLayersInt, + # "Temps": { + # "BedActual": bedActual, + # "BedTarget": bedTarget, + # "HotendActual": hotendActual, + # "HotendTarget": hotendTarget, + # } + # } + # } + + + # !! Platform Command Handler Interface Function !! + # This must return the platform version as a string. + def GetPlatformVersionStr(self): + return "Elegoo-CentauriCarbon" + # version = BambuClient.Get().GetVersion() + # if version is None: + # return "0.0.0" + # return f"{version.SoftwareVersion}-{version.PrinterName}" + + + # !! Platform Command Handler Interface Function !! + # This must check that the printer state is valid for the pause and the plugin is connected to the host. + # If not, it must return the correct two error codes accordingly. + # This must return a CommandResponse. + def ExecutePause(self, smartPause, suppressNotificationBool, disableHotendBool, disableBedBool, zLiftMm, retractFilamentMm, showSmartPausePopup) -> CommandResponse: + return CommandResponse.Success(None) + + # if BambuClient.Get().SendPause(): + # return CommandResponse.Success(None) + # else: + # return CommandResponse.Error(400, "Failed to send command to printer.") + + + # !! Platform Command Handler Interface Function !! + # This must check that the printer state is valid for the resume and the plugin is connected to the host. + # If not, it must return the correct two error codes accordingly. + # This must return a CommandResponse. + def ExecuteResume(self) -> CommandResponse: + return CommandResponse.Success(None) + + # if BambuClient.Get().SendResume(): + # return CommandResponse.Success(None) + # else: + # return CommandResponse.Error(400, "Failed to send command to printer.") + + + # !! Platform Command Handler Interface Function !! + # This must check that the printer state is valid for the cancel and the plugin is connected to the host. + # If not, it must return the correct two error codes accordingly. + # This must return a CommandResponse. + def ExecuteCancel(self) -> CommandResponse: + return CommandResponse.Success(None) + + # if BambuClient.Get().SendCancel(): + # return CommandResponse.Success(None) + # else: + # return CommandResponse.Error(400, "Failed to send command to printer.") diff --git a/elegoo_octoeverywhere/elegoohost.py b/elegoo_octoeverywhere/elegoohost.py new file mode 100644 index 0000000..fb3c317 --- /dev/null +++ b/elegoo_octoeverywhere/elegoohost.py @@ -0,0 +1,290 @@ +import logging +import traceback + +from octoeverywhere.mdns import MDns +from octoeverywhere.sentry import Sentry +from octoeverywhere.deviceid import DeviceId +from octoeverywhere.telemetry import Telemetry +from octoeverywhere.hostcommon import HostCommon +from octoeverywhere.compression import Compression +from octoeverywhere.printinfo import PrintInfoManager +from octoeverywhere.httpsessions import HttpSessions +from octoeverywhere.Webcam.webcamhelper import WebcamHelper +from octoeverywhere.octopingpong import OctoPingPong +from octoeverywhere.commandhandler import CommandHandler +from octoeverywhere.octoeverywhereimpl import OctoEverywhere +from octoeverywhere.notificationshandler import NotificationsHandler +from octoeverywhere.octohttprequest import OctoHttpRequest +from octoeverywhere.Proto.ServerHost import ServerHost +from octoeverywhere.compat import Compat + +from linux_host.config import Config +from linux_host.secrets import Secrets +from linux_host.version import Version +from linux_host.logger import LoggerInit + +#from .bambucloud import BambuCloud +#from .bambuclient import BambuClient +from .elegoowebcamhelper import ElegooWebcamHelper +from .elegoocommandhandler import ElegooCommandHandler +#from .bambustatetranslater import BambuStateTranslator + +# This file is the main host for the elegoo os service. +class ElegooHost: + + def __init__(self, configDir:str, logDir:str, devConfig_CanBeNone) -> None: + # When we create our class, make sure all of our core requirements are created. + self.Secrets = None + self.NotificationHandler:NotificationsHandler = None + + # Let the compat system know this is an elegoo host. + Compat.SetIsElegooOs(True) + + try: + # First, we need to load our config. + # Note that the config MUST BE WRITTEN into this folder, that's where the setup installer is going to look for it. + # If this fails, it will throw. + self.Config = Config(configDir) + + # Setup the logger. + logLevelOverride_CanBeNone = self.GetDevConfigStr(devConfig_CanBeNone, "LogLevel") + self.Logger = LoggerInit.GetLogger(self.Config, logDir, logLevelOverride_CanBeNone) + self.Config.SetLogger(self.Logger) + + # Give the logger to Sentry ASAP. + Sentry.SetLogger(self.Logger) + + except Exception as e: + tb = traceback.format_exc() + print("Failed to init Elegoo Host! "+str(e) + "; "+str(tb)) + # Raise the exception so we don't continue. + raise + + + def RunBlocking(self, configPath, localStorageDir, repoRoot, devConfig_CanBeNone): + # Do all of this in a try catch, so we can log any issues before exiting + try: + self.Logger.info("####################################################") + self.Logger.info("#### OctoEverywhere Elegoo OS Connect Starting #####") + self.Logger.info("####################################################") + + # Find the version of the plugin, this is required and it will throw if it fails. + pluginVersionStr = Version.GetPluginVersion(repoRoot) + self.Logger.info("Plugin Version: %s", pluginVersionStr) + + # Setup the HttpSession cache early, so it can be used whenever + HttpSessions.Init(self.Logger) + + # As soon as we have the plugin version, setup Sentry + # Enabling profiling and no filtering, since we are the only PY in this process. + Sentry.Setup(pluginVersionStr, "elegoo", devConfig_CanBeNone is not None, enableProfiling=True, filterExceptionsByPackage=False, restartOnCantCreateThreadBug=True) + + # Before the first time setup, we must also init the Secrets class and do the migration for the printer id and private key, if needed. + self.Secrets = Secrets(self.Logger, localStorageDir) + + # Now, detect if this is a new instance and we need to init our global vars. If so, the setup script will be waiting on this. + self.DoFirstTimeSetupIfNeeded() + + # Get our required vars + printerId = self.GetPrinterId() + privateKey = self.GetPrivateKey() + + # Set the printer ID into sentry. + Sentry.SetPrinterId(printerId) + + # Unpack any dev vars that might exist + DevLocalServerAddress_CanBeNone = self.GetDevConfigStr(devConfig_CanBeNone, "LocalServerAddress") + if DevLocalServerAddress_CanBeNone is not None: + self.Logger.warning("~~~ Using Local Dev Server Address: %s ~~~", DevLocalServerAddress_CanBeNone) + + # Init Sentry, but it won't report since we are in dev mode. + Telemetry.Init(self.Logger) + if DevLocalServerAddress_CanBeNone is not None: + Telemetry.SetServerProtocolAndDomain("http://"+DevLocalServerAddress_CanBeNone) + + # Init compression + Compression.Init(self.Logger, localStorageDir) + + # Init the mdns client + MDns.Init(self.Logger, localStorageDir) + + # Init device id + DeviceId.Init(self.Logger) + + # Setup the print info manager. + PrintInfoManager.Init(self.Logger, localStorageDir) + + # For Elegoo OS printers, the Elegoo interface is running on 3030 + # and there's an http proxy running on 80. + OctoHttpRequest.SetLocalOctoPrintPort(3030) + OctoHttpRequest.SetLocalHttpProxyPort(80) + OctoHttpRequest.SetLocalHttpProxyIsHttps(False) + + # Init the ping pong helper. + OctoPingPong.Init(self.Logger, localStorageDir, printerId) + if DevLocalServerAddress_CanBeNone is not None: + OctoPingPong.Get().DisablePrimaryOverride() + + # Setup the webcam helper + webcamHelper = ElegooWebcamHelper(self.Logger, self.Config) + WebcamHelper.Init(self.Logger, webcamHelper, localStorageDir) + + # # Setup the state translator and notification handler + # stateTranslator = BambuStateTranslator(self.Logger) + self.NotificationHandler = NotificationsHandler(self.Logger, None) + self.NotificationHandler.SetPrinterId(printerId) + # self.NotificationHandler.SetBedCooldownThresholdTemp(self.Config.GetFloat(Config.GeneralSection, Config.GeneralBedCooldownThresholdTempC, Config.GeneralBedCooldownThresholdTempCDefault)) + # stateTranslator.SetNotificationHandler(self.NotificationHandler) + + # Setup the command handler + CommandHandler.Init(self.Logger, self.NotificationHandler, ElegooCommandHandler(self.Logger), self) + + # # Setup the cloud if it's setup in the config. + # BambuCloud.Init(self.Logger, self.Config) + + # # Setup and start the Bambu Client + # BambuClient.Init(self.Logger, self.Config, stateTranslator) + + # Now start the main runner! + OctoEverywhereWsUri = HostCommon.c_OctoEverywhereOctoClientWsUri + if DevLocalServerAddress_CanBeNone is not None: + OctoEverywhereWsUri = "ws://"+DevLocalServerAddress_CanBeNone+"/octoclientws" + # TODO - Update the server host! + oe = OctoEverywhere(OctoEverywhereWsUri, printerId, privateKey, self.Logger, self, self, pluginVersionStr, ServerHost.Moonraker, False) + oe.RunBlocking() + except Exception as e: + Sentry.Exception("!! Exception thrown out of main host run function.", e) + + # Allow the loggers to flush before we exit + try: + self.Logger.info("##################################") + self.Logger.info("#### OctoEverywhere Exiting ######") + self.Logger.info("##################################") + logging.shutdown() + except Exception as e: + print("Exception in logging.shutdown "+str(e)) + + + # Ensures all required values are setup and valid before starting. + def DoFirstTimeSetupIfNeeded(self): + # Try to get the printer id from the config. + printerId = self.GetPrinterId() + if HostCommon.IsPrinterIdValid(printerId) is False: + if printerId is None: + self.Logger.info("No printer id was found, generating one now!") + else: + self.Logger.info("An invalid printer id was found [%s], regenerating!", str(printerId)) + + # Make a new, valid, key + printerId = HostCommon.GeneratePrinterId() + + # Save it + self.Secrets.SetPrinterId(printerId) + self.Logger.info("New printer id created: %s", printerId) + + privateKey = self.GetPrivateKey() + if HostCommon.IsPrivateKeyValid(privateKey) is False: + if privateKey is None: + self.Logger.info("No private key was found, generating one now!") + else: + self.Logger.info("An invalid private key was found [%s], regenerating!", str(privateKey)) + + # Make a new, valid, key + privateKey = HostCommon.GeneratePrivateKey() + + # Save it + self.Secrets.SetPrivateKey(privateKey) + self.Logger.info("New private key created.") + + + # Returns None if no printer id has been set. + def GetPrinterId(self): + return self.Secrets.GetPrinterId() + + + # Returns None if no private id has been set. + def GetPrivateKey(self): + return self.Secrets.GetPrivateKey() + + + # Tries to load a dev config option as a string. + # If not found or it fails, this return None + def GetDevConfigStr(self, devConfig, value): + if devConfig is None: + return None + if value in devConfig: + v = devConfig[value] + if v is not None and len(v) > 0 and v != "None": + return v + return None + + + # This is a destructive action! It will remove the printer id and private key from the system and restart the plugin. + def Rekey(self, reason:str): + #pylint: disable=logging-fstring-interpolation + self.Logger.error(f"HOST REKEY CALLED {reason} - Clearing keys...") + # It's important we clear the key, or we will reload, fail to connect, try to rekey, and restart again! + self.Secrets.SetPrinterId(None) + self.Secrets.SetPrivateKey(None) + self.Logger.error("Key clear complete, restarting plugin.") + HostCommon.RestartPlugin() + + + # UiPopupInvoker Interface function - Sends a UI popup message for various uses. + # Must stay in sync with the OctoPrint handler! + # title - string, the title text. + # text - string, the message. + # type - string, [notice, info, success, error] the type of message shown. + # actionText - string, if not None or empty, this is the text to show on the action button or text link. + # actionLink - string, if not None or empty, this is the URL to show on the action button or text link. + # onlyShowIfLoadedViaOeBool - bool, if set, the message should only be shown on browsers loading the portal from OE. + def ShowUiPopup(self, title:str, text:str, msgType:str, actionText:str, actionLink:str, showForSec:int, onlyShowIfLoadedViaOeBool:bool): + # This isn't supported on Bambu + pass + + + # + # StatusChangeHandler Interface - Called by the OctoEverywhere logic when the server connection has been established. + # + def OnPrimaryConnectionEstablished(self, octoKey, connectedAccounts): + self.Logger.info("Primary Connection To OctoEverywhere Established - We Are Ready To Go!") + + # Give the octoKey to who needs it. + self.NotificationHandler.SetOctoKey(octoKey) + + # Check if this printer is unlinked, if so add a message to the log to help the user setup the printer if desired. + # This would be if the skipped the printer link or missed it in the setup script. + if connectedAccounts is None or len(connectedAccounts) == 0: + self.Logger.warning("") + self.Logger.warning("") + self.Logger.warning("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + self.Logger.warning("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + self.Logger.warning(" This Plugin Isn't Connected To OctoEverywhere! ") + self.Logger.warning(" Use the following link to finish the setup and get remote access:") + self.Logger.warning(" %s", HostCommon.GetAddPrinterUrl(self.GetPrinterId(), False)) + self.Logger.warning("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + self.Logger.warning("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + self.Logger.warning("") + self.Logger.warning("") + + + # + # StatusChangeHandler Interface - Called by the OctoEverywhere logic when a plugin update is required for this client. + # + def OnPluginUpdateRequired(self): + self.Logger.error("!!! A Plugin Update Is Required -- If This Plugin Isn't Updated It Might Stop Working !!!") + self.Logger.error("!!! Please use the update manager in Mainsail of Fluidd to update this plugin !!!") + + + # + # StatusChangeHandler Interface - Called by the OctoEverywhere handshake when a rekey is required. + # + def OnRekeyRequired(self): + self.Rekey("Handshake Failed") + + + # + # Command Host Interface - Called by the command handler, when called the plugin must clear it's keys and restart to generate new ones. + # + def OnRekeyCommand(self): + self.Rekey("Command") diff --git a/elegoo_octoeverywhere/elegoowebcamhelper.py b/elegoo_octoeverywhere/elegoowebcamhelper.py new file mode 100644 index 0000000..915ded2 --- /dev/null +++ b/elegoo_octoeverywhere/elegoowebcamhelper.py @@ -0,0 +1,49 @@ +import logging +import time + +from linux_host.config import Config + +from octoeverywhere.sentry import Sentry +from octoeverywhere.Webcam.webcamsettingitem import WebcamSettingItem + +from .bambuclient import BambuClient + + +# This class implements the webcam platform helper interface for elegoo os. +class ElegooWebcamHelper(): + + + def __init__(self, logger:logging.Logger, config:Config) -> None: + self.Logger = logger + self.Config = config + + + # !! Interface Function !! + # This must return an array of WebcamSettingItems. + # Index 0 is used as the default webcam. + # The order the webcams are returned is the order the user will see in any selection UIs. + # Returns None on failure. + def GetWebcamConfig(self): + # For Elegoo OS printers, there's only one webcam setup by default. + # It's running on a webcam server on 3031. + # The frontend also always adds a timestamp, probably for cache busting. + # TODO - This is a hardcoded URL, we should get this from the config. + timeSinceEpochSec = int(time.time() / 1000) + jmpegStreamUrl = f"http://10.0.0.101:3031/video?timestamp={timeSinceEpochSec}" + return [WebcamSettingItem("Elegoo Cam", None, jmpegStreamUrl, False, False, 0)] + + + # !! Interface Function !! + # This function is called to determine if a QuickCam stream should keep running or not. + # The idea is since a QuickCam stream can take longer to start, for example, the Bambu Websocket stream on sends 1FPS, + # we can keep the stream running while the print is running to lower the latency of getting images. + # Most most platforms, this should return true if the print is running or paused, otherwise false. + # Also consider something like Gadget, it takes pictures every 20-40 seconds, so the stream will be started frequently if it's not already running. + def ShouldQuickCamStreamKeepRunning(self) -> bool: + # TODO - Implement this. + # For Bambu, we want to keep the stream running if the printer is printing. + # state = BambuClient.Get().GetState() + # if state is None: + # return False + # return state.IsPrinting(True) + return True diff --git a/octoeverywhere/compat.py b/octoeverywhere/compat.py index f46a30f..491af75 100644 --- a/octoeverywhere/compat.py +++ b/octoeverywhere/compat.py @@ -8,6 +8,7 @@ class Compat: _IsMoonrakerHost = False _IsCompanionMode = False _IsBambu = False + _IsElegooOs = False @staticmethod def IsOctoPrint() -> bool: return Compat._IsOctoPrintHost @@ -29,6 +30,9 @@ def SetIsCompanionMode(b): @staticmethod def SetIsBambu(b): Compat._IsBambu = b + @staticmethod + def SetIsElegooOs(b): + Compat._IsElegooOs = b _LocalAuthObj = None