STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
controller_integration.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Controller Integration Module
4Provides actuator command/status communication and controller wrapper
5for integration with the calibration orchestrator.
6
7Uses exact DiabloAvionics packet format from combined_gui.py
8"""
9
10import socket
11import struct
12import time
13import threading
14import logging
15from typing import Dict, Optional, Tuple, List
16from dataclasses import dataclass
17from collections import deque
18
19logger = logging.getLogger(__name__)
20
21# ── DiabloAvionics Protocol Constants (from combined_gui.py) ─────────────
22DIABLO_COMMS_VERSION = 0
23MAX_PACKET_SIZE = 512
24
25
27 BOARD_HEARTBEAT = 1
28 SERVER_HEARTBEAT = 2
29 SENSOR_DATA = 3
30 ACTUATOR_COMMAND = 4
31 SENSOR_CONFIG = 5
32 ACTUATOR_CONFIG = 6
33 ABORT = 7
34 ABORT_DONE = 8
35 CLEAR_ABORT = 9
36 PWM_ACTUATOR_COMMAND = 10
37
38
39# Struct format strings (little-endian, matching C++ packed structs)
40PACKET_HEADER_FORMAT = "<BBI" # 6 bytes total
41PACKET_HEADER_SIZE = 6
42
43ACTUATOR_COMMAND_PACKET_FORMAT = "<B" # 1 byte
44ACTUATOR_COMMAND_PACKET_SIZE = 1
45
46ACTUATOR_COMMAND_FORMAT = "<BB" # 2 bytes
47ACTUATOR_COMMAND_SIZE = 2
48
49PWM_ACTUATOR_COMMAND_PACKET_FORMAT = "<B"
50PWM_ACTUATOR_COMMAND_PACKET_SIZE = 1
51# PWM Command: actuator_id (u8), duration_ms (u32), duty_cycle (float), frequency (float)
52PWM_ACTUATOR_COMMAND_FORMAT = "<BIff"
53PWM_ACTUATOR_COMMAND_SIZE = 13
54
55
56@dataclass
58 """Actuator command structure"""
59
60 actuator_id: int # 1-10 (1-indexed)
61 state: int # 0=OFF, 1=ON
62 timestamp: float = 0.0
63
64
65@dataclass
67 """Actuator status from current sense"""
68
69 actuator_id: int
70 current_voltage: float # Current sense voltage
71 timestamp: float
72
73
75 """Actuator command/status communication handler"""
76
77 def __init__(
78 self, actuator_board_ip: str, command_port: int = 5005, status_port: int = 5006
79 ):
80 self.actuator_ip = actuator_board_ip
81 self.command_port = command_port
82 self.status_port = status_port
83
84 # UDP sockets
85 self.command_sock: Optional[socket.socket] = None
86 self.status_sock: Optional[socket.socket] = None
87
88 # Status tracking
89 self.actuator_statuses: Dict[int, ActuatorStatus] = {}
90 self.status_queue: deque = deque(maxlen=1000)
91
92 # Threading
93 self.status_thread: Optional[threading.Thread] = None
94 self.running = False
95
96 def start(self) -> bool:
97 """Start actuator communication"""
98 try:
99 # Command socket (sender)
100 self.command_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
101 logger.info(
102 f"📡 Actuator command socket ready (→ {self.actuator_ip}:{self.command_port})"
103 )
104
105 # Status socket (receiver) - shares port 5006 with sensors
106 self.status_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
107 self.status_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
108 self.status_sock.settimeout(0.2)
109 # Note: Status comes on same port as sensors, filtered by source IP
110 # We'll parse it in the main receiver thread
111
112 self.running = True
113 logger.info("✅ Actuator communication started")
114 return True
115 except Exception as e:
116 logger.error(f"❌ Failed to start actuator comm: {e}")
117 return False
118
119 def stop(self):
120 """Stop actuator communication"""
121 self.running = False
122 if self.command_sock:
123 self.command_sock.close()
124 if self.status_sock:
125 self.status_sock.close()
126 logger.info("🛑 Actuator communication stopped")
127
128 def create_actuator_command_packet(self, commands: List[Tuple[int, int]]) -> bytes:
129 """
130 Create an actuator command packet (exact format from combined_gui.py).
131
132 Args:
133 commands: List of (actuator_id, actuator_state) tuples
134 actuator_id: 1-10 (1-indexed)
135 actuator_state: 0 = OFF, non-zero = ON
136
137 Returns:
138 Packet bytes, or empty bytes if invalid
139 """
140 if len(commands) == 0 or len(commands) > 255:
141 return b""
142
143 # Calculate packet size
144 header_size = PACKET_HEADER_SIZE
145 body_size = ACTUATOR_COMMAND_PACKET_SIZE
146 commands_size = len(commands) * ACTUATOR_COMMAND_SIZE
147 total_size = header_size + body_size + commands_size
148
149 if total_size > MAX_PACKET_SIZE:
150 return b""
151
152 # Create packet buffer
153 packet = bytearray(total_size)
154 offset = 0
155
156 # Packet header
157 packet_type = PacketType.ACTUATOR_COMMAND
158 version = DIABLO_COMMS_VERSION
159 timestamp = (
160 int(time.time() * 1000) & 0xFFFFFFFF
161 ) # 32-bit timestamp in milliseconds
162
163 struct.pack_into(
164 PACKET_HEADER_FORMAT, packet, offset, packet_type, version, timestamp
165 )
166 offset += PACKET_HEADER_SIZE
167
168 # Actuator command packet body
169 num_commands = len(commands)
170 struct.pack_into(ACTUATOR_COMMAND_PACKET_FORMAT, packet, offset, num_commands)
171 offset += ACTUATOR_COMMAND_PACKET_SIZE
172
173 # Actuator commands
174 for actuator_id, actuator_state in commands:
175 struct.pack_into(
176 ACTUATOR_COMMAND_FORMAT, packet, offset, actuator_id, actuator_state
177 )
178 offset += ACTUATOR_COMMAND_SIZE
179
180 return bytes(packet)
181
182 def send_command(self, actuator_id: int, state: int) -> bool:
183 """
184 Send actuator command via UDP (exact DiabloAvionics format).
185
186 Args:
187 actuator_id: 1-10 (1-indexed channel on actuator board)
188 state: 0=OFF, 1=ON (hardware level)
189
190 Returns:
191 True if sent successfully
192 """
193 if not self.command_sock:
194 logger.error("Command socket not initialized")
195 return False
196
197 try:
198 commands = [(actuator_id, state)]
199 packet = self.create_actuator_command_packet(commands)
200 if len(packet) > 0:
201 self.command_sock.sendto(packet, (self.actuator_ip, self.command_port))
202 logger.debug(
203 f"📤 Sent: actuator {actuator_id} → {'ON' if state else 'OFF'}"
204 )
205 return True
206 else:
207 logger.error(f"Failed to create packet for actuator {actuator_id}")
208 return False
209 except OSError as e:
210 err = e.errno
211 if err == 65:
212 msg = f"No route to host — check device IP ({self.actuator_ip})"
213 elif err == 64:
214 msg = f"Network unreachable — check WiFi/Ethernet"
215 else:
216 msg = f"Network error: [{err}] {e}"
217 logger.error(f"Error sending command: {msg}")
218 return False
219 except Exception as e:
220 logger.error(f"Failed to send actuator command: {e}")
221 return False
222
223 def send_commands_batch(self, commands: List[Tuple[int, int]]) -> bool:
224 """
225 Send multiple actuator commands in a single packet.
226
227 Args:
228 commands: List of (actuator_id, state) tuples
229
230 Returns:
231 True if sent successfully
232 """
233 if not self.command_sock:
234 return False
235
236 try:
237 packet = self.create_actuator_command_packet(commands)
238 if len(packet) > 0:
239 self.command_sock.sendto(packet, (self.actuator_ip, self.command_port))
240 logger.debug(f"📤 Sent batch: {len(commands)} commands")
241 return True
242 return False
243 except Exception as e:
244 logger.error(f"Failed to send batch commands: {e}")
245 return False
246
247 def parse_actuator_status(
248 self, data: bytes, source_ip: str
249 ) -> List[ActuatorStatus]:
250 """
251 Parse actuator current sense data from sensor data packet.
252 Actuator board sends current sense as PT samples on same port as sensors (5006).
253 These come as sensor data packets with channel_id = actuator_id (1-10).
254
255 Returns:
256 List of ActuatorStatus objects
257 """
258 if source_ip != self.actuator_ip:
259 return []
260
261 # Parse sensor data packet (same format as PT board)
262 # Packet format: [header:6] [num_chunks:1] [num_samples:1] [chunks...]
263 # Each chunk: [timestamp:4] [samples...]
264 # Each sample: [sensor_id:1] [adc_code:4]
265 try:
266 if len(data) < 8:
267 return []
268
269 # Skip header (6 bytes)
270 offset = 6
271 num_chunks, num_samples = struct.unpack("<BB", data[offset : offset + 2])
272 offset += 2
273
274 statuses = []
275 for _ in range(num_chunks):
276 if offset + 4 > len(data):
277 break
278 # timestamp = struct.unpack('<I', data[offset:offset+4])[0]
279 offset += 4
280
281 for _ in range(num_samples):
282 if offset + 5 > len(data):
283 break
284 sensor_id, adc_raw = struct.unpack("<BI", data[offset : offset + 5])
285 offset += 5
286
287 # Convert ADC to voltage (2.5V ref, 24-bit sign-extended to 32-bit)
288 signed = adc_raw if adc_raw < 0x80000000 else adc_raw - 0x100000000
289 voltage = (signed * 2.5) / 2147483648.0
290
291 statuses.append(
292 ActuatorStatus(
293 actuator_id=sensor_id,
294 current_voltage=voltage,
295 timestamp=time.time(),
296 )
297 )
298
299 return statuses
300 except Exception as e:
301 logger.debug(f"Failed to parse actuator status: {e}")
302 return []
303
304 # Note: RobustDDPController runs in C++ (FSW/src/control/RobustDDPController.cpp)
305 # This module only handles actuator communication (send commands, receive status)
306 # The controller integration happens in daq_bridge_main.cpp or a separate control process
307
308 def reset(self):
309 """Reset controller state"""
310 pass