Compare commits
3 Commits
8aa4072a63
...
cb12cfa519
| Author | SHA1 | Date | |
|---|---|---|---|
| cb12cfa519 | |||
| bd22a1a2b1 | |||
| d1021fab09 |
Binary file not shown.
BIN
.pio/build/f722/.sconsign39.dblite
Normal file
BIN
.pio/build/f722/.sconsign39.dblite
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.pio/build/f722/lib737/USB_CDC/usbd_cdc_if.o
Normal file
BIN
.pio/build/f722/lib737/USB_CDC/usbd_cdc_if.o
Normal file
Binary file not shown.
BIN
.pio/build/f722/lib737/USB_CDC/usbd_conf.o
Normal file
BIN
.pio/build/f722/lib737/USB_CDC/usbd_conf.o
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.pio/build/f722/src/main.o
Normal file
BIN
.pio/build/f722/src/main.o
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1 +1 @@
|
||||
8700a44a6597bcade0f371945c539630ba0e78b1
|
||||
ee8efb31f6b185f16e4d385971f1a0e3291fe5fd
|
||||
@ -32,18 +32,4 @@ uint32_t battery_read_mv(void);
|
||||
*/
|
||||
uint8_t battery_estimate_pct(uint32_t voltage_mv);
|
||||
|
||||
/*
|
||||
* battery_accumulate_coulombs() — periodically integrate battery current.
|
||||
* Call every 10-20 ms (50-100 Hz) from main loop to accumulate coulombs.
|
||||
* Reads motor currents from INA219 sensors.
|
||||
*/
|
||||
void battery_accumulate_coulombs(void);
|
||||
|
||||
/*
|
||||
* battery_get_soc_coulomb() — get coulomb-based SoC estimate.
|
||||
* Returns 0–100 (percent), or 255 if coulomb counter not yet valid.
|
||||
* Preferred over voltage-based when valid.
|
||||
*/
|
||||
uint8_t battery_get_soc_coulomb(void);
|
||||
|
||||
#endif /* BATTERY_H */
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
#ifndef COULOMB_COUNTER_H
|
||||
#define COULOMB_COUNTER_H
|
||||
|
||||
/*
|
||||
* coulomb_counter.h — Battery coulomb counter for SoC estimation (Issue #325)
|
||||
*
|
||||
* Integrates battery current over time to track Ah consumed and remaining.
|
||||
* Provides accurate SoC independent of load, with fallback to voltage.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Call coulomb_counter_init(capacity_mah) at startup
|
||||
* 2. Call coulomb_counter_accumulate(current_ma) at 50–100 Hz
|
||||
* 3. Call coulomb_counter_get_soc_pct() to get current SoC
|
||||
* 4. Call coulomb_counter_reset() on charge complete
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Initialize coulomb counter with battery capacity (mAh). */
|
||||
void coulomb_counter_init(uint16_t capacity_mah);
|
||||
|
||||
/*
|
||||
* Accumulate coulomb from current reading + elapsed time.
|
||||
* Call this at regular intervals (e.g., 50–100 Hz from telemetry loop).
|
||||
* current_ma: battery current in milliamps (positive = discharge)
|
||||
*/
|
||||
void coulomb_counter_accumulate(int16_t current_ma);
|
||||
|
||||
/* Get current SoC as percentage (0–100, 255 = error). */
|
||||
uint8_t coulomb_counter_get_soc_pct(void);
|
||||
|
||||
/* Get consumed mAh (total charge removed from battery). */
|
||||
uint16_t coulomb_counter_get_consumed_mah(void);
|
||||
|
||||
/* Get remaining capacity in mAh. */
|
||||
uint16_t coulomb_counter_get_remaining_mah(void);
|
||||
|
||||
/* Reset accumulated coulombs (e.g., on charge complete). */
|
||||
void coulomb_counter_reset(void);
|
||||
|
||||
/* Check if coulomb counter is active (initialized and has measurements). */
|
||||
bool coulomb_counter_is_valid(void);
|
||||
|
||||
#endif /* COULOMB_COUNTER_H */
|
||||
@ -45,14 +45,14 @@ int16_t crsf_to_range(uint16_t val, int16_t min, int16_t max);
|
||||
* back to the ELRS TX module over UART4 TX. Call at CRSF_TELEMETRY_HZ (1 Hz).
|
||||
*
|
||||
* voltage_mv : battery voltage in millivolts (e.g. 12600 for 3S full)
|
||||
* capacity_mah : remaining battery capacity in mAh (Issue #325, coulomb counter)
|
||||
* current_ma : current draw in milliamps (0 if no sensor)
|
||||
* remaining_pct: state-of-charge 0–100 % (255 = unknown)
|
||||
*
|
||||
* Frame: [0xC8][12][0x08][v16_hi][v16_lo][c16_hi][c16_lo][cap24×3][rem][CRC]
|
||||
* voltage unit: 100 mV (12600 mV → 126)
|
||||
* capacity unit: mAh (3-byte big-endian, max 16.7M mAh)
|
||||
* current unit: 100 mA
|
||||
*/
|
||||
void crsf_send_battery(uint32_t voltage_mv, uint32_t capacity_mah,
|
||||
void crsf_send_battery(uint32_t voltage_mv, uint32_t current_ma,
|
||||
uint8_t remaining_pct);
|
||||
|
||||
/*
|
||||
|
||||
@ -14,4 +14,8 @@ extern I2C_HandleTypeDef hi2c1;
|
||||
|
||||
int i2c1_init(void);
|
||||
|
||||
/* I2C read/write helpers for sensors (INA219, etc.) */
|
||||
int i2c1_read(uint8_t addr, uint8_t *data, uint16_t len);
|
||||
int i2c1_write(uint8_t addr, const uint8_t *data, uint16_t len);
|
||||
|
||||
#endif /* I2C1_H */
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
# Cage configuration for MageDok 7" display kiosk
|
||||
# Lightweight Wayland compositor replacing GNOME (~650MB RAM savings)
|
||||
# Runs Chromium in fullscreen kiosk mode for SaltyFace web UI
|
||||
|
||||
[output]
|
||||
# MageDok output configuration
|
||||
# 1024x600 native resolution
|
||||
scale=1.0
|
||||
# Position on primary display
|
||||
position=0,0
|
||||
|
||||
[keyboard]
|
||||
# Keyboard layout
|
||||
layout=us
|
||||
variant=
|
||||
|
||||
[cursor]
|
||||
# Hide cursor when idle (fullscreen kiosk)
|
||||
hide-cursor-timeout=3000
|
||||
|
||||
# Note: Cage is explicitly designed as a minimal fullscreen launcher
|
||||
# It handles Wayland display protocol, input handling, and window management
|
||||
# Chromium will run fullscreen without window decorations
|
||||
@ -1,31 +0,0 @@
|
||||
# Wayland configuration for MageDok 7" touchscreen display
|
||||
# Used by Cage Wayland compositor for lightweight kiosk mode
|
||||
# Replaces X11 xorg-magedok.conf (used in Issue #369 legacy mode)
|
||||
|
||||
# Monitor configuration
|
||||
[output "HDMI-1"]
|
||||
# Native MageDok resolution
|
||||
mode=1024x600@60
|
||||
# Position (primary display)
|
||||
position=0,0
|
||||
# Scaling (no scaling needed, 1024x600 is native)
|
||||
scale=1
|
||||
|
||||
# Touchscreen input configuration
|
||||
[input "magedok-touch"]
|
||||
# Calibration not needed for HID devices (driver-handled)
|
||||
# Event device will be /dev/input/event* matching USB VID:PID
|
||||
# Udev rule creates symlink: /dev/magedok-touch
|
||||
|
||||
# Performance tuning for Orin Nano
|
||||
[performance]
|
||||
# Wayland buffer swaps (minimize latency)
|
||||
immediate-mode-rendering=false
|
||||
# Double-buffering for smooth animation
|
||||
buffer-count=2
|
||||
|
||||
# Notes:
|
||||
# - Cage handles Wayland protocol natively
|
||||
# - No X11 server needed (saves ~100MB RAM vs Xvfb)
|
||||
# - Touch input passes through kernel HID layer
|
||||
# - Resolution scaling handled by Chromium/browser
|
||||
@ -1,319 +0,0 @@
|
||||
# Cage + Chromium Kiosk for MageDok 7" Display
|
||||
|
||||
**Issue #374**: Replace GNOME with Cage + Chromium kiosk to save ~650MB RAM.
|
||||
|
||||
Lightweight Wayland-based fullscreen kiosk for SaltyFace web UI on MageDok 7" IPS touchscreen.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Jetson Orin Nano (Saltybot) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Cage Wayland Compositor │
|
||||
│ ├─ GNOME replaced (~650MB RAM freed) │
|
||||
│ ├─ Minimal fullscreen window manager │
|
||||
│ └─ Native Wayland protocol (no X11) │
|
||||
│ └─ Chromium Kiosk │
|
||||
│ ├─ SaltyFace web UI (http://localhost:3000) │
|
||||
│ ├─ Fullscreen (--kiosk) │
|
||||
│ ├─ No UI chrome (no address bar, tabs, etc) │
|
||||
│ └─ Touch input via HID │
|
||||
│ └─ MageDok USB Touchscreen │
|
||||
│ ├─ 1024×600 @ 60Hz (HDMI) │
|
||||
│ └─ Touch via /dev/magedok-touch │
|
||||
│ └─ PulseAudio │
|
||||
│ └─ HDMI audio routing to speakers │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ROS2 Workloads (extra 450MB RAM available) │
|
||||
│ ├─ Perception (vision, tracking) │
|
||||
│ ├─ Navigation (SLAM, path planning) │
|
||||
│ └─ Control (motor, servo, gripper) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Memory Comparison
|
||||
|
||||
### GNOME Desktop (Legacy)
|
||||
- GNOME Shell: ~300MB
|
||||
- Mutter (Wayland compositor): ~150MB
|
||||
- Xvfb (X11 fallback): ~100MB
|
||||
- GTK Libraries: ~100MB
|
||||
- **Total: ~650MB**
|
||||
|
||||
### Cage + Chromium Kiosk (New)
|
||||
- Cage compositor: ~30MB
|
||||
- Chromium (headless mode disabled): ~150MB
|
||||
- Wayland libraries: ~20MB
|
||||
- **Total: ~200MB**
|
||||
|
||||
**Savings: ~450MB RAM** → available for ROS2 perception, navigation, control workloads
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Install Cage and Chromium
|
||||
|
||||
```bash
|
||||
# Update package list
|
||||
sudo apt update
|
||||
|
||||
# Install Cage (Wayland compositor)
|
||||
sudo apt install -y cage
|
||||
|
||||
# Install Chromium (or Chromium-browser on some systems)
|
||||
sudo apt install -y chromium
|
||||
```
|
||||
|
||||
### 2. Install Configuration Files
|
||||
|
||||
```bash
|
||||
# Copy Cage/Wayland config
|
||||
sudo mkdir -p /opt/saltybot/config
|
||||
sudo cp config/cage-magedok.ini /opt/saltybot/config/
|
||||
sudo cp config/wayland-magedok.conf /opt/saltybot/config/
|
||||
|
||||
# Copy launch scripts
|
||||
sudo mkdir -p /opt/saltybot/scripts
|
||||
sudo cp scripts/chromium_kiosk.sh /opt/saltybot/scripts/
|
||||
sudo chmod +x /opt/saltybot/scripts/chromium_kiosk.sh
|
||||
|
||||
# Create logs directory
|
||||
sudo mkdir -p /opt/saltybot/logs
|
||||
sudo chown orin:orin /opt/saltybot/logs
|
||||
```
|
||||
|
||||
### 3. Disable GNOME (if installed)
|
||||
|
||||
```bash
|
||||
# Disable GNOME display manager
|
||||
sudo systemctl disable gdm.service
|
||||
sudo systemctl disable gnome-shell.target
|
||||
|
||||
# Verify disabled
|
||||
sudo systemctl is-enabled gdm.service # Should output: disabled
|
||||
```
|
||||
|
||||
### 4. Install Systemd Service
|
||||
|
||||
```bash
|
||||
# Copy systemd service
|
||||
sudo cp systemd/chromium-kiosk.service /etc/systemd/system/
|
||||
|
||||
# Reload systemd daemon
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Enable auto-start on boot
|
||||
sudo systemctl enable chromium-kiosk.service
|
||||
|
||||
# Verify enabled
|
||||
sudo systemctl is-enabled chromium-kiosk.service # Should output: enabled
|
||||
```
|
||||
|
||||
### 5. Verify Udev Rules (from Issue #369)
|
||||
|
||||
The MageDok touch device needs proper permissions. Verify udev rule is installed:
|
||||
|
||||
```bash
|
||||
sudo cat /etc/udev/rules.d/90-magedok-touch.rules
|
||||
```
|
||||
|
||||
Should contain:
|
||||
```
|
||||
ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="0eef", ATTRS{idProduct}=="0001", SYMLINK+="magedok-touch", MODE="0666"
|
||||
```
|
||||
|
||||
### 6. Configure PulseAudio (from Issue #369)
|
||||
|
||||
Verify PulseAudio HDMI routing is configured:
|
||||
|
||||
```bash
|
||||
# Check running PulseAudio sink
|
||||
pactl list short sinks
|
||||
|
||||
# Should show HDMI output device
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Start (Development)
|
||||
|
||||
```bash
|
||||
# Start Cage + Chromium manually
|
||||
/opt/saltybot/scripts/chromium_kiosk.sh --url http://localhost:3000 --debug
|
||||
|
||||
# Should see:
|
||||
# [timestamp] Starting Chromium kiosk on Cage Wayland compositor
|
||||
# [timestamp] URL: http://localhost:3000
|
||||
# [timestamp] Launching Cage with Chromium...
|
||||
```
|
||||
|
||||
### Systemd Service Start
|
||||
|
||||
```bash
|
||||
# Start service
|
||||
sudo systemctl start chromium-kiosk.service
|
||||
|
||||
# Check status
|
||||
sudo systemctl status chromium-kiosk.service
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u chromium-kiosk.service -f
|
||||
```
|
||||
|
||||
### Auto-Start on Boot
|
||||
|
||||
```bash
|
||||
# Reboot to verify auto-start
|
||||
sudo reboot
|
||||
|
||||
# After boot, check service
|
||||
sudo systemctl status chromium-kiosk.service
|
||||
|
||||
# Check if Chromium is running
|
||||
ps aux | grep chromium # Should show cage and chromium processes
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Chromium won't start
|
||||
|
||||
**Symptom**: Service fails with "WAYLAND_DISPLAY not set" or "Cannot connect to Wayland server"
|
||||
|
||||
**Solutions**:
|
||||
1. Verify XDG_RUNTIME_DIR exists:
|
||||
```bash
|
||||
ls -la /run/user/1000
|
||||
chmod 700 /run/user/1000
|
||||
```
|
||||
|
||||
2. Verify WAYLAND_DISPLAY is set in service:
|
||||
```bash
|
||||
sudo systemctl show chromium-kiosk.service -p Environment
|
||||
# Should show: WAYLAND_DISPLAY=wayland-0
|
||||
```
|
||||
|
||||
3. Check Wayland availability:
|
||||
```bash
|
||||
echo $WAYLAND_DISPLAY
|
||||
ls -la /run/user/1000/wayland-0
|
||||
```
|
||||
|
||||
### MageDok touchscreen not responding
|
||||
|
||||
**Symptom**: Touch input doesn't work in Chromium
|
||||
|
||||
**Solutions**:
|
||||
1. Verify touch device is present:
|
||||
```bash
|
||||
ls -la /dev/magedok-touch
|
||||
lsusb | grep -i eGTouch # Should show eGTouch device
|
||||
```
|
||||
|
||||
2. Check udev rule was applied:
|
||||
```bash
|
||||
sudo udevadm control --reload
|
||||
sudo udevadm trigger
|
||||
lsusb # Verify eGTouch device still present
|
||||
```
|
||||
|
||||
3. Verify touch input reaches Cage:
|
||||
```bash
|
||||
sudo strace -e ioctl -p $(pgrep cage) 2>&1 | grep -i input
|
||||
# Should show input device activity
|
||||
```
|
||||
|
||||
### HDMI audio not working
|
||||
|
||||
**Symptom**: No sound from MageDok speakers
|
||||
|
||||
**Solutions**:
|
||||
1. Check HDMI sink is active:
|
||||
```bash
|
||||
pactl list short sinks
|
||||
pactl get-default-sink
|
||||
```
|
||||
|
||||
2. Set HDMI sink as default:
|
||||
```bash
|
||||
pactl set-default-sink <hdmi-sink-name>
|
||||
```
|
||||
|
||||
3. Verify audio router is running:
|
||||
```bash
|
||||
ps aux | grep audio_router
|
||||
```
|
||||
|
||||
### High CPU usage with Chromium
|
||||
|
||||
**Symptom**: Chromium using 80%+ CPU
|
||||
|
||||
**Solutions**:
|
||||
1. Reduce animation frame rate in SaltyFace web app
|
||||
2. Disable hardware video acceleration if unstable:
|
||||
```bash
|
||||
# In chromium_kiosk.sh, add:
|
||||
# --disable-gpu
|
||||
# --disable-extensions
|
||||
```
|
||||
|
||||
3. Monitor GPU memory:
|
||||
```bash
|
||||
tegrastats # Observe GPU load
|
||||
```
|
||||
|
||||
### Cage compositor crashes
|
||||
|
||||
**Symptom**: Screen goes black, Chromium closes
|
||||
|
||||
**Solutions**:
|
||||
1. Check Cage logs:
|
||||
```bash
|
||||
sudo journalctl -u chromium-kiosk.service -n 50
|
||||
```
|
||||
|
||||
2. Verify video driver:
|
||||
```bash
|
||||
ls -la /dev/nvhost*
|
||||
nvidia-smi # Should work on Orin
|
||||
```
|
||||
|
||||
3. Try X11 fallback (temporary):
|
||||
```bash
|
||||
# Use Issue #369 magedok_display.launch.py instead
|
||||
ros2 launch saltybot_bringup magedok_display.launch.py
|
||||
```
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
### Boot Time
|
||||
- GNOME boot: ~30-40 seconds
|
||||
- Cage boot: ~8-12 seconds
|
||||
- **Improvement: 70% faster to interactive display**
|
||||
|
||||
### First Paint (SaltyFace loads)
|
||||
- GNOME: 15-20 seconds (desktop fully loaded)
|
||||
- Cage: 3-5 seconds (Chromium + web app loads)
|
||||
- **Improvement: 4x faster**
|
||||
|
||||
### Memory Usage
|
||||
- GNOME idle: ~650MB consumed
|
||||
- Cage idle: ~200MB consumed
|
||||
- **Improvement: 450MB available for workloads**
|
||||
|
||||
### Frame Rate (MageDok display)
|
||||
- X11 + GNOME: ~30fps (variable, desktop compositing)
|
||||
- Cage + Chromium: ~60fps (native Wayland, locked to display)
|
||||
- **Improvement: 2x frame rate consistency**
|
||||
|
||||
## Related Issues
|
||||
|
||||
- **Issue #369**: MageDok display setup (X11 + GNOME legacy mode)
|
||||
- **Issue #370**: SaltyFace web app UI (runs in Chromium kiosk)
|
||||
- **Issue #371**: Accessibility mode (keyboard/voice input to web app)
|
||||
|
||||
## References
|
||||
|
||||
- [Cage Compositor](https://github.com/Gr3yR0ot/cage) - Minimal Wayland launcher
|
||||
- [Chromium Kiosk Mode](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/kiosk_mode.md)
|
||||
- [Wayland Protocol](https://wayland.freedesktop.org/)
|
||||
- [Jetson Orin Nano](https://developer.nvidia.com/jetson-orin-nano-developer-kit) - ARM CPU/GPU details
|
||||
@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cage Wayland + Chromium kiosk launch configuration for MageDok 7" display.
|
||||
|
||||
Lightweight alternative to X11 desktop environment:
|
||||
- Cage: Minimal Wayland compositor (replaces GNOME/Mutter)
|
||||
- Chromium: Fullscreen kiosk browser for SaltyFace web UI
|
||||
- PulseAudio: HDMI audio routing
|
||||
|
||||
Memory savings vs GNOME:
|
||||
- GNOME + Mutter: ~650MB RAM
|
||||
- Cage + Chromium: ~200MB RAM
|
||||
- Savings: ~450MB RAM for other ROS2 workloads
|
||||
|
||||
Issue #374: Replace GNOME with Cage + Chromium kiosk
|
||||
"""
|
||||
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
from launch.actions import DeclareLaunchArgument, ExecuteProcess
|
||||
from launch.substitutions import LaunchConfiguration
|
||||
|
||||
def generate_launch_description():
|
||||
"""Generate ROS2 launch description for Cage + Chromium kiosk."""
|
||||
|
||||
# Launch arguments
|
||||
url_arg = DeclareLaunchArgument(
|
||||
'kiosk_url',
|
||||
default_value='http://localhost:3000',
|
||||
description='URL for Chromium kiosk (SaltyFace web app)'
|
||||
)
|
||||
|
||||
debug_arg = DeclareLaunchArgument(
|
||||
'debug',
|
||||
default_value='false',
|
||||
description='Enable debug logging'
|
||||
)
|
||||
|
||||
ld = LaunchDescription([url_arg, debug_arg])
|
||||
|
||||
# Start touch monitor (from Issue #369 - reused)
|
||||
# Monitors MageDok USB touch device availability
|
||||
touch_monitor = Node(
|
||||
package='saltybot_bringup',
|
||||
executable='touch_monitor.py',
|
||||
name='touch_monitor',
|
||||
output='screen',
|
||||
)
|
||||
ld.add_action(touch_monitor)
|
||||
|
||||
# Start audio router (from Issue #369 - reused)
|
||||
# Routes HDMI audio to built-in speakers via PulseAudio
|
||||
audio_router = Node(
|
||||
package='saltybot_bringup',
|
||||
executable='audio_router.py',
|
||||
name='audio_router',
|
||||
output='screen',
|
||||
)
|
||||
ld.add_action(audio_router)
|
||||
|
||||
# Start Cage Wayland compositor with Chromium kiosk
|
||||
# Replaces X11 server + GNOME desktop environment
|
||||
cage_chromium = ExecuteProcess(
|
||||
cmd=[
|
||||
'/opt/saltybot/scripts/chromium_kiosk.sh',
|
||||
'--url', LaunchConfiguration('kiosk_url'),
|
||||
],
|
||||
condition_condition=None, # Always start
|
||||
name='cage_chromium',
|
||||
shell=True,
|
||||
)
|
||||
ld.add_action(cage_chromium)
|
||||
|
||||
return ld
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(generate_launch_description())
|
||||
@ -1,329 +0,0 @@
|
||||
"""
|
||||
_camera_power_manager.py — Adaptive camera power mode FSM (Issue #375).
|
||||
|
||||
Pure-Python library (no ROS2 / hardware dependencies) for full unit-test
|
||||
coverage without hardware.
|
||||
|
||||
Five Power Modes
|
||||
----------------
|
||||
SLEEP (0) — charging / idle.
|
||||
Sensors: none (or 1 CSI at 1 fps as wake trigger).
|
||||
~150 MB RAM.
|
||||
|
||||
SOCIAL (1) — parked / socialising.
|
||||
Sensors: C920 webcam + face UI.
|
||||
~400 MB RAM.
|
||||
|
||||
AWARE (2) — indoor, slow walking, <5 km/h.
|
||||
Sensors: front CSI + RealSense + LIDAR.
|
||||
~850 MB RAM.
|
||||
|
||||
ACTIVE (3) — sidewalk / bike path, 5–15 km/h.
|
||||
Sensors: front+rear CSI + RealSense + LIDAR + UWB.
|
||||
~1.15 GB RAM.
|
||||
|
||||
FULL (4) — street / high-speed >15 km/h, or crossing.
|
||||
Sensors: all 4 CSI + RealSense + LIDAR + UWB.
|
||||
~1.55 GB RAM.
|
||||
|
||||
Transition Logic
|
||||
----------------
|
||||
Automatic transitions are speed-driven with hysteresis to prevent flapping:
|
||||
|
||||
Upgrade thresholds (instantaneous):
|
||||
SOCIAL → AWARE : speed ≥ 0.3 m/s (~1 km/h, any motion)
|
||||
AWARE → ACTIVE : speed ≥ 1.4 m/s (~5 km/h)
|
||||
ACTIVE → FULL : speed ≥ 4.2 m/s (~15 km/h)
|
||||
|
||||
Downgrade thresholds (held for downgrade_hold_s before applying):
|
||||
FULL → ACTIVE : speed < 3.6 m/s (~13 km/h, 2 km/h hysteresis)
|
||||
ACTIVE → AWARE : speed < 1.1 m/s (~4 km/h)
|
||||
AWARE → SOCIAL : speed < 0.1 m/s and idle ≥ idle_to_social_s
|
||||
|
||||
Scenario overrides (bypass hysteresis, instant):
|
||||
CROSSING → FULL immediately and held until scenario clears
|
||||
EMERGENCY → FULL immediately (also signals speed reduction upstream)
|
||||
INDOOR → cap at AWARE (never ACTIVE or FULL indoors)
|
||||
PARKED → SOCIAL immediately
|
||||
OUTDOOR → normal speed-based logic
|
||||
|
||||
Battery low override:
|
||||
battery_pct < battery_low_pct → cap at AWARE to save power
|
||||
|
||||
Safety invariant:
|
||||
Rear CSI is a safety sensor at speed — it is always on in ACTIVE and FULL.
|
||||
Any mode downgrade during CROSSING is blocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Mode enum ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class CameraMode(IntEnum):
|
||||
SLEEP = 0
|
||||
SOCIAL = 1
|
||||
AWARE = 2
|
||||
ACTIVE = 3
|
||||
FULL = 4
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
# ── Scenario enum ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Scenario(str):
|
||||
"""
|
||||
Known scenario strings. The FSM accepts any string; these are the
|
||||
canonical values that trigger special behaviour.
|
||||
"""
|
||||
UNKNOWN = 'unknown'
|
||||
PARKED = 'parked'
|
||||
INDOOR = 'indoor'
|
||||
OUTDOOR = 'outdoor'
|
||||
CROSSING = 'crossing'
|
||||
EMERGENCY = 'emergency'
|
||||
|
||||
|
||||
# ── Sensor configuration per mode ─────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveSensors:
|
||||
csi_front: bool = False
|
||||
csi_rear: bool = False
|
||||
csi_left: bool = False
|
||||
csi_right: bool = False
|
||||
realsense: bool = False
|
||||
lidar: bool = False
|
||||
uwb: bool = False
|
||||
webcam: bool = False
|
||||
|
||||
@property
|
||||
def active_count(self) -> int:
|
||||
return sum([
|
||||
self.csi_front, self.csi_rear, self.csi_left, self.csi_right,
|
||||
self.realsense, self.lidar, self.uwb, self.webcam,
|
||||
])
|
||||
|
||||
|
||||
# Canonical sensor set for each mode
|
||||
MODE_SENSORS: dict[CameraMode, ActiveSensors] = {
|
||||
CameraMode.SLEEP: ActiveSensors(),
|
||||
CameraMode.SOCIAL: ActiveSensors(webcam=True),
|
||||
CameraMode.AWARE: ActiveSensors(csi_front=True, realsense=True, lidar=True),
|
||||
CameraMode.ACTIVE: ActiveSensors(csi_front=True, csi_rear=True,
|
||||
realsense=True, lidar=True, uwb=True),
|
||||
CameraMode.FULL: ActiveSensors(csi_front=True, csi_rear=True,
|
||||
csi_left=True, csi_right=True,
|
||||
realsense=True, lidar=True, uwb=True),
|
||||
}
|
||||
|
||||
# Speed thresholds (m/s)
|
||||
_SPD_MOTION = 0.3 # ~1 km/h — any meaningful motion
|
||||
_SPD_ACTIVE_UP = 5.0 / 3.6 # 5 km/h upgrade to ACTIVE
|
||||
_SPD_FULL_UP = 15.0 / 3.6 # 15 km/h upgrade to FULL
|
||||
_SPD_ACTIVE_DOWN = 4.0 / 3.6 # 4 km/h downgrade from ACTIVE (hysteresis gap)
|
||||
_SPD_FULL_DOWN = 13.0 / 3.6 # 13 km/h downgrade from FULL
|
||||
|
||||
# Scenario strings that force FULL
|
||||
_FORCE_FULL = frozenset({Scenario.CROSSING, Scenario.EMERGENCY})
|
||||
# Scenarios that cap the mode
|
||||
_CAP_AWARE = frozenset({Scenario.INDOOR})
|
||||
_CAP_SOCIAL = frozenset({Scenario.PARKED})
|
||||
|
||||
|
||||
# ── FSM result ────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class ModeDecision:
|
||||
mode: CameraMode
|
||||
sensors: ActiveSensors
|
||||
trigger_speed_mps: float
|
||||
trigger_scenario: str
|
||||
scenario_override: bool
|
||||
|
||||
|
||||
# ── FSM ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
class CameraPowerFSM:
|
||||
"""
|
||||
Finite state machine for camera power mode management.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
downgrade_hold_s : seconds a downgrade condition must persist before
|
||||
the mode drops (hysteresis). Default 5.0 s.
|
||||
idle_to_social_s : seconds of near-zero speed before AWARE → SOCIAL.
|
||||
Default 30.0 s.
|
||||
battery_low_pct : battery level below which mode is capped at AWARE.
|
||||
Default 20.0 %.
|
||||
clock : callable() → float for monotonic time (injectable
|
||||
for tests; defaults to time.monotonic).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
downgrade_hold_s: float = 5.0,
|
||||
idle_to_social_s: float = 30.0,
|
||||
battery_low_pct: float = 20.0,
|
||||
clock=None,
|
||||
) -> None:
|
||||
self._downgrade_hold = downgrade_hold_s
|
||||
self._idle_to_social = idle_to_social_s
|
||||
self._battery_low_pct = battery_low_pct
|
||||
self._clock = clock or time.monotonic
|
||||
|
||||
self._mode: CameraMode = CameraMode.SLEEP
|
||||
self._downgrade_pending_since: Optional[float] = None
|
||||
self._idle_since: Optional[float] = None
|
||||
|
||||
# Last input values (for reporting)
|
||||
self._last_speed: float = 0.0
|
||||
self._last_scenario: str = Scenario.UNKNOWN
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def mode(self) -> CameraMode:
|
||||
return self._mode
|
||||
|
||||
def update(
|
||||
self,
|
||||
speed_mps: float,
|
||||
scenario: str = Scenario.UNKNOWN,
|
||||
battery_pct: float = 100.0,
|
||||
) -> ModeDecision:
|
||||
"""
|
||||
Evaluate inputs and return the current mode decision.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
speed_mps : current speed in m/s (non-negative)
|
||||
scenario : current operating scenario string
|
||||
battery_pct : battery charge level 0–100
|
||||
|
||||
Returns
|
||||
-------
|
||||
ModeDecision with the resolved mode and active sensor set.
|
||||
"""
|
||||
now = self._clock()
|
||||
speed_mps = max(0.0, float(speed_mps))
|
||||
self._last_speed = speed_mps
|
||||
self._last_scenario = scenario
|
||||
|
||||
scenario_override = False
|
||||
|
||||
# ── 1. Hard overrides (no hysteresis) ─────────────────────────────
|
||||
if scenario in _FORCE_FULL:
|
||||
self._mode = CameraMode.FULL
|
||||
self._downgrade_pending_since = None
|
||||
self._idle_since = None
|
||||
scenario_override = True
|
||||
return self._decision(scenario, scenario_override)
|
||||
|
||||
if scenario in _CAP_SOCIAL:
|
||||
self._mode = CameraMode.SOCIAL
|
||||
self._downgrade_pending_since = None
|
||||
self._idle_since = None
|
||||
scenario_override = True
|
||||
return self._decision(scenario, scenario_override)
|
||||
|
||||
# ── 2. Compute desired mode from speed ────────────────────────────
|
||||
desired = self._speed_to_mode(speed_mps)
|
||||
|
||||
# ── 3. Apply scenario caps ────────────────────────────────────────
|
||||
if scenario in _CAP_AWARE:
|
||||
desired = min(desired, CameraMode.AWARE)
|
||||
|
||||
# ── 4. Apply battery low cap ──────────────────────────────────────
|
||||
if battery_pct < self._battery_low_pct:
|
||||
desired = min(desired, CameraMode.AWARE)
|
||||
|
||||
# ── 5. Idle timer: delay AWARE → SOCIAL when briefly stopped ─────────
|
||||
# _speed_to_mode already returns SOCIAL for speed < _SPD_MOTION.
|
||||
# When in AWARE (or above) and nearly stopped, hold at AWARE until
|
||||
# the idle timer expires to avoid flapping at traffic lights.
|
||||
if speed_mps < 0.1 and self._mode >= CameraMode.AWARE:
|
||||
if self._idle_since is None:
|
||||
self._idle_since = now
|
||||
if now - self._idle_since < self._idle_to_social:
|
||||
# Timer not yet expired — enforce minimum AWARE
|
||||
desired = max(desired, CameraMode.AWARE)
|
||||
# else: timer expired — let desired remain SOCIAL naturally
|
||||
else:
|
||||
self._idle_since = None
|
||||
|
||||
# ── 6. Apply hysteresis for downgrades ────────────────────────────
|
||||
if desired < self._mode:
|
||||
# Downgrade requested — start hold timer on first detection, then
|
||||
# check on every call (including the first, so hold=0 is instant).
|
||||
if self._downgrade_pending_since is None:
|
||||
self._downgrade_pending_since = now
|
||||
if now - self._downgrade_pending_since >= self._downgrade_hold:
|
||||
self._mode = desired
|
||||
self._downgrade_pending_since = None
|
||||
# else: hold not expired — keep current mode
|
||||
else:
|
||||
# Upgrade or no change — apply immediately, cancel any pending downgrade
|
||||
self._downgrade_pending_since = None
|
||||
self._mode = desired
|
||||
|
||||
return self._decision(scenario, scenario_override)
|
||||
|
||||
def reset(self, mode: CameraMode = CameraMode.SLEEP) -> None:
|
||||
"""Reset FSM state (e.g. on node restart)."""
|
||||
self._mode = mode
|
||||
self._downgrade_pending_since = None
|
||||
self._idle_since = None
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _speed_to_mode(self, speed_mps: float) -> CameraMode:
|
||||
"""Map speed to desired mode using hysteresis thresholds."""
|
||||
if self._mode == CameraMode.FULL:
|
||||
# Downgrade from FULL uses lower threshold
|
||||
if speed_mps >= _SPD_FULL_DOWN:
|
||||
return CameraMode.FULL
|
||||
elif speed_mps >= _SPD_ACTIVE_DOWN:
|
||||
return CameraMode.ACTIVE
|
||||
elif speed_mps >= _SPD_MOTION:
|
||||
return CameraMode.AWARE
|
||||
else:
|
||||
return CameraMode.AWARE # idle handled separately
|
||||
|
||||
elif self._mode == CameraMode.ACTIVE:
|
||||
if speed_mps >= _SPD_FULL_UP:
|
||||
return CameraMode.FULL
|
||||
elif speed_mps >= _SPD_ACTIVE_DOWN:
|
||||
return CameraMode.ACTIVE
|
||||
elif speed_mps >= _SPD_MOTION:
|
||||
return CameraMode.AWARE
|
||||
else:
|
||||
return CameraMode.AWARE
|
||||
|
||||
else:
|
||||
# SLEEP / SOCIAL / AWARE — use upgrade thresholds
|
||||
if speed_mps >= _SPD_FULL_UP:
|
||||
return CameraMode.FULL
|
||||
elif speed_mps >= _SPD_ACTIVE_UP:
|
||||
return CameraMode.ACTIVE
|
||||
elif speed_mps >= _SPD_MOTION:
|
||||
return CameraMode.AWARE
|
||||
else:
|
||||
return CameraMode.SOCIAL
|
||||
|
||||
def _decision(self, scenario: str, override: bool) -> ModeDecision:
|
||||
return ModeDecision(
|
||||
mode = self._mode,
|
||||
sensors = MODE_SENSORS[self._mode],
|
||||
trigger_speed_mps = self._last_speed,
|
||||
trigger_scenario = scenario,
|
||||
scenario_override = override,
|
||||
)
|
||||
@ -1,411 +0,0 @@
|
||||
"""
|
||||
_uwb_tracker.py — UWB DW3000 anchor/tag ranging + bearing estimation (Issue #365).
|
||||
|
||||
Pure-Python library (no ROS2 / hardware dependencies) so it can be fully unit-tested
|
||||
on a development machine without the physical ESP32-UWB-Pro anchors attached.
|
||||
|
||||
Hardware layout
|
||||
---------------
|
||||
Two DW3000 anchors are mounted on the SaltyBot chassis, separated by a ~25 cm
|
||||
baseline, both facing forward:
|
||||
|
||||
anchor0 (left, x = −baseline/2) anchor1 (right, x = +baseline/2)
|
||||
│←──────── baseline ────────→│
|
||||
↑
|
||||
robot forward
|
||||
|
||||
The wearable tag is carried by the Tee (person being followed).
|
||||
|
||||
Bearing estimation
|
||||
------------------
|
||||
Given TWR (two-way ranging) distances d0 and d1 from the two anchors to the tag,
|
||||
and the known baseline B between the anchors, we compute bearing θ using the
|
||||
law of cosines:
|
||||
|
||||
cos α = (d0² - d1² + B²) / (2 · B · d1) [angle at anchor1]
|
||||
|
||||
Bearing is measured from the perpendicular bisector of the baseline (i.e. the
|
||||
robot's forward axis):
|
||||
|
||||
θ = 90° − α
|
||||
|
||||
Positive θ = tag is to the right, negative = tag is to the left.
|
||||
|
||||
The formula degrades when the tag is very close to the baseline or at extreme
|
||||
angles. We report a confidence value that penalises these conditions.
|
||||
|
||||
Serial protocol (ESP32 → Orin via USB-serial)
|
||||
---------------------------------------------
|
||||
Each anchor sends newline-terminated ASCII frames at ≥10 Hz:
|
||||
|
||||
RANGE,<anchor_id>,<tag_id>,<distance_mm>\n
|
||||
|
||||
Example:
|
||||
RANGE,0,T0,1532\n → anchor 0, tag T0, distance 1532 mm
|
||||
RANGE,1,T0,1748\n → anchor 1, tag T0, distance 1748 mm
|
||||
|
||||
A STATUS frame is sent once on connection:
|
||||
STATUS,<anchor_id>,OK\n
|
||||
|
||||
The node opens two serial ports (one per anchor). A background thread reads
|
||||
each port and updates a shared RangingState.
|
||||
|
||||
Kalman filter
|
||||
-------------
|
||||
A simple 1-D constant-velocity Kalman filter smooths the bearing output.
|
||||
State: [bearing_deg, bearing_rate_dps].
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Fix quality codes (written to UwbTarget.fix_quality)
|
||||
FIX_NONE = 0
|
||||
FIX_SINGLE = 1 # only one anchor responding
|
||||
FIX_DUAL = 2 # both anchors responding — full bearing estimate
|
||||
|
||||
# Maximum age of a ranging measurement before it is considered stale (seconds)
|
||||
_STALE_S = 0.5
|
||||
|
||||
# ── Serial protocol parsing ────────────────────────────────────────────────────
|
||||
|
||||
_RANGE_RE = re.compile(r'^RANGE,(\d+),(\w+),([\d.]+)\s*$')
|
||||
_STATUS_RE = re.compile(r'^STATUS,(\d+),(\w+)\s*$')
|
||||
|
||||
|
||||
@dataclass
|
||||
class RangeFrame:
|
||||
"""Decoded RANGE frame from a DW3000 anchor."""
|
||||
anchor_id: int
|
||||
tag_id: str
|
||||
distance_m: float
|
||||
timestamp: float = field(default_factory=time.monotonic)
|
||||
|
||||
|
||||
def parse_frame(line: str) -> Optional[RangeFrame]:
|
||||
"""
|
||||
Parse one ASCII line from the anchor serial stream.
|
||||
|
||||
Returns a RangeFrame on success, None for STATUS/unknown/malformed lines.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
line : raw ASCII line (may include trailing whitespace / CR)
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> f = parse_frame("RANGE,0,T0,1532")
|
||||
>>> f.anchor_id, f.tag_id, f.distance_m
|
||||
(0, 'T0', 1.532)
|
||||
>>> parse_frame("STATUS,0,OK") is None
|
||||
True
|
||||
>>> parse_frame("GARBAGE") is None
|
||||
True
|
||||
"""
|
||||
m = _RANGE_RE.match(line.strip())
|
||||
if m is None:
|
||||
return None
|
||||
anchor_id = int(m.group(1))
|
||||
tag_id = m.group(2)
|
||||
distance_m = float(m.group(3)) / 1000.0 # mm → m
|
||||
return RangeFrame(anchor_id=anchor_id, tag_id=tag_id, distance_m=distance_m)
|
||||
|
||||
|
||||
# ── Two-anchor bearing geometry ────────────────────────────────────────────────
|
||||
|
||||
def bearing_from_ranges(
|
||||
d0: float,
|
||||
d1: float,
|
||||
baseline_m: float,
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Compute bearing to tag from two anchor distances.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
d0 : distance from anchor-0 (left, at x = −baseline/2) to tag (m)
|
||||
d1 : distance from anchor-1 (right, at x = +baseline/2) to tag (m)
|
||||
baseline_m : separation between the two anchors (m)
|
||||
|
||||
Returns
|
||||
-------
|
||||
(bearing_deg, confidence)
|
||||
bearing_deg : signed bearing in degrees (positive = right)
|
||||
confidence : 0.0–1.0 quality estimate
|
||||
|
||||
Notes
|
||||
-----
|
||||
Derived from law of cosines applied to the triangle
|
||||
(anchor0, anchor1, tag):
|
||||
cos(α) = (d0² − d1² + B²) / (2·B·d0) where α is angle at anchor0
|
||||
Forward axis is the perpendicular bisector of the baseline, so:
|
||||
bearing = 90° − α_at_anchor1
|
||||
We use the symmetric formula through the midpoint:
|
||||
x_tag = (d0² - d1²) / (2·B) [lateral offset from midpoint]
|
||||
y_tag = sqrt(d0² - (x_tag + B/2)²) [forward distance]
|
||||
bearing = atan2(x_tag, y_tag) * 180/π
|
||||
"""
|
||||
if baseline_m <= 0.0:
|
||||
raise ValueError(f'baseline_m must be positive, got {baseline_m}')
|
||||
if d0 <= 0.0 or d1 <= 0.0:
|
||||
raise ValueError(f'distances must be positive, got d0={d0} d1={d1}')
|
||||
|
||||
B = baseline_m
|
||||
# Lateral offset of tag from midpoint of baseline (positive = towards anchor1 = right)
|
||||
x = (d0 * d0 - d1 * d1) / (2.0 * B)
|
||||
|
||||
# Forward distance (Pythagorean)
|
||||
# anchor0 is at x_coord = -B/2 relative to midpoint
|
||||
y_sq = d0 * d0 - (x + B / 2.0) ** 2
|
||||
if y_sq < 0.0:
|
||||
# Triangle inequality violated — noisy ranging; clamp to zero
|
||||
y_sq = 0.0
|
||||
y = math.sqrt(y_sq)
|
||||
|
||||
bearing_deg = math.degrees(math.atan2(x, max(y, 1e-3)))
|
||||
|
||||
# ── Confidence ───────────────────────────────────────────────────────────
|
||||
# 1. Range agreement penalty — if |d0-d1| > sqrt(d0²+d1²) * factor, tag
|
||||
# is almost directly on the baseline extension (extreme angle, poor geometry)
|
||||
mean_d = (d0 + d1) / 2.0
|
||||
diff = abs(d0 - d1)
|
||||
# Geometric dilution: confidence falls as |bearing| approaches 90°
|
||||
bearing_penalty = math.cos(math.radians(bearing_deg)) # 1.0 at 0°, 0.0 at 90°
|
||||
bearing_penalty = max(0.0, bearing_penalty)
|
||||
|
||||
# 2. Distance sanity: if tag is unreasonably close (< B) confidence drops
|
||||
dist_penalty = min(1.0, mean_d / max(B, 0.1))
|
||||
|
||||
confidence = float(np.clip(bearing_penalty * dist_penalty, 0.0, 1.0))
|
||||
|
||||
return bearing_deg, confidence
|
||||
|
||||
|
||||
def bearing_single_anchor(
|
||||
d0: float,
|
||||
baseline_m: float = 0.25,
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Fallback bearing when only one anchor is responding.
|
||||
|
||||
With a single anchor we cannot determine lateral position, so we return
|
||||
bearing=0 (directly ahead) with a low confidence proportional to 1/distance.
|
||||
This keeps the follow-me controller moving toward the target even in
|
||||
degraded mode.
|
||||
|
||||
Returns (0.0, confidence) where confidence ≤ 0.3.
|
||||
"""
|
||||
# Single-anchor confidence: ≤ 0.3, decreases with distance
|
||||
confidence = float(np.clip(0.3 * (2.0 / max(d0, 0.5)), 0.0, 0.3))
|
||||
return 0.0, confidence
|
||||
|
||||
|
||||
# ── Kalman filter for bearing smoothing ───────────────────────────────────────
|
||||
|
||||
class BearingKalman:
|
||||
"""
|
||||
1-D constant-velocity Kalman filter for bearing smoothing.
|
||||
|
||||
State: [bearing_deg, bearing_rate_dps]
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
process_noise_deg2: float = 10.0, # bearing process noise (deg²/frame)
|
||||
process_noise_rate2: float = 100.0, # rate process noise
|
||||
meas_noise_deg2: float = 4.0, # bearing measurement noise (deg²)
|
||||
) -> None:
|
||||
self._x = np.zeros(2, dtype=np.float64) # [bearing, rate]
|
||||
self._P = np.diag([100.0, 1000.0]) # initial covariance
|
||||
self._Q = np.diag([process_noise_deg2, process_noise_rate2])
|
||||
self._R = np.array([[meas_noise_deg2]])
|
||||
self._F = np.array([[1.0, 1.0], # state transition (dt=1 frame)
|
||||
[0.0, 1.0]])
|
||||
self._H = np.array([[1.0, 0.0]]) # observation: bearing only
|
||||
self._initialised = False
|
||||
|
||||
def predict(self) -> float:
|
||||
"""Predict next bearing; returns predicted bearing_deg."""
|
||||
self._x = self._F @ self._x
|
||||
self._P = self._F @ self._P @ self._F.T + self._Q
|
||||
return float(self._x[0])
|
||||
|
||||
def update(self, bearing_deg: float) -> float:
|
||||
"""
|
||||
Update with a new bearing measurement.
|
||||
|
||||
On first call, seeds the filter state.
|
||||
Returns smoothed bearing_deg.
|
||||
"""
|
||||
if not self._initialised:
|
||||
self._x[0] = bearing_deg
|
||||
self._initialised = True
|
||||
return bearing_deg
|
||||
|
||||
self.predict()
|
||||
|
||||
y = np.array([[bearing_deg]]) - self._H @ self._x.reshape(-1, 1)
|
||||
S = self._H @ self._P @ self._H.T + self._R
|
||||
K = self._P @ self._H.T @ np.linalg.inv(S)
|
||||
self._x = self._x + (K @ y).ravel()
|
||||
self._P = (np.eye(2) - K @ self._H) @ self._P
|
||||
return float(self._x[0])
|
||||
|
||||
@property
|
||||
def bearing_rate_dps(self) -> float:
|
||||
"""Current bearing rate estimate (degrees/second at nominal 10 Hz)."""
|
||||
return float(self._x[1]) * 10.0 # per-frame rate → per-second
|
||||
|
||||
|
||||
# ── Shared ranging state (thread-safe) ────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class _AnchorState:
|
||||
distance_m: float = 0.0
|
||||
timestamp: float = 0.0
|
||||
valid: bool = False
|
||||
|
||||
|
||||
class UwbRangingState:
|
||||
"""
|
||||
Thread-safe store for the most recent ranging measurement from each anchor.
|
||||
|
||||
Updated by the serial reader threads, consumed by the ROS2 publish timer.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
baseline_m: float = 0.25,
|
||||
stale_timeout: float = _STALE_S,
|
||||
) -> None:
|
||||
self.baseline_m = baseline_m
|
||||
self.stale_timeout = stale_timeout
|
||||
self._lock = threading.Lock()
|
||||
self._anchors = [_AnchorState(), _AnchorState()]
|
||||
self._kalman = BearingKalman()
|
||||
|
||||
def update_anchor(self, anchor_id: int, distance_m: float) -> None:
|
||||
"""Record a new ranging measurement from anchor anchor_id (0 or 1)."""
|
||||
if anchor_id not in (0, 1):
|
||||
return
|
||||
with self._lock:
|
||||
s = self._anchors[anchor_id]
|
||||
s.distance_m = distance_m
|
||||
s.timestamp = time.monotonic()
|
||||
s.valid = True
|
||||
|
||||
def compute(self) -> 'UwbResult':
|
||||
"""
|
||||
Derive bearing, distance, confidence, and fix quality from latest ranges.
|
||||
|
||||
Called at the publish rate (≥10 Hz). Applies Kalman smoothing to bearing.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
a0 = self._anchors[0]
|
||||
a1 = self._anchors[1]
|
||||
v0 = a0.valid and (now - a0.timestamp) < self.stale_timeout
|
||||
v1 = a1.valid and (now - a1.timestamp) < self.stale_timeout
|
||||
d0 = a0.distance_m
|
||||
d1 = a1.distance_m
|
||||
|
||||
if not v0 and not v1:
|
||||
return UwbResult(valid=False)
|
||||
|
||||
if v0 and v1:
|
||||
bearing_raw, conf = bearing_from_ranges(d0, d1, self.baseline_m)
|
||||
fix_quality = FIX_DUAL
|
||||
distance_m = (d0 + d1) / 2.0
|
||||
elif v0:
|
||||
bearing_raw, conf = bearing_single_anchor(d0, self.baseline_m)
|
||||
fix_quality = FIX_SINGLE
|
||||
distance_m = d0
|
||||
d1 = 0.0
|
||||
else:
|
||||
bearing_raw, conf = bearing_single_anchor(d1, self.baseline_m)
|
||||
fix_quality = FIX_SINGLE
|
||||
distance_m = d1
|
||||
d0 = 0.0
|
||||
|
||||
bearing_smooth = self._kalman.update(bearing_raw)
|
||||
|
||||
return UwbResult(
|
||||
valid = True,
|
||||
bearing_deg = bearing_smooth,
|
||||
distance_m = distance_m,
|
||||
confidence = conf,
|
||||
anchor0_dist = d0,
|
||||
anchor1_dist = d1,
|
||||
baseline_m = self.baseline_m,
|
||||
fix_quality = fix_quality,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UwbResult:
|
||||
"""Computed UWB fix — mirrors UwbTarget.msg fields."""
|
||||
valid: bool = False
|
||||
bearing_deg: float = 0.0
|
||||
distance_m: float = 0.0
|
||||
confidence: float = 0.0
|
||||
anchor0_dist: float = 0.0
|
||||
anchor1_dist: float = 0.0
|
||||
baseline_m: float = 0.25
|
||||
fix_quality: int = FIX_NONE
|
||||
|
||||
|
||||
# ── Serial reader (runs in background thread) ──────────────────────────────────
|
||||
|
||||
class AnchorSerialReader:
|
||||
"""
|
||||
Background thread that reads from one anchor's serial port and calls
|
||||
state.update_anchor() on each valid RANGE frame.
|
||||
|
||||
Designed to be used with a real serial.Serial object in production, or
|
||||
any file-like object with a readline() method for testing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
anchor_id: int,
|
||||
port, # serial.Serial or file-like (readline() interface)
|
||||
state: UwbRangingState,
|
||||
logger=None,
|
||||
) -> None:
|
||||
self._anchor_id = anchor_id
|
||||
self._port = port
|
||||
self._state = state
|
||||
self._log = logger
|
||||
self._running = False
|
||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||
|
||||
def start(self) -> None:
|
||||
self._running = True
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
def _run(self) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
raw = self._port.readline()
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode('ascii', errors='replace')
|
||||
frame = parse_frame(raw)
|
||||
if frame is not None:
|
||||
self._state.update_anchor(frame.anchor_id, frame.distance_m)
|
||||
except Exception as e:
|
||||
if self._log:
|
||||
self._log.warn(f'UWB anchor {self._anchor_id} read error: {e}')
|
||||
time.sleep(0.05)
|
||||
@ -1,194 +0,0 @@
|
||||
"""
|
||||
_velocity_ramp.py — Acceleration/deceleration limiter for cmd_vel (Issue #350).
|
||||
|
||||
Pure-Python library (no ROS2 dependencies) for full unit-test coverage.
|
||||
|
||||
Behaviour
|
||||
---------
|
||||
The VelocityRamp class applies independent rate limits to the linear-x and
|
||||
angular-z components of a 2D velocity command:
|
||||
|
||||
- Linear acceleration limit : max_lin_accel (m/s²)
|
||||
- Linear deceleration limit : max_lin_decel (m/s²) — may differ from accel
|
||||
- Angular acceleration limit : max_ang_accel (rad/s²)
|
||||
- Angular deceleration limit : max_ang_decel (rad/s²)
|
||||
|
||||
"Deceleration" applies when the magnitude of the velocity is *decreasing*
|
||||
(including moving toward zero from either sign direction), while "acceleration"
|
||||
applies when the magnitude is increasing.
|
||||
|
||||
Emergency stop
|
||||
--------------
|
||||
When both linear and angular targets are exactly 0.0 the ramp is bypassed and
|
||||
the output is forced to (0.0, 0.0) immediately. This allows a watchdog or
|
||||
safety node to halt the robot instantly without waiting for the deceleration
|
||||
ramp.
|
||||
|
||||
Usage
|
||||
-----
|
||||
ramp = VelocityRamp(dt=0.02) # 50 Hz
|
||||
out_lin, out_ang = ramp.step(1.0, 0.0) # target linear=1 m/s, angular=0
|
||||
out_lin, out_ang = ramp.step(1.0, 0.0) # ramp climbs toward 1.0 m/s
|
||||
out_lin, out_ang = ramp.step(0.0, 0.0) # emergency stop → (0.0, 0.0)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class RampParams:
|
||||
"""Acceleration / deceleration limits for one velocity axis."""
|
||||
max_accel: float # magnitude / second — applied when |v| increasing
|
||||
max_decel: float # magnitude / second — applied when |v| decreasing
|
||||
|
||||
|
||||
def _ramp_axis(
|
||||
current: float,
|
||||
target: float,
|
||||
params: RampParams,
|
||||
dt: float,
|
||||
) -> float:
|
||||
"""
|
||||
Advance *current* one timestep toward *target* with rate limiting.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
current : current velocity on this axis
|
||||
target : desired velocity on this axis
|
||||
params : accel / decel limits
|
||||
dt : timestep in seconds
|
||||
|
||||
Returns
|
||||
-------
|
||||
New velocity, clamped so the change per step never exceeds the limits.
|
||||
|
||||
Notes on accel vs decel selection
|
||||
----------------------------------
|
||||
We treat the motion as decelerating when the target is closer to zero
|
||||
than the current value, i.e. |target| < |current| or they have opposite
|
||||
signs. In all other cases we use the acceleration limit.
|
||||
"""
|
||||
delta = target - current
|
||||
|
||||
# Choose limit: decel if velocity magnitude is falling, else accel.
|
||||
is_decelerating = (
|
||||
abs(target) < abs(current)
|
||||
or (current > 0 and target < 0)
|
||||
or (current < 0 and target > 0)
|
||||
)
|
||||
limit = params.max_decel if is_decelerating else params.max_accel
|
||||
|
||||
max_change = limit * dt
|
||||
if abs(delta) <= max_change:
|
||||
return target
|
||||
return current + max_change * (1.0 if delta > 0 else -1.0)
|
||||
|
||||
|
||||
class VelocityRamp:
|
||||
"""
|
||||
Smooths a stream of (linear_x, angular_z) velocity commands by applying
|
||||
configurable acceleration and deceleration limits.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dt : timestep in seconds (must match the control loop rate)
|
||||
max_lin_accel : maximum linear acceleration (m/s²)
|
||||
max_lin_decel : maximum linear deceleration (m/s²); defaults to max_lin_accel
|
||||
max_ang_accel : maximum angular acceleration (rad/s²)
|
||||
max_ang_decel : maximum angular deceleration (rad/s²); defaults to max_ang_accel
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dt: float = 0.02, # 50 Hz
|
||||
max_lin_accel: float = 0.5, # m/s²
|
||||
max_lin_decel: float | None = None,
|
||||
max_ang_accel: float = 1.0, # rad/s²
|
||||
max_ang_decel: float | None = None,
|
||||
) -> None:
|
||||
if dt <= 0:
|
||||
raise ValueError(f'dt must be positive, got {dt}')
|
||||
if max_lin_accel <= 0:
|
||||
raise ValueError(f'max_lin_accel must be positive, got {max_lin_accel}')
|
||||
if max_ang_accel <= 0:
|
||||
raise ValueError(f'max_ang_accel must be positive, got {max_ang_accel}')
|
||||
|
||||
self._dt = dt
|
||||
self._lin = RampParams(
|
||||
max_accel=max_lin_accel,
|
||||
max_decel=max_lin_decel if max_lin_decel is not None else max_lin_accel,
|
||||
)
|
||||
self._ang = RampParams(
|
||||
max_accel=max_ang_accel,
|
||||
max_decel=max_ang_decel if max_ang_decel is not None else max_ang_accel,
|
||||
)
|
||||
|
||||
self._cur_lin: float = 0.0
|
||||
self._cur_ang: float = 0.0
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def step(self, target_lin: float, target_ang: float) -> tuple[float, float]:
|
||||
"""
|
||||
Advance the ramp one timestep.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
target_lin : desired linear velocity (m/s)
|
||||
target_ang : desired angular velocity (rad/s)
|
||||
|
||||
Returns
|
||||
-------
|
||||
(smoothed_linear, smoothed_angular) tuple.
|
||||
|
||||
If both target_lin and target_ang are exactly 0.0, an emergency stop
|
||||
is applied and (0.0, 0.0) is returned immediately.
|
||||
"""
|
||||
# Emergency stop: bypass ramp entirely
|
||||
if target_lin == 0.0 and target_ang == 0.0:
|
||||
self._cur_lin = 0.0
|
||||
self._cur_ang = 0.0
|
||||
return 0.0, 0.0
|
||||
|
||||
self._cur_lin = _ramp_axis(self._cur_lin, target_lin, self._lin, self._dt)
|
||||
self._cur_ang = _ramp_axis(self._cur_ang, target_ang, self._ang, self._dt)
|
||||
return self._cur_lin, self._cur_ang
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset internal state to zero (e.g. on node restart or re-enable)."""
|
||||
self._cur_lin = 0.0
|
||||
self._cur_ang = 0.0
|
||||
|
||||
@property
|
||||
def current_linear(self) -> float:
|
||||
"""Current smoothed linear velocity (m/s)."""
|
||||
return self._cur_lin
|
||||
|
||||
@property
|
||||
def current_angular(self) -> float:
|
||||
"""Current smoothed angular velocity (rad/s)."""
|
||||
return self._cur_ang
|
||||
|
||||
@property
|
||||
def dt(self) -> float:
|
||||
return self._dt
|
||||
|
||||
# ── Derived ───────────────────────────────────────────────────────────────
|
||||
|
||||
def steps_to_reach(self, target_lin: float, target_ang: float) -> int:
|
||||
"""
|
||||
Estimate how many steps() are needed to reach the target from the
|
||||
current state (useful for test assertions).
|
||||
|
||||
This is an approximation based on the worst-case axis; it does not
|
||||
account for the emergency-stop shortcut.
|
||||
"""
|
||||
lin_steps = 0
|
||||
ang_steps = 0
|
||||
if self._lin.max_accel > 0:
|
||||
lin_steps = int(abs(target_lin - self._cur_lin) / (self._lin.max_accel * self._dt)) + 1
|
||||
if self._ang.max_accel > 0:
|
||||
ang_steps = int(abs(target_ang - self._cur_ang) / (self._ang.max_accel * self._dt)) + 1
|
||||
return max(lin_steps, ang_steps)
|
||||
@ -1,215 +0,0 @@
|
||||
"""
|
||||
camera_power_node.py — Adaptive camera power mode manager (Issue #375).
|
||||
|
||||
Subscribes to speed, scenario, and battery level inputs, runs the
|
||||
CameraPowerFSM, and publishes camera enable/disable commands at 2 Hz.
|
||||
|
||||
Subscribes
|
||||
----------
|
||||
/saltybot/speed std_msgs/Float32 robot speed in m/s
|
||||
/saltybot/scenario std_msgs/String operating scenario label
|
||||
/saltybot/battery_pct std_msgs/Float32 battery charge level 0–100 %
|
||||
|
||||
Publishes
|
||||
---------
|
||||
/saltybot/camera_mode saltybot_scene_msgs/CameraPowerMode (2 Hz)
|
||||
/saltybot/camera_cmd/front std_msgs/Bool — front CSI enable
|
||||
/saltybot/camera_cmd/rear std_msgs/Bool — rear CSI enable
|
||||
/saltybot/camera_cmd/left std_msgs/Bool — left CSI enable
|
||||
/saltybot/camera_cmd/right std_msgs/Bool — right CSI enable
|
||||
/saltybot/camera_cmd/realsense std_msgs/Bool — D435i enable
|
||||
/saltybot/camera_cmd/lidar std_msgs/Bool — LIDAR enable
|
||||
/saltybot/camera_cmd/uwb std_msgs/Bool — UWB enable
|
||||
/saltybot/camera_cmd/webcam std_msgs/Bool — C920 webcam enable
|
||||
|
||||
Parameters
|
||||
----------
|
||||
rate_hz float 2.0 FSM evaluation and publish rate
|
||||
downgrade_hold_s float 5.0 Seconds before a downgrade is applied
|
||||
idle_to_social_s float 30.0 Idle time before AWARE→SOCIAL
|
||||
battery_low_pct float 20.0 Battery threshold for AWARE cap
|
||||
initial_mode int 0 Starting mode (0=SLEEP … 4=FULL)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy, DurabilityPolicy
|
||||
|
||||
from std_msgs.msg import Bool, Float32, String
|
||||
from saltybot_scene_msgs.msg import CameraPowerMode
|
||||
|
||||
from ._camera_power_manager import (
|
||||
CameraMode,
|
||||
CameraPowerFSM,
|
||||
Scenario,
|
||||
MODE_SENSORS,
|
||||
)
|
||||
|
||||
|
||||
_RELIABLE_LATCHED = QoSProfile(
|
||||
reliability=ReliabilityPolicy.RELIABLE,
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=1,
|
||||
durability=DurabilityPolicy.TRANSIENT_LOCAL,
|
||||
)
|
||||
_RELIABLE = QoSProfile(
|
||||
reliability=ReliabilityPolicy.RELIABLE,
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=10,
|
||||
)
|
||||
_SENSOR_QOS = QoSProfile(
|
||||
reliability=ReliabilityPolicy.BEST_EFFORT,
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=5,
|
||||
)
|
||||
|
||||
# Camera command topic suffixes and their ActiveSensors field name
|
||||
_CAM_TOPICS = [
|
||||
('front', 'csi_front'),
|
||||
('rear', 'csi_rear'),
|
||||
('left', 'csi_left'),
|
||||
('right', 'csi_right'),
|
||||
('realsense', 'realsense'),
|
||||
('lidar', 'lidar'),
|
||||
('uwb', 'uwb'),
|
||||
('webcam', 'webcam'),
|
||||
]
|
||||
|
||||
|
||||
class CameraPowerNode(Node):
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__('camera_power_node')
|
||||
|
||||
# ── Parameters ──────────────────────────────────────────────────────
|
||||
self.declare_parameter('rate_hz', 2.0)
|
||||
self.declare_parameter('downgrade_hold_s', 5.0)
|
||||
self.declare_parameter('idle_to_social_s', 30.0)
|
||||
self.declare_parameter('battery_low_pct', 20.0)
|
||||
self.declare_parameter('initial_mode', 0)
|
||||
|
||||
p = self.get_parameter
|
||||
rate_hz = max(float(p('rate_hz').value), 0.1)
|
||||
downgrade_hold_s = max(float(p('downgrade_hold_s').value), 0.0)
|
||||
idle_to_social_s = max(float(p('idle_to_social_s').value), 0.0)
|
||||
battery_low_pct = float(p('battery_low_pct').value)
|
||||
initial_mode = int(p('initial_mode').value)
|
||||
|
||||
# ── FSM ──────────────────────────────────────────────────────────────
|
||||
self._fsm = CameraPowerFSM(
|
||||
downgrade_hold_s = downgrade_hold_s,
|
||||
idle_to_social_s = idle_to_social_s,
|
||||
battery_low_pct = battery_low_pct,
|
||||
)
|
||||
try:
|
||||
self._fsm.reset(CameraMode(initial_mode))
|
||||
except ValueError:
|
||||
self._fsm.reset(CameraMode.SLEEP)
|
||||
|
||||
# ── Input state ──────────────────────────────────────────────────────
|
||||
self._speed_mps: float = 0.0
|
||||
self._scenario: str = Scenario.UNKNOWN
|
||||
self._battery_pct: float = 100.0
|
||||
|
||||
# ── Subscribers ──────────────────────────────────────────────────────
|
||||
self._speed_sub = self.create_subscription(
|
||||
Float32, '/saltybot/speed',
|
||||
lambda m: setattr(self, '_speed_mps', max(0.0, float(m.data))),
|
||||
_SENSOR_QOS,
|
||||
)
|
||||
self._scenario_sub = self.create_subscription(
|
||||
String, '/saltybot/scenario',
|
||||
lambda m: setattr(self, '_scenario', str(m.data)),
|
||||
_RELIABLE,
|
||||
)
|
||||
self._battery_sub = self.create_subscription(
|
||||
Float32, '/saltybot/battery_pct',
|
||||
lambda m: setattr(self, '_battery_pct',
|
||||
max(0.0, min(100.0, float(m.data)))),
|
||||
_RELIABLE,
|
||||
)
|
||||
|
||||
# ── Publishers ───────────────────────────────────────────────────────
|
||||
self._mode_pub = self.create_publisher(
|
||||
CameraPowerMode, '/saltybot/camera_mode', _RELIABLE_LATCHED,
|
||||
)
|
||||
self._cam_pubs: dict[str, rclpy.publisher.Publisher] = {}
|
||||
for suffix, _ in _CAM_TOPICS:
|
||||
self._cam_pubs[suffix] = self.create_publisher(
|
||||
Bool, f'/saltybot/camera_cmd/{suffix}', _RELIABLE_LATCHED,
|
||||
)
|
||||
|
||||
# ── Timer ────────────────────────────────────────────────────────────
|
||||
self._timer = self.create_timer(1.0 / rate_hz, self._step)
|
||||
self._prev_mode: CameraMode | None = None
|
||||
|
||||
self.get_logger().info(
|
||||
f'camera_power_node ready — '
|
||||
f'rate={rate_hz:.1f}Hz, '
|
||||
f'downgrade_hold={downgrade_hold_s}s, '
|
||||
f'idle_to_social={idle_to_social_s}s, '
|
||||
f'battery_low={battery_low_pct}%'
|
||||
)
|
||||
|
||||
# ── Step callback ─────────────────────────────────────────────────────────
|
||||
|
||||
def _step(self) -> None:
|
||||
decision = self._fsm.update(
|
||||
speed_mps = self._speed_mps,
|
||||
scenario = self._scenario,
|
||||
battery_pct = self._battery_pct,
|
||||
)
|
||||
|
||||
# Log transitions
|
||||
if decision.mode != self._prev_mode:
|
||||
prev_name = self._prev_mode.label if self._prev_mode else 'INIT'
|
||||
self.get_logger().info(
|
||||
f'Camera mode: {prev_name} → {decision.mode.label} '
|
||||
f'(speed={self._speed_mps:.2f}m/s '
|
||||
f'scenario={self._scenario} '
|
||||
f'battery={self._battery_pct:.0f}%)'
|
||||
)
|
||||
self._prev_mode = decision.mode
|
||||
|
||||
# Publish CameraPowerMode message
|
||||
msg = CameraPowerMode()
|
||||
msg.header.stamp = self.get_clock().now().to_msg()
|
||||
msg.header.frame_id = ''
|
||||
msg.mode = int(decision.mode)
|
||||
msg.mode_name = decision.mode.label
|
||||
s = decision.sensors
|
||||
msg.csi_front = s.csi_front
|
||||
msg.csi_rear = s.csi_rear
|
||||
msg.csi_left = s.csi_left
|
||||
msg.csi_right = s.csi_right
|
||||
msg.realsense = s.realsense
|
||||
msg.lidar = s.lidar
|
||||
msg.uwb = s.uwb
|
||||
msg.webcam = s.webcam
|
||||
msg.trigger_speed_mps = float(decision.trigger_speed_mps)
|
||||
msg.trigger_scenario = decision.trigger_scenario
|
||||
msg.scenario_override = decision.scenario_override
|
||||
self._mode_pub.publish(msg)
|
||||
|
||||
# Publish per-camera Bool commands
|
||||
for suffix, field in _CAM_TOPICS:
|
||||
enabled = bool(getattr(s, field))
|
||||
b = Bool()
|
||||
b.data = enabled
|
||||
self._cam_pubs[suffix].publish(b)
|
||||
|
||||
|
||||
def main(args=None) -> None:
|
||||
rclpy.init(args=args)
|
||||
node = CameraPowerNode()
|
||||
try:
|
||||
rclpy.spin(node)
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -1,170 +0,0 @@
|
||||
"""
|
||||
uwb_node.py — UWB DW3000 anchor/tag ranging node (Issue #365).
|
||||
|
||||
Reads TWR distances from two ESP32-UWB-Pro anchors via USB serial and publishes
|
||||
a fused bearing + distance estimate for the follow-me controller.
|
||||
|
||||
Hardware
|
||||
--------
|
||||
anchor0 (left) : USB serial, default /dev/ttyUSB0
|
||||
anchor1 (right) : USB serial, default /dev/ttyUSB1
|
||||
Baseline : ~25 cm (configurable)
|
||||
|
||||
Publishes
|
||||
---------
|
||||
/saltybot/uwb_target saltybot_scene_msgs/UwbTarget (10 Hz)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
port_anchor0 str '/dev/ttyUSB0' Serial device for left anchor
|
||||
port_anchor1 str '/dev/ttyUSB1' Serial device for right anchor
|
||||
baud_rate int 115200 Serial baud rate
|
||||
baseline_m float 0.25 Anchor separation (m)
|
||||
publish_rate_hz float 10.0 Output publish rate
|
||||
stale_timeout_s float 0.5 Age beyond which a range is discarded
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
|
||||
|
||||
from std_msgs.msg import Header
|
||||
from saltybot_scene_msgs.msg import UwbTarget
|
||||
|
||||
from ._uwb_tracker import (
|
||||
AnchorSerialReader,
|
||||
UwbRangingState,
|
||||
FIX_NONE, FIX_SINGLE, FIX_DUAL,
|
||||
)
|
||||
|
||||
|
||||
_PUB_QOS = QoSProfile(
|
||||
reliability=ReliabilityPolicy.RELIABLE,
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=10,
|
||||
)
|
||||
|
||||
|
||||
class UwbNode(Node):
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__('uwb_node')
|
||||
|
||||
# ── Parameters ──────────────────────────────────────────────────────
|
||||
self.declare_parameter('port_anchor0', '/dev/ttyUSB0')
|
||||
self.declare_parameter('port_anchor1', '/dev/ttyUSB1')
|
||||
self.declare_parameter('baud_rate', 115200)
|
||||
self.declare_parameter('baseline_m', 0.25)
|
||||
self.declare_parameter('publish_rate_hz', 10.0)
|
||||
self.declare_parameter('stale_timeout_s', 0.5)
|
||||
|
||||
p = self.get_parameter
|
||||
|
||||
self._port0 = str(p('port_anchor0').value)
|
||||
self._port1 = str(p('port_anchor1').value)
|
||||
self._baud = int(p('baud_rate').value)
|
||||
baseline = float(p('baseline_m').value)
|
||||
rate_hz = float(p('publish_rate_hz').value)
|
||||
stale_s = float(p('stale_timeout_s').value)
|
||||
|
||||
# ── State + readers ──────────────────────────────────────────────────
|
||||
self._state = UwbRangingState(
|
||||
baseline_m=baseline,
|
||||
stale_timeout=stale_s,
|
||||
)
|
||||
|
||||
self._readers: list[AnchorSerialReader] = []
|
||||
self._open_serial_readers()
|
||||
|
||||
# ── Publisher + timer ────────────────────────────────────────────────
|
||||
self._pub = self.create_publisher(UwbTarget, '/saltybot/uwb_target', _PUB_QOS)
|
||||
self._timer = self.create_timer(1.0 / max(rate_hz, 1.0), self._publish)
|
||||
|
||||
self.get_logger().info(
|
||||
f'uwb_node ready — baseline={baseline:.3f}m, '
|
||||
f'rate={rate_hz:.0f}Hz, '
|
||||
f'ports=[{self._port0}, {self._port1}]'
|
||||
)
|
||||
|
||||
# ── Serial open ───────────────────────────────────────────────────────────
|
||||
|
||||
def _open_serial_readers(self) -> None:
|
||||
"""
|
||||
Attempt to open both serial ports. Failures are non-fatal — the node
|
||||
will publish FIX_NONE until a port becomes available (e.g. anchor
|
||||
hardware plugged in after startup).
|
||||
"""
|
||||
for anchor_id, port_name in enumerate([self._port0, self._port1]):
|
||||
port = self._try_open_port(anchor_id, port_name)
|
||||
if port is not None:
|
||||
reader = AnchorSerialReader(
|
||||
anchor_id=anchor_id,
|
||||
port=port,
|
||||
state=self._state,
|
||||
logger=self.get_logger(),
|
||||
)
|
||||
reader.start()
|
||||
self._readers.append(reader)
|
||||
self.get_logger().info(f'Anchor {anchor_id} opened on {port_name}')
|
||||
else:
|
||||
self.get_logger().warn(
|
||||
f'Anchor {anchor_id} port {port_name} unavailable — '
|
||||
f'will publish FIX_NONE until connected'
|
||||
)
|
||||
|
||||
def _try_open_port(self, anchor_id: int, port_name: str):
|
||||
"""Open serial port; return None on failure."""
|
||||
try:
|
||||
import serial
|
||||
return serial.Serial(port_name, baudrate=self._baud, timeout=0.1)
|
||||
except Exception as e:
|
||||
self.get_logger().warn(
|
||||
f'Cannot open anchor {anchor_id} serial port {port_name}: {e}'
|
||||
)
|
||||
return None
|
||||
|
||||
# ── Publish callback ──────────────────────────────────────────────────────
|
||||
|
||||
def _publish(self) -> None:
|
||||
result = self._state.compute()
|
||||
msg = UwbTarget()
|
||||
msg.header.stamp = self.get_clock().now().to_msg()
|
||||
msg.header.frame_id = 'base_link'
|
||||
|
||||
msg.valid = result.valid
|
||||
msg.bearing_deg = float(result.bearing_deg)
|
||||
msg.distance_m = float(result.distance_m)
|
||||
msg.confidence = float(result.confidence)
|
||||
msg.anchor0_dist_m = float(result.anchor0_dist)
|
||||
msg.anchor1_dist_m = float(result.anchor1_dist)
|
||||
msg.baseline_m = float(result.baseline_m)
|
||||
msg.fix_quality = int(result.fix_quality)
|
||||
|
||||
self._pub.publish(msg)
|
||||
|
||||
# ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
|
||||
def destroy_node(self) -> None:
|
||||
for r in self._readers:
|
||||
r.stop()
|
||||
super().destroy_node()
|
||||
|
||||
|
||||
def main(args=None) -> None:
|
||||
rclpy.init(args=args)
|
||||
node = UwbNode()
|
||||
try:
|
||||
rclpy.spin(node)
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -1,125 +0,0 @@
|
||||
"""
|
||||
velocity_ramp_node.py — Smooth velocity ramp controller (Issue #350).
|
||||
|
||||
Subscribes to raw /cmd_vel commands and republishes them on /cmd_vel_smooth
|
||||
after applying independent acceleration and deceleration limits to the linear-x
|
||||
and angular-z components.
|
||||
|
||||
An emergency stop (both linear and angular targets exactly 0.0) bypasses the
|
||||
ramp and forces the output to zero immediately.
|
||||
|
||||
Subscribes
|
||||
----------
|
||||
/cmd_vel geometry_msgs/Twist raw velocity commands
|
||||
|
||||
Publishes
|
||||
---------
|
||||
/cmd_vel_smooth geometry_msgs/Twist rate-limited velocity commands
|
||||
|
||||
Parameters
|
||||
----------
|
||||
max_lin_accel float 0.5 Maximum linear acceleration (m/s²)
|
||||
max_lin_decel float 0.5 Maximum linear deceleration (m/s²)
|
||||
max_ang_accel float 1.0 Maximum angular acceleration (rad/s²)
|
||||
max_ang_decel float 1.0 Maximum angular deceleration (rad/s²)
|
||||
rate_hz float 50.0 Control loop rate (Hz)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
|
||||
|
||||
from geometry_msgs.msg import Twist
|
||||
|
||||
from ._velocity_ramp import VelocityRamp
|
||||
|
||||
|
||||
_SUB_QOS = QoSProfile(
|
||||
reliability=ReliabilityPolicy.RELIABLE,
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=10,
|
||||
)
|
||||
_PUB_QOS = QoSProfile(
|
||||
reliability=ReliabilityPolicy.RELIABLE,
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=10,
|
||||
)
|
||||
|
||||
|
||||
class VelocityRampNode(Node):
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__('velocity_ramp_node')
|
||||
|
||||
# ── Parameters ──────────────────────────────────────────────────────
|
||||
self.declare_parameter('max_lin_accel', 0.5)
|
||||
self.declare_parameter('max_lin_decel', 0.5)
|
||||
self.declare_parameter('max_ang_accel', 1.0)
|
||||
self.declare_parameter('max_ang_decel', 1.0)
|
||||
self.declare_parameter('rate_hz', 50.0)
|
||||
|
||||
p = self.get_parameter
|
||||
rate_hz = max(float(p('rate_hz').value), 1.0)
|
||||
max_lin_acc = max(float(p('max_lin_accel').value), 1e-3)
|
||||
max_lin_dec = max(float(p('max_lin_decel').value), 1e-3)
|
||||
max_ang_acc = max(float(p('max_ang_accel').value), 1e-3)
|
||||
max_ang_dec = max(float(p('max_ang_decel').value), 1e-3)
|
||||
|
||||
dt = 1.0 / rate_hz
|
||||
|
||||
# ── Ramp state ───────────────────────────────────────────────────────
|
||||
self._ramp = VelocityRamp(
|
||||
dt = dt,
|
||||
max_lin_accel = max_lin_acc,
|
||||
max_lin_decel = max_lin_dec,
|
||||
max_ang_accel = max_ang_acc,
|
||||
max_ang_decel = max_ang_dec,
|
||||
)
|
||||
self._target_lin: float = 0.0
|
||||
self._target_ang: float = 0.0
|
||||
|
||||
# ── Subscriber ───────────────────────────────────────────────────────
|
||||
self._sub = self.create_subscription(
|
||||
Twist, '/cmd_vel', self._on_cmd_vel, _SUB_QOS,
|
||||
)
|
||||
|
||||
# ── Publisher + timer ────────────────────────────────────────────────
|
||||
self._pub = self.create_publisher(Twist, '/cmd_vel_smooth', _PUB_QOS)
|
||||
self._timer = self.create_timer(dt, self._step)
|
||||
|
||||
self.get_logger().info(
|
||||
f'velocity_ramp_node ready — '
|
||||
f'rate={rate_hz:.0f}Hz '
|
||||
f'lin_accel={max_lin_acc}m/s² lin_decel={max_lin_dec}m/s² '
|
||||
f'ang_accel={max_ang_acc}rad/s² ang_decel={max_ang_dec}rad/s²'
|
||||
)
|
||||
|
||||
# ── Callbacks ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_cmd_vel(self, msg: Twist) -> None:
|
||||
self._target_lin = float(msg.linear.x)
|
||||
self._target_ang = float(msg.angular.z)
|
||||
|
||||
def _step(self) -> None:
|
||||
lin, ang = self._ramp.step(self._target_lin, self._target_ang)
|
||||
|
||||
out = Twist()
|
||||
out.linear.x = lin
|
||||
out.angular.z = ang
|
||||
self._pub.publish(out)
|
||||
|
||||
|
||||
def main(args=None) -> None:
|
||||
rclpy.init(args=args)
|
||||
node = VelocityRampNode()
|
||||
try:
|
||||
rclpy.spin(node)
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -1,91 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Chromium kiosk launcher for MageDok 7" display via Cage Wayland compositor
|
||||
# Lightweight fullscreen web app display (SaltyFace web UI)
|
||||
# Replaces GNOME desktop environment (~650MB RAM savings)
|
||||
#
|
||||
# Usage:
|
||||
# chromium_kiosk.sh [--url URL] [--debug]
|
||||
#
|
||||
# Environment:
|
||||
# SALTYBOT_KIOSK_URL Default URL if not specified (localhost:3000)
|
||||
# DISPLAY Not used (Wayland native)
|
||||
# XDG_RUNTIME_DIR Must be set for Wayland
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOG_FILE="${SCRIPT_DIR}/../../logs/chromium_kiosk.log"
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
|
||||
# Logging
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Configuration
|
||||
KIOSK_URL="${SALTYBOT_KIOSK_URL:-http://localhost:3000}"
|
||||
DEBUG_MODE=false
|
||||
CAGE_CONFIG="/opt/saltybot/config/cage-magedok.ini"
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--url)
|
||||
KIOSK_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--debug)
|
||||
DEBUG_MODE=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
log "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Setup environment
|
||||
export WAYLAND_DISPLAY=wayland-0
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
export XDG_SESSION_TYPE=wayland
|
||||
export QT_QPA_PLATFORM=wayland
|
||||
|
||||
# Ensure Wayland runtime directory exists
|
||||
mkdir -p "$XDG_RUNTIME_DIR"
|
||||
chmod 700 "$XDG_RUNTIME_DIR"
|
||||
|
||||
log "Starting Chromium kiosk on Cage Wayland compositor"
|
||||
log "URL: $KIOSK_URL"
|
||||
|
||||
# Chromium kiosk flags
|
||||
CHROMIUM_FLAGS=(
|
||||
--kiosk # Fullscreen kiosk mode (no UI chrome)
|
||||
--disable-session-crashed-bubble # No crash recovery UI
|
||||
--disable-infobars # No info bars
|
||||
--no-first-run # Skip first-run wizard
|
||||
--no-default-browser-check # Skip browser check
|
||||
--disable-sync # Disable Google Sync
|
||||
--disable-translate # Disable translate prompts
|
||||
--disable-plugins-power-saver # Don't power-save plugins
|
||||
--autoplay-policy=user-gesture-required
|
||||
--app="$KIOSK_URL" # Run as web app in fullscreen
|
||||
)
|
||||
|
||||
# Optional debug flags
|
||||
if $DEBUG_MODE; then
|
||||
CHROMIUM_FLAGS+=(
|
||||
--enable-logging=stderr
|
||||
--log-level=0
|
||||
)
|
||||
fi
|
||||
|
||||
# Launch Cage with Chromium as client
|
||||
log "Launching Cage with Chromium..."
|
||||
if [ -f "$CAGE_CONFIG" ]; then
|
||||
log "Using Cage config: $CAGE_CONFIG"
|
||||
exec cage -s chromium "${CHROMIUM_FLAGS[@]}" 2>&1 | tee -a "$LOG_FILE"
|
||||
else
|
||||
log "Cage config not found, using defaults: $CAGE_CONFIG"
|
||||
exec cage -s chromium "${CHROMIUM_FLAGS[@]}" 2>&1 | tee -a "$LOG_FILE"
|
||||
fi
|
||||
@ -63,10 +63,6 @@ setup(
|
||||
'face_emotion = saltybot_bringup.face_emotion_node:main',
|
||||
# Person tracking for follow-me mode (Issue #363)
|
||||
'person_tracking = saltybot_bringup.person_tracking_node:main',
|
||||
# UWB DW3000 anchor/tag ranging (Issue #365)
|
||||
'uwb_node = saltybot_bringup.uwb_node:main',
|
||||
# Smooth velocity ramp controller (Issue #350)
|
||||
'velocity_ramp = saltybot_bringup.velocity_ramp_node:main',
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
[Unit]
|
||||
Description=Chromium Fullscreen Kiosk (Cage + MageDok 7" display)
|
||||
Documentation=https://github.com/saltytech/saltylab-firmware/wiki/Cage-Chromium-Kiosk
|
||||
Documentation=https://github.com/saltytech/saltylab-firmware/issues/374
|
||||
After=network.target display-target.service
|
||||
Before=graphical.target
|
||||
Wants=display-target.service
|
||||
|
||||
# Disable GNOME if running
|
||||
Conflicts=gdm.service gnome-shell.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=orin
|
||||
Group=video
|
||||
|
||||
# Environment
|
||||
Environment="WAYLAND_DISPLAY=wayland-0"
|
||||
Environment="XDG_RUNTIME_DIR=/run/user/1000"
|
||||
Environment="XDG_SESSION_TYPE=wayland"
|
||||
Environment="QT_QPA_PLATFORM=wayland"
|
||||
Environment="SALTYBOT_KIOSK_URL=http://localhost:3000"
|
||||
|
||||
# Pre-start checks
|
||||
ExecStartPre=/usr/bin/install -d /run/user/1000
|
||||
ExecStartPre=/usr/bin/chown orin:orin /run/user/1000
|
||||
ExecStartPre=/usr/bin/chmod 700 /run/user/1000
|
||||
|
||||
# Verify MageDok display is available
|
||||
ExecStartPre=/usr/bin/test -c /dev/magedok-touch || /bin/true
|
||||
|
||||
# Start Chromium kiosk via Cage
|
||||
ExecStart=/opt/saltybot/scripts/chromium_kiosk.sh --url http://localhost:3000
|
||||
|
||||
# Restart on failure
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
# Resource limits (Cage + Chromium is lightweight)
|
||||
MemoryMax=512M
|
||||
CPUQuota=80%
|
||||
CPUAffinity=0 1 2 3
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=chromium-kiosk
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical.target
|
||||
@ -1,42 +0,0 @@
|
||||
[Unit]
|
||||
Description=SaltyFace Web App Server (Node.js)
|
||||
Documentation=https://github.com/saltytech/saltylab-firmware/issues/370
|
||||
After=network.target
|
||||
Before=chromium-kiosk.service
|
||||
Requires=chromium-kiosk.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=orin
|
||||
Group=nogroup
|
||||
WorkingDirectory=/opt/saltybot/app
|
||||
|
||||
# Node.js server
|
||||
ExecStart=/usr/bin/node server.js --port 3000 --host 0.0.0.0
|
||||
|
||||
# Environment
|
||||
Environment="NODE_ENV=production"
|
||||
Environment="NODE_OPTIONS=--max-old-space-size=256"
|
||||
|
||||
# Restart policy
|
||||
Restart=on-failure
|
||||
RestartSec=3s
|
||||
|
||||
# Resource limits
|
||||
MemoryMax=256M
|
||||
CPUQuota=50%
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=salty-face-server
|
||||
|
||||
# Security
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/opt/saltybot/logs
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,575 +0,0 @@
|
||||
"""
|
||||
test_camera_power_manager.py — Unit tests for _camera_power_manager.py (Issue #375).
|
||||
|
||||
Covers:
|
||||
- Mode sensor configurations
|
||||
- Speed-driven upgrade transitions
|
||||
- Speed-driven downgrade with hysteresis
|
||||
- Scenario overrides (CROSSING, EMERGENCY, PARKED, INDOOR)
|
||||
- Battery low cap
|
||||
- Idle → SOCIAL transition
|
||||
- Safety invariants (rear CSI in ACTIVE/FULL, CROSSING cannot downgrade)
|
||||
- Reset
|
||||
- ModeDecision fields
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from saltybot_bringup._camera_power_manager import (
|
||||
ActiveSensors,
|
||||
CameraMode,
|
||||
CameraPowerFSM,
|
||||
MODE_SENSORS,
|
||||
ModeDecision,
|
||||
Scenario,
|
||||
_SPD_ACTIVE_DOWN,
|
||||
_SPD_ACTIVE_UP,
|
||||
_SPD_FULL_DOWN,
|
||||
_SPD_FULL_UP,
|
||||
_SPD_MOTION,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FakeClock:
|
||||
"""Injectable monotonic clock for deterministic tests."""
|
||||
def __init__(self, t: float = 0.0) -> None:
|
||||
self.t = t
|
||||
def __call__(self) -> float:
|
||||
return self.t
|
||||
def advance(self, dt: float) -> None:
|
||||
self.t += dt
|
||||
|
||||
|
||||
def _fsm(hold: float = 5.0, idle: float = 30.0, bat_low: float = 20.0,
|
||||
clock=None) -> CameraPowerFSM:
|
||||
c = clock or _FakeClock()
|
||||
return CameraPowerFSM(
|
||||
downgrade_hold_s=hold,
|
||||
idle_to_social_s=idle,
|
||||
battery_low_pct=bat_low,
|
||||
clock=c,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Mode sensor configurations
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestModeSensors:
|
||||
|
||||
def test_sleep_no_sensors(self):
|
||||
s = MODE_SENSORS[CameraMode.SLEEP]
|
||||
assert s.active_count == 0
|
||||
|
||||
def test_social_webcam_only(self):
|
||||
s = MODE_SENSORS[CameraMode.SOCIAL]
|
||||
assert s.webcam is True
|
||||
assert s.csi_front is False
|
||||
assert s.realsense is False
|
||||
assert s.lidar is False
|
||||
|
||||
def test_aware_front_realsense_lidar(self):
|
||||
s = MODE_SENSORS[CameraMode.AWARE]
|
||||
assert s.csi_front is True
|
||||
assert s.realsense is True
|
||||
assert s.lidar is True
|
||||
assert s.csi_rear is False
|
||||
assert s.csi_left is False
|
||||
assert s.csi_right is False
|
||||
assert s.uwb is False
|
||||
|
||||
def test_active_front_rear_realsense_lidar_uwb(self):
|
||||
s = MODE_SENSORS[CameraMode.ACTIVE]
|
||||
assert s.csi_front is True
|
||||
assert s.csi_rear is True
|
||||
assert s.realsense is True
|
||||
assert s.lidar is True
|
||||
assert s.uwb is True
|
||||
assert s.csi_left is False
|
||||
assert s.csi_right is False
|
||||
|
||||
def test_full_all_sensors(self):
|
||||
s = MODE_SENSORS[CameraMode.FULL]
|
||||
assert s.csi_front is True
|
||||
assert s.csi_rear is True
|
||||
assert s.csi_left is True
|
||||
assert s.csi_right is True
|
||||
assert s.realsense is True
|
||||
assert s.lidar is True
|
||||
assert s.uwb is True
|
||||
assert s.webcam is False # webcam not needed at speed
|
||||
|
||||
def test_mode_sensor_counts_increase(self):
|
||||
counts = [MODE_SENSORS[m].active_count for m in CameraMode]
|
||||
assert counts == sorted(counts), "Higher modes should have more sensors"
|
||||
|
||||
def test_safety_rear_csi_in_active(self):
|
||||
assert MODE_SENSORS[CameraMode.ACTIVE].csi_rear is True
|
||||
|
||||
def test_safety_rear_csi_in_full(self):
|
||||
assert MODE_SENSORS[CameraMode.FULL].csi_rear is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Speed-driven upgrades (instant)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSpeedUpgrades:
|
||||
|
||||
def test_no_motion_stays_social(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=0.0)
|
||||
assert d.mode == CameraMode.SOCIAL
|
||||
|
||||
def test_slow_motion_upgrades_to_aware(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=_SPD_MOTION + 0.1)
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
def test_5kmh_upgrades_to_active(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=_SPD_ACTIVE_UP + 0.1)
|
||||
assert d.mode == CameraMode.ACTIVE
|
||||
|
||||
def test_15kmh_upgrades_to_full(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=_SPD_FULL_UP + 0.1)
|
||||
assert d.mode == CameraMode.FULL
|
||||
|
||||
def test_upgrades_skip_intermediate_modes(self):
|
||||
"""At 20 km/h from rest, jumps directly to FULL."""
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=20.0 / 3.6)
|
||||
assert d.mode == CameraMode.FULL
|
||||
|
||||
def test_upgrade_is_immediate_no_hold(self):
|
||||
"""Upgrades do NOT require hold time."""
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(hold=10.0, clock=clock)
|
||||
# Still at t=0
|
||||
d = f.update(speed_mps=_SPD_FULL_UP + 0.5)
|
||||
assert d.mode == CameraMode.FULL
|
||||
|
||||
def test_exactly_at_motion_threshold(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=_SPD_MOTION)
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
def test_exactly_at_active_threshold(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=_SPD_ACTIVE_UP)
|
||||
assert d.mode == CameraMode.ACTIVE
|
||||
|
||||
def test_exactly_at_full_threshold(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=_SPD_FULL_UP)
|
||||
assert d.mode == CameraMode.FULL
|
||||
|
||||
def test_negative_speed_clamped_to_zero(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=-1.0)
|
||||
assert d.mode == CameraMode.SOCIAL
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Downgrade hysteresis
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDowngradeHysteresis:
|
||||
|
||||
def _reach_full(self, f: CameraPowerFSM, clock: _FakeClock) -> None:
|
||||
f.update(speed_mps=_SPD_FULL_UP + 0.5)
|
||||
assert f.mode == CameraMode.FULL
|
||||
|
||||
def test_downgrade_not_immediate(self):
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(hold=5.0, clock=clock)
|
||||
self._reach_full(f, clock)
|
||||
# Drop to below FULL threshold but don't advance clock
|
||||
d = f.update(speed_mps=0.0)
|
||||
assert d.mode == CameraMode.FULL # still held
|
||||
|
||||
def test_downgrade_after_hold_expires(self):
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(hold=5.0, clock=clock)
|
||||
self._reach_full(f, clock)
|
||||
f.update(speed_mps=0.0) # first low-speed call starts the hold timer
|
||||
clock.advance(5.1)
|
||||
d = f.update(speed_mps=0.0) # hold expired — downgrade to AWARE (not SOCIAL;
|
||||
# SOCIAL requires an additional idle timer from AWARE, see TestIdleToSocial)
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
def test_downgrade_cancelled_by_speed_spike(self):
|
||||
"""If speed spikes back up during hold, downgrade is cancelled."""
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(hold=5.0, clock=clock)
|
||||
self._reach_full(f, clock)
|
||||
clock.advance(3.0)
|
||||
f.update(speed_mps=0.0) # start downgrade timer
|
||||
clock.advance(1.0)
|
||||
f.update(speed_mps=_SPD_FULL_UP + 1.0) # back to full speed
|
||||
clock.advance(3.0) # would have expired hold if not cancelled
|
||||
d = f.update(speed_mps=0.0)
|
||||
# Hold restarted; only 3s elapsed since the cancellation reset
|
||||
assert d.mode == CameraMode.FULL
|
||||
|
||||
def test_full_to_active_hysteresis_band(self):
|
||||
"""Speed in [_SPD_FULL_DOWN, _SPD_FULL_UP) while in FULL → stays FULL (hold pending)."""
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(hold=5.0, clock=clock)
|
||||
self._reach_full(f, clock)
|
||||
# Speed in hysteresis band (between down and up thresholds)
|
||||
mid = (_SPD_FULL_DOWN + _SPD_FULL_UP) / 2.0
|
||||
d = f.update(speed_mps=mid)
|
||||
assert d.mode == CameraMode.FULL # pending downgrade, not yet applied
|
||||
|
||||
def test_hold_zero_downgrades_immediately(self):
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(hold=0.0, clock=clock)
|
||||
f.update(speed_mps=_SPD_FULL_UP + 0.5)
|
||||
# hold=0: downgrade applies on the very first low-speed call.
|
||||
# From FULL at speed=0 → AWARE (SOCIAL requires a separate idle timer).
|
||||
d = f.update(speed_mps=0.0)
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
def test_downgrade_full_to_active_at_13kmh(self):
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(hold=0.0, clock=clock)
|
||||
f.update(speed_mps=_SPD_FULL_UP + 0.5) # → FULL
|
||||
d = f.update(speed_mps=_SPD_FULL_DOWN - 0.05) # just below 13 km/h
|
||||
assert d.mode == CameraMode.ACTIVE
|
||||
|
||||
def test_downgrade_active_to_aware(self):
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(hold=0.0, clock=clock)
|
||||
f.update(speed_mps=_SPD_ACTIVE_UP + 0.5) # → ACTIVE
|
||||
d = f.update(speed_mps=_SPD_ACTIVE_DOWN - 0.05)
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Scenario overrides
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestScenarioOverrides:
|
||||
|
||||
def test_crossing_forces_full_from_rest(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=0.0, scenario=Scenario.CROSSING)
|
||||
assert d.mode == CameraMode.FULL
|
||||
assert d.scenario_override is True
|
||||
|
||||
def test_crossing_forces_full_from_aware(self):
|
||||
f = _fsm()
|
||||
f.update(speed_mps=0.5) # AWARE
|
||||
d = f.update(speed_mps=0.5, scenario=Scenario.CROSSING)
|
||||
assert d.mode == CameraMode.FULL
|
||||
|
||||
def test_emergency_forces_full(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=0.0, scenario=Scenario.EMERGENCY)
|
||||
assert d.mode == CameraMode.FULL
|
||||
assert d.scenario_override is True
|
||||
|
||||
def test_crossing_bypasses_hysteresis(self):
|
||||
"""CROSSING forces FULL even if a downgrade is pending."""
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(hold=5.0, clock=clock)
|
||||
f.update(speed_mps=_SPD_FULL_UP + 1.0) # FULL
|
||||
clock.advance(4.0)
|
||||
f.update(speed_mps=0.0) # pending downgrade started
|
||||
d = f.update(speed_mps=0.0, scenario=Scenario.CROSSING)
|
||||
assert d.mode == CameraMode.FULL
|
||||
|
||||
def test_parked_forces_social(self):
|
||||
f = _fsm()
|
||||
f.update(speed_mps=_SPD_FULL_UP + 1.0) # FULL
|
||||
d = f.update(speed_mps=0.0, scenario=Scenario.PARKED)
|
||||
assert d.mode == CameraMode.SOCIAL
|
||||
assert d.scenario_override is True
|
||||
|
||||
def test_indoor_caps_at_aware(self):
|
||||
f = _fsm()
|
||||
# Speed says ACTIVE but indoor caps to AWARE
|
||||
d = f.update(speed_mps=_SPD_ACTIVE_UP + 0.5, scenario=Scenario.INDOOR)
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
def test_indoor_cannot_reach_full(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=_SPD_FULL_UP + 2.0, scenario=Scenario.INDOOR)
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
def test_outdoor_uses_speed_logic(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=_SPD_FULL_UP + 0.5, scenario=Scenario.OUTDOOR)
|
||||
assert d.mode == CameraMode.FULL
|
||||
|
||||
def test_unknown_scenario_uses_speed_logic(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=_SPD_ACTIVE_UP + 0.5, scenario=Scenario.UNKNOWN)
|
||||
assert d.mode == CameraMode.ACTIVE
|
||||
|
||||
def test_crossing_sets_all_csi_active(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=0.0, scenario=Scenario.CROSSING)
|
||||
s = d.sensors
|
||||
assert s.csi_front and s.csi_rear and s.csi_left and s.csi_right
|
||||
|
||||
def test_scenario_override_false_for_speed_transition(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=_SPD_ACTIVE_UP + 0.5, scenario=Scenario.OUTDOOR)
|
||||
assert d.scenario_override is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Battery low cap
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestBatteryLow:
|
||||
|
||||
def test_battery_low_caps_at_aware(self):
|
||||
f = _fsm(bat_low=20.0)
|
||||
d = f.update(speed_mps=_SPD_FULL_UP + 1.0, battery_pct=15.0)
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
def test_battery_low_prevents_active(self):
|
||||
f = _fsm(bat_low=20.0)
|
||||
d = f.update(speed_mps=_SPD_ACTIVE_UP + 0.5, battery_pct=19.9)
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
def test_battery_full_no_cap(self):
|
||||
f = _fsm(bat_low=20.0)
|
||||
d = f.update(speed_mps=_SPD_FULL_UP + 1.0, battery_pct=100.0)
|
||||
assert d.mode == CameraMode.FULL
|
||||
|
||||
def test_battery_at_threshold_not_capped(self):
|
||||
f = _fsm(bat_low=20.0)
|
||||
d = f.update(speed_mps=_SPD_FULL_UP + 1.0, battery_pct=20.0)
|
||||
# At exactly threshold — not below — so no cap
|
||||
assert d.mode == CameraMode.FULL
|
||||
|
||||
def test_battery_low_allows_aware(self):
|
||||
f = _fsm(bat_low=20.0)
|
||||
d = f.update(speed_mps=_SPD_MOTION + 0.1, battery_pct=10.0)
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Idle → SOCIAL transition
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestIdleToSocial:
|
||||
|
||||
def test_idle_transitions_to_social_after_timeout(self):
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(idle=10.0, hold=0.0, clock=clock)
|
||||
# Bring to AWARE
|
||||
f.update(speed_mps=_SPD_MOTION + 0.1)
|
||||
# Reduce to near-zero
|
||||
clock.advance(5.0)
|
||||
f.update(speed_mps=0.05) # below 0.1, idle timer starts
|
||||
clock.advance(10.1)
|
||||
d = f.update(speed_mps=0.05)
|
||||
assert d.mode == CameraMode.SOCIAL
|
||||
|
||||
def test_motion_resets_idle_timer(self):
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(idle=10.0, hold=0.0, clock=clock)
|
||||
f.update(speed_mps=_SPD_MOTION + 0.1) # AWARE
|
||||
clock.advance(5.0)
|
||||
f.update(speed_mps=0.05) # idle timer starts
|
||||
clock.advance(5.0)
|
||||
f.update(speed_mps=1.0) # motion resets timer
|
||||
clock.advance(6.0)
|
||||
d = f.update(speed_mps=0.05) # timer restarted, only 6s elapsed
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
def test_not_idle_when_moving(self):
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(idle=5.0, clock=clock)
|
||||
f.update(speed_mps=_SPD_MOTION + 0.1)
|
||||
clock.advance(100.0)
|
||||
d = f.update(speed_mps=_SPD_MOTION + 0.1) # still moving
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Reset
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestReset:
|
||||
|
||||
def test_reset_to_sleep(self):
|
||||
f = _fsm()
|
||||
f.update(speed_mps=_SPD_FULL_UP + 1.0)
|
||||
f.reset(CameraMode.SLEEP)
|
||||
assert f.mode == CameraMode.SLEEP
|
||||
|
||||
def test_reset_clears_downgrade_timer(self):
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(hold=5.0, clock=clock)
|
||||
f.update(speed_mps=_SPD_FULL_UP + 1.0)
|
||||
f.update(speed_mps=0.0) # pending downgrade started
|
||||
f.reset(CameraMode.AWARE)
|
||||
# After reset, pending downgrade should be cleared
|
||||
clock.advance(10.0)
|
||||
d = f.update(speed_mps=0.0)
|
||||
# From AWARE at speed 0 → idle timer not yet expired → stays AWARE (not SLEEP)
|
||||
# Actually: speed 0 < _SPD_MOTION → desired=SOCIAL, hold=5.0
|
||||
# With hold=5.0 and clock just advanced, pending since = now → no downgrade yet
|
||||
assert d.mode in (CameraMode.AWARE, CameraMode.SOCIAL)
|
||||
|
||||
def test_reset_to_full(self):
|
||||
f = _fsm()
|
||||
f.reset(CameraMode.FULL)
|
||||
assert f.mode == CameraMode.FULL
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ModeDecision fields
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestModeDecisionFields:
|
||||
|
||||
def test_decision_has_mode(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=0.0)
|
||||
assert isinstance(d.mode, CameraMode)
|
||||
|
||||
def test_decision_has_sensors(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=0.0)
|
||||
assert isinstance(d.sensors, ActiveSensors)
|
||||
|
||||
def test_decision_sensors_match_mode(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=_SPD_FULL_UP + 1.0)
|
||||
assert d.sensors == MODE_SENSORS[CameraMode.FULL]
|
||||
|
||||
def test_decision_trigger_speed_recorded(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=2.5)
|
||||
assert abs(d.trigger_speed_mps - 2.5) < 1e-6
|
||||
|
||||
def test_decision_trigger_scenario_recorded(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=0.0, scenario='indoor')
|
||||
assert d.trigger_scenario == 'indoor'
|
||||
|
||||
def test_scenario_override_false_for_normal(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=1.0)
|
||||
assert d.scenario_override is False
|
||||
|
||||
def test_scenario_override_true_for_crossing(self):
|
||||
f = _fsm()
|
||||
d = f.update(speed_mps=0.0, scenario=Scenario.CROSSING)
|
||||
assert d.scenario_override is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Active sensor counts (RAM budget)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestActiveSensors:
|
||||
|
||||
def test_active_sensor_count_non_negative(self):
|
||||
for m, s in MODE_SENSORS.items():
|
||||
assert s.active_count >= 0
|
||||
|
||||
def test_sleep_zero_sensors(self):
|
||||
assert MODE_SENSORS[CameraMode.SLEEP].active_count == 0
|
||||
|
||||
def test_full_seven_sensors(self):
|
||||
# csi x4 + realsense + lidar + uwb = 7
|
||||
assert MODE_SENSORS[CameraMode.FULL].active_count == 7
|
||||
|
||||
def test_active_sensors_correct(self):
|
||||
# csi_front + csi_rear + realsense + lidar + uwb = 5
|
||||
assert MODE_SENSORS[CameraMode.ACTIVE].active_count == 5
|
||||
|
||||
def test_aware_sensors_correct(self):
|
||||
# csi_front + realsense + lidar = 3
|
||||
assert MODE_SENSORS[CameraMode.AWARE].active_count == 3
|
||||
|
||||
def test_social_sensors_correct(self):
|
||||
# webcam = 1
|
||||
assert MODE_SENSORS[CameraMode.SOCIAL].active_count == 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Mode labels
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestModeLabels:
|
||||
|
||||
def test_all_modes_have_labels(self):
|
||||
for m in CameraMode:
|
||||
assert isinstance(m.label, str)
|
||||
assert len(m.label) > 0
|
||||
|
||||
def test_sleep_label(self):
|
||||
assert CameraMode.SLEEP.label == 'SLEEP'
|
||||
|
||||
def test_full_label(self):
|
||||
assert CameraMode.FULL.label == 'FULL'
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Integration: typical ride scenario
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestIntegrationRideScenario:
|
||||
"""Simulates a typical follow-me trip: start→walk→jog→sprint→crossing→indoor."""
|
||||
|
||||
def test_full_ride(self):
|
||||
clock = _FakeClock(0.0)
|
||||
f = _fsm(hold=2.0, idle=5.0, clock=clock)
|
||||
|
||||
# Starting up
|
||||
d = f.update(0.0, Scenario.OUTDOOR, 100.0)
|
||||
assert d.mode == CameraMode.SOCIAL
|
||||
|
||||
# Walking pace (~3 km/h)
|
||||
d = f.update(0.8, Scenario.OUTDOOR, 95.0)
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
# Jogging (~7 km/h)
|
||||
d = f.update(2.0, Scenario.OUTDOOR, 90.0)
|
||||
assert d.mode == CameraMode.ACTIVE
|
||||
|
||||
# High-speed following (~20 km/h)
|
||||
d = f.update(5.6, Scenario.OUTDOOR, 85.0)
|
||||
assert d.mode == CameraMode.FULL
|
||||
|
||||
# Street crossing — even at slow speed, stays FULL
|
||||
d = f.update(1.0, Scenario.CROSSING, 85.0)
|
||||
assert d.mode == CameraMode.FULL
|
||||
assert d.scenario_override is True
|
||||
|
||||
# Back to outdoor walk
|
||||
clock.advance(3.0)
|
||||
d = f.update(1.0, Scenario.OUTDOOR, 80.0)
|
||||
assert d.mode == CameraMode.FULL # hold not expired yet
|
||||
|
||||
clock.advance(2.1)
|
||||
d = f.update(0.8, Scenario.OUTDOOR, 80.0)
|
||||
assert d.mode == CameraMode.AWARE # hold expired, down to walk speed
|
||||
|
||||
# Enter supermarket (indoor cap)
|
||||
d = f.update(0.8, Scenario.INDOOR, 78.0)
|
||||
assert d.mode == CameraMode.AWARE
|
||||
|
||||
# Park and wait
|
||||
d = f.update(0.0, Scenario.PARKED, 75.0)
|
||||
assert d.mode == CameraMode.SOCIAL
|
||||
|
||||
# Low battery during fast follow
|
||||
d = f.update(5.6, Scenario.OUTDOOR, 15.0)
|
||||
assert d.mode == CameraMode.AWARE # battery cap
|
||||
@ -1,575 +0,0 @@
|
||||
"""
|
||||
test_uwb_tracker.py — Unit tests for _uwb_tracker.py (Issue #365).
|
||||
|
||||
Covers:
|
||||
- Serial frame parsing (valid, malformed, STATUS, edge cases)
|
||||
- Bearing geometry (straight ahead, left, right, extreme angles)
|
||||
- Single-anchor fallback
|
||||
- Kalman filter seeding and smoothing
|
||||
- UwbRangingState thread safety and stale timeout
|
||||
- AnchorSerialReader with mock serial port
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from saltybot_bringup._uwb_tracker import (
|
||||
AnchorSerialReader,
|
||||
BearingKalman,
|
||||
FIX_DUAL,
|
||||
FIX_NONE,
|
||||
FIX_SINGLE,
|
||||
RangeFrame,
|
||||
UwbRangingState,
|
||||
UwbResult,
|
||||
bearing_from_ranges,
|
||||
bearing_single_anchor,
|
||||
parse_frame,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Serial frame parsing
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestParseFrame:
|
||||
|
||||
def test_valid_range_frame(self):
|
||||
f = parse_frame("RANGE,0,T0,1532\n")
|
||||
assert isinstance(f, RangeFrame)
|
||||
assert f.anchor_id == 0
|
||||
assert f.tag_id == 'T0'
|
||||
assert abs(f.distance_m - 1.532) < 1e-6
|
||||
|
||||
def test_anchor_id_1(self):
|
||||
f = parse_frame("RANGE,1,T0,2000\n")
|
||||
assert f.anchor_id == 1
|
||||
assert abs(f.distance_m - 2.0) < 1e-6
|
||||
|
||||
def test_large_distance(self):
|
||||
f = parse_frame("RANGE,0,T0,45000\n")
|
||||
assert abs(f.distance_m - 45.0) < 1e-6
|
||||
|
||||
def test_zero_distance(self):
|
||||
# Very short distance — still valid protocol
|
||||
f = parse_frame("RANGE,0,T0,0\n")
|
||||
assert f is not None
|
||||
assert f.distance_m == 0.0
|
||||
|
||||
def test_status_frame_returns_none(self):
|
||||
assert parse_frame("STATUS,0,OK\n") is None
|
||||
|
||||
def test_status_frame_1(self):
|
||||
assert parse_frame("STATUS,1,OK\n") is None
|
||||
|
||||
def test_garbage_returns_none(self):
|
||||
assert parse_frame("GARBAGE\n") is None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert parse_frame("") is None
|
||||
|
||||
def test_partial_frame(self):
|
||||
assert parse_frame("RANGE,0") is None
|
||||
|
||||
def test_no_newline(self):
|
||||
f = parse_frame("RANGE,0,T0,1500")
|
||||
assert f is not None
|
||||
assert abs(f.distance_m - 1.5) < 1e-6
|
||||
|
||||
def test_crlf_terminator(self):
|
||||
f = parse_frame("RANGE,0,T0,3000\r\n")
|
||||
assert f is not None
|
||||
assert abs(f.distance_m - 3.0) < 1e-6
|
||||
|
||||
def test_whitespace_preserved_tag_id(self):
|
||||
f = parse_frame("RANGE,0,TAG_ABC,1000\n")
|
||||
assert f.tag_id == 'TAG_ABC'
|
||||
|
||||
def test_mm_to_m_conversion(self):
|
||||
f = parse_frame("RANGE,0,T0,1000\n")
|
||||
assert abs(f.distance_m - 1.0) < 1e-9
|
||||
|
||||
def test_timestamp_is_recent(self):
|
||||
before = time.monotonic()
|
||||
f = parse_frame("RANGE,0,T0,1000\n")
|
||||
after = time.monotonic()
|
||||
assert before <= f.timestamp <= after
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Bearing geometry
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestBearingFromRanges:
|
||||
"""
|
||||
Baseline B = 0.25 m. Anchors at x = -0.125 (left) and x = +0.125 (right).
|
||||
"""
|
||||
|
||||
B = 0.25
|
||||
|
||||
def _geometry(self, x_tag: float, y_tag: float) -> tuple[float, float]:
|
||||
"""Compute expected d0, d1 from tag position (x, y) relative to midpoint."""
|
||||
d0 = math.sqrt((x_tag + self.B / 2) ** 2 + y_tag ** 2)
|
||||
d1 = math.sqrt((x_tag - self.B / 2) ** 2 + y_tag ** 2)
|
||||
return d0, d1
|
||||
|
||||
def test_straight_ahead_bearing_zero(self):
|
||||
d0, d1 = self._geometry(0.0, 2.0)
|
||||
bearing, conf = bearing_from_ranges(d0, d1, self.B)
|
||||
assert abs(bearing) < 0.5
|
||||
assert conf > 0.9
|
||||
|
||||
def test_tag_right_positive_bearing(self):
|
||||
d0, d1 = self._geometry(1.0, 2.0)
|
||||
bearing, conf = bearing_from_ranges(d0, d1, self.B)
|
||||
assert bearing > 0
|
||||
expected = math.degrees(math.atan2(1.0, 2.0))
|
||||
assert abs(bearing - expected) < 1.0
|
||||
|
||||
def test_tag_left_negative_bearing(self):
|
||||
d0, d1 = self._geometry(-1.0, 2.0)
|
||||
bearing, conf = bearing_from_ranges(d0, d1, self.B)
|
||||
assert bearing < 0
|
||||
expected = math.degrees(math.atan2(-1.0, 2.0))
|
||||
assert abs(bearing - expected) < 1.0
|
||||
|
||||
def test_symmetry(self):
|
||||
"""Equal offset left and right should give equal magnitude, opposite sign."""
|
||||
d0r, d1r = self._geometry(0.5, 3.0)
|
||||
d0l, d1l = self._geometry(-0.5, 3.0)
|
||||
b_right, _ = bearing_from_ranges(d0r, d1r, self.B)
|
||||
b_left, _ = bearing_from_ranges(d0l, d1l, self.B)
|
||||
assert abs(b_right + b_left) < 0.5 # sum ≈ 0
|
||||
assert abs(abs(b_right) - abs(b_left)) < 0.5 # magnitudes match
|
||||
|
||||
def test_confidence_high_straight_ahead(self):
|
||||
d0, d1 = self._geometry(0.0, 3.0)
|
||||
_, conf = bearing_from_ranges(d0, d1, self.B)
|
||||
assert conf > 0.8
|
||||
|
||||
def test_confidence_lower_extreme_angle(self):
|
||||
"""Tag almost directly to the side — poor geometry."""
|
||||
d0, d1 = self._geometry(5.0, 0.3)
|
||||
_, conf_side = bearing_from_ranges(d0, d1, self.B)
|
||||
d0f, d1f = self._geometry(0.0, 5.0)
|
||||
_, conf_front = bearing_from_ranges(d0f, d1f, self.B)
|
||||
assert conf_side < conf_front
|
||||
|
||||
def test_confidence_zero_to_one(self):
|
||||
d0, d1 = self._geometry(0.3, 2.0)
|
||||
_, conf = bearing_from_ranges(d0, d1, self.B)
|
||||
assert 0.0 <= conf <= 1.0
|
||||
|
||||
def test_negative_baseline_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
bearing_from_ranges(2.0, 2.0, -0.1)
|
||||
|
||||
def test_zero_baseline_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
bearing_from_ranges(2.0, 2.0, 0.0)
|
||||
|
||||
def test_zero_distance_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
bearing_from_ranges(0.0, 2.0, 0.25)
|
||||
|
||||
def test_triangle_inequality_violation_no_crash(self):
|
||||
"""When d0+d1 < B (impossible geometry), should not raise."""
|
||||
bearing, conf = bearing_from_ranges(0.1, 0.1, 0.25)
|
||||
assert math.isfinite(bearing)
|
||||
assert 0.0 <= conf <= 1.0
|
||||
|
||||
def test_far_distance_bearing_approaches_atan2(self):
|
||||
"""At large distances bearing formula should match simple atan2."""
|
||||
x, y = 2.0, 50.0
|
||||
d0, d1 = self._geometry(x, y)
|
||||
bearing, _ = bearing_from_ranges(d0, d1, self.B)
|
||||
expected = math.degrees(math.atan2(x, y))
|
||||
assert abs(bearing - expected) < 0.5
|
||||
|
||||
def test_45_degree_right(self):
|
||||
x, y = 2.0, 2.0
|
||||
d0, d1 = self._geometry(x, y)
|
||||
bearing, _ = bearing_from_ranges(d0, d1, self.B)
|
||||
assert abs(bearing - 45.0) < 2.0
|
||||
|
||||
def test_45_degree_left(self):
|
||||
x, y = -2.0, 2.0
|
||||
d0, d1 = self._geometry(x, y)
|
||||
bearing, _ = bearing_from_ranges(d0, d1, self.B)
|
||||
assert abs(bearing + 45.0) < 2.0
|
||||
|
||||
def test_30_degree_right(self):
|
||||
y = 3.0
|
||||
x = y * math.tan(math.radians(30.0))
|
||||
d0, d1 = self._geometry(x, y)
|
||||
bearing, _ = bearing_from_ranges(d0, d1, self.B)
|
||||
assert abs(bearing - 30.0) < 2.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Single-anchor fallback
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestBearingSingleAnchor:
|
||||
|
||||
def test_returns_zero_bearing(self):
|
||||
bearing, _ = bearing_single_anchor(2.0)
|
||||
assert bearing == 0.0
|
||||
|
||||
def test_confidence_at_most_0_3(self):
|
||||
_, conf = bearing_single_anchor(2.0)
|
||||
assert conf <= 0.3
|
||||
|
||||
def test_confidence_decreases_with_distance(self):
|
||||
_, c_near = bearing_single_anchor(0.5)
|
||||
_, c_far = bearing_single_anchor(5.0)
|
||||
assert c_near > c_far
|
||||
|
||||
def test_confidence_non_negative(self):
|
||||
_, conf = bearing_single_anchor(100.0)
|
||||
assert conf >= 0.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Kalman filter
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestBearingKalman:
|
||||
|
||||
def test_first_update_returns_input(self):
|
||||
kf = BearingKalman()
|
||||
out = kf.update(15.0)
|
||||
assert abs(out - 15.0) < 1e-6
|
||||
|
||||
def test_smoothing_reduces_noise(self):
|
||||
kf = BearingKalman()
|
||||
noisy = [0.0, 10.0, -5.0, 3.0, 7.0, -2.0, 1.0, 4.0]
|
||||
outputs = [kf.update(b) for b in noisy]
|
||||
# Variance of outputs should be lower than variance of inputs
|
||||
assert float(np.std(outputs)) < float(np.std(noisy))
|
||||
|
||||
def test_converges_to_constant_input(self):
|
||||
kf = BearingKalman()
|
||||
for _ in range(20):
|
||||
out = kf.update(30.0)
|
||||
assert abs(out - 30.0) < 2.0
|
||||
|
||||
def test_tracks_slow_change(self):
|
||||
kf = BearingKalman()
|
||||
target = 0.0
|
||||
for i in range(30):
|
||||
target += 1.0
|
||||
kf.update(target)
|
||||
final = kf.update(target)
|
||||
# Should be within a few degrees of the current target
|
||||
assert abs(final - target) < 5.0
|
||||
|
||||
def test_bearing_rate_initialised_to_zero(self):
|
||||
kf = BearingKalman()
|
||||
kf.update(10.0)
|
||||
assert abs(kf.bearing_rate_dps) < 50.0 # should be small initially
|
||||
|
||||
def test_bearing_rate_positive_for_increasing_bearing(self):
|
||||
kf = BearingKalman()
|
||||
for i in range(10):
|
||||
kf.update(float(i * 5))
|
||||
assert kf.bearing_rate_dps > 0
|
||||
|
||||
def test_predict_returns_float(self):
|
||||
kf = BearingKalman()
|
||||
kf.update(10.0)
|
||||
p = kf.predict()
|
||||
assert isinstance(p, float)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# UwbRangingState
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestUwbRangingState:
|
||||
|
||||
def test_initial_state_invalid(self):
|
||||
state = UwbRangingState()
|
||||
result = state.compute()
|
||||
assert not result.valid
|
||||
assert result.fix_quality == FIX_NONE
|
||||
|
||||
def test_single_anchor_fix(self):
|
||||
state = UwbRangingState()
|
||||
state.update_anchor(0, 2.5)
|
||||
result = state.compute()
|
||||
assert result.valid
|
||||
assert result.fix_quality == FIX_SINGLE
|
||||
|
||||
def test_dual_anchor_fix(self):
|
||||
state = UwbRangingState()
|
||||
state.update_anchor(0, 2.0)
|
||||
state.update_anchor(1, 2.0)
|
||||
result = state.compute()
|
||||
assert result.valid
|
||||
assert result.fix_quality == FIX_DUAL
|
||||
|
||||
def test_dual_anchor_straight_ahead_bearing(self):
|
||||
state = UwbRangingState(baseline_m=0.25)
|
||||
# Symmetric distances → bearing ≈ 0
|
||||
state.update_anchor(0, 2.0)
|
||||
state.update_anchor(1, 2.0)
|
||||
result = state.compute()
|
||||
assert abs(result.bearing_deg) < 1.0
|
||||
|
||||
def test_dual_anchor_distance_is_mean(self):
|
||||
state = UwbRangingState(baseline_m=0.25)
|
||||
state.update_anchor(0, 1.5)
|
||||
state.update_anchor(1, 2.5)
|
||||
result = state.compute()
|
||||
assert abs(result.distance_m - 2.0) < 0.01
|
||||
|
||||
def test_anchor0_dist_recorded(self):
|
||||
state = UwbRangingState()
|
||||
state.update_anchor(0, 3.0)
|
||||
state.update_anchor(1, 3.0)
|
||||
result = state.compute()
|
||||
assert abs(result.anchor0_dist - 3.0) < 1e-6
|
||||
|
||||
def test_anchor1_dist_recorded(self):
|
||||
state = UwbRangingState()
|
||||
state.update_anchor(0, 3.0)
|
||||
state.update_anchor(1, 4.0)
|
||||
result = state.compute()
|
||||
assert abs(result.anchor1_dist - 4.0) < 1e-6
|
||||
|
||||
def test_stale_anchor_ignored(self):
|
||||
state = UwbRangingState(stale_timeout=0.01)
|
||||
state.update_anchor(0, 2.0)
|
||||
time.sleep(0.05) # let it go stale
|
||||
state.update_anchor(1, 2.5) # fresh
|
||||
result = state.compute()
|
||||
assert result.fix_quality == FIX_SINGLE
|
||||
|
||||
def test_both_stale_returns_invalid(self):
|
||||
state = UwbRangingState(stale_timeout=0.01)
|
||||
state.update_anchor(0, 2.0)
|
||||
state.update_anchor(1, 2.0)
|
||||
time.sleep(0.05)
|
||||
result = state.compute()
|
||||
assert not result.valid
|
||||
|
||||
def test_invalid_anchor_id_ignored(self):
|
||||
state = UwbRangingState()
|
||||
state.update_anchor(5, 2.0) # invalid index
|
||||
result = state.compute()
|
||||
assert not result.valid
|
||||
|
||||
def test_confidence_is_clipped(self):
|
||||
state = UwbRangingState()
|
||||
state.update_anchor(0, 2.0)
|
||||
state.update_anchor(1, 2.0)
|
||||
result = state.compute()
|
||||
assert 0.0 <= result.confidence <= 1.0
|
||||
|
||||
def test_thread_safety(self):
|
||||
"""Multiple threads updating anchors concurrently should not crash."""
|
||||
state = UwbRangingState()
|
||||
errors = []
|
||||
|
||||
def writer(anchor_id: int):
|
||||
for i in range(100):
|
||||
try:
|
||||
state.update_anchor(anchor_id, float(i + 1) / 10.0)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
def reader():
|
||||
for _ in range(100):
|
||||
try:
|
||||
state.compute()
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=writer, args=(0,)),
|
||||
threading.Thread(target=writer, args=(1,)),
|
||||
threading.Thread(target=reader),
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=5.0)
|
||||
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_baseline_stored(self):
|
||||
state = UwbRangingState(baseline_m=0.30)
|
||||
state.update_anchor(0, 2.0)
|
||||
state.update_anchor(1, 2.0)
|
||||
result = state.compute()
|
||||
assert abs(result.baseline_m - 0.30) < 1e-6
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# AnchorSerialReader
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _MockSerial:
|
||||
"""Simulates a serial port by feeding pre-defined lines."""
|
||||
|
||||
def __init__(self, lines: list[str], *, loop: bool = False) -> None:
|
||||
self._lines = lines
|
||||
self._idx = 0
|
||||
self._loop = loop
|
||||
self._done = threading.Event()
|
||||
|
||||
def readline(self) -> bytes:
|
||||
if self._idx >= len(self._lines):
|
||||
if self._loop:
|
||||
self._idx = 0
|
||||
else:
|
||||
self._done.wait(timeout=0.05)
|
||||
return b''
|
||||
line = self._lines[self._idx]
|
||||
self._idx += 1
|
||||
time.sleep(0.005) # simulate inter-frame gap
|
||||
return line.encode('ascii')
|
||||
|
||||
def wait_until_consumed(self, timeout: float = 2.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while self._idx < len(self._lines) and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
class TestAnchorSerialReader:
|
||||
|
||||
def test_range_frames_update_state(self):
|
||||
state = UwbRangingState()
|
||||
port = _MockSerial(["RANGE,0,T0,2000\n", "RANGE,0,T0,2100\n"])
|
||||
reader = AnchorSerialReader(anchor_id=0, port=port, state=state)
|
||||
reader.start()
|
||||
port.wait_until_consumed()
|
||||
reader.stop()
|
||||
|
||||
result = state.compute()
|
||||
# distance should be ≈ 2.1 (last update)
|
||||
assert result.valid or True # may be stale in CI — just check no crash
|
||||
|
||||
def test_status_frames_ignored(self):
|
||||
state = UwbRangingState()
|
||||
port = _MockSerial(["STATUS,0,OK\n"])
|
||||
reader = AnchorSerialReader(anchor_id=0, port=port, state=state)
|
||||
reader.start()
|
||||
time.sleep(0.05)
|
||||
reader.stop()
|
||||
result = state.compute()
|
||||
assert not result.valid # no RANGE frame — state should be empty
|
||||
|
||||
def test_anchor_id_used_from_frame(self):
|
||||
"""The frame's anchor_id field is used (not the reader's anchor_id)."""
|
||||
state = UwbRangingState()
|
||||
port = _MockSerial(["RANGE,1,T0,3000\n"])
|
||||
reader = AnchorSerialReader(anchor_id=0, port=port, state=state)
|
||||
reader.start()
|
||||
port.wait_until_consumed()
|
||||
time.sleep(0.05)
|
||||
reader.stop()
|
||||
# Anchor 1 should be updated
|
||||
assert state._anchors[1].valid or True # timing-dependent, no crash
|
||||
|
||||
def test_malformed_lines_no_crash(self):
|
||||
state = UwbRangingState()
|
||||
port = _MockSerial(["GARBAGE\n", "RANGE,0,T0,1000\n", "MORE_GARBAGE\n"])
|
||||
reader = AnchorSerialReader(anchor_id=0, port=port, state=state)
|
||||
reader.start()
|
||||
port.wait_until_consumed()
|
||||
reader.stop()
|
||||
|
||||
def test_stop_terminates_thread(self):
|
||||
state = UwbRangingState()
|
||||
port = _MockSerial([], loop=True) # infinite empty stream
|
||||
reader = AnchorSerialReader(anchor_id=0, port=port, state=state)
|
||||
reader.start()
|
||||
reader.stop()
|
||||
reader._thread.join(timeout=1.0)
|
||||
# Thread should stop within 1 second of stop()
|
||||
assert not reader._thread.is_alive() or True # graceful stop
|
||||
|
||||
def test_bytes_decoded(self):
|
||||
"""Reader should handle bytes from real serial.Serial."""
|
||||
state = UwbRangingState()
|
||||
port = _MockSerial(["RANGE,0,T0,1500\n"])
|
||||
reader = AnchorSerialReader(anchor_id=0, port=port, state=state)
|
||||
reader.start()
|
||||
port.wait_until_consumed()
|
||||
time.sleep(0.05)
|
||||
reader.stop()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Integration: full pipeline
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestIntegration:
|
||||
"""End-to-end: mock serial → reader → state → bearing output."""
|
||||
|
||||
B = 0.25
|
||||
|
||||
def _d(self, x: float, y: float) -> tuple[float, float]:
|
||||
d0 = math.sqrt((x + self.B / 2) ** 2 + y ** 2)
|
||||
d1 = math.sqrt((x - self.B / 2) ** 2 + y ** 2)
|
||||
return d0, d1
|
||||
|
||||
def test_straight_ahead_pipeline(self):
|
||||
d0, d1 = self._d(0.0, 3.0)
|
||||
state = UwbRangingState(baseline_m=self.B)
|
||||
state.update_anchor(0, d0)
|
||||
state.update_anchor(1, d1)
|
||||
result = state.compute()
|
||||
assert result.valid
|
||||
assert result.fix_quality == FIX_DUAL
|
||||
assert abs(result.bearing_deg) < 2.0
|
||||
assert abs(result.distance_m - 3.0) < 0.1
|
||||
|
||||
def test_right_offset_pipeline(self):
|
||||
d0, d1 = self._d(1.0, 2.0)
|
||||
state = UwbRangingState(baseline_m=self.B)
|
||||
state.update_anchor(0, d0)
|
||||
state.update_anchor(1, d1)
|
||||
result = state.compute()
|
||||
assert result.bearing_deg > 0
|
||||
|
||||
def test_left_offset_pipeline(self):
|
||||
d0, d1 = self._d(-1.0, 2.0)
|
||||
state = UwbRangingState(baseline_m=self.B)
|
||||
state.update_anchor(0, d0)
|
||||
state.update_anchor(1, d1)
|
||||
result = state.compute()
|
||||
assert result.bearing_deg < 0
|
||||
|
||||
def test_sequential_updates_kalman_smooths(self):
|
||||
state = UwbRangingState(baseline_m=self.B)
|
||||
outputs = []
|
||||
for i in range(10):
|
||||
noise = float(np.random.default_rng(i).normal(0, 0.01))
|
||||
d0, d1 = self._d(0.0, 3.0 + noise)
|
||||
state.update_anchor(0, d0)
|
||||
state.update_anchor(1, d1)
|
||||
outputs.append(state.compute().bearing_deg)
|
||||
# All outputs should be close to 0 (straight ahead) after Kalman
|
||||
assert all(abs(b) < 5.0 for b in outputs)
|
||||
|
||||
def test_uwb_result_fields(self):
|
||||
d0, d1 = self._d(0.5, 2.0)
|
||||
state = UwbRangingState(baseline_m=self.B)
|
||||
state.update_anchor(0, d0)
|
||||
state.update_anchor(1, d1)
|
||||
result = state.compute()
|
||||
assert isinstance(result, UwbResult)
|
||||
assert math.isfinite(result.bearing_deg)
|
||||
assert result.distance_m > 0
|
||||
assert 0.0 <= result.confidence <= 1.0
|
||||
@ -1,431 +0,0 @@
|
||||
"""
|
||||
test_velocity_ramp.py — Unit tests for _velocity_ramp.py (Issue #350).
|
||||
|
||||
Covers:
|
||||
- Linear ramp-up (acceleration)
|
||||
- Linear ramp-down (deceleration)
|
||||
- Angular ramp-up / ramp-down
|
||||
- Asymmetric accel vs decel limits
|
||||
- Emergency stop (both targets = 0.0)
|
||||
- Non-emergency partial decel (one axis non-zero)
|
||||
- Sign reversal (positive → negative)
|
||||
- Already-at-target (no overshoot)
|
||||
- Reset
|
||||
- Parameter validation
|
||||
- _ramp_axis helper directly
|
||||
- steps_to_reach estimate
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import pytest
|
||||
|
||||
from saltybot_bringup._velocity_ramp import (
|
||||
RampParams,
|
||||
VelocityRamp,
|
||||
_ramp_axis,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# _ramp_axis helper
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRampAxis:
|
||||
|
||||
def _p(self, accel=1.0, decel=1.0) -> RampParams:
|
||||
return RampParams(max_accel=accel, max_decel=decel)
|
||||
|
||||
def test_advances_toward_target(self):
|
||||
val = _ramp_axis(0.0, 1.0, self._p(accel=0.5), dt=1.0)
|
||||
assert abs(val - 0.5) < 1e-9
|
||||
|
||||
def test_reaches_target_exactly(self):
|
||||
val = _ramp_axis(0.9, 1.0, self._p(accel=0.5), dt=1.0)
|
||||
assert val == 1.0 # remaining gap 0.1 < max_change 0.5
|
||||
|
||||
def test_no_overshoot(self):
|
||||
val = _ramp_axis(0.8, 1.0, self._p(accel=5.0), dt=1.0)
|
||||
assert val == 1.0
|
||||
|
||||
def test_negative_direction(self):
|
||||
val = _ramp_axis(0.0, -1.0, self._p(accel=0.5), dt=1.0)
|
||||
assert abs(val - (-0.5)) < 1e-9
|
||||
|
||||
def test_decel_used_when_magnitude_falling(self):
|
||||
# current=1.0, target=0.5 → magnitude falling → use decel=0.2
|
||||
val = _ramp_axis(1.0, 0.5, self._p(accel=1.0, decel=0.2), dt=1.0)
|
||||
assert abs(val - 0.8) < 1e-9
|
||||
|
||||
def test_accel_used_when_magnitude_rising(self):
|
||||
# current=0.5, target=1.0 → magnitude rising → use accel=0.3
|
||||
val = _ramp_axis(0.5, 1.0, self._p(accel=0.3, decel=1.0), dt=1.0)
|
||||
assert abs(val - 0.8) < 1e-9
|
||||
|
||||
def test_sign_reversal_uses_decel(self):
|
||||
# current=0.5 (positive), target=-0.5 → opposite sign → decel
|
||||
val = _ramp_axis(0.5, -0.5, self._p(accel=1.0, decel=0.1), dt=1.0)
|
||||
assert abs(val - 0.4) < 1e-9
|
||||
|
||||
def test_already_at_target_no_change(self):
|
||||
val = _ramp_axis(1.0, 1.0, self._p(), dt=0.02)
|
||||
assert val == 1.0
|
||||
|
||||
def test_zero_to_zero_no_change(self):
|
||||
val = _ramp_axis(0.0, 0.0, self._p(), dt=0.02)
|
||||
assert val == 0.0
|
||||
|
||||
def test_small_dt(self):
|
||||
val = _ramp_axis(0.0, 10.0, self._p(accel=1.0), dt=0.02)
|
||||
assert abs(val - 0.02) < 1e-9
|
||||
|
||||
def test_negative_to_less_negative(self):
|
||||
# current=-1.0, target=-0.5 → magnitude falling (decelerating)
|
||||
val = _ramp_axis(-1.0, -0.5, self._p(accel=1.0, decel=0.2), dt=1.0)
|
||||
assert abs(val - (-0.8)) < 1e-9
|
||||
|
||||
def test_negative_to_more_negative(self):
|
||||
# current=-0.5, target=-1.0 → magnitude rising (accelerating)
|
||||
val = _ramp_axis(-0.5, -1.0, self._p(accel=0.3, decel=1.0), dt=1.0)
|
||||
assert abs(val - (-0.8)) < 1e-9
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# VelocityRamp construction
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestVelocityRampConstruction:
|
||||
|
||||
def test_default_params(self):
|
||||
r = VelocityRamp()
|
||||
assert r.dt == 0.02
|
||||
assert r.current_linear == 0.0
|
||||
assert r.current_angular == 0.0
|
||||
|
||||
def test_custom_dt(self):
|
||||
r = VelocityRamp(dt=0.05)
|
||||
assert r.dt == 0.05
|
||||
|
||||
def test_invalid_dt_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
VelocityRamp(dt=0.0)
|
||||
|
||||
def test_negative_dt_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
VelocityRamp(dt=-0.01)
|
||||
|
||||
def test_invalid_lin_accel_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
VelocityRamp(max_lin_accel=0.0)
|
||||
|
||||
def test_invalid_ang_accel_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
VelocityRamp(max_ang_accel=-1.0)
|
||||
|
||||
def test_asymmetric_decel_stored(self):
|
||||
r = VelocityRamp(max_lin_accel=0.5, max_lin_decel=2.0)
|
||||
assert r._lin.max_accel == 0.5
|
||||
assert r._lin.max_decel == 2.0
|
||||
|
||||
def test_decel_defaults_to_accel(self):
|
||||
r = VelocityRamp(max_lin_accel=0.5)
|
||||
assert r._lin.max_decel == 0.5
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Linear ramp-up
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLinearRampUp:
|
||||
"""
|
||||
VelocityRamp(dt=1.0, max_lin_accel=0.5) → 0.5 m/s per step.
|
||||
"""
|
||||
|
||||
def _ramp(self, **kw):
|
||||
return VelocityRamp(dt=1.0, max_lin_accel=0.5, max_ang_accel=1.0, **kw)
|
||||
|
||||
def test_first_step(self):
|
||||
r = self._ramp()
|
||||
lin, _ = r.step(1.0, 0.0)
|
||||
assert abs(lin - 0.5) < 1e-9
|
||||
|
||||
def test_second_step(self):
|
||||
r = self._ramp()
|
||||
r.step(1.0, 0.0)
|
||||
lin, _ = r.step(1.0, 0.0)
|
||||
assert abs(lin - 1.0) < 1e-9
|
||||
|
||||
def test_reaches_target(self):
|
||||
r = self._ramp()
|
||||
lin = None
|
||||
for _ in range(10):
|
||||
lin, _ = r.step(1.0, 0.0)
|
||||
assert lin == 1.0
|
||||
|
||||
def test_no_overshoot(self):
|
||||
r = self._ramp()
|
||||
outputs = [r.step(1.0, 0.0)[0] for _ in range(20)]
|
||||
assert all(v <= 1.0 + 1e-9 for v in outputs)
|
||||
|
||||
def test_current_linear_updated(self):
|
||||
r = self._ramp()
|
||||
r.step(1.0, 0.0)
|
||||
assert abs(r.current_linear - 0.5) < 1e-9
|
||||
|
||||
def test_negative_target(self):
|
||||
r = self._ramp()
|
||||
lin, _ = r.step(-1.0, 0.0)
|
||||
assert abs(lin - (-0.5)) < 1e-9
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Linear deceleration
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLinearDecel:
|
||||
|
||||
def _ramp(self, decel=None):
|
||||
return VelocityRamp(
|
||||
dt=1.0, max_lin_accel=1.0,
|
||||
max_lin_decel=decel if decel else 1.0,
|
||||
max_ang_accel=1.0,
|
||||
)
|
||||
|
||||
def _at_speed(self, r: VelocityRamp, speed: float) -> None:
|
||||
"""Bring ramp to given linear speed."""
|
||||
for _ in range(20):
|
||||
r.step(speed, 0.0)
|
||||
|
||||
def test_decel_from_1_to_0(self):
|
||||
r = self._ramp(decel=0.5)
|
||||
self._at_speed(r, 1.0)
|
||||
lin, _ = r.step(0.5, 0.0) # slow down: target < current
|
||||
assert abs(lin - 0.5) < 0.01 # 0.5 step downward
|
||||
|
||||
def test_asymmetric_faster_decel(self):
|
||||
"""With max_lin_decel=2.0, deceleration is twice as fast."""
|
||||
r = VelocityRamp(dt=1.0, max_lin_accel=0.5, max_lin_decel=2.0, max_ang_accel=1.0)
|
||||
self._at_speed(r, 1.0)
|
||||
lin, _ = r.step(0.0, 0.0) # decelerate — but target=0 triggers e-stop
|
||||
# e-stop bypasses ramp
|
||||
assert lin == 0.0
|
||||
|
||||
def test_partial_decel_non_zero_target(self):
|
||||
"""With target=0.5 and current=1.0, decel limit applies (non-zero → no e-stop)."""
|
||||
r = VelocityRamp(dt=1.0, max_lin_accel=0.5, max_lin_decel=2.0, max_ang_accel=1.0)
|
||||
self._at_speed(r, 2.0)
|
||||
# target=0.5 angular=0.1 (non-zero) → ramp decel applies
|
||||
lin, _ = r.step(0.5, 0.1)
|
||||
# current was 2.0, decel=2.0 → max step = 2.0 → should reach 0.5 immediately
|
||||
assert abs(lin - 0.5) < 1e-9
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Angular ramp
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAngularRamp:
|
||||
|
||||
def _ramp(self, **kw):
|
||||
return VelocityRamp(dt=1.0, max_lin_accel=1.0, max_ang_accel=0.5, **kw)
|
||||
|
||||
def test_angular_first_step(self):
|
||||
r = self._ramp()
|
||||
_, ang = r.step(0.0, 1.0)
|
||||
# target_lin=0 & target_ang=1 → not e-stop (only ang non-zero)
|
||||
# Wait — step(0.0, 1.0): only lin=0, ang=1 → not BOTH zero → ramp applies
|
||||
assert abs(ang - 0.5) < 1e-9
|
||||
|
||||
def test_angular_reaches_target(self):
|
||||
r = self._ramp()
|
||||
ang = None
|
||||
for _ in range(10):
|
||||
_, ang = r.step(0.0, 1.0)
|
||||
assert ang == 1.0
|
||||
|
||||
def test_angular_decel(self):
|
||||
r = self._ramp(max_ang_decel=0.25)
|
||||
for _ in range(10):
|
||||
r.step(0.0, 1.0)
|
||||
# decel with max_ang_decel=0.25: step is 0.25 per second
|
||||
_, ang = r.step(0.0, 0.5)
|
||||
assert abs(ang - 0.75) < 1e-9
|
||||
|
||||
def test_angular_negative(self):
|
||||
r = self._ramp()
|
||||
_, ang = r.step(0.0, -1.0)
|
||||
assert abs(ang - (-0.5)) < 1e-9
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Emergency stop
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestEmergencyStop:
|
||||
|
||||
def _at_full_speed(self) -> VelocityRamp:
|
||||
r = VelocityRamp(dt=1.0, max_lin_accel=0.5, max_ang_accel=0.5)
|
||||
for _ in range(10):
|
||||
r.step(2.0, 2.0)
|
||||
return r
|
||||
|
||||
def test_estop_returns_zero_immediately(self):
|
||||
r = self._at_full_speed()
|
||||
lin, ang = r.step(0.0, 0.0)
|
||||
assert lin == 0.0
|
||||
assert ang == 0.0
|
||||
|
||||
def test_estop_updates_internal_state(self):
|
||||
r = self._at_full_speed()
|
||||
r.step(0.0, 0.0)
|
||||
assert r.current_linear == 0.0
|
||||
assert r.current_angular == 0.0
|
||||
|
||||
def test_estop_from_rest_still_zero(self):
|
||||
r = VelocityRamp(dt=0.02)
|
||||
lin, ang = r.step(0.0, 0.0)
|
||||
assert lin == 0.0 and ang == 0.0
|
||||
|
||||
def test_estop_then_ramp_resumes(self):
|
||||
r = self._at_full_speed()
|
||||
r.step(0.0, 0.0) # e-stop
|
||||
lin, _ = r.step(1.0, 0.0) # ramp from 0 → first step
|
||||
assert lin > 0.0
|
||||
assert lin < 1.0
|
||||
|
||||
def test_partial_zero_not_estop(self):
|
||||
"""lin=0 but ang≠0 should NOT trigger e-stop."""
|
||||
r = VelocityRamp(dt=1.0, max_lin_accel=0.5, max_ang_accel=0.5)
|
||||
for _ in range(10):
|
||||
r.step(1.0, 1.0)
|
||||
# Now command lin=0, ang=0.5 — not an e-stop
|
||||
lin, ang = r.step(0.0, 0.5)
|
||||
# lin should ramp down (decel), NOT snap to 0
|
||||
assert lin > 0.0
|
||||
assert ang < 1.0 # angular also ramping down toward 0.5
|
||||
|
||||
def test_negative_zero_not_estop(self):
|
||||
"""step(-0.0, 0.0) — Python -0.0 == 0.0, should still e-stop."""
|
||||
r = VelocityRamp(dt=0.02)
|
||||
for _ in range(20):
|
||||
r.step(1.0, 0.0)
|
||||
lin, ang = r.step(-0.0, 0.0)
|
||||
assert lin == 0.0 and ang == 0.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Sign reversal
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSignReversal:
|
||||
|
||||
def test_reversal_decelerates_first(self):
|
||||
"""Reversing from +1 to -1 should pass through 0, not jump instantly."""
|
||||
r = VelocityRamp(dt=1.0, max_lin_accel=0.5, max_lin_decel=0.5, max_ang_accel=1.0)
|
||||
for _ in range(5):
|
||||
r.step(1.0, 0.1) # reach ~1.0 forward; use ang=0.1 to avoid e-stop
|
||||
outputs = []
|
||||
for _ in range(15):
|
||||
lin, _ = r.step(-1.0, 0.1)
|
||||
outputs.append(lin)
|
||||
# Velocity should pass through zero on its way to -1
|
||||
assert any(v < 0 for v in outputs), "Should cross zero during reversal"
|
||||
assert any(v > 0 for v in outputs[:3]), "Should start from positive side"
|
||||
|
||||
def test_reversal_completes(self):
|
||||
r = VelocityRamp(dt=1.0, max_lin_accel=0.5, max_lin_decel=0.5, max_ang_accel=1.0)
|
||||
for _ in range(5):
|
||||
r.step(1.0, 0.1)
|
||||
for _ in range(20):
|
||||
lin, _ = r.step(-1.0, 0.1)
|
||||
assert abs(lin - (-1.0)) < 1e-9
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Reset
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestReset:
|
||||
|
||||
def test_reset_clears_state(self):
|
||||
r = VelocityRamp(dt=1.0, max_lin_accel=0.5, max_ang_accel=0.5)
|
||||
r.step(2.0, 2.0)
|
||||
r.reset()
|
||||
assert r.current_linear == 0.0
|
||||
assert r.current_angular == 0.0
|
||||
|
||||
def test_after_reset_ramp_from_zero(self):
|
||||
r = VelocityRamp(dt=1.0, max_lin_accel=0.5, max_ang_accel=0.5)
|
||||
for _ in range(5):
|
||||
r.step(2.0, 0.0)
|
||||
r.reset()
|
||||
lin, _ = r.step(1.0, 0.0)
|
||||
assert abs(lin - 0.5) < 1e-9
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Monotonicity
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestMonotonicity:
|
||||
|
||||
def test_linear_ramp_up_monotonic(self):
|
||||
r = VelocityRamp(dt=0.02, max_lin_accel=0.5, max_ang_accel=1.0)
|
||||
prev = 0.0
|
||||
for _ in range(100):
|
||||
lin, _ = r.step(1.0, 0.0)
|
||||
assert lin >= prev - 1e-9
|
||||
prev = lin
|
||||
|
||||
def test_linear_ramp_down_monotonic(self):
|
||||
r = VelocityRamp(dt=0.02, max_lin_accel=0.5, max_ang_accel=1.0)
|
||||
for _ in range(200):
|
||||
r.step(1.0, 0.0)
|
||||
prev = r.current_linear
|
||||
for _ in range(100):
|
||||
lin, _ = r.step(0.5, 0.1) # decel toward 0.5 (non-zero ang avoids e-stop)
|
||||
assert lin <= prev + 1e-9
|
||||
prev = lin
|
||||
|
||||
def test_angular_ramp_monotonic(self):
|
||||
r = VelocityRamp(dt=0.02, max_lin_accel=1.0, max_ang_accel=1.0)
|
||||
prev = 0.0
|
||||
for _ in range(50):
|
||||
_, ang = r.step(0.1, 2.0)
|
||||
assert ang >= prev - 1e-9
|
||||
prev = ang
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Timing / rate accuracy
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRateAccuracy:
|
||||
|
||||
def test_50hz_correct_step_size(self):
|
||||
"""At 50 Hz with 0.5 m/s² → step = 0.01 m/s per tick."""
|
||||
r = VelocityRamp(dt=0.02, max_lin_accel=0.5, max_ang_accel=1.0)
|
||||
lin, _ = r.step(1.0, 0.0)
|
||||
assert abs(lin - 0.01) < 1e-9
|
||||
|
||||
def test_10hz_correct_step_size(self):
|
||||
"""At 10 Hz with 0.5 m/s² → step = 0.05 m/s per tick."""
|
||||
r = VelocityRamp(dt=0.1, max_lin_accel=0.5, max_ang_accel=1.0)
|
||||
lin, _ = r.step(1.0, 0.0)
|
||||
assert abs(lin - 0.05) < 1e-9
|
||||
|
||||
def test_steps_to_target_50hz(self):
|
||||
"""At 50 Hz, 0.5 m/s², reaching 1.0 m/s takes 100 steps (2 s)."""
|
||||
r = VelocityRamp(dt=0.02, max_lin_accel=0.5, max_ang_accel=1.0)
|
||||
steps = 0
|
||||
while r.current_linear < 1.0 - 1e-9:
|
||||
r.step(1.0, 0.0)
|
||||
steps += 1
|
||||
assert steps < 200, "Should converge in under 200 steps"
|
||||
assert 99 <= steps <= 101
|
||||
|
||||
def test_steps_to_reach_estimate(self):
|
||||
r = VelocityRamp(dt=0.02, max_lin_accel=0.5, max_ang_accel=1.0)
|
||||
est = r.steps_to_reach(1.0, 0.0)
|
||||
assert est > 0
|
||||
@ -37,8 +37,6 @@ rosidl_generate_interfaces(${PROJECT_NAME}
|
||||
"msg/FaceEmotionArray.msg"
|
||||
# Issue #363 — person tracking for follow-me mode
|
||||
"msg/TargetTrack.msg"
|
||||
# Issue #365 — UWB DW3000 anchor/tag ranging
|
||||
"msg/UwbTarget.msg"
|
||||
DEPENDENCIES std_msgs geometry_msgs vision_msgs builtin_interfaces
|
||||
)
|
||||
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
std_msgs/Header header
|
||||
|
||||
# Current power mode (use constants below)
|
||||
uint8 mode
|
||||
uint8 MODE_SLEEP = 0 # charging / idle — minimal sensors
|
||||
uint8 MODE_SOCIAL = 1 # parked / socialising — webcam + face UI only
|
||||
uint8 MODE_AWARE = 2 # indoor / slow (<5 km/h) — front CSI + RealSense + LIDAR
|
||||
uint8 MODE_ACTIVE = 3 # sidewalk / bike path (5–15 km/h) — front+rear + RealSense + LIDAR + UWB
|
||||
uint8 MODE_FULL = 4 # street / high-speed (>15 km/h) or crossing — all sensors
|
||||
|
||||
string mode_name # human-readable label
|
||||
|
||||
# Active sensor flags for this mode
|
||||
bool csi_front
|
||||
bool csi_rear
|
||||
bool csi_left
|
||||
bool csi_right
|
||||
bool realsense
|
||||
bool lidar
|
||||
bool uwb
|
||||
bool webcam
|
||||
|
||||
# Transition metadata
|
||||
float32 trigger_speed_mps # speed that triggered the last transition
|
||||
string trigger_scenario # scenario that triggered the last transition
|
||||
bool scenario_override # true when scenario (not speed) forced the mode
|
||||
@ -1,13 +0,0 @@
|
||||
std_msgs/Header header
|
||||
bool valid # false when no recent UWB fix
|
||||
float32 bearing_deg # horizontal bearing to tag (°, right=+, left=−)
|
||||
float32 distance_m # range to tag (m); arithmetic mean of both anchors
|
||||
float32 confidence # 0.0–1.0; degrades when anchors disagree or stale
|
||||
|
||||
# Raw per-anchor two-way-ranging distances
|
||||
float32 anchor0_dist_m # left anchor (anchor index 0)
|
||||
float32 anchor1_dist_m # right anchor (anchor index 1)
|
||||
|
||||
# Derived geometry
|
||||
float32 baseline_m # measured anchor separation (m) — used for sanity check
|
||||
uint8 fix_quality # 0=no fix 1=single-anchor 2=dual-anchor
|
||||
@ -9,18 +9,12 @@
|
||||
*/
|
||||
|
||||
#include "battery.h"
|
||||
#include "coulomb_counter.h"
|
||||
#include "config.h"
|
||||
#include "stm32f7xx_hal.h"
|
||||
#include "ina219.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
static ADC_HandleTypeDef s_hadc;
|
||||
static bool s_ready = false;
|
||||
static bool s_coulomb_valid = false;
|
||||
|
||||
/* Default battery capacity: 2200 mAh (typical lab 3S LiPo) */
|
||||
#define DEFAULT_BATTERY_CAPACITY_MAH 2200u
|
||||
|
||||
void battery_init(void) {
|
||||
__HAL_RCC_ADC3_CLK_ENABLE();
|
||||
@ -55,10 +49,6 @@ void battery_init(void) {
|
||||
ch.SamplingTime = ADC_SAMPLETIME_480CYCLES;
|
||||
if (HAL_ADC_ConfigChannel(&s_hadc, &ch) != HAL_OK) return;
|
||||
|
||||
/* Initialize coulomb counter with default battery capacity */
|
||||
coulomb_counter_init(DEFAULT_BATTERY_CAPACITY_MAH);
|
||||
s_coulomb_valid = true;
|
||||
|
||||
s_ready = true;
|
||||
}
|
||||
|
||||
@ -76,7 +66,7 @@ uint32_t battery_read_mv(void) {
|
||||
}
|
||||
|
||||
/*
|
||||
* Coarse SoC estimate (voltage-based fallback).
|
||||
* Coarse SoC estimate.
|
||||
* 3S LiPo: 9.9 V (0%) – 12.6 V (100%) — detect by Vbat < 13 V
|
||||
* 4S LiPo: 13.2 V (0%) – 16.8 V (100%) — detect by Vbat ≥ 13 V
|
||||
*/
|
||||
@ -98,34 +88,3 @@ uint8_t battery_estimate_pct(uint32_t voltage_mv) {
|
||||
|
||||
return (uint8_t)(((voltage_mv - v_min_mv) * 100u) / (v_max_mv - v_min_mv));
|
||||
}
|
||||
|
||||
/*
|
||||
* battery_accumulate_coulombs() — call periodically (50-100 Hz) to track
|
||||
* battery current and integrate coulombs. Reads motor currents via INA219.
|
||||
*/
|
||||
void battery_accumulate_coulombs(void) {
|
||||
if (!s_coulomb_valid) return;
|
||||
|
||||
/* Sum left + right motor currents as proxy for battery draw
|
||||
* (simple approach; doesn't include subsystem drain like OSD, audio) */
|
||||
int16_t left_ma = 0, right_ma = 0;
|
||||
ina219_read_current_ma(INA219_LEFT_MOTOR, &left_ma);
|
||||
ina219_read_current_ma(INA219_RIGHT_MOTOR, &right_ma);
|
||||
|
||||
/* Total battery current ≈ motors + subsystem baseline (~200 mA) */
|
||||
int16_t total_ma = left_ma + right_ma + 200;
|
||||
|
||||
/* Accumulate to coulomb counter */
|
||||
coulomb_counter_accumulate(total_ma);
|
||||
}
|
||||
|
||||
/*
|
||||
* battery_get_soc_coulomb() — get coulomb-based SoC (0-100, 255=invalid).
|
||||
* Preferred over voltage-based when available.
|
||||
*/
|
||||
uint8_t battery_get_soc_coulomb(void) {
|
||||
if (!s_coulomb_valid || !coulomb_counter_is_valid()) {
|
||||
return 255; /* Invalid */
|
||||
}
|
||||
return coulomb_counter_get_soc_pct();
|
||||
}
|
||||
|
||||
@ -1,118 +0,0 @@
|
||||
/*
|
||||
* coulomb_counter.c — Battery coulomb counter (Issue #325)
|
||||
*
|
||||
* Tracks Ah consumed from current readings, provides SoC independent of load.
|
||||
* Time integration: consumed_mah += current_ma * dt_ms / 3600000
|
||||
*/
|
||||
|
||||
#include "coulomb_counter.h"
|
||||
#include "stm32f7xx_hal.h"
|
||||
|
||||
/* State structure */
|
||||
static struct {
|
||||
bool initialized;
|
||||
bool valid; /* At least one measurement taken */
|
||||
uint16_t capacity_mah; /* Battery capacity in mAh */
|
||||
uint32_t accumulated_mah_x100; /* Accumulated coulombs in mAh×100 (fixed-point) */
|
||||
uint32_t last_tick_ms; /* Last update timestamp (ms) */
|
||||
} s_state = {0};
|
||||
|
||||
void coulomb_counter_init(uint16_t capacity_mah) {
|
||||
if (capacity_mah == 0 || capacity_mah > 20000) {
|
||||
/* Sanity check: reasonable battery is 100–20000 mAh */
|
||||
return;
|
||||
}
|
||||
|
||||
s_state.capacity_mah = capacity_mah;
|
||||
s_state.accumulated_mah_x100 = 0;
|
||||
s_state.last_tick_ms = HAL_GetTick();
|
||||
s_state.initialized = true;
|
||||
s_state.valid = false;
|
||||
}
|
||||
|
||||
void coulomb_counter_accumulate(int16_t current_ma) {
|
||||
if (!s_state.initialized) return;
|
||||
|
||||
uint32_t now_ms = HAL_GetTick();
|
||||
uint32_t dt_ms = now_ms - s_state.last_tick_ms;
|
||||
|
||||
/* Handle tick wraparound (~49.7 days at 32-bit ms) */
|
||||
if (dt_ms > 86400000UL) {
|
||||
/* If jump > 1 day, likely wraparound; skip this sample */
|
||||
s_state.last_tick_ms = now_ms;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Prevent negative dt or dt=0 */
|
||||
if (dt_ms == 0) return;
|
||||
if (dt_ms > 1000) {
|
||||
/* Cap to 1 second max per call to prevent overflow */
|
||||
dt_ms = 1000;
|
||||
}
|
||||
|
||||
/* Accumulate: mAh += mA × dt_ms / 3600000
|
||||
* Using fixed-point (×100): accumulated_mah_x100 += mA × dt_ms / 36000 */
|
||||
int32_t coulomb_x100 = (int32_t)current_ma * (int32_t)dt_ms / 36000;
|
||||
|
||||
/* Only accumulate if discharging (positive current) or realistic charging */
|
||||
if (coulomb_x100 > 0) {
|
||||
s_state.accumulated_mah_x100 += (uint32_t)coulomb_x100;
|
||||
} else if (coulomb_x100 < 0 && s_state.accumulated_mah_x100 > 0) {
|
||||
/* Allow charging (negative current) to reduce accumulated coulombs */
|
||||
int32_t new_val = (int32_t)s_state.accumulated_mah_x100 + coulomb_x100;
|
||||
if (new_val < 0) {
|
||||
s_state.accumulated_mah_x100 = 0;
|
||||
} else {
|
||||
s_state.accumulated_mah_x100 = (uint32_t)new_val;
|
||||
}
|
||||
}
|
||||
|
||||
/* Clamp to capacity */
|
||||
if (s_state.accumulated_mah_x100 > (uint32_t)s_state.capacity_mah * 100) {
|
||||
s_state.accumulated_mah_x100 = (uint32_t)s_state.capacity_mah * 100;
|
||||
}
|
||||
|
||||
s_state.last_tick_ms = now_ms;
|
||||
s_state.valid = true;
|
||||
}
|
||||
|
||||
uint8_t coulomb_counter_get_soc_pct(void) {
|
||||
if (!s_state.valid) return 255; /* 255 = invalid/not measured */
|
||||
|
||||
/* SoC = 100 - (consumed_mah / capacity_mah) * 100 */
|
||||
uint32_t consumed_mah = s_state.accumulated_mah_x100 / 100;
|
||||
|
||||
if (consumed_mah >= s_state.capacity_mah) {
|
||||
return 0; /* Fully discharged */
|
||||
}
|
||||
|
||||
uint32_t remaining_mah = s_state.capacity_mah - consumed_mah;
|
||||
uint8_t soc = (uint8_t)((remaining_mah * 100u) / s_state.capacity_mah);
|
||||
|
||||
return soc;
|
||||
}
|
||||
|
||||
uint16_t coulomb_counter_get_consumed_mah(void) {
|
||||
return (uint16_t)(s_state.accumulated_mah_x100 / 100);
|
||||
}
|
||||
|
||||
uint16_t coulomb_counter_get_remaining_mah(void) {
|
||||
if (!s_state.valid) return s_state.capacity_mah;
|
||||
|
||||
uint32_t consumed = s_state.accumulated_mah_x100 / 100;
|
||||
if (consumed >= s_state.capacity_mah) {
|
||||
return 0;
|
||||
}
|
||||
return (uint16_t)(s_state.capacity_mah - consumed);
|
||||
}
|
||||
|
||||
void coulomb_counter_reset(void) {
|
||||
if (!s_state.initialized) return;
|
||||
|
||||
s_state.accumulated_mah_x100 = 0;
|
||||
s_state.last_tick_ms = HAL_GetTick();
|
||||
}
|
||||
|
||||
bool coulomb_counter_is_valid(void) {
|
||||
return s_state.valid;
|
||||
}
|
||||
17
src/crsf.c
17
src/crsf.c
@ -320,21 +320,18 @@ static uint8_t crsf_build_frame(uint8_t *buf, uint8_t frame_type,
|
||||
/*
|
||||
* crsf_send_battery() — type 0x08 battery sensor.
|
||||
* voltage_mv → units of 100 mV (big-endian uint16)
|
||||
* capacity_mah → remaining capacity in mAh (Issue #325, coulomb counter)
|
||||
* remaining_pct→ 0–100 % (uint8)
|
||||
* current_ma → units of 100 mA (big-endian uint16)
|
||||
* remaining_pct→ 0–100 % (uint8); capacity mAh always 0 (no coulomb counter)
|
||||
*/
|
||||
void crsf_send_battery(uint32_t voltage_mv, uint32_t capacity_mah,
|
||||
void crsf_send_battery(uint32_t voltage_mv, uint32_t current_ma,
|
||||
uint8_t remaining_pct) {
|
||||
uint16_t v100 = (uint16_t)(voltage_mv / 100u); /* 100 mV units */
|
||||
/* Convert capacity (mAh) to 3-byte big-endian: cap_hi, cap_mid, cap_lo */
|
||||
uint32_t cap = capacity_mah & 0xFFFFFFu; /* 24-bit cap max */
|
||||
/* Payload: [v_hi][v_lo][current_hi][current_lo][cap_hi][cap_mid][cap_lo][remaining] */
|
||||
uint16_t c100 = (uint16_t)(current_ma / 100u); /* 100 mA units */
|
||||
/* Payload: [v_hi][v_lo][c_hi][c_lo][cap_hi][cap_mid][cap_lo][remaining] */
|
||||
uint8_t payload[8] = {
|
||||
(uint8_t)(v100 >> 8), (uint8_t)(v100 & 0xFF),
|
||||
0, 0, /* current: not available on STM32, always 0 for now */
|
||||
(uint8_t)((cap >> 16) & 0xFF), /* cap_hi */
|
||||
(uint8_t)((cap >> 8) & 0xFF), /* cap_mid */
|
||||
(uint8_t)(cap & 0xFF), /* cap_lo */
|
||||
(uint8_t)(c100 >> 8), (uint8_t)(c100 & 0xFF),
|
||||
0, 0, 0, /* capacity mAh — not tracked */
|
||||
remaining_pct,
|
||||
};
|
||||
uint8_t frame[CRSF_MAX_FRAME_LEN];
|
||||
|
||||
21
src/fan.c
21
src/fan.c
@ -47,6 +47,8 @@ static FanState_t s_fan = {
|
||||
.is_ramping = false
|
||||
};
|
||||
|
||||
static TIM_HandleTypeDef s_htim1 = {0};
|
||||
|
||||
/* ================================================================
|
||||
* Hardware Initialization
|
||||
* ================================================================ */
|
||||
@ -71,14 +73,13 @@ void fan_init(void)
|
||||
* For 25kHz frequency: PSC = 346, ARR = 25
|
||||
* Duty cycle = CCR / ARR (e.g., 12.5/25 = 50%)
|
||||
*/
|
||||
TIM_HandleTypeDef htim1 = {0};
|
||||
htim1.Instance = FAN_TIM;
|
||||
htim1.Init.Prescaler = 346 - 1; /* 216MHz / 346 ≈ 624kHz clock */
|
||||
htim1.Init.CounterMode = TIM_COUNTERMODE_UP;
|
||||
htim1.Init.Period = 25 - 1; /* 624kHz / 25 = 25kHz */
|
||||
htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
|
||||
htim1.Init.RepetitionCounter = 0;
|
||||
HAL_TIM_PWM_Init(&htim1);
|
||||
s_htim1.Instance = FAN_TIM;
|
||||
s_htim1.Init.Prescaler = 346 - 1; /* 216MHz / 346 ≈ 624kHz clock */
|
||||
s_htim1.Init.CounterMode = TIM_COUNTERMODE_UP;
|
||||
s_htim1.Init.Period = 25 - 1; /* 624kHz / 25 = 25kHz */
|
||||
s_htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
|
||||
s_htim1.Init.RepetitionCounter = 0;
|
||||
HAL_TIM_PWM_Init(&s_htim1);
|
||||
|
||||
/* Configure PWM on CH2: 0% duty initially (fan off) */
|
||||
TIM_OC_InitTypeDef oc_init = {0};
|
||||
@ -86,10 +87,10 @@ void fan_init(void)
|
||||
oc_init.Pulse = 0; /* Start at 0% duty (off) */
|
||||
oc_init.OCPolarity = TIM_OCPOLARITY_HIGH;
|
||||
oc_init.OCFastMode = TIM_OCFAST_DISABLE;
|
||||
HAL_TIM_PWM_ConfigChannel(&htim1, &oc_init, FAN_TIM_CHANNEL);
|
||||
HAL_TIM_PWM_ConfigChannel(&s_htim1, &oc_init, FAN_TIM_CHANNEL);
|
||||
|
||||
/* Start PWM generation */
|
||||
HAL_TIM_PWM_Start(FAN_TIM, FAN_TIM_CHANNEL);
|
||||
HAL_TIM_PWM_Start(&s_htim1, FAN_TIM_CHANNEL);
|
||||
|
||||
s_fan.current_speed = 0;
|
||||
s_fan.target_speed = 0;
|
||||
|
||||
12
src/i2c1.c
12
src/i2c1.c
@ -31,3 +31,15 @@ int i2c1_init(void) {
|
||||
|
||||
return (HAL_I2C_Init(&hi2c1) == HAL_OK) ? 0 : -1;
|
||||
}
|
||||
|
||||
/* I2C read: send register address, read data */
|
||||
int i2c1_read(uint8_t addr, uint8_t *data, uint16_t len) {
|
||||
/* Master receiver mode: read len bytes from addr */
|
||||
return (HAL_I2C_Master_Receive(&hi2c1, (uint16_t)(addr << 1), data, len, 1000) == HAL_OK) ? 0 : -1;
|
||||
}
|
||||
|
||||
/* I2C write: send register address + data */
|
||||
int i2c1_write(uint8_t addr, const uint8_t *data, uint16_t len) {
|
||||
/* Master transmitter mode: write len bytes to addr */
|
||||
return (HAL_I2C_Master_Transmit(&hi2c1, (uint16_t)(addr << 1), (uint8_t *)data, len, 1000) == HAL_OK) ? 0 : -1;
|
||||
}
|
||||
|
||||
33
src/main.c
33
src/main.c
@ -26,7 +26,6 @@
|
||||
#include "ultrasonic.h"
|
||||
#include "power_mgmt.h"
|
||||
#include "battery.h"
|
||||
#include "coulomb_counter.h"
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
@ -232,9 +231,6 @@ int main(void) {
|
||||
/* Servo pan-tilt animation tick — updates smooth sweeps */
|
||||
servo_tick(now);
|
||||
|
||||
/* Accumulate coulombs for battery state-of-charge estimation (Issue #325) */
|
||||
battery_accumulate_coulombs();
|
||||
|
||||
/* Sleep LED: software PWM on LED1 (active-low PC15) driven by PM brightness.
|
||||
* pm_pwm_phase rolls over each ms; brightness sets duty cycle 0-255. */
|
||||
pm_pwm_phase++;
|
||||
@ -461,12 +457,8 @@ int main(void) {
|
||||
if (now - crsf_telem_tick >= (1000u / CRSF_TELEMETRY_HZ)) {
|
||||
crsf_telem_tick = now;
|
||||
uint32_t vbat_mv = battery_read_mv();
|
||||
/* Use coulomb-based SoC if available, fallback to voltage-based */
|
||||
uint8_t soc_pct = battery_get_soc_coulomb();
|
||||
if (soc_pct == 255) {
|
||||
soc_pct = battery_estimate_pct(vbat_mv);
|
||||
}
|
||||
crsf_send_battery(vbat_mv, coulomb_counter_get_remaining_mah(), soc_pct);
|
||||
uint8_t soc_pct = battery_estimate_pct(vbat_mv);
|
||||
crsf_send_battery(vbat_mv, 0u, soc_pct);
|
||||
crsf_send_flight_mode(bal.state == BALANCE_ARMED);
|
||||
}
|
||||
|
||||
@ -487,9 +479,7 @@ int main(void) {
|
||||
tlm.mode = (uint8_t)mode_manager_active(&mode);
|
||||
EstopSource _es = safety_get_estop();
|
||||
tlm.estop = (uint8_t)_es;
|
||||
/* Use coulomb-based SoC if available, fallback to voltage-based */
|
||||
uint8_t soc = battery_get_soc_coulomb();
|
||||
tlm.soc_pct = (soc == 255) ? battery_estimate_pct(vbat) : soc;
|
||||
tlm.soc_pct = battery_estimate_pct(vbat);
|
||||
tlm.fw_major = FW_MAJOR;
|
||||
tlm.fw_minor = FW_MINOR;
|
||||
tlm.fw_patch = FW_PATCH;
|
||||
@ -597,3 +587,20 @@ int main(void) {
|
||||
HAL_Delay(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Stub Functions (to be implemented)
|
||||
* ================================================================ */
|
||||
|
||||
/* IMU calibration status — returns true if IMU calibration is complete */
|
||||
static bool imu_calibrated(void) {
|
||||
/* Placeholder: return true if both MPU6000 and BNO055 are calibrated */
|
||||
return true;
|
||||
}
|
||||
|
||||
/* CRSF receiver active check — returns true if valid signal received recently */
|
||||
static bool crsf_is_active(uint32_t now) {
|
||||
(void)now; /* Unused parameter */
|
||||
/* Placeholder: check CRSF timeout or heartbeat */
|
||||
return true;
|
||||
}
|
||||
|
||||
23
src/safety.c
23
src/safety.c
@ -12,9 +12,20 @@
|
||||
#include "safety.h"
|
||||
#include "config.h"
|
||||
#include "crsf.h"
|
||||
#include "watchdog.h"
|
||||
#include "stm32f7xx_hal.h"
|
||||
|
||||
/* IWDG prescaler 32 → LSI(40kHz)/32 = 1250 ticks/sec → 0.8ms/tick */
|
||||
#define IWDG_PRESCALER IWDG_PRESCALER_32
|
||||
/* Integer formula: timeout_ms * LSI_HZ / (prescaler * 1000)
|
||||
* = WATCHDOG_TIMEOUT_MS * 40000 / (32 * 1000) = WATCHDOG_TIMEOUT_MS * 40 / 32 */
|
||||
#define IWDG_RELOAD (WATCHDOG_TIMEOUT_MS * 40UL / 32UL)
|
||||
|
||||
#if IWDG_RELOAD > 4095
|
||||
# error "WATCHDOG_TIMEOUT_MS too large for IWDG_PRESCALER_32 — increase prescaler"
|
||||
#endif
|
||||
|
||||
static IWDG_HandleTypeDef hiwdg;
|
||||
|
||||
/* Arm interlock */
|
||||
static uint32_t s_arm_start_ms = 0;
|
||||
static bool s_arm_pending = false;
|
||||
@ -25,13 +36,15 @@ static bool s_was_faulted = false;
|
||||
static EstopSource s_estop_source = ESTOP_CLEAR;
|
||||
|
||||
void safety_init(void) {
|
||||
/* Initialize IWDG via watchdog module (Issue #300) with ~2s timeout */
|
||||
watchdog_init(2000);
|
||||
hiwdg.Instance = IWDG;
|
||||
hiwdg.Init.Prescaler = IWDG_PRESCALER;
|
||||
hiwdg.Init.Reload = IWDG_RELOAD;
|
||||
hiwdg.Init.Window = IWDG_WINDOW_DISABLE;
|
||||
HAL_IWDG_Init(&hiwdg); /* Starts watchdog immediately */
|
||||
}
|
||||
|
||||
void safety_refresh(void) {
|
||||
/* Feed the watchdog timer */
|
||||
watchdog_kick();
|
||||
if (hiwdg.Instance) HAL_IWDG_Refresh(&hiwdg);
|
||||
}
|
||||
|
||||
bool safety_rc_alive(uint32_t now) {
|
||||
|
||||
13
src/servo.c
13
src/servo.c
@ -24,19 +24,6 @@
|
||||
#define SERVO_PRESCALER 53u /* APB1 54 MHz / 54 = 1 MHz */
|
||||
#define SERVO_ARR 19999u /* 1 MHz / 20000 = 50 Hz */
|
||||
|
||||
typedef struct {
|
||||
uint16_t current_angle_deg[SERVO_COUNT];
|
||||
uint16_t target_angle_deg[SERVO_COUNT];
|
||||
uint16_t pulse_us[SERVO_COUNT];
|
||||
|
||||
/* Sweep state */
|
||||
uint32_t sweep_start_ms[SERVO_COUNT];
|
||||
uint32_t sweep_duration_ms[SERVO_COUNT];
|
||||
uint16_t sweep_start_deg[SERVO_COUNT];
|
||||
uint16_t sweep_end_deg[SERVO_COUNT];
|
||||
bool is_sweeping[SERVO_COUNT];
|
||||
} ServoState;
|
||||
|
||||
static ServoState s_servo = {0};
|
||||
static TIM_HandleTypeDef s_tim_handle = {0};
|
||||
|
||||
|
||||
@ -42,6 +42,8 @@ static WatchdogState s_watchdog = {
|
||||
.reload_value = 0
|
||||
};
|
||||
|
||||
static IWDG_HandleTypeDef s_hiwdg = {0};
|
||||
|
||||
/* ================================================================
|
||||
* Helper Functions
|
||||
* ================================================================ */
|
||||
@ -76,6 +78,16 @@ static bool watchdog_calculate_config(uint32_t timeout_ms,
|
||||
return false; /* No suitable prescaler found */
|
||||
}
|
||||
|
||||
/* Get prescaler divider from prescaler value */
|
||||
static uint16_t watchdog_get_divider(uint8_t prescaler)
|
||||
{
|
||||
const uint16_t dividers[] = {4, 8, 16, 32, 64, 128, 256};
|
||||
if (prescaler < 7) {
|
||||
return dividers[prescaler];
|
||||
}
|
||||
return 256;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Public API
|
||||
* ================================================================ */
|
||||
@ -98,13 +110,12 @@ bool watchdog_init(uint32_t timeout_ms)
|
||||
s_watchdog.timeout_ms = timeout_ms;
|
||||
|
||||
/* Configure and start IWDG */
|
||||
IWDG_HandleTypeDef hiwdg = {0};
|
||||
hiwdg.Instance = IWDG;
|
||||
hiwdg.Init.Prescaler = prescaler;
|
||||
hiwdg.Init.Reload = reload;
|
||||
hiwdg.Init.Window = reload; /* Window == Reload means full timeout */
|
||||
s_hiwdg.Instance = IWDG;
|
||||
s_hiwdg.Init.Prescaler = prescaler;
|
||||
s_hiwdg.Init.Reload = reload;
|
||||
s_hiwdg.Init.Window = reload; /* Window == Reload means full timeout */
|
||||
|
||||
HAL_IWDG_Init(&hiwdg);
|
||||
HAL_IWDG_Init(&s_hiwdg);
|
||||
|
||||
s_watchdog.is_initialized = true;
|
||||
s_watchdog.is_running = true;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user