UC2-REST 0.2.0.30__tar.gz → 0.2.0.32__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.
Files changed (47) hide show
  1. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/PKG-INFO +1 -1
  2. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/UC2_REST.egg-info/PKG-INFO +1 -1
  3. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/UC2_REST.egg-info/SOURCES.txt +4 -0
  4. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/UC2Client.py +12 -0
  5. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/__version__.py +1 -1
  6. uc2_rest-0.2.0.32/uc2rest/camera_trigger.py +197 -0
  7. uc2_rest-0.2.0.32/uc2rest/can.py +143 -0
  8. uc2_rest-0.2.0.32/uc2rest/canota.py +256 -0
  9. uc2_rest-0.2.0.32/uc2rest/digitalin.py +122 -0
  10. uc2_rest-0.2.0.32/uc2rest/digitalout.py +187 -0
  11. uc2_rest-0.2.0.32/uc2rest/home.py +314 -0
  12. uc2_rest-0.2.0.32/uc2rest/laser.py +143 -0
  13. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/motor.py +607 -84
  14. uc2_rest-0.2.0.32/uc2rest/motor_config.py +315 -0
  15. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/mserial.py +112 -96
  16. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/objective.py +35 -33
  17. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/state.py +1 -1
  18. uc2_rest-0.2.0.30/uc2rest/can.py +0 -33
  19. uc2_rest-0.2.0.30/uc2rest/digitalout.py +0 -65
  20. uc2_rest-0.2.0.30/uc2rest/home.py +0 -125
  21. uc2_rest-0.2.0.30/uc2rest/laser.py +0 -77
  22. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/LICENSE +0 -0
  23. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/README.md +0 -0
  24. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/UC2_REST.egg-info/dependency_links.txt +0 -0
  25. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/UC2_REST.egg-info/not-zip-safe +0 -0
  26. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/UC2_REST.egg-info/requires.txt +0 -0
  27. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/UC2_REST.egg-info/top_level.txt +0 -0
  28. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/setup.cfg +0 -0
  29. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/setup.py +0 -0
  30. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/MockSerial.py +0 -0
  31. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/__init__.py +0 -0
  32. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/analog.py +0 -0
  33. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/camera.py +0 -0
  34. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/cmdrecorder.py +0 -0
  35. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/galvo.py +0 -0
  36. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/gripper.py +0 -0
  37. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/lcddisplay.py +0 -0
  38. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/ledmatrix.py +0 -0
  39. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/logger.py +0 -0
  40. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/message.py +0 -0
  41. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/modules.py +0 -0
  42. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/pid.py +0 -0
  43. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/rotator.py +0 -0
  44. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/slm.py +0 -0
  45. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/temperature.py +0 -0
  46. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/utils.py +0 -0
  47. {uc2_rest-0.2.0.30 → uc2_rest-0.2.0.32}/uc2rest/wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: UC2-REST
3
- Version: 0.2.0.30
3
+ Version: 0.2.0.32
4
4
  Summary: This pacage will help you to drive the ESP32-driven microscopy control modules from UC2
5
5
  Home-page: https://github.com/openUC2/UC2-REST
6
6
  Author: Benedict Diederich
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: UC2-REST
3
- Version: 0.2.0.30
3
+ Version: 0.2.0.32
4
4
  Summary: This pacage will help you to drive the ESP32-driven microscopy control modules from UC2
5
5
  Home-page: https://github.com/openUC2/UC2-REST
6
6
  Author: Benedict Diederich
@@ -13,8 +13,11 @@ uc2rest/__init__.py
13
13
  uc2rest/__version__.py
14
14
  uc2rest/analog.py
15
15
  uc2rest/camera.py
16
+ uc2rest/camera_trigger.py
16
17
  uc2rest/can.py
18
+ uc2rest/canota.py
17
19
  uc2rest/cmdrecorder.py
20
+ uc2rest/digitalin.py
18
21
  uc2rest/digitalout.py
19
22
  uc2rest/galvo.py
20
23
  uc2rest/gripper.py
@@ -26,6 +29,7 @@ uc2rest/logger.py
26
29
  uc2rest/message.py
27
30
  uc2rest/modules.py
28
31
  uc2rest/motor.py
32
+ uc2rest/motor_config.py
29
33
  uc2rest/mserial.py
30
34
  uc2rest/objective.py
31
35
  uc2rest/pid.py
@@ -21,12 +21,15 @@ from .camera import Camera
21
21
  from .analog import Analog
22
22
  from .modules import Modules
23
23
  from .digitalout import DigitalOut
24
+ from .digitalin import DigitalIn
24
25
  from .rotator import Rotator
25
26
  from .logger import Logger
26
27
  from .cmdrecorder import cmdRecorder
27
28
  from .temperature import Temperature
28
29
  from .message import Message
29
30
  from .can import CAN
31
+ from .canota import CANOTA
32
+ from .camera_trigger import CameraTrigger
30
33
  try:
31
34
  import requests
32
35
  except:
@@ -109,6 +112,9 @@ class UC2Client(object):
109
112
  # initialize CAN
110
113
  self.can = CAN(self)
111
114
 
115
+ # initialize CAN OTA
116
+ self.canota = CANOTA(self)
117
+
112
118
  # initialize gripper
113
119
  self.gripper = Gripper(self)
114
120
 
@@ -145,8 +151,14 @@ class UC2Client(object):
145
151
  # initialize digital out
146
152
  self.digitalout = DigitalOut(self)
147
153
 
154
+ # initialize digital in
155
+ self.digitalin = DigitalIn(self)
156
+
148
157
  # initialize messaging
149
158
  self.message = Message(self)
159
+
160
+ # initialize camera trigger callback handler
161
+ self.camera_trigger = CameraTrigger(self)
150
162
 
151
163
  # initialize module controller
152
164
  self.modules = Modules(parent=self)
@@ -6,7 +6,7 @@ __version__.py
6
6
 
7
7
  __title__ = 'UC2-REST'
8
8
  __description__ = 'This pacage will help you to drive the ESP32-driven microscopy control modules from UC2'
9
- __version__ = "v0.2.0.30"
9
+ __version__ = "v0.2.0.32"
10
10
  __author__ = 'Benedict Diederich'
11
11
  __author_email__ = 'benedictdied@gmail.com'
12
12
  __license__ = 'GPL v3'
@@ -0,0 +1,197 @@
1
+ """
2
+ Camera trigger callback module for UC2-REST.
3
+
4
+ This module handles camera trigger signals from the firmware ({"cam":1})
5
+ to enable software triggering based on hardware events during stage scanning.
6
+ """
7
+
8
+ import numpy as np
9
+ import time
10
+ import json
11
+
12
+
13
+ gTIMEOUT = 1 # seconds to wait for a response from the ESP32
14
+
15
+
16
+ class CameraTrigger(object):
17
+ """
18
+ This class parses incoming camera trigger signals from the ESP32 firmware.
19
+
20
+ When the firmware sends {"cam":1}, this module triggers registered callbacks
21
+ which can be used for software-triggered image acquisition during stage scanning.
22
+
23
+ Example usage:
24
+ import uc2rest
25
+
26
+ ESP32 = uc2rest.UC2Client(serialport=port, baudrate=500000)
27
+
28
+ # Register callback for camera trigger
29
+ def my_camera_callback(data):
30
+ print(f"Camera trigger received: {data}")
31
+ # Trigger image acquisition here
32
+
33
+ ESP32.camera_trigger.register_callback(0, my_camera_callback)
34
+ """
35
+
36
+ def __init__(self, parent=None, nCallbacks=10):
37
+ """
38
+ Initialize camera trigger handler.
39
+
40
+ Args:
41
+ parent: Parent UC2Client instance
42
+ nCallbacks: Maximum number of callback functions to register
43
+ """
44
+ self._parent = parent
45
+ self.nCallbacks = nCallbacks
46
+
47
+ # Track trigger count for diagnostics
48
+ self._trigger_count = 0
49
+ self._last_trigger_time = None
50
+
51
+ # Initialize callback functions
52
+ self._callbackPerKey = {}
53
+ self.init_callback_functions(self.nCallbacks)
54
+
55
+ # Register callback for camera trigger on serial loop
56
+ if hasattr(self._parent, "serial"):
57
+ self._parent.serial.register_callback(self._callback_camera_trigger, pattern="cam")
58
+
59
+ def _callback_camera_trigger(self, data):
60
+ """
61
+ Parse camera trigger message from firmware.
62
+
63
+ Expected JSON format:
64
+ {
65
+ "cam": 1 # Trigger signal
66
+ }
67
+
68
+ or with additional data:
69
+ {
70
+ "cam": {
71
+ "trigger": 1,
72
+ "frame_id": 123,
73
+ "illumination": 0
74
+ }
75
+ }
76
+
77
+ Args:
78
+ data: JSON data dictionary from firmware
79
+ """
80
+ try:
81
+ # Update trigger statistics
82
+ self._trigger_count += 1
83
+ self._last_trigger_time = time.time()
84
+
85
+ # Extract trigger information
86
+ cam_data = data.get("cam", {})
87
+
88
+ # Handle simple trigger ({"cam": 1})
89
+ if isinstance(cam_data, (int, float)):
90
+ trigger_info = {
91
+ "trigger": int(cam_data),
92
+ "frame_id": self._trigger_count,
93
+ "timestamp": self._last_trigger_time
94
+ }
95
+ self._parent.logger.debug(f"Camera trigger received: {trigger_info}")
96
+ else:
97
+ # Handle extended trigger data
98
+ trigger_info = {
99
+ "trigger": cam_data.get("trigger", 1),
100
+ "frame_id": cam_data.get("frame_id", self._trigger_count),
101
+ "illumination": cam_data.get("illumination", -1),
102
+ "timestamp": self._last_trigger_time
103
+ }
104
+ self._parent.logger.debug(f"Camera trigger with data received: {trigger_info}")
105
+
106
+ # Call all registered callbacks
107
+ for key, callback in self._callbackPerKey.items():
108
+ if callback is not None and callable(callback):
109
+ try:
110
+ callback(trigger_info)
111
+ except Exception as callback_error:
112
+ print(f"Error in camera trigger callback {key}: {callback_error}")
113
+
114
+ except Exception as e:
115
+ print(f"Error in _callback_camera_trigger: {e}")
116
+
117
+ def init_callback_functions(self, nCallbacks=10):
118
+ """
119
+ Initialize callback function dictionary.
120
+
121
+ Args:
122
+ nCallbacks: Number of callback slots to create
123
+ """
124
+ self._callbackPerKey = {}
125
+ self.nCallbacks = nCallbacks
126
+ for i in range(nCallbacks):
127
+ self._callbackPerKey[i] = None
128
+
129
+ def register_callback(self, key, callback):
130
+ """
131
+ Register a callback function for camera trigger events.
132
+
133
+ Args:
134
+ key: Integer key (0 to nCallbacks-1) for this callback
135
+ callback: Function to call when trigger is received.
136
+ Function signature: callback(trigger_info: dict)
137
+
138
+ Example:
139
+ def on_camera_trigger(info):
140
+ print(f"Frame {info['frame_id']} triggered at {info['timestamp']}")
141
+ camera.snap_image()
142
+
143
+ ESP32.camera_trigger.register_callback(0, on_camera_trigger)
144
+ """
145
+ if key < 0 or key >= self.nCallbacks:
146
+ raise ValueError(f"Callback key must be between 0 and {self.nCallbacks-1}")
147
+ self._callbackPerKey[key] = callback
148
+
149
+ def unregister_callback(self, key):
150
+ """
151
+ Remove a registered callback.
152
+
153
+ Args:
154
+ key: Integer key of callback to remove
155
+ """
156
+ if key in self._callbackPerKey:
157
+ self._callbackPerKey[key] = None
158
+
159
+ def clear_all_callbacks(self):
160
+ """Remove all registered callbacks."""
161
+ self.init_callback_functions(self.nCallbacks)
162
+
163
+ def get_trigger_count(self):
164
+ """
165
+ Get the total number of triggers received since initialization.
166
+
167
+ Returns:
168
+ int: Number of camera triggers received
169
+ """
170
+ return self._trigger_count
171
+
172
+ def get_last_trigger_time(self):
173
+ """
174
+ Get the timestamp of the last trigger received.
175
+
176
+ Returns:
177
+ float or None: Unix timestamp of last trigger, or None if no triggers received
178
+ """
179
+ return self._last_trigger_time
180
+
181
+ def reset_trigger_count(self):
182
+ """Reset the trigger counter to zero."""
183
+ self._trigger_count = 0
184
+ self._last_trigger_time = None
185
+
186
+ def get_trigger_stats(self):
187
+ """
188
+ Get statistics about trigger events.
189
+
190
+ Returns:
191
+ dict: Dictionary with trigger statistics
192
+ """
193
+ return {
194
+ "total_triggers": self._trigger_count,
195
+ "last_trigger_time": self._last_trigger_time,
196
+ "callbacks_registered": sum(1 for cb in self._callbackPerKey.values() if cb is not None)
197
+ }
@@ -0,0 +1,143 @@
1
+ class CAN(object):
2
+ def __init__(self, parent):
3
+ """
4
+ CANController handles sending commands to a remote CAN device via the parent post_json interface.
5
+
6
+ :param parent: Parent object with post_json(path, payload, getReturn, timeout, nResponses)
7
+ """
8
+ self._parent = parent
9
+
10
+ # Store latest scan results
11
+ self.scanResults = []
12
+ self.deviceCount = 0
13
+
14
+ # Register a callback function for the CAN status on the serial loop
15
+ if hasattr(self._parent, "serial"):
16
+ self._parent.serial.register_callback(self._callback_can_status, pattern="scan")
17
+
18
+ # Announce a function that is called when we receive a CAN scan update through the callback
19
+ self._callbackPerKey = {}
20
+ self.nCallbacks = 10
21
+ self._callbackPerKey = self.init_callback_functions(nCallbacks=self.nCallbacks)
22
+ print(self._callbackPerKey)
23
+
24
+ def init_callback_functions(self, nCallbacks=10):
25
+ """Initialize the callback functions."""
26
+ _callbackPerKey = {}
27
+ self.nCallbacks = nCallbacks
28
+ for i in range(nCallbacks):
29
+ _callbackPerKey[i] = []
30
+ return _callbackPerKey
31
+
32
+ def _callback_can_status(self, data):
33
+ """
34
+ Cast the json in the form:
35
+ {
36
+ "scan": [
37
+ {"canId": 20, "deviceType": 1, "status": 0, "deviceTypeStr": "laser", "statusStr": "idle"},
38
+ {"canId": 10, "deviceType": 0, "status": 0, "deviceTypeStr": "motor", "statusStr": "idle"}
39
+ ],
40
+ "qid": 2,
41
+ "count": 1
42
+ }
43
+ into the scan results array.
44
+ """
45
+ try:
46
+ if "scan" in data:
47
+ self.scanResults = data["scan"]
48
+ self.deviceCount = data.get("count", len(self.scanResults))
49
+
50
+ # Call registered callback function with scan results
51
+ if callable(self._callbackPerKey[0]):
52
+ self._callbackPerKey[0](self.scanResults)
53
+ except Exception as e:
54
+ print("Error in _callback_can_status: ", e)
55
+
56
+ def register_callback(self, key, callbackfct):
57
+ """Register a callback function for a specific key."""
58
+ self._callbackPerKey[key] = callbackfct
59
+
60
+ def reboot_remote(self, qid=1, can_address=0, isBlocking=False, timeout=2):
61
+ """
62
+ Send a reboot signal to the remote CAN device.
63
+
64
+ :param qid: Query ID for the CAN command (default: 1)
65
+ :param isBlocking: If True, wait for response
66
+ :param timeout: Timeout for the command in seconds
67
+ :param can_address: Address of the CAN device to reboot (0 is master)
68
+ :return: Response from the device
69
+ """
70
+ path = "/can_act"
71
+ payload = {
72
+ "task": path,
73
+ "restart": int(can_address)
74
+ }
75
+ nResponses = 1 if isBlocking else 0
76
+ # Send the payload to the parent, which handles the actual communication
77
+ return self._parent.post_json(
78
+ path,
79
+ payload,
80
+ getReturn=isBlocking,
81
+ timeout=timeout if isBlocking else 0,
82
+ nResponses=nResponses
83
+ )
84
+
85
+ def scan(self, qid=1, timeout=5):
86
+ """
87
+ Scan the CAN bus for connected devices.
88
+
89
+ :param qid: Query ID for the CAN command (default: 1)
90
+ :param timeout: Timeout for the scan in seconds (default: 5)
91
+ :return: Response containing scan results with device information
92
+ Example: {
93
+ "scan": [
94
+ {"canId": 10, "deviceType": 0, "deviceTypeStr": "motor", "status": 0, "statusStr": "idle"},
95
+ {"canId": 20, "deviceType": 1, "deviceTypeStr": "laser", "status": 0, "statusStr": "idle"}
96
+ ],
97
+ "qid": 1,
98
+ "count": 2
99
+ }
100
+ """
101
+ path = "/can_act"
102
+ payload = {
103
+ "task": path,
104
+ "scan": True,
105
+ "qid": qid
106
+ }
107
+ return self._parent.post_json(
108
+ path,
109
+ payload,
110
+ getReturn=True,
111
+ timeout=timeout,
112
+ nResponses=2
113
+ )
114
+
115
+ def get_available_devices(self, timeout=2):
116
+ """
117
+ Get list of available CAN devices.
118
+
119
+ :param timeout: Timeout for the command in seconds (default: 2)
120
+ :return: Response containing available CAN IDs and other bus information
121
+ Example: {
122
+ "input": {"task": "/can_get"},
123
+ "address": 1,
124
+ "addresspref": 1,
125
+ "addressgetcan": 1,
126
+ "nonworking": [0,0,0,...],
127
+ "available": [20,0,0,...],
128
+ "rx": 18,
129
+ "tx": 17
130
+ }
131
+ """
132
+ path = "/can_act"
133
+ payload = {
134
+ "task": path,
135
+ "scan": True
136
+ }
137
+ return self._parent.post_json(
138
+ path,
139
+ payload,
140
+ getReturn=True,
141
+ timeout=timeout,
142
+ nResponses=2
143
+ )
@@ -0,0 +1,256 @@
1
+ import time
2
+ import json
3
+
4
+
5
+ gTIMEOUT = 10 # seconds to wait for a response from the ESP32
6
+
7
+
8
+ class CANOTA(object):
9
+ """
10
+ CAN OTA (Over-The-Air) update controller for UC2 satellite devices.
11
+
12
+ This class handles sending OTA commands to CAN slave devices and processing
13
+ the responses through callbacks. It manages the WiFi connection setup and
14
+ OTA server initialization on remote devices.
15
+
16
+ The OTA process involves:
17
+ 1. Sending OTA command with WiFi credentials to a specific CAN device
18
+ 2. Device connects to WiFi and starts ArduinoOTA server
19
+ 3. Device sends back acknowledgment and IP address via serial
20
+ 4. Device becomes available as UC2-CAN-<HEXID>.local for firmware upload
21
+ """
22
+
23
+ def __init__(self, parent=None, nCallbacks=10):
24
+ """
25
+ Initialize the CAN OTA controller.
26
+
27
+ :param parent: Parent UC2Client object with post_json and serial interfaces
28
+ :param nCallbacks: Number of callback slots to initialize (default: 10)
29
+ """
30
+ self._parent = parent
31
+ self.nCallbacks = nCallbacks
32
+
33
+ # Initialize callback functions for different types of OTA events
34
+ self.init_callback_functions(self.nCallbacks)
35
+
36
+ # Register callback function for OTA status messages on the serial loop
37
+ if hasattr(self._parent, "serial"):
38
+ self._parent.serial.register_callback(self._callback_ota_status, pattern="ota")
39
+
40
+ def init_callback_functions(self, nCallbacks=10):
41
+ """
42
+ Initialize the callback function dictionary.
43
+
44
+ :param nCallbacks: Number of callback slots to create
45
+ """
46
+ self._callbackPerKey = {}
47
+ self.nCallbacks = nCallbacks
48
+ for i in range(nCallbacks):
49
+ self._callbackPerKey[i] = None
50
+
51
+ def _callback_ota_status(self, data):
52
+ """
53
+ Process incoming OTA status messages from CAN devices.
54
+
55
+ Expected message format:
56
+ {
57
+ "ota": {
58
+ "canId": 20,
59
+ "status": 0,
60
+ "statusMsg": "Success",
61
+ "ip": "192.168.2.137",
62
+ "hostname": "UC2-CAN-14.local"
63
+ }
64
+ }
65
+
66
+ Status codes:
67
+ - 0: Success (WiFi connected, OTA server started)
68
+ - 1: WiFi connection failed
69
+ - 2: OTA start failed
70
+
71
+ :param data: JSON data containing OTA status information
72
+ """
73
+ try:
74
+ ota_data = data["ota"]
75
+ can_id = ota_data.get("canId")
76
+ status = ota_data.get("status")
77
+ status_msg = ota_data.get("statusMsg", "")
78
+ ip_address = ota_data.get("ip", "")
79
+ hostname = ota_data.get("hostname", "")
80
+
81
+ # Create a structured response for callbacks
82
+ ota_response = {
83
+ "canId": can_id,
84
+ "status": status,
85
+ "statusMsg": status_msg,
86
+ "ip": ip_address,
87
+ "hostname": hostname,
88
+ "success": status == 0
89
+ }
90
+
91
+ # Trigger callbacks for different events
92
+ # Key 0: General OTA status updates
93
+ if callable(self._callbackPerKey[0]):
94
+ self._callbackPerKey[0](ota_response)
95
+
96
+ # Key for specific CAN ID (use can_id % nCallbacks to avoid overflow)
97
+ if can_id is not None:
98
+ callback_key = (can_id % (self.nCallbacks - 1)) + 1 # Reserve key 0 for general
99
+ if callable(self._callbackPerKey[callback_key]):
100
+ self._callbackPerKey[callback_key](ota_response)
101
+
102
+ except Exception as e:
103
+ print(f"Error in _callback_ota_status: {e}")
104
+
105
+ def register_callback(self, key, callback_function):
106
+ """
107
+ Register a callback function for OTA events.
108
+
109
+ :param key: Callback key (0 for general events, 1-9 for specific CAN IDs)
110
+ :param callback_function: Function to call when OTA event occurs
111
+ Function should accept one parameter: ota_response dict
112
+
113
+ Example:
114
+ def my_ota_callback(ota_response):
115
+ if ota_response["success"]:
116
+ print(f"Device {ota_response['canId']} ready at {ota_response['ip']}")
117
+ else:
118
+ print(f"OTA failed for device {ota_response['canId']}: {ota_response['statusMsg']}")
119
+
120
+ ESP32.canota.register_callback(0, my_ota_callback)
121
+ """
122
+ if 0 <= key < self.nCallbacks:
123
+ self._callbackPerKey[key] = callback_function
124
+ else:
125
+ raise ValueError(f"Callback key must be between 0 and {self.nCallbacks-1}")
126
+
127
+ def start_ota_update(self, can_id, ssid, password, timeout=300000, is_blocking=False,
128
+ response_timeout=gTIMEOUT):
129
+ """
130
+ Send OTA command to a specific CAN slave device.
131
+
132
+ This tells the slave to connect to WiFi and start an OTA server.
133
+ The device will become available as UC2-CAN-<HEXID>.local for firmware upload.
134
+
135
+ :param can_id: CAN ID of the target device (e.g., 11 for Motor X, 20 for Laser)
136
+ :param ssid: WiFi network name
137
+ :param password: WiFi password
138
+ :param timeout: OTA timeout in milliseconds (default: 300000 = 5 minutes)
139
+ :param is_blocking: If True, wait for acknowledgment response
140
+ :param response_timeout: Timeout for response in seconds
141
+ :return: Response from the device (if blocking)
142
+
143
+ Examples:
144
+ # Motor X (CAN ID 11)
145
+ ESP32.canota.start_ota_update(11, "WiFi", "pass123")
146
+
147
+ # Laser (CAN ID 20) with custom timeout
148
+ ESP32.canota.start_ota_update(20, "WiFi", "pass123", timeout=600000)
149
+ """
150
+ path = "/can_act"
151
+ payload = {
152
+ "task": path,
153
+ "ota": {
154
+ "canid": can_id,
155
+ "ssid": ssid,
156
+ "password": password,
157
+ "timeout": timeout
158
+ }
159
+ }
160
+
161
+ # Send the payload to the parent, which handles the actual communication
162
+ nResponses = 1 if is_blocking else 0
163
+ return self._parent.post_json(
164
+ path,
165
+ payload,
166
+ getReturn=is_blocking,
167
+ timeout=response_timeout if is_blocking else 0,
168
+ nResponses=nResponses
169
+ )
170
+
171
+ def start_motor_ota(self, motor_axis, ssid, password, timeout=300000, is_blocking=False):
172
+ """
173
+ Convenience method for starting OTA on motor controllers.
174
+
175
+ :param motor_axis: Motor axis ("X", "Y", "Z") or CAN ID (11, 12, 13)
176
+ :param ssid: WiFi network name
177
+ :param password: WiFi password
178
+ :param timeout: OTA timeout in milliseconds
179
+ :param is_blocking: If True, wait for acknowledgment response
180
+ :return: Response from the device
181
+
182
+ Examples:
183
+ ESP32.canota.start_motor_ota("X", "WiFi", "pass123")
184
+ ESP32.canota.start_motor_ota(11, "WiFi", "pass123") # Same as above
185
+ """
186
+ # Map motor axes to CAN IDs
187
+ motor_can_ids = {
188
+ "X": 11,
189
+ "Y": 12,
190
+ "Z": 13
191
+ }
192
+
193
+ if isinstance(motor_axis, str):
194
+ motor_axis = motor_axis.upper()
195
+ if motor_axis not in motor_can_ids:
196
+ raise ValueError(f"Invalid motor axis '{motor_axis}'. Use 'X', 'Y', or 'Z'")
197
+ can_id = motor_can_ids[motor_axis]
198
+ else:
199
+ can_id = motor_axis
200
+
201
+ return self.start_ota_update(can_id, ssid, password, timeout, is_blocking)
202
+
203
+ def start_led_ota(self, ssid, password, timeout=300000, is_blocking=False):
204
+ """
205
+ Convenience method for starting OTA on LED controller (CAN ID 30).
206
+
207
+ :param ssid: WiFi network name
208
+ :param password: WiFi password
209
+ :param timeout: OTA timeout in milliseconds
210
+ :param is_blocking: If True, wait for acknowledgment response
211
+ :return: Response from the device
212
+ """
213
+ return self.start_ota_update(30, ssid, password, timeout, is_blocking)
214
+
215
+ def start_laser_ota(self, laser_id=0, ssid="", password="", timeout=300000, is_blocking=False):
216
+ """
217
+ Convenience method for starting OTA on laser controller.
218
+
219
+ :param laser_id: Laser ID (0 for main laser, maps to CAN ID 20)
220
+ :param ssid: WiFi network name
221
+ :param password: WiFi password
222
+ :param timeout: OTA timeout in milliseconds
223
+ :param is_blocking: If True, wait for acknowledgment response
224
+ :return: Response from the device
225
+ """
226
+ # Map laser ID to CAN ID (for now, laser 0 = CAN ID 20)
227
+ can_id = 20 + laser_id
228
+ return self.start_ota_update(can_id, ssid, password, timeout, is_blocking)
229
+
230
+ def get_ota_hostname(self, can_id):
231
+ """
232
+ Generate the expected hostname for a CAN device in OTA mode.
233
+
234
+ :param can_id: CAN ID of the device
235
+ :return: Expected hostname (e.g., "UC2-CAN-14.local" for CAN ID 20)
236
+ """
237
+ hex_id = format(can_id, 'X') # Convert to hexadecimal
238
+ return f"UC2-CAN-{hex_id}.local"
239
+
240
+ def get_platformio_upload_command(self, can_id, project_path="."):
241
+ """
242
+ Generate the PlatformIO upload command for OTA firmware update.
243
+
244
+ :param can_id: CAN ID of the target device
245
+ :param project_path: Path to the PlatformIO project (default: current directory)
246
+ :return: PlatformIO upload command string
247
+
248
+ Example:
249
+ cmd = ESP32.canota.get_platformio_upload_command(20)
250
+ # Returns: "platformio run -t upload --upload-port UC2-CAN-14.local"
251
+ """
252
+ hostname = self.get_ota_hostname(can_id)
253
+ if project_path != ".":
254
+ return f"platformio run -t upload --upload-port {hostname} -d {project_path}"
255
+ else:
256
+ return f"platformio run -t upload --upload-port {hostname}"