Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
50e3c4a
- diagnostics: added end-to-end nosetest that launches `launchROSServ…
ddoiron79 Dec 9, 2025
07aa6c4
- diagnostics: fixed indentation in the launchROSService websocket te…
ddoiron79 Dec 9, 2025
db9ab77
- e2e: added Playwright-based headless UI smoke test (verifies `statu…
ddoiron79 Dec 18, 2025
797a6cc
- diagnostics: start the WebSocket server from inside the asyncio loo…
ddoiron79 Dec 18, 2025
801013c
- launch: avoid `set -u` in `launchROSService.sh` so sourcing `/opt/r…
ddoiron79 Dec 18, 2025
98d5a27
- launch/e2e: pass `loggerPath` and `configPath` from `POSEIDON_LOGGE…
ddoiron79 Dec 18, 2025
75187d3
- e2e: added Playwright-based headless UI smoke test (verifies `diagn…
ddoiron79 Dec 18, 2025
a949be0
- diagnostics/e2e: make IMU/sonar communication thresholds configurab…
ddoiron79 Dec 18, 2025
8212823
- e2e: avoid `set -u` in the E2E runner when sourcing ROS setup scrip…
ddoiron79 Dec 18, 2025
375c079
- e2e: keep a DBT NMEA writer running for the whole E2E so `/depth` c…
ddoiron79 Dec 18, 2025
33f31ad
Merge branch 'CIDCO-dev:master' into master
Ddoiron-cidco Dec 18, 2025
d734f44
- e2e: allow launching `launchROSService.sh` via sudo when `POSEIDON_…
ddoiron79 Jan 8, 2026
d4bcaf7
- e2e: require root launch by default (`POSEIDON_E2E_LAUNCH_AS_ROOT=1…
ddoiron79 Jan 12, 2026
4095745
CI: clean Catkin build/devel/logs on self-hosted runners before buil…
ddoiron79 Jan 12, 2026
62e1242
- CI: pre-clean the GitHub Actions workspace with sudo before checkou…
ddoiron79 Jan 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ on:
branches: ["**"]
pull_request:
branches: ["**"]
workflow_dispatch: {}
workflow_dispatch:
inputs:
run_hardware_e2e:
description: "Run hardware E2E (backend websocket + headless UI)"
required: false
type: boolean
default: false

defaults:
run:
Expand Down Expand Up @@ -49,18 +55,33 @@ jobs:
libopencv-dev libexiv2-dev libwebsocketpp-dev \
rapidjson-dev libssl-dev libgps-dev libboost-system-dev \
python3-lxml libxml2-dev libxslt1-dev \
lcov
lcov \
python3-websockets
sudo -H python3 -m pip install --no-cache-dir catkin-lint flake8 coverage pytest
if [ ! -f /etc/ros/rosdep/sources.list.d/20-default.list ]; then
sudo rosdep init
fi
rosdep update

- name: Pre-clean workspace (sudo)
run: |
set -eo pipefail
sudo rm -rf "$GITHUB_WORKSPACE"/*
sudo mkdir -p "$GITHUB_WORKSPACE"
sudo chown -R "$USER:$USER" "$GITHUB_WORKSPACE"

- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive

- name: Clean Catkin workspace (self-hosted)
run: |
set -eo pipefail
if [ -d "$CATKIN_WS" ]; then
sudo rm -rf "$CATKIN_WS/build" "$CATKIN_WS/devel" "$CATKIN_WS/logs"
fi

- name: Resolve ROS package dependencies
run: |
set -eo pipefail
Expand Down Expand Up @@ -120,6 +141,49 @@ jobs:
# Report results but do not fail the whole job on first adoption
catkin_test_results build/test_results || true

- name: Setup Playwright (hardware E2E)
if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.run_hardware_e2e == 'true') || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')) }}
run: |
set -eo pipefail
cd test/e2e
npm install --no-package-lock
npx playwright install --with-deps chromium

- name: Run hardware E2E (backend + headless UI)
if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.run_hardware_e2e == 'true') || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')) }}
env:
# Hardware-specific: set this to the USB serial adapter used for injecting fake NMEA DBT strings.
# Must NOT point at /dev/sonar.
DIAGNOSTICS_FAKE_SERIAL_PORT: "/dev/ttyUSB1"
# Relaxed defaults; adjust if the bench needs longer to come up.
POSEIDON_E2E_WAIT_SECONDS: "120"
DIAGNOSTICS_WS_LAUNCH_TIMEOUT: "120"
POSEIDON_E2E_TIMEOUT_MS: "120000"
POSEIDON_E2E_LAUNCH_AS_ROOT: "1"
# Fail early if these expected rows disappear.
POSEIDON_E2E_REQUIRED_DIAGNOSTICS: "GNSS Fix,IMU Communication,Sonar Communication"
# Avoid port/node conflicts with a system-installed Poseidon service on the bench.
POSEIDON_E2E_EXCLUSIVE: "1"
# Make comm diagnostics more tolerant on benches where rates can be lower than nominal.
POSEIDON_DIAG_IMU_HZ: "5"
POSEIDON_DIAG_IMU_WINDOW: "2.0"
POSEIDON_DIAG_IMU_RATIO: "0.5"
POSEIDON_DIAG_SONAR_HZ: "1"
POSEIDON_DIAG_SONAR_WINDOW: "5.0"
POSEIDON_DIAG_SONAR_RATIO: "0.2"
POSEIDON_E2E_DIAGNOSTICS_REFRESH_DELAY_MS: "7000"
run: |
set -eo pipefail
bash test/e2e/run_poseidon_e2e.sh

- name: Upload hardware E2E artifacts
if: ${{ always() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.run_hardware_e2e == 'true') || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'))) }}
uses: actions/upload-artifact@v4
with:
name: e2e-artifacts
path: test/e2e/artifacts
if-no-files-found: ignore

- name: C++ coverage report (gcovr)
run: |
set -eo pipefail
Expand Down
2 changes: 2 additions & 0 deletions agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This guide targets contributors (humans or agents) working in Poseidon. It cover
- Documentation policy: English only across the repository.
- Track ROS1 changes in `version.md`; keep it up to date and group entries by date.
- When editing `version.md`, add new notes under the heading matching the current date (do not add entries under older dates).
- Keep contributor guidance current; update `agents.md`, `version.md`, and `manifest.md` when conventions or structure change.
- Make minimal, safe, targeted changes.
- Respect Catkin structure and separation of C++/Python/launch.
- Keep compatibility across embedded targets (RPi/RockPi) and VM.
Expand Down Expand Up @@ -93,6 +94,7 @@ This guide targets contributors (humans or agents) working in Poseidon. It cover
- [ ] Inline code comments/docstrings remain accurate
- [ ] Related docs in `doc/` updated when behavior/usage changes
- [ ] `version.md` updated with notable changes
- [ ] `agents.md` updated when contributor or automation guidance changes
- [ ] No secrets or non-portable hardcoded paths
- [ ] `manifest.md` updated for structural changes
- [ ] Run `clang-format` and `clang-tidy` for C++ changes (style/diagnostics)
Expand Down
18 changes: 16 additions & 2 deletions launchROSService.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

# Used to call a launch file as a service on boot

set -eo pipefail

# Allow overriding the Poseidon root (useful for CI where the repo is checked out elsewhere).
POSEIDON_ROOT="${POSEIDON_ROOT:-"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"}"
export POSEIDON_ROOT

source /opt/ros/noetic/setup.bash
source /opt/Poseidon/src/workspace/devel/setup.bash
source "$POSEIDON_ROOT/src/workspace/devel/setup.bash"

########################
# Echoboat #
Expand Down Expand Up @@ -104,7 +110,15 @@ source /opt/Poseidon/src/workspace/devel/setup.bash
# Dummy simulator
#roslaunch /opt/Poseidon/src/workspace/launch/Simulator/dummy_simulator.launch

roslaunch /opt/Poseidon/src/workspace/launch/Hydrobox/hydrobox_rpi_nmeadevice_ZED-F9P_bno055.launch time_now:=$(date +%Y.%m.%d_%H%M%S)
LOGGER_PATH="${POSEIDON_LOGGER_PATH:-"$POSEIDON_ROOT/www/webroot/record/"}"
CONFIG_PATH="${POSEIDON_CONFIG_PATH:-"$POSEIDON_ROOT/config.txt"}"
SONAR_DEVICE="${POSEIDON_SONAR_DEVICE:-"/dev/sonar"}"

roslaunch "$POSEIDON_ROOT/src/workspace/launch/Hydrobox/hydrobox_rpi_nmeadevice_ZED-F9P_bno055.launch" \
time_now:=$(date +%Y.%m.%d_%H%M%S) \
loggerPath:="$LOGGER_PATH" \
configPath:="$CONFIG_PATH" \
sonarDevice:="$SONAR_DEVICE"

########################
# Configuration #
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@
<launch>

<arg name="time_now" default="temp" />
<arg name="gnssPortPath" value="/dev/gnss"/>
<arg name="gpsdIp" value="localhost"/>
<arg name="gpsdPort" value="2947"/>
<arg name="gnssPortPath" default="/dev/gnss"/>
<arg name="gpsdIp" default="localhost"/>
<arg name="gpsdPort" default="2947"/>

<arg name="loggerPath" value="/opt/Poseidon/www/webroot/record/"/>
<arg name="configPath" value="/opt/Poseidon/config.txt"/>
<arg name="loggerPath" default="/opt/Poseidon/www/webroot/record/"/>
<arg name="configPath" default="/opt/Poseidon/config.txt"/>

<arg name="boardVersion" value="2.2"/>
<arg name="sonarDevice" default="/dev/sonar"/>

<!-- sonar_nmea_0183_tcp_client/nmea_device_node reads global params under /Sonar/* -->
<param name="/Sonar/device" value="$(arg sonarDevice)"/>
<param name="/Sonar/useDepth" value="true"/>
<param name="/Sonar/usePosition" value="false"/>
<param name="/Sonar/useAttitude" value="false"/>

<node pkg="i2c_controller" name="i2c_controller" type="i2c_controller_node" output="screen" args="$(arg boardVersion)" respawn="true" respawn_delay="1"/>

Expand All @@ -28,8 +35,6 @@


<node pkg="sonar_nmea_0183_tcp_client" type="nmea_device_node" name="Sonar_NMEA" output="screen" respawn="true" respawn_delay="1">
<param name="ip_address" value="127.0.0.1"/>
<param name="port" value="5000"/>
</node>

<node pkg="wifi_file_transfer_config" type="wifi_config_node.py" name="wifi_config" output="screen" respawn="true" respawn_delay="1"/>
Expand Down
1 change: 1 addition & 0 deletions src/workspace/src/diagnostics/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ if(CATKIN_ENABLE_TESTING)
)

catkin_add_nosetests(tests/test_diagnostics_unit.py)
catkin_add_nosetests(tests/test_launchrosservice_websocket.py)
add_rostest(tests/diagnostics_websocket.test)
endif()
2 changes: 2 additions & 0 deletions src/workspace/src/diagnostics/package.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
<exec_depend>rospy</exec_depend>
<exec_depend>diagnostic_msgs</exec_depend>
<exec_depend>std_msgs</exec_depend>
<exec_depend>python3-websockets</exec_depend>
<test_depend>rostest</test_depend>
<test_depend>python3-websockets</test_depend>


<!-- The export tag contains other, unspecified, tags -->
Expand Down
56 changes: 45 additions & 11 deletions src/workspace/src/diagnostics/scripts/diagnostics_websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,37 @@ async def websocket_handler(self, websocket, path):
def start_server(self, port=9099):
"""Lauch WebSocket server in asyncio loop."""
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
try:
asyncio.set_event_loop(self.loop)
except AssertionError:
# Tests may inject a dummy loop that is not an AbstractEventLoop; skip binding in that case.
pass

async def _start_server():
# Newer versions of `websockets` require `serve()` to be called from a running loop.
return await websockets.serve(self.websocket_handler, "0.0.0.0", port)

try:
self.server = self.loop.run_until_complete(_start_server())
except Exception as exc:
ws_version = getattr(websockets, "__version__", "unknown")
rospy.logerr(
f"Failed to start DiagnosticsServer WebSocket on port {port} "
f"(websockets={ws_version}, python={sys.version.split()[0]}): {exc}"
)
return

self.server = websockets.serve(self.websocket_handler, "0.0.0.0", port)
self.loop.run_until_complete(self.server)
rospy.loginfo(f"DiagnosticsServer WebSocket listen to port {port}")
self.loop.run_forever()

def stop_server(self):
"""Stop WebSocket server."""
if self.server:
try:
self.server.close()
except Exception:
pass

if self.loop and self.loop.is_running():
rospy.loginfo("Stopping WebSocket server...")
self.loop.call_soon_threadsafe(self.loop.stop)
Expand All @@ -149,6 +171,18 @@ def main():
rospy.init_node('diagnostics')
server = DiagnosticsServer()

def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, str(default)))
except Exception:
return default

def _env_float(name: str, default: float) -> float:
try:
return float(os.environ.get(name, str(default)))
except Exception:
return default

# Connectivity first to gate DNS/API tests
server.add_test(InternetConnectivityDiagnostic(url="http://example.com", timeout_s=3.0))
server.add_test(DnsResolutionDiagnostic(hostname="google.com"))
Expand All @@ -168,18 +202,18 @@ def main():
))
server.add_test(ImuCommunicationDiagnostic(
name="IMU Communication",
message_frequency=100,
topic="/imu/data",
timeout_s=1.0,
expected_ratio=0.8
message_frequency=_env_int("POSEIDON_DIAG_IMU_HZ", 100),
topic=os.environ.get("POSEIDON_DIAG_IMU_TOPIC", "/imu/data"),
timeout_s=_env_float("POSEIDON_DIAG_IMU_WINDOW", 1.0),
expected_ratio=_env_float("POSEIDON_DIAG_IMU_RATIO", 0.8),
))
server.add_test(SerialNumberDiagnostic(name="Serial Number Pattern Validation"))
server.add_test(SonarCommunicationDiagnostic(
name="Sonar Communication",
message_frequency=1, # car /depth 1 Hz
topic="/depth",
timeout_s=1.5,
expected_ratio=0.8
message_frequency=_env_int("POSEIDON_DIAG_SONAR_HZ", 1), # /depth is often ~1 Hz
topic=os.environ.get("POSEIDON_DIAG_SONAR_TOPIC", "/depth"),
timeout_s=_env_float("POSEIDON_DIAG_SONAR_WINDOW", 1.5),
expected_ratio=_env_float("POSEIDON_DIAG_SONAR_RATIO", 0.8),
))


Expand Down
Loading
Loading