Skip to content

Commit 98c8f4b

Browse files
authored
Add Elegoo support
* Setup elegoo * Add database connection * Test and debug Elegoo
1 parent 352e61d commit 98c8f4b

15 files changed

+234
-827
lines changed

elegoo_octoapp/elegooappstorage.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import hashlib
2+
import base64
3+
import json
4+
import atexit
5+
import signal
6+
import requests
7+
import time
8+
import threading
9+
from typing import List, Tuple, Any, Dict
10+
11+
from Crypto.Cipher import AES
12+
13+
from octoapp.logging import LoggerLike
14+
from octoapp.appsstorage import AppInstance
15+
from octoapp.appsstorage import AppStoragePlatformHelper
16+
17+
from .elegooclient import ElegooClient
18+
19+
def sha256_urlsafe_base64(data: bytes) -> str:
20+
return base64.urlsafe_b64encode(hashlib.sha256(data).digest()).decode()
21+
22+
def pbkdf2_key(password: str, salt: str) -> str:
23+
key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100_000)
24+
return base64.b64encode(key).decode()
25+
26+
class ElegooAppStorage(AppStoragePlatformHelper):
27+
28+
def __init__(self, logger:LoggerLike, pluginVersion:str):
29+
self.PluginVersion = pluginVersion
30+
self.First = False
31+
self.Logger = logger
32+
self.AppsDatabaseUrl = "https://octoapp-companion-connections-apps.europe-west1.firebasedatabase.app"
33+
self.PresenceDatabaseUrl = "https://octoapp-companion-connections.europe-west1.firebasedatabase.app"
34+
self._ContinuouslyAnnouncePresence()
35+
self._RegisterLastWillHandler()
36+
37+
def _RegisterLastWillHandler(self):
38+
# Register cleanup for normal exit
39+
atexit.register(self._SendLastWillInactive)
40+
# Register cleanup for SIGTERM and SIGINT
41+
signal.signal(signal.SIGTERM, lambda signum, frame: self._SendLastWillInactive())
42+
signal.signal(signal.SIGINT, lambda signum, frame: self._SendLastWillInactive())
43+
44+
def _SendLastWillInactive(self):
45+
try:
46+
printerId = self._GetPrinterId()
47+
url = f"{self.PresenceDatabaseUrl}/{printerId}.json?print=silent"
48+
data = {
49+
"active": "false",
50+
"lastSeen": int(time.time()),
51+
"version": self.PluginVersion,
52+
}
53+
resp = requests.put(url, json=data)
54+
if resp.status_code > 299:
55+
self.Logger.error(f"Failed to send last will: {resp.status_code} {resp.text}")
56+
else:
57+
self.Logger.info(f"Sent last will (inactive) for printer {printerId}")
58+
except PrinterIdUnavailableException as e:
59+
self.Logger.error(f"Unable to send last will, printer id not available: {str(e)}")
60+
except Exception as e:
61+
self.Logger.error(f"Exception in last will handler: {str(e)}")
62+
63+
def _ContinuouslyAnnouncePresence(self):
64+
t = threading.Thread(target=self._DoContinuouslyAnnouncePresence)
65+
t.daemon = True
66+
t.start()
67+
68+
def _DoContinuouslyAnnouncePresence(self):
69+
while True:
70+
try:
71+
self._AnnouncePresence()
72+
time.sleep(1800)
73+
except PrinterIdUnavailableException as e:
74+
self.Logger.error(f"Cannot announce presence: {str(e)}")
75+
time.sleep(5)
76+
except Exception as e:
77+
self.Logger.error(f"Failed to announce presence: {str(e)}")
78+
time.sleep(60)
79+
80+
def _AnnouncePresence(self):
81+
printerId = self._GetPrinterId()
82+
url = f"{self.PresenceDatabaseUrl}/{printerId}.json?print=silent"
83+
data = {
84+
"active": "true",
85+
"lastSeen": int(time.time()),
86+
"version": self.PluginVersion,
87+
}
88+
resp = requests.put(url, json=data)
89+
90+
if resp.status_code > 299:
91+
self.Logger.error(f"Failed to announce presence: {resp.status_code} {resp.text}")
92+
else:
93+
self.Logger.info(f"Announced presence for printer {printerId}")
94+
95+
def GetAllApps(self) -> List[AppInstance]:
96+
printerId = self._GetPrinterId()
97+
url = f"{self.AppsDatabaseUrl}/{printerId}.json"
98+
resp = requests.get(url)
99+
if resp.status_code != 200:
100+
self.Logger.error(f"Failed to fetch apps: {resp.status_code} {resp.text}")
101+
return []
102+
103+
data = resp.json()
104+
if data is None:
105+
return []
106+
107+
apps:List[AppInstance] = []
108+
for appId, encryptedAppJson in data.items():
109+
try:
110+
# Decrypt app instance (you need to implement AES-256-GCM decryption separately)
111+
decryptedJson = self._DecryptAppInstance(appId, encryptedAppJson)
112+
appInstance = AppInstance.FromDict(decryptedJson)
113+
apps.append(appInstance)
114+
except Exception as e:
115+
self.Logger.error(f"Failed to decrypt or parse app {appId}: {str(e)}")
116+
return apps
117+
118+
def RemoveApps(self, apps: List[AppInstance]):
119+
printerId = self._GetPrinterId()
120+
for app in apps:
121+
instanceId = app.InstanceId
122+
appId = sha256_urlsafe_base64(instanceId.encode())
123+
url = f"{self.AppsDatabaseUrl}/{printerId}/{appId}.json?print=silent"
124+
resp = requests.delete(url)
125+
if resp.status_code != 200:
126+
self.Logger.error(f"Failed to remove app {instanceId}: {resp.status_code} {resp.text}")
127+
128+
129+
def GetOrCreateEncryptionKey(self) -> str:
130+
mainboardId, mainboardMac = self._GetIdBaseValues()
131+
return pbkdf2_key(mainboardId, mainboardMac)
132+
133+
def _GetIdBaseValues(self) -> Tuple[str, str]:
134+
attributes = ElegooClient.Get().GetAttributes()
135+
if attributes is None or attributes.MainboardId is None or attributes.MainboardMac is None:
136+
raise PrinterIdUnavailableException("Failed to get attributes from Elegoo Client.")
137+
return (attributes.MainboardId, attributes.MainboardMac)
138+
139+
def _GetPrinterId(self) -> str:
140+
mainboardId, mainboardMac = self._GetIdBaseValues()
141+
plainId = f"printer:{mainboardId}/{mainboardMac}"
142+
printerId = sha256_urlsafe_base64(plainId.encode())
143+
return printerId
144+
145+
def _DecryptAppInstance(self, appId: str, encryptedBase64: str) -> Dict[str, Any]:
146+
encryptionKey = self.GetOrCreateEncryptionKey()
147+
key_bytes = base64.urlsafe_b64decode(encryptionKey)
148+
149+
# Decode and pad base64 if needed
150+
padded = encryptedBase64 + '=' * (-len(encryptedBase64) % 4)
151+
encrypted = base64.urlsafe_b64decode(padded)
152+
153+
# Split ciphertext and tag (GCM tag is 16 bytes at the end). Remove the first 12 bytes for IV.
154+
if len(encrypted) < (16 + 12):
155+
raise ValueError("Encrypted data too short for GCM tag")
156+
ciphertext = encrypted[12:-16]
157+
iv = encrypted[:12]
158+
tag = encrypted[-16:]
159+
160+
cipher = AES.new(key_bytes, AES.MODE_GCM, nonce=iv)#type:ignore
161+
decrypted_bytes = cipher.decrypt_and_verify(ciphertext, tag)
162+
163+
decrypted_json = decrypted_bytes.decode('utf-8')
164+
return json.loads(decrypted_json)
165+
166+
class PrinterIdUnavailableException(Exception):
167+
pass
Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
import json
33
import random
44
import string
5-
import logging
65
import threading
76
from typing import Any, Dict, List, Optional
87

98
from octoapp.compat import Compat
9+
from octoapp.logging import LoggerLike
1010
from octoapp.sentry import Sentry
1111
from octoapp.websocketimpl import Client
1212
from octoapp.octohttprequest import OctoHttpRequest
@@ -77,19 +77,18 @@ class ElegooClient:
7777
WebSocketMessageDebugging = False
7878

7979
@staticmethod
80-
def Init(logger:logging.Logger, config:Config, pluginId:str, pluginVersion:str, stateTranslator:IStateTranslator, websocketMux:IWebsocketMux, fileManager:IFileManager) -> None:
81-
ElegooClient._Instance = ElegooClient(logger, config, pluginId, pluginVersion, stateTranslator, websocketMux, fileManager)
80+
def Init(logger:LoggerLike, config:Config, pluginVersion:str, stateTranslator:IStateTranslator, websocketMux:IWebsocketMux, fileManager:IFileManager) -> None:
81+
ElegooClient._Instance = ElegooClient(logger, config, pluginVersion, stateTranslator, websocketMux, fileManager)
8282

8383

8484
@staticmethod
8585
def Get() -> 'ElegooClient':
8686
return ElegooClient._Instance
8787

8888

89-
def __init__(self, logger:logging.Logger, config:Config, pluginId:str, pluginVersion:str, stateTranslator:IStateTranslator, websocketMux:IWebsocketMux, fileManager:IFileManager) -> None:
89+
def __init__(self, logger:LoggerLike, config:Config, pluginVersion:str, stateTranslator:IStateTranslator, websocketMux:IWebsocketMux, fileManager:IFileManager) -> None:
9090
self.Logger = logger
9191
self.Config = config
92-
self.PluginId = pluginId
9392
self.PluginVersion = pluginVersion
9493
self.StateTranslator = stateTranslator
9594
self.WebsocketMux = websocketMux
@@ -147,10 +146,6 @@ def __init__(self, logger:logging.Logger, config:Config, pluginId:str, pluginVer
147146
OctoHttpRequest.SetLocalHttpProxyIsHttps(False)
148147
OctoHttpRequest.SetLocalHttpProxyPort(80)
149148

150-
# Start the client worker thread.
151-
t = threading.Thread(target=self._ClientWorker)
152-
t.start()
153-
154149

155150
# Returns the local printer state object with the most up-to-date information.
156151
# Returns None if the printer is not connected or the state is unknown.
@@ -236,7 +231,7 @@ def SendRequest(self, cmdId:int, data:Optional[Dict[str, Any]]=None, waitForResp
236231

237232
# Try to send. default=str makes the json dump use the str function if it fails to serialize something.
238233
jsonStr = json.dumps(obj, default=str)
239-
if ElegooClient.WebSocketMessageDebugging and self.Logger.isEnabledFor(logging.DEBUG):
234+
if ElegooClient.WebSocketMessageDebugging:
240235
self.Logger.debug("Elegoo WS Msg Request - %s : %s : %s", str(requestId), str(cmdId), jsonStr)
241236
if self._WebSocketSend(Buffer(jsonStr.encode("utf-8"))) is False:
242237
self.Logger.info("Elegoo client failed to send request msg.")
@@ -297,7 +292,7 @@ def _WebSocketSend(self, buffer:Buffer) -> bool:
297292
return False
298293

299294
# Print for debugging.
300-
if ElegooClient.WebSocketMessageDebugging and self.Logger.isEnabledFor(logging.DEBUG):
295+
if ElegooClient.WebSocketMessageDebugging:
301296
self.Logger.debug("Ws ->: %s", buffer.GetBytesLike().decode("utf-8"))
302297

303298
try:
@@ -311,7 +306,7 @@ def _WebSocketSend(self, buffer:Buffer) -> bool:
311306

312307

313308
# Sets up, runs, and maintains the websocket connection.
314-
def _ClientWorker(self):
309+
def RunBlocking(self):
315310
isConnectAttemptFromEventBump = False
316311
while True:
317312
try:
@@ -426,7 +421,7 @@ def _OnWsData(self, ws:IWebSocketClient, buffer:Buffer, msgType:WebSocketOpCode)
426421
raise Exception("Parsed json message returned None")
427422

428423
# Print for debugging if desired.
429-
if ElegooClient.WebSocketMessageDebugging and self.Logger.isEnabledFor(logging.DEBUG):
424+
if ElegooClient.WebSocketMessageDebugging:
430425
self.Logger.debug("Incoming Elegoo Message:\r\n"+json.dumps(msg, indent=3))
431426

432427
# If set, this message should be sent to all mux sockets.
@@ -677,7 +672,6 @@ def _SendMuxFrontendMessage(self, data:Optional[Dict[str, Any]]=None) -> None:
677672
data = {}
678673

679674
# Always include the plugin id and version.
680-
data["PluginId"] = self.PluginId
681675
data["PluginVersion"] = self.PluginVersion
682676

683677
# The outside object needs to be a valid response object, so the Elegoo frontend can parse it and ignore it.

elegoo_octoeverywhere/elegoofilemanager.py renamed to elegoo_octoapp/elegoofilemanager.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import logging
21
import threading
32

43
from typing import Any, Dict, List, Optional
54

5+
from octoapp.logging import LoggerLike
66
from octoapp.sentry import Sentry
77

88
from .elegooclient import ElegooClient
@@ -15,7 +15,7 @@
1515
# But once a field is set, it can't be changed.
1616
class FileInfo:
1717

18-
def __init__(self, logger:logging.Logger, fileDirInfo:Dict[str, Any]) -> None:
18+
def __init__(self, logger:LoggerLike, fileDirInfo:Dict[str, Any]) -> None:
1919
self.FileNameWithPath:str = fileDirInfo.get("name", "Unknown")
2020
# Get a version of the file name without the path.
2121
folderIndex = self.FileNameWithPath.rfind("/")
@@ -61,7 +61,7 @@ class ElegooFileManager(IFileManager):
6161
_Instance: "ElegooFileManager" = None #pyright: ignore[reportAssignmentType]
6262

6363
@staticmethod
64-
def Init(logger:logging.Logger):
64+
def Init(logger:LoggerLike):
6565
ElegooFileManager._Instance = ElegooFileManager(logger)
6666

6767

@@ -70,7 +70,7 @@ def Get() -> "ElegooFileManager":
7070
return ElegooFileManager._Instance
7171

7272

73-
def __init__(self, logger:logging.Logger) -> None:
73+
def __init__(self, logger:LoggerLike) -> None:
7474
self.Logger = logger
7575

7676
self.Files:List[FileInfo] = []

0 commit comments

Comments
 (0)