Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,9 @@ dmypy.json
# OS
.DS_Store
Thumbs.db

# Session tracking (git-excluded by convention)
RULES.md
PROBLEM_STATEMENT.md
PR_DESCRIPTION.md
REPEATER_CHANGES.md
20 changes: 10 additions & 10 deletions src/pymc_core/hardware/gpio_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ def setup_output_pin(self, pin_number: int, initial_value: bool = False) -> bool
print(f"\nDebug: sudo lsof /dev/gpiochip* | grep {pin_number}")
print("\nThe system cannot function without GPIO access.")
print("━" * 60)
sys.exit(1)
raise RuntimeError(f"GPIO pin {pin_number} is already in use by another process") from e
elif "permission denied" in error_msg:
logger.error(f"Permission denied for GPIO pin {pin_number}: {e}")
print(f"\nFATAL: Permission denied for GPIO pin {pin_number}")
Expand All @@ -263,7 +263,7 @@ def setup_output_pin(self, pin_number: int, initial_value: bool = False) -> bool
print(" • Add user to gpio group: sudo usermod -a -G gpio $USER")
print(" • Then logout and login again")
print("━" * 60)
sys.exit(1)
raise RuntimeError(f"Permission denied for GPIO pin {pin_number}") from e
else:
logger.error(
f"Failed to setup output pin {pin_number} on {self._gpio_chip} "
Expand All @@ -277,7 +277,7 @@ def setup_output_pin(self, pin_number: int, initial_value: bool = False) -> bool
print(f"Error: {e}")
print("\nThe system cannot function without GPIO access.")
print("━" * 60)
sys.exit(1)
raise RuntimeError(f"Cannot setup GPIO output pin {pin_number}: {e}") from e

def setup_input_pin(
self,
Expand Down Expand Up @@ -340,7 +340,7 @@ def setup_input_pin(
print(f"\nDebug: sudo lsof /dev/gpiochip* | grep {pin_number}")
print("\nThe system cannot function without GPIO access.")
print("━" * 60)
sys.exit(1)
raise RuntimeError(f"GPIO pin {pin_number} is already in use by another process") from e
elif "permission denied" in error_msg:
logger.error(f"Permission denied for GPIO pin {pin_number}: {e}")
print(f"\nFATAL: Permission denied for GPIO pin {pin_number}")
Expand All @@ -349,7 +349,7 @@ def setup_input_pin(
print(" • Add user to gpio group: sudo usermod -a -G gpio $USER")
print(" • Then logout and login again")
print("━" * 60)
sys.exit(1)
raise RuntimeError(f"Permission denied for GPIO pin {pin_number}") from e
else:
logger.error(
f"Failed to setup input pin {pin_number} on {self._gpio_chip} "
Expand All @@ -363,7 +363,7 @@ def setup_input_pin(
print(f"Error: {e}")
print("\nThe system cannot function without GPIO access.")
print("━" * 60)
sys.exit(1)
raise RuntimeError(f"Cannot setup GPIO input pin {pin_number}: {e}") from e

def setup_interrupt_pin(
self,
Expand Down Expand Up @@ -423,15 +423,15 @@ def setup_interrupt_pin(
print(f"\nDebug: sudo lsof /dev/gpiochip* | grep {pin_number}")
print("\nThe system cannot function without GPIO access.")
print("━" * 60)
sys.exit(1)
raise RuntimeError(f"GPIO pin {pin_number} is already in use by another process") from e
elif "permission denied" in error_msg:
print(f"\nFATAL: Permission denied for GPIO pin {pin_number}")
print("━" * 60)
print("Solutions:")
print(" • Add user to gpio group: sudo usermod -a -G gpio $USER")
print(" • Then logout and login again")
print("━" * 60)
sys.exit(1)
raise RuntimeError(f"Permission denied for GPIO pin {pin_number}") from e
else:
logger.error(
f"Failed to setup interrupt pin {pin_number} on {self._gpio_chip} "
Expand All @@ -445,7 +445,7 @@ def setup_interrupt_pin(
print(f"Error: {e}")
print("\nThe system cannot function without GPIO access.")
print("━" * 60)
sys.exit(1)
raise RuntimeError(f"Cannot setup GPIO interrupt pin {pin_number}: {e}") from e

def _start_edge_detection(self, pin_number: int) -> None:
"""Start hardware edge detection thread"""
Expand Down Expand Up @@ -526,7 +526,7 @@ def _monitor_edge_events(self, pin_number: int, stop_event: threading.Event) ->
while not stop_event.is_set() and pin_number in self._pins:
try:
# Wait for edge event (kernel blocks until interrupt)
if gpio.poll(30.0) and not stop_event.is_set():
if gpio.poll(1.0) and not stop_event.is_set():
# Consume event from kernel queue to prevent repeated triggers
event: EdgeEvent = gpio.read_event()

Expand Down
18 changes: 12 additions & 6 deletions src/pymc_core/hardware/kiss_modem_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ def __init__(

# Event loop for thread-safe async callback invocation
self._event_loop: Optional[asyncio.AbstractEventLoop] = None

# When no event loop is set, run callback in a worker so RX thread never blocks
self._callback_executor: Optional[ThreadPoolExecutor] = None

Expand Down Expand Up @@ -433,6 +434,9 @@ def disconnect(self):

logger.info(f"KISS modem disconnected from {self.port}")

def cleanup(self) -> None:
self.disconnect()

def _write_frame(self, frame: bytes) -> bool:
"""
Write a complete KISS frame to the serial port.
Expand Down Expand Up @@ -1440,7 +1444,7 @@ def _process_received_frame(self):

def _rx_worker(self):
"""Background thread for receiving data"""
while not self.stop_event.is_set() and self.is_connected:
while not self.stop_event.is_set():
try:
if self.serial_conn and self.serial_conn.in_waiting > 0:
data = self.serial_conn.read(self.serial_conn.in_waiting)
Expand All @@ -1452,13 +1456,14 @@ def _rx_worker(self):
threading.Event().wait(0.01)

except Exception as e:
if self.is_connected:
logger.error(f"RX worker error: {e}")
logger.error(f"RX worker error: {e}")
self.is_connected = False
self.stop_event.set()
break

def _tx_worker(self):
"""Background thread for sending data"""
while not self.stop_event.is_set() and self.is_connected:
while not self.stop_event.is_set():
try:
if self.tx_buffer:
frame = self.tx_buffer.popleft()
Expand All @@ -1475,8 +1480,9 @@ def _tx_worker(self):
threading.Event().wait(0.01)

except Exception as e:
if self.is_connected:
logger.error(f"TX worker error: {e}")
logger.error(f"TX worker error: {e}")
self.is_connected = False
self.stop_event.set()
break

def __enter__(self):
Expand Down
18 changes: 12 additions & 6 deletions src/pymc_core/hardware/kiss_serial_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ def __init__(
# Callbacks
self.on_frame_received = on_frame_received


# KISS Configuration
self.config = {
"txdelay": 30, # TX delay (units of 10ms)
Expand Down Expand Up @@ -183,6 +184,9 @@ def disconnect(self):

logger.info(f"KISS serial disconnected from {self.port}")

def cleanup(self) -> None:
self.disconnect()

def send_frame(self, data: bytes) -> bool:
"""
Send a data frame via KISS protocol
Expand Down Expand Up @@ -649,7 +653,7 @@ def _process_received_frame(self):

def _rx_worker(self):
"""Background thread for receiving data"""
while not self.stop_event.is_set() and self.is_connected:
while not self.stop_event.is_set():
try:
if self.serial_conn and self.serial_conn.in_waiting > 0:
# Read available bytes
Expand All @@ -664,13 +668,14 @@ def _rx_worker(self):
threading.Event().wait(0.01)

except Exception as e:
if self.is_connected: # Only log if we expect to be connected
logger.error(f"RX worker error: {e}")
logger.error(f"RX worker error: {e}")
self.is_connected = False
self.stop_event.set()
break

def _tx_worker(self):
"""Background thread for sending data"""
while not self.stop_event.is_set() and self.is_connected:
while not self.stop_event.is_set():
try:
if self.tx_buffer:
# Get frame from buffer
Expand All @@ -690,8 +695,9 @@ def _tx_worker(self):
threading.Event().wait(0.01)

except Exception as e:
if self.is_connected: # Only log if we expect to be connected
logger.error(f"TX worker error: {e}")
logger.error(f"TX worker error: {e}")
self.is_connected = False
self.stop_event.set()
break

def __enter__(self):
Expand Down
66 changes: 64 additions & 2 deletions src/pymc_core/hardware/sx1262_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ def __init__(
# Track event loop for thread-safe interrupt handling
self._event_loop = None


# Store CAD results from interrupt handler
self._last_cad_detected = False
self._last_cad_irq_status = 0
Expand Down Expand Up @@ -541,7 +542,7 @@ async def _rx_irq_background_task(self):

except Exception as e:
logger.error(f"[RX Task] Unexpected error: {e}")
await asyncio.sleep(1.0) # Wait and continue
await asyncio.sleep(1.0)

logger.warning("[RX] RX IRQ background task exiting")

Expand Down Expand Up @@ -831,7 +832,7 @@ def begin(self) -> bool:
except Exception as e:
logger.error(f"Failed to initialize SX1262 radio: '{e}'")
self._initialized = False
# Hard fail immediately - no retries
self.cleanup()
raise RuntimeError(f"Failed to initialize SX1262 radio: {e}") from e

def _calculate_tx_timeout(self, packet_length: int) -> tuple[int, int]:
Expand Down Expand Up @@ -1396,6 +1397,59 @@ def set_bw():
"set bandwidth", set_bw, f"Bandwidth set to {bw/1000:.0f} kHz"
)

def configure_radio(
self,
frequency: Optional[int] = None,
bandwidth: Optional[int] = None,
spreading_factor: Optional[int] = None,
coding_rate: Optional[int] = None,
) -> bool:
if not self._initialized or self.lora is None:
logger.error("Cannot configure radio: not initialised")
return False

freq = frequency if frequency is not None else self.frequency
bw = bandwidth if bandwidth is not None else self.bandwidth
sf = spreading_factor if spreading_factor is not None else self.spreading_factor
cr = coding_rate if coding_rate is not None else self.coding_rate
ldro = sf >= 11 and bw <= 125000

deadline = time.monotonic() + 10.0
while self._tx_lock.locked():
if time.monotonic() > deadline:
logger.error("configure_radio: TX did not complete within 10s")
return False
time.sleep(0.05)

try:
self.lora.clearIrqStatus(0xFFFF)
self.lora.setStandby(self.lora.STANDBY_RC)
time.sleep(self._RADIO_TIMING_DELAY)
self.lora.setFrequency(freq)
self.lora.setLoRaModulation(sf, bw, cr, ldro)
self.frequency = freq
self.bandwidth = bw
self.spreading_factor = sf
self.coding_rate = cr
self._noise_floor = -99.0
self._num_floor_samples = 0
self._floor_sample_sum = 0.0
rx_mask = self._get_rx_irq_mask()
self.lora.clearIrqStatus(0xFFFF)
self.lora.setDioIrqParams(rx_mask, rx_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE)
self.lora.request(self.lora.RX_CONTINUOUS)
time.sleep(self._RADIO_TIMING_DELAY)
self.lora.clearIrqStatus(0xFFFF)
self._control_tx_rx_pins(tx_mode=False)
logger.info(
"Radio reconfigured: %.3f MHz BW=%.1f kHz SF%d CR4/%d",
freq / 1e6, bw / 1000, sf, cr,
)
return True
except Exception as e:
logger.error("Failed to configure radio: %s", e)
return False

def get_status(self) -> dict:
"""Get radio status information"""
status = {
Expand Down Expand Up @@ -1652,13 +1706,21 @@ async def perform_cad(

def cleanup(self) -> None:
"""Clean up radio resources"""
if hasattr(self, "_rx_irq_task") and self._rx_irq_task and not self._rx_irq_task.done():
self._rx_irq_task.cancel()

if hasattr(self, "lora") and self.lora:
try:
self.lora.end()
except Exception as e:
logger.error(f"Error during cleanup: {e}")

if hasattr(self, "_gpio_manager"):
for pin in [self.txen_pin, self.rxen_pin]:
if pin != -1:
self._gpio_manager.set_pin_low(pin)
for pin in self.en_pins:
self._gpio_manager.set_pin_low(pin)
self._gpio_manager.cleanup_all()

self._interrupt_setup = False
Expand Down