96 def start(self) -> bool:
97 """Start actuator communication"""
99 # Command socket (sender)
100 self.command_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
102 f"📡 Actuator command socket ready (→ {self.actuator_ip}:{self.command_port})"
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
113 logger.info("✅ Actuator communication started")
115 except Exception as e:
116 logger.error(f"❌ Failed to start actuator comm: {e}")
128 def create_actuator_command_packet(self, commands: List[Tuple[int, int]]) -> bytes:
130 Create an actuator command packet (exact format from combined_gui.py).
133 commands: List of (actuator_id, actuator_state) tuples
134 actuator_id: 1-10 (1-indexed)
135 actuator_state: 0 = OFF, non-zero = ON
138 Packet bytes, or empty bytes if invalid
140 if len(commands) == 0 or len(commands) > 255:
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
149 if total_size > MAX_PACKET_SIZE:
152 # Create packet buffer
153 packet = bytearray(total_size)
157 packet_type = PacketType.ACTUATOR_COMMAND
158 version = DIABLO_COMMS_VERSION
160 int(time.time() * 1000) & 0xFFFFFFFF
161 ) # 32-bit timestamp in milliseconds
164 PACKET_HEADER_FORMAT, packet, offset, packet_type, version, timestamp
166 offset += PACKET_HEADER_SIZE
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
174 for actuator_id, actuator_state in commands:
176 ACTUATOR_COMMAND_FORMAT, packet, offset, actuator_id, actuator_state
178 offset += ACTUATOR_COMMAND_SIZE
182 def send_command(self, actuator_id: int, state: int) -> bool:
184 Send actuator command via UDP (exact DiabloAvionics format).
187 actuator_id: 1-10 (1-indexed channel on actuator board)
188 state: 0=OFF, 1=ON (hardware level)
191 True if sent successfully
193 if not self.command_sock:
194 logger.error("Command socket not initialized")
198 commands = [(actuator_id, state)]
199 packet = self.create_actuator_command_packet(commands)
201 self.command_sock.sendto(packet, (self.actuator_ip, self.command_port))
203 f"📤 Sent: actuator {actuator_id} → {'ON' if state else 'OFF'}"
207 logger.error(f"Failed to create packet for actuator {actuator_id}")
212 msg = f"No route to host — check device IP ({self.actuator_ip})"
214 msg = f"Network unreachable — check WiFi/Ethernet"
216 msg = f"Network error: [{err}] {e}"
217 logger.error(f"Error sending command: {msg}")
219 except Exception as e:
220 logger.error(f"Failed to send actuator command: {e}")
223 def send_commands_batch(self, commands: List[Tuple[int, int]]) -> bool:
225 Send multiple actuator commands in a single packet.
228 commands: List of (actuator_id, state) tuples
231 True if sent successfully
233 if not self.command_sock:
237 packet = self.create_actuator_command_packet(commands)
239 self.command_sock.sendto(packet, (self.actuator_ip, self.command_port))
240 logger.debug(f"📤 Sent batch: {len(commands)} commands")
243 except Exception as e:
244 logger.error(f"Failed to send batch commands: {e}")
247 def parse_actuator_status(
248 self, data: bytes, source_ip: str
249 ) -> List[ActuatorStatus]:
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).
256 List of ActuatorStatus objects
258 if source_ip != self.actuator_ip:
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]
269 # Skip header (6 bytes)
271 num_chunks, num_samples = struct.unpack("<BB", data[offset : offset + 2])
275 for _ in range(num_chunks):
276 if offset + 4 > len(data):
278 # timestamp = struct.unpack('<I', data[offset:offset+4])[0]
281 for _ in range(num_samples):
282 if offset + 5 > len(data):
284 sensor_id, adc_raw = struct.unpack("<BI", data[offset : offset + 5])
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
293 actuator_id=sensor_id,
294 current_voltage=voltage,
295 timestamp=time.time(),
300 except Exception as e:
301 logger.debug(f"Failed to parse actuator status: {e}")