thonny-open-cube 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Open-Cube
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,3 @@
1
+ include LICENSE.md
2
+ include README.md
3
+ recursive-include thonnycontrib *.png
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: thonny-open-cube
3
+ Version: 0.1.0
4
+ Summary: Open-Cube Bluetooth serial program upload and run plugin for Thonny
5
+ Author: Vaclav Jelinek
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Operating System :: OS Independent
8
+ Requires-Python: >=3.6
9
+ License-File: LICENSE.md
10
+ Requires-Dist: pyserial
11
+ Requires-Dist: thonny>=4.1.3
12
+ Dynamic: author
13
+ Dynamic: classifier
14
+ Dynamic: license-file
15
+ Dynamic: requires-dist
16
+ Dynamic: requires-python
17
+ Dynamic: summary
@@ -0,0 +1,23 @@
1
+ # Thonny Open-Cube Bluetooth Upload Plugin
2
+
3
+ ## Installation
4
+
5
+ - Download [Open-Cube Thonny plugin](dist/thonny_open_cube-0.1.0-py3-none-any.whl)
6
+ - Open Thonny
7
+ - Tools -> Manage plug-ins... -> Install from local file
8
+
9
+ ## Features
10
+
11
+ A Thonny plugin for sending programs over Bluetooth to Open-Cube devices and running.
12
+
13
+ This plugin adds:
14
+ - A new toolbar button: **OC Run**
15
+ - A new toolbar button: **OC Stop**
16
+ - A dockable view for **log messages**
17
+ - A configurable COM port option for Windows and Linux
18
+ - File transfer with feedback using a custom Selective Repeat ARQ protocol
19
+
20
+ - Automatically uploads the active editor file to the Open-Cube device
21
+ - Runs the uploaded script
22
+ - Configurable serial port in Thonny settings
23
+ - Supports Windows (COM ports) and Linux (`/dev/rfcomm`)
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,23 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ packages = ["thonnycontrib.open_cube"]
4
+ print(f"Found packages: {packages}")
5
+
6
+
7
+ setup(
8
+ name="thonny-open-cube",
9
+ version="0.1.0",
10
+ description="Open-Cube Bluetooth serial program upload and run plugin for Thonny",
11
+ author="Vaclav Jelinek",
12
+ packages=packages,
13
+ include_package_data=True,
14
+ install_requires=[
15
+ "pyserial",
16
+ "thonny >= 4.1.3",
17
+ ],
18
+ classifiers=[
19
+ "Programming Language :: Python :: 3",
20
+ "Operating System :: OS Independent",
21
+ ],
22
+ python_requires=">=3.6",
23
+ )
@@ -0,0 +1,99 @@
1
+ import importlib.util
2
+ from pathlib import Path
3
+ import sys
4
+
5
+
6
+ PLUGIN_ROOT = Path(__file__).resolve().parents[1]
7
+ MODULE_PATH = PLUGIN_ROOT / "thonnycontrib" / "open_cube" / "program_load_bin.py"
8
+
9
+
10
+ def load_program_load_bin_module():
11
+ spec = importlib.util.spec_from_file_location("open_cube_program_load_bin", MODULE_PATH)
12
+ if spec is None or spec.loader is None:
13
+ raise RuntimeError(f"Failed to load sender module from {MODULE_PATH}")
14
+ module = importlib.util.module_from_spec(spec)
15
+ sys.modules.setdefault("open_cube_program_load_bin", module)
16
+ spec.loader.exec_module(module)
17
+ return module
18
+
19
+
20
+ plb = load_program_load_bin_module()
21
+
22
+
23
+ class FakeSerial:
24
+ def __init__(self, port, baudrate, timeout, responses=None):
25
+ self.port = port
26
+ self.baudrate = baudrate
27
+ self.timeout = timeout
28
+ self.responses = list(responses or [])
29
+ self.writes = []
30
+ self.closed = False
31
+ self._buffer = b""
32
+
33
+ def write(self, data):
34
+ self.writes.append(data)
35
+
36
+ def readline(self):
37
+ if self.responses:
38
+ return self.responses.pop(0)
39
+ return b""
40
+
41
+ @property
42
+ def in_waiting(self):
43
+ return len(self._buffer)
44
+
45
+ def read(self, size):
46
+ data = self._buffer[:size]
47
+ self._buffer = self._buffer[size:]
48
+ return data
49
+
50
+ def close(self):
51
+ self.closed = True
52
+
53
+
54
+ def test_chunk_data_splits_payload():
55
+ chunks = plb.chunk_data(b"abcdefgh", 3)
56
+ assert chunks == [b"abc", b"def", b"gh"]
57
+
58
+
59
+ def test_set_sock_reopens_when_port_changes(monkeypatch):
60
+ created = []
61
+
62
+ def serial_factory(port, baudrate, timeout):
63
+ fake = FakeSerial(port, baudrate, timeout)
64
+ created.append(fake)
65
+ return fake
66
+
67
+ monkeypatch.setattr(plb.serial, "Serial", serial_factory)
68
+ monkeypatch.setattr(plb.time, "sleep", lambda *_args, **_kwargs: None)
69
+
70
+ sender = plb.Sender()
71
+ sender.set_sock("COM7")
72
+ first = sender.sock
73
+ sender.set_sock("COM7")
74
+ sender.set_sock("COM8")
75
+
76
+ assert len(created) == 2
77
+ assert first.closed is True
78
+ assert sender.sock is created[-1]
79
+ assert sender.port == "COM8"
80
+
81
+
82
+ def test_send_filename_retries_until_cube_ack(monkeypatch):
83
+ sender = plb.Sender()
84
+ sender.filename = "demo.py"
85
+ sender.sock = FakeSerial("COM7", 921600, 0.1, [b"", b"oc prg ok\n"])
86
+ monkeypatch.setattr(plb.time, "sleep", lambda *_args, **_kwargs: None)
87
+
88
+ assert sender.send_filename() is True
89
+ assert sender.sock.writes == [b"\n", b"oc prg demo.py\n"]
90
+
91
+
92
+ def test_build_seq_packet_contains_header_and_crc():
93
+ sender = plb.Sender()
94
+ sender.set_content("print('ok')\n", "demo.py", packet_size=8)
95
+
96
+ packet = sender.build_seq_packet(0)
97
+
98
+ assert packet[:4] == plb.HEADER.to_bytes(4, "big")
99
+ assert len(packet) > 10
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: thonny-open-cube
3
+ Version: 0.1.0
4
+ Summary: Open-Cube Bluetooth serial program upload and run plugin for Thonny
5
+ Author: Vaclav Jelinek
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Operating System :: OS Independent
8
+ Requires-Python: >=3.6
9
+ License-File: LICENSE.md
10
+ Requires-Dist: pyserial
11
+ Requires-Dist: thonny>=4.1.3
12
+ Dynamic: author
13
+ Dynamic: classifier
14
+ Dynamic: license-file
15
+ Dynamic: requires-dist
16
+ Dynamic: requires-python
17
+ Dynamic: summary
@@ -0,0 +1,15 @@
1
+ LICENSE.md
2
+ MANIFEST.in
3
+ README.md
4
+ setup.py
5
+ tests/test_program_load_bin.py
6
+ thonny_open_cube.egg-info/PKG-INFO
7
+ thonny_open_cube.egg-info/SOURCES.txt
8
+ thonny_open_cube.egg-info/dependency_links.txt
9
+ thonny_open_cube.egg-info/requires.txt
10
+ thonny_open_cube.egg-info/top_level.txt
11
+ thonnycontrib/open_cube/__init__.py
12
+ thonnycontrib/open_cube/btl_logo.png
13
+ thonnycontrib/open_cube/oc_run.png
14
+ thonnycontrib/open_cube/oc_stop.png
15
+ thonnycontrib/open_cube/program_load_bin.py
@@ -0,0 +1,2 @@
1
+ pyserial
2
+ thonny>=4.1.3
@@ -0,0 +1 @@
1
+ thonnycontrib
@@ -0,0 +1,301 @@
1
+ from thonny import get_workbench
2
+ from thonny.languages import tr
3
+ from tkinter import filedialog, messagebox
4
+ from thonny.tktextext import EnhancedText, TextFrame
5
+ from thonny.ui_utils import TextMenu, AutoScrollbar
6
+ from .program_load_bin import PACKET_TIMEOUT, Sender, PACKET_SIZE
7
+ from thonny.common import is_remote_path
8
+ from thonny.editors import extract_target_path
9
+ from thonny.config_ui import ConfigurationPage
10
+ from thonny.misc_utils import (
11
+ running_on_linux,
12
+ running_on_mac_os,
13
+ running_on_rpi,
14
+ running_on_windows,
15
+ )
16
+ import tkinter as tk
17
+ from tkinter import ttk
18
+
19
+ import webbrowser
20
+ import serial.tools.list_ports
21
+ import threading, os, serial
22
+
23
+ def get_bt_port():
24
+ wb = get_workbench()
25
+ view = wb.get_view(OpenCubeView.__name__)
26
+ port = get_workbench().get_option("oc_bt_run.com_port")
27
+ if port is not None and "No port found" not in port:
28
+ return port
29
+ # fallback
30
+ view.append_text("run")
31
+ if running_on_windows():
32
+ view.append_text("good")
33
+ return "COM3"
34
+ else:
35
+ return "/dev/rfcomm0"
36
+
37
+ def get_current_file_content_and_filename():
38
+ wb = get_workbench()
39
+ editor = wb.get_editor_notebook().get_current_editor()
40
+ filepath = editor.get_filename()
41
+ if filepath is None:
42
+ return None, None
43
+ elif is_remote_path(filepath):
44
+ path = extract_target_path(filepath)
45
+ filename = path.split("/")[-1]
46
+ else:
47
+ filename = os.path.basename(filepath)
48
+ content = editor.get_content()
49
+ return content, filename
50
+
51
+ def open_btl():
52
+ webbrowser.open("https://btl-kariera.cz/")
53
+
54
+ def send_stop():
55
+ wb = get_workbench()
56
+ view = wb.get_view(OpenCubeView.__name__)
57
+ view.append_debug("OC Stop pressed")
58
+ if view.sender.running_program:
59
+ view.append_text("\n***Stopping program***")
60
+ view.sender.reset_cube()
61
+ return
62
+ if view.sender.running:
63
+ view.append_text("\n***Stopping upload***")
64
+ view.sender.abort_upload()
65
+ return
66
+
67
+ #sock_set = view.set_sock()
68
+ #if not sock_set:
69
+ # return
70
+
71
+ def send_file_over_serial():
72
+ wb = get_workbench()
73
+ view = wb.get_view(OpenCubeView.__name__)
74
+ view.append_debug("OC Run pressed")
75
+ wb.show_view(OpenCubeView.__name__, False)
76
+
77
+ if view.sender.running:
78
+ view.append_text("*Sender running*")
79
+ return
80
+
81
+ sock_set = view.set_sock()
82
+ if not sock_set:
83
+ return
84
+
85
+ content, filename = get_current_file_content_and_filename()
86
+ if content is None:
87
+ view.append_text("No file selected. Open a file in the editor.")
88
+ return
89
+ view.append_text(f"\n***Starting file transfer ({filename})***")
90
+ packet_size = wb.get_option("oc_bt_run.packet_size")
91
+ #check if packet size is valid
92
+ if not isinstance(packet_size, int) or packet_size <= 0 or packet_size > 4096:
93
+ packet_size = PACKET_SIZE
94
+ try:
95
+ packet_timeout = wb.get_option("oc_bt_run.packet_timeout")
96
+
97
+ packet_timeout = float(packet_timeout)/1000
98
+ except (ValueError, TypeError):
99
+ packet_timeout = PACKET_TIMEOUT
100
+ view.append_debug(f"Using packet size: {packet_size}, packet timeout: {packet_timeout}")
101
+ view.sender.set_content(content, filename, packet_size, packet_timeout)
102
+
103
+ view.sender.set_control(wb.get_option("oc_bt_run.take_control"))
104
+
105
+ run_after_upload = wb.get_option("oc_bt_run.run_after_upload")
106
+ view.append_debug("Starting upload thread.")
107
+ view.upload_thread = threading.Thread(target=view.sender.upload, daemon=True, args=(run_after_upload,))
108
+ view.upload_thread.start()
109
+
110
+ class SerialText(EnhancedText):
111
+ def __init__(self, master, **kw):
112
+ EnhancedText.__init__(self, master=master, wrap="word", undo=True, **kw)
113
+ self.context_menu = TextMenu(self)
114
+
115
+ def on_secondary_click(self, event=None):
116
+ super().on_secondary_click(event=event)
117
+ self.context_menu.tk_popup(event.x_root, event.y_root)
118
+
119
+ class OpenCubeView(TextFrame):
120
+ def __init__(self, master):
121
+ super().__init__(
122
+ master, text_class=SerialText, horizontal_scrollbar_class=AutoScrollbar
123
+ )
124
+ self.text.edit_reset()
125
+ self.append_text("***Open-Cube upload and run plugin loaded***")
126
+ self.sender = Sender()
127
+ self.sender.set_logger(self.append_text)
128
+ self.sender.set_debug(self.append_debug)
129
+ self.append_debug(f"Running on Windows: {running_on_windows()}")
130
+
131
+ def set_sock(self):
132
+ try:
133
+ port = get_bt_port()
134
+ self.append_debug(f"Used port: {port}")
135
+ self.sender.set_sock(port)
136
+ except Exception as e:
137
+ self.append_text("Please bind Bluetooth socket:")
138
+ if running_on_windows():
139
+ self.append_text("Set correct COM port in settings")
140
+ else:
141
+ self.append_text("Please bind rfcomm:")
142
+ self.append_text("sudo rfcomm bind /dev/rfcomm0 <device-MAC>")
143
+ self.append_text(str(e))
144
+ return False
145
+ return True
146
+
147
+ def append_text(self, msg):
148
+ if msg is None:
149
+ return
150
+ self.text.config(state="normal")
151
+ self.text.insert("end", msg + "\n")
152
+ self.text.config(state="disabled")
153
+ self.text.see("end") # Auto-scrol
154
+
155
+ def append_debug(self, msg):
156
+ if msg is None:
157
+ return
158
+ if get_workbench().get_option("oc_bt_run.debug_log"):
159
+ self.append_text("DEBUG: " + msg)
160
+
161
+ def save_content(self, event=None):
162
+ pass
163
+
164
+ def destroy(self):
165
+ if self.sender.sock is not None:
166
+ self.sender.sock.close()
167
+ self.sender.sock = None
168
+ super().destroy()
169
+
170
+ class OpenCubeConfigPage(ConfigurationPage):
171
+ def __init__(self, master):
172
+ super().__init__(master)
173
+
174
+ self.ports = [p.device for p in serial.tools.list_ports.comports()]
175
+ if running_on_windows():
176
+ self.ports = [p for p in self.ports if p.startswith("COM")]
177
+ else:
178
+ self.ports = [p for p in self.ports if p.startswith("/dev/rfcomm")]
179
+ if not self.ports:
180
+ self.ports = ["No port found"]
181
+ self.ports.sort()
182
+
183
+ self.columnconfigure(1, weight=1)
184
+
185
+ self.add_checkbox(
186
+ "oc_bt_run.run_after_upload",
187
+ tr("Run after upload"),
188
+ 5,
189
+ 0,
190
+ columnspan=2,
191
+ pady=(0, 10),
192
+ )
193
+
194
+ self.add_checkbox(
195
+ "oc_bt_run.take_control",
196
+ tr("Take control (allow OC Stop command and wireless printing)"),
197
+ 10,
198
+ 0,
199
+ columnspan=2,
200
+ pady=(0, 10),
201
+ )
202
+
203
+ self.add_checkbox(
204
+ "oc_bt_run.debug_log",
205
+ tr("Print debug messages"),
206
+ 15,
207
+ 0,
208
+ columnspan=2,
209
+ pady=(0, 10),
210
+ )
211
+
212
+ com_port_label = ttk.Label(
213
+ self,
214
+ text=tr("Open-Cube BT port")
215
+ )
216
+ com_port_label.grid(row=20, column=0, sticky=tk.W)
217
+ self.add_combobox("oc_bt_run.com_port",
218
+ self.ports,
219
+ 20,
220
+ 1,
221
+ columnspan=1,
222
+ width=20)
223
+
224
+ packet_size_label = ttk.Label(
225
+ self,
226
+ text=tr("Packet size")
227
+ )
228
+ packet_size_label.grid(row=30, column=0, sticky=tk.W)
229
+ self.add_entry("oc_bt_run.packet_size",
230
+ row=30,
231
+ column=1,
232
+ columnspan=1,
233
+ pady=0,
234
+ width=None)
235
+
236
+ packet_timeout_label = ttk.Label(
237
+ self,
238
+ text=tr("Packet timeout (ms)")
239
+ )
240
+ packet_timeout_label.grid(row=40, column=0, sticky=tk.W)
241
+ self.add_entry("oc_bt_run.packet_timeout",
242
+ row=40,
243
+ column=1,
244
+ columnspan=1,
245
+ pady=0,
246
+ width=None)
247
+
248
+ def load_plugin():
249
+ # Set plugin variables
250
+ get_workbench().set_default("oc_bt_run.run_after_upload", True)
251
+ get_workbench().set_default("oc_bt_run.take_control", True)
252
+ get_workbench().set_default("oc_bt_run.debug_log", False)
253
+ get_workbench().set_default("oc_bt_run.packet_size", PACKET_SIZE)
254
+ get_workbench().set_default("oc_bt_run.packet_timeout", PACKET_TIMEOUT*1000) # Store in milliseconds for easier user input
255
+ if running_on_windows():
256
+ get_workbench().set_default("oc_bt_run.com_port", "COM3")
257
+ else:
258
+ get_workbench().set_default("oc_bt_run.com_port", "/dev/rfcomm0")
259
+
260
+ # Register run command with toolbar icon
261
+ run_image_path = os.path.join(os.path.dirname(__file__), "oc_run.png")
262
+ get_workbench().add_command(
263
+ command_id="oc_upload_run",
264
+ menu_name="run",
265
+ command_label="Open-Cube upload and run",
266
+ handler=send_file_over_serial,
267
+ include_in_toolbar=True,
268
+ caption="Open-Cube run",
269
+ image=run_image_path,
270
+ group=800,
271
+ )
272
+
273
+ # Register stop command with toolbar icon
274
+ stop_image_path = os.path.join(os.path.dirname(__file__), "oc_stop.png")
275
+ get_workbench().add_command(
276
+ command_id="oc_stop",
277
+ menu_name="run",
278
+ command_label="Open-Cube stop",
279
+ handler=send_stop,
280
+ include_in_toolbar=True,
281
+ caption="Open-Cube stop",
282
+ image=stop_image_path,
283
+ group=800,
284
+ )
285
+
286
+ # Register BTL butto
287
+ btl_image_path = os.path.join(os.path.dirname(__file__), "btl_logo.png")
288
+ get_workbench().add_command(
289
+ command_id="BTL",
290
+ menu_name="help",
291
+ command_label="BTL",
292
+ handler=open_btl,
293
+ include_in_toolbar=True,
294
+ caption="BTL",
295
+ image=btl_image_path,
296
+ group=800,
297
+ )
298
+ # Add Open-Cube log view
299
+ get_workbench().add_view(OpenCubeView, tr("Open-Cube log"), "se")
300
+ # Add Open-Cube config page
301
+ get_workbench().add_configuration_page("Open-Cube", tr("Open-Cube"), OpenCubeConfigPage, order=85)
@@ -0,0 +1,459 @@
1
+ import serial
2
+ import time
3
+ import os
4
+ import zlib
5
+ import threading
6
+ import hashlib
7
+
8
+
9
+ # Max safe payload is 660 bytes (BT L2CAP default MTU 672 minus 12 bytes
10
+ # packet overhead: 4 header + 2 length + 2 seq + 4 CRC). Packets above
11
+ # this fragment at the L2CAP layer and become unreliable. 640B works but
12
+ # shows occasional NAKs; 512B is rock-solid with zero NAKs and consistent
13
+ # throughput, so we use it as the default.
14
+ PACKET_SIZE = 512
15
+ WINDOW_SIZE = 20
16
+ ACK_TIMEOUT = 0.4
17
+ RESET_TIMEOUT = 1
18
+ PACKET_TIMEOUT = 0.015
19
+ MIN_PACKET_TIMEOUT = 0.008
20
+ MAX_PACKET_TIMEOUT = 0.015
21
+ HEADER = 0x793F5CFE
22
+ CRC_SIZE = 4
23
+ NUM_ATTEMPTS = 3
24
+ MAX_RESENDS = 3
25
+
26
+ def chunk_data(data, size):
27
+ return [data[i:i+size] for i in range(0, len(data), size)]
28
+
29
+ class Sender:
30
+ def __init__(self):
31
+ self.filepath = None
32
+ self.sock = None
33
+ self.acked = set()
34
+ self.naks = set()
35
+ self.window_end_idx = WINDOW_SIZE
36
+ self.transfer_complete = False
37
+ self.transfer_success = False
38
+ self.cube_abort = False
39
+ self.logger = None
40
+ self.debug_logger = None
41
+ self.progress_callback = None
42
+ self.ack_thread = None
43
+ self.running = False
44
+ self.running_program = False
45
+ self.port = None
46
+ self.take_control = False
47
+ self.state_lock = threading.Lock()
48
+ self.packet_size = PACKET_SIZE
49
+ self.packet_timeout = PACKET_TIMEOUT
50
+
51
+ def set_sock(self, rfcom_port):
52
+ if not rfcom_port:
53
+ if self.sock:
54
+ self.sock.close()
55
+ self.sock = None
56
+ self.debug("Serial port closed.")
57
+ self.port = None
58
+ return
59
+
60
+ if self.sock and self.port == rfcom_port:
61
+ self.debug(f"Serial port unchanged {rfcom_port}.")
62
+ return
63
+
64
+ if self.sock:
65
+ self.sock.close()
66
+ self.sock = None
67
+
68
+ self.port = rfcom_port
69
+ self.sock = serial.Serial(rfcom_port, 921600, timeout=0.1)
70
+ time.sleep(1)
71
+ self.debug(f"Opened serial port {rfcom_port}.")
72
+
73
+ def log_and_debug(self, message, logger):
74
+ if logger is None:
75
+ print(message)
76
+ elif hasattr(logger, "insert"): # Tkinter Text widget
77
+ logger.insert("end", message + "\n")
78
+ logger.see("end")
79
+ elif callable(logger):
80
+ logger(message)
81
+
82
+ def log(self, message):
83
+ self.log_and_debug(message, self.logger)
84
+
85
+ def set_logger(self, logger_widget_or_func):
86
+ self.logger = logger_widget_or_func
87
+
88
+ def debug(self, message):
89
+ self.log_and_debug(message, self.debug_logger)
90
+
91
+ def set_debug(self, debug_widget_or_func):
92
+ self.debug_logger = debug_widget_or_func
93
+
94
+ def set_progress_callback(self, callback_func):
95
+ self.progress_callback = callback_func
96
+
97
+ def set_control(self, take_control=False):
98
+ self.take_control = take_control
99
+ self.debug(f"Take control set to {take_control}.")
100
+
101
+ def adaptive_delay(self):
102
+ with self.state_lock:
103
+ in_flight = self.window_end_idx - len(self.acked)
104
+ if in_flight < WINDOW_SIZE // 2:
105
+ time.sleep(MIN_PACKET_TIMEOUT)
106
+ elif in_flight > WINDOW_SIZE * 3 // 4:
107
+ time.sleep(MAX_PACKET_TIMEOUT)
108
+ else:
109
+ time.sleep((MIN_PACKET_TIMEOUT + MAX_PACKET_TIMEOUT) / 2)
110
+
111
+ def reset(self):
112
+ with self.state_lock:
113
+ self.acked = set()
114
+ self.naks = set()
115
+ self.window_end_idx = WINDOW_SIZE
116
+ self.transfer_complete = False
117
+ self.transfer_success = False
118
+ self.cube_abort = False
119
+
120
+ def set_file(self, filepath, packet_size=PACKET_SIZE):
121
+ self.reset()
122
+ self.filepath = filepath
123
+ self.packet_size = packet_size
124
+ with open(filepath, "rb") as f:
125
+ self.filedata = f.read()
126
+ self.filesize = len(self.filedata)
127
+ self.chunks = chunk_data(self.filedata, packet_size)
128
+ self.total = len(self.chunks)
129
+ self.filename = os.path.basename(filepath)
130
+ self.log(f"File size: {self.filesize} bytes, Number of packets: {self.total}")
131
+
132
+ def set_content(self, content, filename, packet_size=PACKET_SIZE, packet_timeout=PACKET_TIMEOUT):
133
+ self.reset()
134
+ self.packet_size = packet_size
135
+ self.filedata = content.encode()
136
+ self.filesize = len(self.filedata)
137
+ self.chunks = chunk_data(self.filedata, packet_size)
138
+ self.total = len(self.chunks)
139
+ self.filename = filename
140
+ self.log(f"File size: {self.filesize} bytes, Number of packets: {self.total}")
141
+ self.packet_timeout = packet_timeout
142
+
143
+ def set_filename(self, filename):
144
+ self.filename = filename
145
+
146
+ def add_seq(self, data, seq):
147
+ payload_w_seq = seq.to_bytes(2, 'little') + data
148
+ return payload_w_seq
149
+
150
+ def add_crc(self, payload):
151
+ crc = zlib.crc32(payload).to_bytes(4, 'little')
152
+ payload_w_crc = payload + crc
153
+ return payload_w_crc
154
+
155
+ # No crc check on length needed - error in lenght causes complete packet loss
156
+ def add_len(self, payload_w_crc):
157
+ length = len(payload_w_crc).to_bytes(2, 'little')
158
+ payload_w_len = length + payload_w_crc
159
+ return payload_w_len
160
+
161
+ def add_header(self, payload):
162
+ header = HEADER.to_bytes(4, 'big')
163
+ packet = header + payload
164
+ return packet
165
+
166
+ def build_seq_packet(self, seq):
167
+ data = self.chunks[seq]
168
+ payload_w_seq = self.add_seq(data, seq)
169
+ packet = self.build_packet(payload_w_seq)
170
+ return packet
171
+
172
+ def build_packet(self, data):
173
+ # Build the packet with header, length, CRC
174
+ payload_w_crc = self.add_crc(data)
175
+ payload_w_len = self.add_len(payload_w_crc)
176
+ packet = self.add_header(payload_w_len)
177
+ return packet
178
+
179
+ def send_packet(self, seq):
180
+ packet = self.build_seq_packet(seq)
181
+ self.sock.write(packet)
182
+ with self.state_lock:
183
+ acked_count = len(self.acked)
184
+ self.log(f"Sent packet {seq + 1}/{self.total} ({acked_count * 100 // self.total}%)")
185
+ if self.progress_callback:
186
+ self.progress_callback(acked_count, self.total)
187
+
188
+ def ping_cube(self):
189
+ time_start = time.time()
190
+ attempts = 0
191
+ while attempts < NUM_ATTEMPTS:
192
+ attempts += 1
193
+ self.debug(f"Sending ping attempt {attempts}")
194
+ self.sock.write(b"oc ping\n")
195
+ time_start = time.time()
196
+ while time.time() - time_start < ACK_TIMEOUT:
197
+ line = self.sock.readline()
198
+ self.debug(f"Received line: {line}")
199
+ if b"oc pong" in line:
200
+ return True
201
+ self.debug("Ping failed.")
202
+ return False
203
+
204
+ def send_size(self):
205
+ data = self.total.to_bytes(2, 'little') + self.packet_size.to_bytes(2, 'little')
206
+ packet = self.build_packet(data)
207
+ self.sock.write(packet)
208
+
209
+ def send_filename(self):
210
+ self.sock.write(b"\n")
211
+ attempts = 0
212
+ while attempts < NUM_ATTEMPTS:
213
+ attempts += 1
214
+ self.debug(f"Sending command attempt {attempts}: oc prg {self.filename}")
215
+ self.sock.write(f"oc prg {self.filename}\n".encode())
216
+ time_start = time.time()
217
+ while time.time() - time_start < ACK_TIMEOUT:
218
+ line = self.sock.readline()
219
+ self.debug(f"Received line: {line}")
220
+ if b"oc prg ok" in line:
221
+ return True
222
+ return False
223
+
224
+ def send_filename_and_size(self):
225
+ self.debug("Send filename.")
226
+ if self.send_filename():
227
+ self.debug("Send size.")
228
+ self.send_size()
229
+ return True
230
+ self.debug("Send filename failed.")
231
+ return False
232
+
233
+ def send_file_end(self):
234
+ packet = self.build_packet((0xFFFF).to_bytes(2, 'little'))
235
+ self.sock.write(packet)
236
+ self.log("Sent end of file packet.")
237
+
238
+ def send_file_hash(self):
239
+ file_hash = hashlib.sha256(self.filedata).digest()
240
+ data = self.add_seq(file_hash, 0xFFFF)
241
+ packet = self.build_packet(data)
242
+ self.sock.write(packet)
243
+
244
+ def send_reset(self):
245
+ self.debug("Sending reset command.")
246
+ self.sock.write(b"oc rs\n")
247
+ time.sleep(ACK_TIMEOUT)
248
+ line = self.sock.readline()
249
+ if line == b"oc rs ok\n":
250
+ self.log("Program stopped.")
251
+
252
+ def safe_flush(self):
253
+ try:
254
+ in_waiting = self.sock.in_waiting
255
+ if in_waiting > 0:
256
+ flushed = self.sock.read(in_waiting)
257
+ self.debug(f"Flushing {in_waiting} bytes from input buffer ({flushed}).")
258
+
259
+ except Exception as e:
260
+ self.debug(f"Error flushing input buffer: {e}")
261
+
262
+ def listen_ack(self):
263
+ #self.log("Listening for ACK/NAK...")
264
+ self.debug(f"Start listen thread.")
265
+ try:
266
+ while self.running:
267
+ with self.state_lock:
268
+ done = len(self.acked) >= self.total
269
+ if done:
270
+ break
271
+ try:
272
+ msg = self.sock.readline()
273
+ if msg.startswith(b"ACK"):
274
+ seq = int(msg[3:].decode())
275
+ with self.state_lock:
276
+ self.window_end_idx += 1
277
+ self.acked.add(seq)
278
+ self.naks.discard(seq)
279
+ self.debug(f"Received ACK for packet {seq}")
280
+ elif msg.startswith(b"NAK"):
281
+ seq = int(msg[3:].decode())
282
+ with self.state_lock:
283
+ self.naks.add(seq)
284
+ self.debug(f"Received NAK for packet {seq}")
285
+ elif msg.startswith(b"oc prg ko"):
286
+ self.debug("Received ko from cube.")
287
+ self.log("Error sending file.")
288
+ self.transfer_complete = True
289
+ self.cube_abort = True
290
+ break
291
+ elif msg != b"":
292
+ self.debug(f"Received unknown message: {msg}")
293
+ except:
294
+ pass
295
+ except Exception as e:
296
+ self.debug(f"Error in listen thread: {e}")
297
+ self.debug("Exit listen thread.")
298
+
299
+ def reset_cube(self):
300
+ self.safe_flush()
301
+ self.running_program = False
302
+ time.sleep(ACK_TIMEOUT)
303
+ self.send_reset()
304
+
305
+ def _upload(self, run=False):
306
+ time_start = time.time()
307
+
308
+ self.safe_flush()
309
+ if not self.ping_cube():
310
+ self.log("Cube not responding.")
311
+ return
312
+
313
+ self.safe_flush()
314
+ if not self.send_filename_and_size():
315
+ self.log("Failed to start transfer.")
316
+ return
317
+
318
+ self.ack_thread = threading.Thread(target=self.listen_ack, daemon=True)
319
+ self.ack_thread.start()
320
+
321
+ resends = 0
322
+ i = 0
323
+ while i < self.total and not self.transfer_complete:
324
+ with self.state_lock:
325
+ pending_naks = sorted(seq for seq in self.naks if seq < self.total and seq not in self.acked)
326
+ for seq in pending_naks:
327
+ self.naks.discard(seq)
328
+ for seq in pending_naks:
329
+ self.send_packet(seq)
330
+ self.adaptive_delay()
331
+
332
+ j = i
333
+ with self.state_lock:
334
+ window_end_idx = self.window_end_idx
335
+ acked = set(self.acked)
336
+ while j < window_end_idx and j < self.total and not self.transfer_complete:
337
+ if j not in acked:
338
+ self.send_packet(j)
339
+ self.adaptive_delay()
340
+ j += 1
341
+ resends += 1
342
+ with self.state_lock:
343
+ acked = set(self.acked)
344
+ while i in acked:
345
+ resends = 0
346
+ i += 1
347
+ with self.state_lock:
348
+ acked = set(self.acked)
349
+ #time.sleep(ACK_TIMEOUT)
350
+ if resends > MAX_RESENDS:
351
+ self.log("Too many resends, transfer aborted.")
352
+ self.transfer_complete = True
353
+ return
354
+
355
+ if self.cube_abort:
356
+ if self.progress_callback:
357
+ self.progress_callback(0, self.total)
358
+ self.log("Transfer aborted from cube.")
359
+ return
360
+ elif self.transfer_complete:
361
+ self.log("Transfer aborted.")
362
+ return
363
+
364
+ self.safe_flush()
365
+ attempts = 0
366
+ while attempts < NUM_ATTEMPTS:
367
+ attempts += 1
368
+ self.debug(f"Sending file hash attempt {attempts}")
369
+ self.send_file_hash()
370
+ time.sleep(ACK_TIMEOUT)
371
+ line = self.sock.readline()
372
+ if line == b'ACK65535\n':
373
+ self.log("Sent file hash.")
374
+ self.debug("Received ACK for file hash.")
375
+ while attempts < NUM_ATTEMPTS*2:
376
+ attempts += 1
377
+ time.sleep(ACK_TIMEOUT)
378
+ line = self.sock.readline()
379
+ self.debug(f"Received line: {line}")
380
+ if b"oc prg ok" in line:
381
+ self.log("File transfer succesful.")
382
+ time_end = time.time()
383
+ self.log(f"Transfer time: {time_end - time_start:.2f} seconds")
384
+ self.transfer_success = True
385
+ if self.progress_callback:
386
+ self.progress_callback(self.total, self.total)
387
+ if run:
388
+ time.sleep(ACK_TIMEOUT)
389
+ self.run()
390
+ return
391
+ if b"oc prg hash" in line:
392
+ self.log("Hash mismatch, transfer failed.")
393
+ if self.progress_callback:
394
+ self.progress_callback(0, self.total)
395
+ return
396
+ if b"oc prg ko" in line:
397
+ self.log("Transfer rejected by cube.")
398
+ if self.progress_callback:
399
+ self.progress_callback(0, self.total)
400
+ return
401
+ if self.progress_callback:
402
+ self.progress_callback(0, self.total)
403
+ self.log("Failed to send file hash.")
404
+
405
+ def upload(self, run=False):
406
+ self.debug(f"Starting upload (run: {run})")
407
+ self.running = True
408
+ try:
409
+ self._upload(run=run)
410
+ except Exception as e:
411
+ self.debug(f"Exception in upload: {e}")
412
+ self.set_sock(None)
413
+ self.running = False
414
+ self.running_program = False
415
+ if self.ack_thread is not None and self.ack_thread.is_alive():
416
+ self.ack_thread.join(timeout=ACK_TIMEOUT)
417
+ self.debug("Exit upload thread.")
418
+
419
+ def upload_and_run(self):
420
+ self.upload(run=True)
421
+
422
+ def listen_cube(self):
423
+ self.log("Listening for cube messages:\n")
424
+ while self.running_program:
425
+ line = self.sock.readline()
426
+ if line:
427
+ if line == b"oc menu\n":
428
+ self.log("Program stopped.")
429
+ break
430
+ self.log(line.decode().strip())
431
+ self.debug("Stop listening cube program.")
432
+
433
+ def run(self):
434
+ if self.filename:
435
+ self.log("Running program...")
436
+ if self.take_control:
437
+ write = "oc rtc "
438
+ else:
439
+ write = "oc run "
440
+ self.safe_flush()
441
+ attempts = 0
442
+ while attempts < NUM_ATTEMPTS:
443
+ attempts += 1
444
+ self.debug(f"Sending run command attempt {attempts}: {write}{self.filename[:-3]}")
445
+ self.sock.write(f"{write}{self.filename[:-3]}\n".encode())
446
+ time.sleep(ACK_TIMEOUT)
447
+ line = self.sock.readline()
448
+ if b"oc run ok" in line:
449
+ self.log("Program started.")
450
+ if self.take_control:
451
+ self.running_program = True
452
+ self.listen_cube()
453
+ return
454
+ if b"oc run ko" in line:
455
+ break
456
+ self.log("Failed to start program.")
457
+
458
+ def abort_upload(self):
459
+ self.transfer_complete = True