diff --git a/ExoMy_Software-master/README.md b/ExoMy_Software-master/README.md index 89553bb..2331c96 100644 --- a/ExoMy_Software-master/README.md +++ b/ExoMy_Software-master/README.md @@ -1,34 +1,154 @@ -# ExoMy - Software Repository -This repository contains the software to run Exomy. The [wiki](https://github.com/esa-prl/ExoMy/wiki) explains you how to use it. +# ExoMy CUNO — Software -![ExoMy image](https://github.com/esa-prl/ExoMy/wiki/images/renderings/2020_02_25.JPG) +ExoMy Mars-Rover auf Basis eines Raspberry Pi mit ROS1 Melodic, betrieben vollständig in einem Docker-Container. -# ExoMy Project Structure +--- -### [Website](https://esa-prl.github.io/ExoMy/) -There is a website about ExoMy. It does not help you build it, but is still nice to look at. +## Zugang zum Raspberry Pi -### [Wiki](https://github.com/esa-prl/ExoMy/wiki) -The [wiki](https://github.com/esa-prl/ExoMy/wiki) contains step by step instructions on how the 3D-printed rover ExoMy can be built, controlled and customized. +| | | +|---|---| +| **Hostname** | `cuno` | +| **Benutzer** | `pi` | +| **LAN-IP** | `192.168.1.83` | +| **WLAN-IP** | `192.168.1.9` (bevorzugt) | +| **Fallback-AP** | SSID `CUNO`, IP `192.168.50.1`, Passwort `astr0cun042` | -### [Documentation Repository](https://github.com/esa-prl/ExoMy) -This repository contains all the files of the documentation of ExoMy. Just click on [*Releases*](https://github.com/esa-prl/ExoMy/releases) and download the files of the latest release. They are explained further in the [wiki](https://github.com/esa-prl/ExoMy/wiki). +```bash +ssh pi@192.168.1.9 +``` -### Social Media - - +--- - -

- - Join the Community! -

-

- - @ExoMy_Rover -

-

- - @ExoMy_Rover -

+## Architektur +``` +Web-GUI (Port 8000) + │ WebSocket (Port 9090) + ▼ +rosbridge_websocket ──► /joy ──► joystick_parser_node ──► /rover_command + │ +f710_joy_node ──────────────────────────────────────────────────┘ +(Logitech F710, /dev/input/js0) + +/rover_command ──► robot_node ──► /motor_commands ──► motor_node ──► PCA9685 PWM ──► Motoren +``` + +**Software auf dem Pi:** `/home/pi/ExoMy_Software/` +**ROS-Workspace im Container:** `/root/exomy_ws/src/exomy/` + +--- + +## Systemd-Services auf dem Pi + +| Service | Beschreibung | +|---|---| +| `exomy-admin-api.service` | Admin-API (`exomy_admin_api.py`) | +| `exomy-camera-stream.service` | MJPEG Kamera-Stream | +| `exomy-wifi-fallback.service` | WLAN-Fallback auf eigenen Access Point | + +```bash +# Status prüfen +systemctl status exomy-admin-api.service +``` + +--- + +## Docker-Container + +Der Container `exomy_autostart` (Image: `exomy`) startet automatisch beim Booten. + +```bash +# Status +docker ps + +# Logs +docker logs exomy_autostart --tail=50 + +# Shell im Container +docker exec -it exomy_autostart bash +``` + +### ROS-Nodes im Container + +| Node | Beschreibung | +|---|---| +| `f710_joy_node.py` | Liest Logitech F710 von `/dev/input/js0`, publiziert auf `/joy` mit 20 Hz — **auch wenn der Joystick in Ruhe liegt** | +| `joystick_parser_node.py` | Konvertiert `/joy` → `/rover_command`; priorisiert Web-GUI über physischen Controller | +| `robot_node.py` | Berechnet Lenkwinkel und Geschwindigkeiten aus `/rover_command` | +| `motor_node.py` | Setzt PWM-Werte über PCA9685; Watchdog stoppt Motoren nach 5 s ohne Befehl | +| `rosbridge_websocket` | WebSocket-Bridge für die Web-GUI (Port 9090) | +| `rosapi_node` | ROS-API für die Web-GUI | + +### Ports + +| Port | Verwendung | +|---|---| +| `8000` | Web-GUI | +| `9090` | ROSBridge WebSocket | +| `8082` | Admin-API | + +--- + +## Deploy-Workflow + +Dateien lokal bearbeiten, dann auf den Pi und in den laufenden Container übertragen: + +```bash +# Datei auf den Pi kopieren +scp ExoMy_Software-master/src/meine_datei.py pi@192.168.1.9:/home/pi/ExoMy_Software/src/ + +# In den Container kopieren +ssh pi@192.168.1.9 "docker cp /home/pi/ExoMy_Software/src/meine_datei.py exomy_autostart:/root/exomy_ws/src/exomy/src/" + +# Container neu starten +ssh pi@192.168.1.9 "docker restart exomy_autostart" +``` + +GUI-Dateien: + +```bash +scp ExoMy_Software-master/gui/index.html pi@192.168.1.9:/home/pi/ExoMy_Software/gui/ +ssh pi@192.168.1.9 "docker cp /home/pi/ExoMy_Software/gui/index.html exomy_autostart:/root/exomy_ws/src/exomy/gui/" +# Kein Neustart nötig — Browser-Reload genügt +``` + +--- + +## Bekannte Probleme & Fixes + +### Web-GUI: Ruckartige Bewegung bei angeschlossenem Controller + +**Problem:** Der `f710_joy_node` sendet permanent mit 20 Hz auf `/joy`, auch wenn der Logitech F710 in Ruhe liegt (Nullwerte). Wenn die Web-GUI gleichzeitig steuert, wechseln sich Fahr- und Stopp-Befehle im 50-ms-Takt ab — der Rover bewegt sich ruckartig. + +**Fix:** `joystick_parser_node.py` ignoriert Nachrichten des physischen Controllers für 2 Sekunden, sobald eine Web-GUI-Nachricht eintrifft (erkennbar an `frame_id == "webgui"`). Danach übernimmt der physische Controller automatisch wieder. + +### SyntaxError in rover.py (Point-Turn-Modus) + +**Problem:** Überzählige schließende Klammer in `rover.py` Zeile 157 ließ `robot_node.py` beim Start abstürzen — der Rover war nicht steuerbar. + +```python +# Falsch: +math.atan((self.wheel_rx + self.wheel_fx) / self.wheel_ry))) +# Richtig: +math.atan((self.wheel_rx + self.wheel_fx) / self.wheel_ry)) +``` + +--- + +## Fahrmodi + +| Modus | Taste (Controller) | Taste (Web-GUI) | +|---|---|---| +| Ackermann | A | Schaltfläche „Ackermann" | +| Point Turn (Drehen auf der Stelle) | X | Schaltfläche „Turn on Point" | +| Crab (Seitwärtsfahrt) | Y | Schaltfläche „Crab" | +| Motoren ein/aus | START | Schaltfläche „Motoren" | + +--- + +## Originales ESA-Projekt + +- [Wiki](https://github.com/esa-prl/ExoMy/wiki) — Bauanleitung und Dokumentation +- [Website](https://esa-prl.github.io/ExoMy/) +- [Dokumentations-Repository](https://github.com/esa-prl/ExoMy) diff --git a/ExoMy_Software-master/config/exomy.remote.yaml b/ExoMy_Software-master/config/exomy.remote.yaml index b55b6f1..ce529da 100644 --- a/ExoMy_Software-master/config/exomy.remote.yaml +++ b/ExoMy_Software-master/config/exomy.remote.yaml @@ -38,8 +38,13 @@ steer_pwm_neutral_rr: 295 # PWW range around steering neutral position steer_pwm_range: 250 -# PWM value for the stop position of driving motors -drive_pwm_neutral: 307 +# PWM values for the stop position of driving motors +drive_pwm_neutral_fl: 307 +drive_pwm_neutral_fr: 307 +drive_pwm_neutral_cl: 307 +drive_pwm_neutral_cr: 307 +drive_pwm_neutral_rl: 307 +drive_pwm_neutral_rr: 307 # PWW range around driving neutral position drive_pwm_range: 100 diff --git a/ExoMy_Software-master/config/exomy.yaml.template b/ExoMy_Software-master/config/exomy.yaml.template index 52e3e8b..d2ef9ad 100644 --- a/ExoMy_Software-master/config/exomy.yaml.template +++ b/ExoMy_Software-master/config/exomy.yaml.template @@ -38,8 +38,13 @@ steer_pwm_neutral_rr: 300 # PWW range around steering neutral position steer_pwm_range: 250 -# PWM value for the stop position of driving motors -drive_pwm_neutral: 307 +# PWM values for the stop position of driving motors +drive_pwm_neutral_fl: 307 +drive_pwm_neutral_fr: 307 +drive_pwm_neutral_cl: 307 +drive_pwm_neutral_cr: 307 +drive_pwm_neutral_rl: 307 +drive_pwm_neutral_rr: 307 # PWW range around driving neutral position drive_pwm_range: 100 diff --git a/ExoMy_Software-master/docker/Dockerfile b/ExoMy_Software-master/docker/Dockerfile index 1a01fef..10c06c8 100644 --- a/ExoMy_Software-master/docker/Dockerfile +++ b/ExoMy_Software-master/docker/Dockerfile @@ -10,15 +10,12 @@ RUN apt-get update && \ # Install additional ros packages RUN apt-get update && apt-get install ros-melodic-rosbridge-server ros-melodic-joy -y -RUN pip install adafruit-pca9685 +RUN apt-get update && apt-get install python-smbus -y +RUN pip install adafruit-pureio +RUN pip install Adafruit-GPIO==1.0.3 --no-deps +RUN pip install Adafruit-PCA9685==1.0.1 --no-deps -# Install packages for web application -RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - -RUN apt-get update && \ - apt-get install nodejs -y -RUN npm install http-server -g - # Install packages for camera use RUN apt-get update && \ apt-get install ros-melodic-web-video-server ros-melodic-usb-cam -y @@ -33,4 +30,5 @@ RUN mkdir -p $EXOMY_WS/src WORKDIR /root COPY ./entrypoint.sh / +RUN chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] diff --git a/ExoMy_Software-master/docker/entrypoint.sh b/ExoMy_Software-master/docker/entrypoint.sh index 04294eb..21a7fbc 100644 --- a/ExoMy_Software-master/docker/entrypoint.sh +++ b/ExoMy_Software-master/docker/entrypoint.sh @@ -1,19 +1,55 @@ #!/bin/bash +set -eo pipefail + +cleanup() { + for pid_var in HTTP_PID ROSMASTER_PID ROSBRIDGE_PID ROSAPI_PID ROBOT_PID MOTOR_PID JOYSTICK_PID JOY_PID; do + if [[ -n "${!pid_var:-}" ]]; then + kill "${!pid_var}" 2>/dev/null || true + fi + done + wait || true +} + if [[ $1 == "config" ]] then cd /root/exomy_ws/src/exomy/scripts bash elif [[ $1 == "autostart" ]] then + trap cleanup EXIT INT TERM source /opt/ros/melodic/setup.bash cd /root/exomy_ws catkin_make - http-server src/exomy/gui -p 8000 & - + export ROS_MASTER_URI=http://localhost:11311 source devel/setup.bash - roslaunch exomy exomy.launch - bash + cd src/exomy/gui + python -m SimpleHTTPServer 8000 & + HTTP_PID=$! + cd /root/exomy_ws + + rosmaster --core -p 11311 -w 3 > /tmp/rosmaster.log 2>&1 & + ROSMASTER_PID=$! + sleep 3 + + rosparam load /root/exomy_ws/src/exomy/config/exomy.yaml + rosparam set /controller logitech-F710 + + /opt/ros/melodic/lib/rosbridge_server/rosbridge_websocket > /tmp/rosbridge.log 2>&1 & + ROSBRIDGE_PID=$! + /opt/ros/melodic/lib/rosapi/rosapi_node > /tmp/rosapi.log 2>&1 & + ROSAPI_PID=$! + python /root/exomy_ws/src/exomy/src/f710_joy_node.py > /tmp/joy_node.log 2>&1 & + JOY_PID=$! + python /root/exomy_ws/src/exomy/src/robot_node.py > /tmp/robot_node.log 2>&1 & + ROBOT_PID=$! + python /root/exomy_ws/src/exomy/src/motor_node.py > /tmp/motor_node.log 2>&1 & + MOTOR_PID=$! + python /root/exomy_ws/src/exomy/src/joystick_parser_node.py > /tmp/joystick_parser.log 2>&1 & + JOYSTICK_PID=$! + + wait -n "$HTTP_PID" "$ROSMASTER_PID" "$ROSBRIDGE_PID" "$ROSAPI_PID" "$ROBOT_PID" "$MOTOR_PID" "$JOYSTICK_PID" "$JOY_PID" + exit 1 elif [[ $1 == "devel" ]] then cd /root/exomy_ws @@ -24,4 +60,3 @@ then else bash fi - diff --git a/ExoMy_Software-master/gui/admin-motor-test.html b/ExoMy_Software-master/gui/admin-motor-test.html index 8080075..dd764ce 100644 --- a/ExoMy_Software-master/gui/admin-motor-test.html +++ b/ExoMy_Software-master/gui/admin-motor-test.html @@ -138,7 +138,7 @@

- Das entspricht [config_drive_motor_neutral.py]: ein gemeinsamer `drive_pwm_neutral`-Wert für alle Fahrmotoren. Mit `-` und `+` sendest du den Stillstands-PWM direkt an alle Antriebe, bis wirklich alles steht. + Mit `-` und `+` stellst du den Stillstands-PWM jetzt pro Fahrmotor einzeln ein. `Speichern` schreibt alle sechs Werte dauerhaft in die `exomy.yaml`.

@@ -146,22 +146,7 @@
-
-
- Drive PWM Neutral - Alle Fahrmotoren -
- -
- - - - -
- -
- Pins: - -
-
+
@@ -188,9 +173,8 @@ var activeMotorTestTab = 'impulse'; var steeringNeutralValues = {}; var steeringNeutralSavedValues = {}; - var driveNeutralValue = null; - var driveNeutralSavedValue = null; - var driveNeutralPins = {}; + var driveNeutralValues = {}; + var driveNeutralSavedValues = {}; var STEERING_NEUTRAL_STEP = 5; var WHEEL_ORDER = ['fl', 'fr', 'cl', 'cr', 'rl', 'rr']; @@ -288,7 +272,7 @@ loadSteeringNeutralValues(); } - if (isDriveNeutral && driveNeutralValue === null) { + if (isDriveNeutral && !Object.keys(driveNeutralValues).length) { loadDriveNeutralValue(); } } @@ -348,38 +332,59 @@ button.disabled = isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || !hasUnsavedNeutralChanges(); } - function renderDriveNeutralCard() { - var valueNode = document.getElementById('drive_neutral_value'); - var pinsNode = document.getElementById('drive_neutral_pins'); - var card = document.getElementById('drive_neutral_card'); - - if (valueNode) { - valueNode.textContent = driveNeutralValue === null ? '-' : String(driveNeutralValue); + function renderDriveNeutralCards() { + var grid = document.getElementById('drive_neutral_grid'); + if (!grid) { + return; } - if (pinsNode) { - var pinValues = WHEEL_ORDER - .filter(function (wheel) { return Object.prototype.hasOwnProperty.call(driveNeutralPins, wheel); }) - .map(function (wheel) { return wheel.toUpperCase() + ':' + driveNeutralPins[wheel]; }); - pinsNode.textContent = pinValues.length ? 'Pins: ' + pinValues.join(' ') : 'Pins: -'; - } + var html = []; + WHEEL_ORDER.forEach(function (wheel) { + var entry = driveNeutralValues[wheel]; + if (!entry) { + return; + } - if (card) { - var isDirty = driveNeutralValue !== null && driveNeutralSavedValue !== null && Number(driveNeutralValue) !== Number(driveNeutralSavedValue); - card.classList.toggle('is_dirty', isDirty); - } + var savedEntry = driveNeutralSavedValues[wheel]; + var isDirty = !savedEntry || Number(savedEntry.value) !== Number(entry.value); + html.push( + '
' + + '
' + + '
' + + '' + entry.wheel_label + '' + + '
' + + '
' + + '' + + '' + entry.value + '' + + '' + + '
' + + '
' + + '
' + ); + }); + grid.innerHTML = html.join(''); updateDriveNeutralSaveButton(); } + function hasUnsavedDriveNeutralChanges() { + return WHEEL_ORDER.some(function (wheel) { + var currentEntry = driveNeutralValues[wheel]; + var savedEntry = driveNeutralSavedValues[wheel]; + if (!currentEntry || !savedEntry) { + return false; + } + return Number(currentEntry.value) !== Number(savedEntry.value); + }); + } + function updateDriveNeutralSaveButton() { var button = document.getElementById('drive_neutral_save_button'); if (!button) { return; } - var hasChanges = driveNeutralValue !== null && driveNeutralSavedValue !== null && Number(driveNeutralValue) !== Number(driveNeutralSavedValue); - button.disabled = isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving || !hasChanges; + button.disabled = isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving || !hasUnsavedDriveNeutralChanges(); } async function fetchMotorTestStatus() { @@ -606,7 +611,7 @@ isDriveNeutralLoading = true; updateDriveNeutralSaveButton(); - setMotorTestStatus('Lade Fahr-Neutralwert...'); + setMotorTestStatus('Lade Fahr-Neutralwerte...'); try { var response = await fetch(adminApiBase + '/api/motor-test/drive-neutral'); @@ -616,37 +621,44 @@ throw new Error(data.status || ('status ' + response.status)); } - driveNeutralValue = Number(data.drive_neutral && data.drive_neutral.value); - driveNeutralSavedValue = driveNeutralValue; - driveNeutralPins = data.drive_neutral && data.drive_neutral.pins ? data.drive_neutral.pins : {}; - renderDriveNeutralCard(); + driveNeutralValues = data.drive_neutral && data.drive_neutral.values ? data.drive_neutral.values : {}; + driveNeutralSavedValues = JSON.parse(JSON.stringify(driveNeutralValues)); + renderDriveNeutralCards(); renderContainerStatus(data); - setMotorTestStatus(data.status || 'Fahr-Neutralwert geladen'); + setMotorTestStatus(data.status || 'Fahr-Neutralwerte geladen'); } catch (error) { console.error(error); setMotorTestStatus('Fehler'); - alert(error.message || 'Der Fahr-Neutralwert konnte nicht geladen werden.'); + alert(error.message || 'Die Fahr-Neutralwerte konnten nicht geladen werden.'); } finally { isDriveNeutralLoading = false; updateDriveNeutralSaveButton(); } } - async function adjustDriveNeutralValue(delta) { + async function adjustDriveNeutralValue(wheel, delta) { if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving) { return; } - if (driveNeutralValue === null) { + var entry = driveNeutralValues[wheel]; + if (!entry) { return; } - driveNeutralValue = Math.max(0, Math.min(4095, Number(driveNeutralValue) + Number(delta))); - renderDriveNeutralCard(); + entry.value = Math.max(0, Math.min(4095, Number(entry.value) + Number(delta))); + renderDriveNeutralCards(); isDriveNeutralPreviewRunning = true; updateDriveNeutralSaveButton(); - setMotorTestStatus('Fahre Fahr-Neutralwert an...'); + setMotorTestStatus('Fahre Fahr-Neutralwerte an...'); + + var values = {}; + WHEEL_ORDER.forEach(function (wheelKey) { + if (driveNeutralValues[wheelKey]) { + values[wheelKey] = Number(driveNeutralValues[wheelKey].value); + } + }); try { var response = await fetch(adminApiBase + '/api/motor-test/drive-neutral/preview', { @@ -655,7 +667,7 @@ 'Content-Type': 'application/json' }, body: JSON.stringify({ - value: driveNeutralValue + values: values }) }); var data = await response.json(); @@ -664,10 +676,10 @@ throw new Error(data.status || ('status ' + response.status)); } - driveNeutralPins = data.drive_neutral && data.drive_neutral.pins ? data.drive_neutral.pins : driveNeutralPins; - renderDriveNeutralCard(); + driveNeutralValues = data.drive_neutral && data.drive_neutral.values ? data.drive_neutral.values : driveNeutralValues; + renderDriveNeutralCards(); renderContainerStatus(data); - setMotorTestStatus(data.status || 'Fahr-Neutralwert angefahren'); + setMotorTestStatus(data.status || 'Fahr-Neutralwerte angefahren'); } catch (error) { console.error(error); setMotorTestStatus('Fehler'); @@ -683,13 +695,21 @@ return; } - if (driveNeutralValue === null || driveNeutralSavedValue === null || Number(driveNeutralValue) === Number(driveNeutralSavedValue)) { + if (!hasUnsavedDriveNeutralChanges()) { return; } isDriveNeutralSaving = true; updateDriveNeutralSaveButton(); - setMotorTestStatus('Speichere Fahr-Neutralwert...'); + setMotorTestStatus('Speichere Fahr-Neutralwerte...'); + + var values = {}; + WHEEL_ORDER.forEach(function (wheel) { + var entry = driveNeutralValues[wheel]; + if (entry) { + values[entry.key] = Number(entry.value); + } + }); try { var response = await fetch(adminApiBase + '/api/motor-test/drive-neutral/save', { @@ -698,7 +718,7 @@ 'Content-Type': 'application/json' }, body: JSON.stringify({ - value: driveNeutralValue + values: values }) }); var data = await response.json(); @@ -707,16 +727,15 @@ throw new Error(data.status || ('status ' + response.status)); } - driveNeutralValue = Number(data.drive_neutral && data.drive_neutral.value); - driveNeutralSavedValue = driveNeutralValue; - driveNeutralPins = data.drive_neutral && data.drive_neutral.pins ? data.drive_neutral.pins : driveNeutralPins; - renderDriveNeutralCard(); + driveNeutralValues = data.drive_neutral && data.drive_neutral.values ? data.drive_neutral.values : driveNeutralValues; + driveNeutralSavedValues = JSON.parse(JSON.stringify(driveNeutralValues)); + renderDriveNeutralCards(); renderContainerStatus(data); - setMotorTestStatus(data.status || 'Fahr-Neutralwert gespeichert'); + setMotorTestStatus(data.status || 'Fahr-Neutralwerte gespeichert'); } catch (error) { console.error(error); setMotorTestStatus('Fehler'); - alert(error.message || 'Der Fahr-Neutralwert konnte nicht gespeichert werden.'); + alert(error.message || 'Die Fahr-Neutralwerte konnten nicht gespeichert werden.'); } finally { isDriveNeutralSaving = false; updateDriveNeutralSaveButton(); diff --git a/ExoMy_Software-master/gui/admin.html b/ExoMy_Software-master/gui/admin.html index 07b9c64..14a5733 100644 --- a/ExoMy_Software-master/gui/admin.html +++ b/ExoMy_Software-master/gui/admin.html @@ -89,6 +89,10 @@ CPU-Temperatur - +
+ Unterspannung + - +
Uptime - @@ -145,12 +149,47 @@ node.classList.add('state_unknown'); } + function setUndervoltageState(id, text, state) { + var node = document.getElementById(id); + if (!node) { + return; + } + + node.textContent = text || '-'; + node.classList.remove('state_active', 'state_inactive', 'state_unknown'); + + if (state === 'active') { + node.classList.add('state_inactive'); + node.style.animation = 'statusBlink 0.9s step-end infinite'; + return; + } + + node.style.animation = ''; + + if (state === 'past') { + node.classList.add('state_unknown'); + return; + } + + if (state === 'clear') { + node.classList.add('state_active'); + return; + } + + node.classList.add('state_unknown'); + } + function renderStatus(data) { setAdminStatus(data.status || 'Bereit'); setText('info_ips', data.system && data.system.ips); setText('info_wifi', data.system && data.system.wifi); setText('info_temp', data.system && data.system.cpu_temperature); + setUndervoltageState( + 'info_undervoltage', + data.system && data.system.undervoltage, + data.system && data.system.undervoltage_state + ); setText('info_uptime', data.system && data.system.uptime); setText('info_disk', data.system && data.system.disk_free); diff --git a/ExoMy_Software-master/gui/index.html b/ExoMy_Software-master/gui/index.html index 8b88ab8..fa7facf 100644 --- a/ExoMy_Software-master/gui/index.html +++ b/ExoMy_Software-master/gui/index.html @@ -187,6 +187,12 @@ header { color: #fff; box-shadow: 0 0 24px rgba(0,136,255,.45), inset 0 0 16px rgba(255,255,255,.1); } +@keyframes mode-flash { + 0% { box-shadow: 0 0 0px rgba(0,255,145,0), background-color: var(--pri); } + 30% { box-shadow: 0 0 28px rgba(0,255,145,.9), background-color: #00ff91; } + 100% { box-shadow: 0 0 24px rgba(0,136,255,.45), background-color: var(--pri); } +} +.dbtn.flash { animation: mode-flash .5s ease-out forwards; } #cam-panel { flex: 1; display: flex; flex-direction: column; min-height: 0; } .cam-port { @@ -205,19 +211,6 @@ header { background: #05070d; z-index: 1; } -.c-scanlines { - position: absolute; - inset: 0; - pointer-events: none; - z-index: 5; - background: repeating-linear-gradient( - 0deg, - transparent, - transparent 3px, - rgba(0,0,0,.09) 3px, - rgba(0,0,0,.09) 4px - ); -} .c-vignette { position: absolute; inset: 0; @@ -230,42 +223,19 @@ header { top: 50%; left: 50%; transform: translate(-50%, -50%); - width: 50px; - height: 50px; + width: 180px; + height: 180px; z-index: 10; + pointer-events: none; } -.c-xhair::before { - content: ""; - position: absolute; - top: 50%; - left: 0; - right: 0; - height: 1px; - background: rgba(0,255,145,.45); - transform: translateY(-50%); +@keyframes xh-spin { to { transform: rotate(360deg); } } +@keyframes xh-pulse { + 0% { transform: scale(1); opacity: .7; } + 100% { transform: scale(2.8); opacity: 0; } } -.c-xhair::after { - content: ""; - position: absolute; - left: 50%; - top: 0; - bottom: 0; - width: 1px; - background: rgba(0,255,145,.45); - transform: translateX(-50%); -} -.xc { - position: absolute; - width: 9px; - height: 9px; - border-color: rgba(0,255,145,.7); - border-style: solid; - border-width: 0; -} -.xc.tl { top: 0; left: 0; border-top-width: 1.5px; border-left-width: 1.5px; } -.xc.tr { top: 0; right: 0; border-top-width: 1.5px; border-right-width: 1.5px; } -.xc.bl { bottom: 0; left: 0; border-bottom-width: 1.5px; border-left-width: 1.5px; } -.xc.br { bottom: 0; right: 0; border-bottom-width: 1.5px; border-right-width: 1.5px; } +.xh-rot { animation: xh-spin 14s linear infinite; transform-box: fill-box; transform-origin: center; } +.xh-rot-rev { animation: xh-spin 8s linear infinite reverse; transform-box: fill-box; transform-origin: center; } +.xh-pulse { animation: xh-pulse 2.5s ease-out infinite; transform-box: fill-box; transform-origin: center; } .c-rec { position: absolute; top: 13px; @@ -328,11 +298,20 @@ header { .sval.g { color: var(--ok); } .sval.o { color: var(--sec); } .sval.b { color: var(--pri); } +.sval.alert { + color: #D50000; + animation: uv-blink 0.9s step-end infinite; +} +.sval.warntext { color: var(--sec); } .sval-conn { display: flex; align-items: center; gap: 5px; color: var(--ok); font-size: 10px; } .sbar-wrap { display: flex; align-items: center; gap: 8px; } .sbar { width: 52px; height: 3px; background: rgba(0,136,255,.12); border-radius: 1px; overflow: hidden; } .sbar-i { height: 100%; border-radius: 1px; background: var(--pri); } .sbar-i.w { background: var(--sec); } +@keyframes uv-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.2; } +} #joy-panel .p-hdr { padding: 14px 12px; } .mode-toggle { display: flex; gap: 2px; margin-left: auto; } @@ -563,10 +542,41 @@ header { STREAM: 8081
-
-
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-
+
@@ -633,6 +643,10 @@ header { IP Adressen -- +
+ Unterspannung + -- +
Uptime Pi -- @@ -704,6 +718,7 @@ header { var hostUrl = window.location.hostname; var ros = null; var joyListener = null; +var roverCommandListener = null; var publishTimer = null; var statusTimer = null; var axes = [0, 0, 0, 0, 0, 0]; @@ -771,6 +786,45 @@ function updateModeDisplay() { setText("stat-mode", label); } +function applyDriveMode(mode, flash) { + var changed = mode !== driveMode; + driveMode = mode; + document.querySelectorAll(".dbtn[data-mode]").forEach(function (btn) { + var isActive = btn.dataset.mode === mode; + btn.classList.toggle("active", isActive); + if (isActive && changed && flash) { + btn.classList.remove("flash"); + void btn.offsetWidth; + btn.classList.add("flash"); + } + }); + updateModeDisplay(); + updateControlLocks(); +} + +function mirrorJoyThumb(x, y) { + if (dragging || inputMode !== "joystick") return; + var data = joyMetrics(); + var dx = x * data.maxD; + var dy = -y * data.maxD; + var thumb = document.getElementById("jthumb"); + thumb.style.transition = "left .05s, top .05s"; + thumb.style.left = "calc(50% + " + dx + "px)"; + thumb.style.top = "calc(50% + " + dy + "px)"; + thumb.style.transform = "translate(-50%,-50%)"; + updateAxesDisplay(x, y); +} + +function locomotionModeToUi(modeValue) { + if (modeValue === 2) { + return "turn-on-point"; + } + if (modeValue === 3) { + return "crab"; + } + return "ackermann"; +} + function updateMotorDisplay() { setText("stat-motors", motorsEnabled ? "Aktiv" : "Aus"); } @@ -787,11 +841,35 @@ function setRosStatus(text) { setText("mission-state", text === "Verbunden" ? "Nominal" : text); } +function updateUndervoltageDisplay(text, state) { + var el = document.getElementById("stat-undervoltage"); + if (!el) { + return; + } + el.textContent = text || "--"; + el.classList.remove("b", "g", "o", "alert", "warntext"); + + if (state === "active") { + el.classList.add("alert"); + return; + } + if (state === "past") { + el.classList.add("warntext"); + return; + } + if (state === "clear") { + el.classList.add("g"); + return; + } + el.classList.add("b"); +} + function updateServiceState(payload) { var system = payload.system || {}; var services = payload.services || {}; setText("stat-wifi", system.wifi || "unbekannt"); setText("stat-ips", system.ips || "unbekannt"); + updateUndervoltageDisplay(system.undervoltage || "unbekannt", system.undervoltage_state || "unknown"); setText("stat-uptime", system.uptime || "unbekannt"); setText("stat-disk", system.disk_free || "unbekannt"); setText("val-cpu", system.cpu_usage || "--"); @@ -832,10 +910,10 @@ function publishJoy() { } function startPublishing() { - publishJoy(); if (publishTimer) { - clearInterval(publishTimer); + return; } + publishJoy(); publishTimer = window.setInterval(publishJoy, 50); } @@ -868,12 +946,7 @@ function pulseButton(buttonIndex) { } function selectDriveMode(mode, buttonIndex) { - driveMode = mode; - document.querySelectorAll(".dbtn[data-mode]").forEach(function (btn) { - btn.classList.toggle("active", btn.dataset.mode === mode); - }); - updateModeDisplay(); - updateControlLocks(); + applyDriveMode(mode); pulseButton(buttonIndex); } @@ -940,6 +1013,7 @@ function moveThumb(clientX, clientY) { var nx = dx / data.maxD; var ny = -dy / data.maxD; setAxes(nx, ny); + publishJoy(); startPublishing(); } @@ -1013,6 +1087,30 @@ window.addEventListener("load", function () { messageType: "sensor_msgs/Joy" }); + roverCommandListener = new ROSLIB.Topic({ + ros: ros, + name: "/rover_command", + messageType: "exomy/RoverCommand" + }); + + roverCommandListener.subscribe(function (message) { + applyDriveMode(locomotionModeToUi(message.locomotion_mode), true); + motorsEnabled = !!message.motors_enabled; + updateMotorDisplay(); + }); + + var joySubscriber = new ROSLIB.Topic({ + ros: ros, + name: "/joy", + messageType: "sensor_msgs/Joy" + }); + joySubscriber.subscribe(function (message) { + if (message.header.frame_id === "webgui") return; + var x = (message.axes[0] || 0); + var y = -(message.axes[1] || 0); + mirrorJoyThumb(x, y); + }); + document.querySelectorAll(".dbtn[data-mode]").forEach(function (button) { button.addEventListener("click", function () { selectDriveMode(button.dataset.mode, parseInt(button.dataset.button, 10)); diff --git a/ExoMy_Software-master/gui/style.css b/ExoMy_Software-master/gui/style.css index df11db5..969a9de 100644 --- a/ExoMy_Software-master/gui/style.css +++ b/ExoMy_Software-master/gui/style.css @@ -455,6 +455,15 @@ h2 { color: #8a6b35; } +@keyframes statusBlink { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.25; + } +} + .admin-page { color: #1b2730; overflow-y: auto; diff --git a/ExoMy_Software-master/scripts/admin_motor_test.py b/ExoMy_Software-master/scripts/admin_motor_test.py index c60656d..0069801 100644 --- a/ExoMy_Software-master/scripts/admin_motor_test.py +++ b/ExoMy_Software-master/scripts/admin_motor_test.py @@ -54,6 +54,15 @@ DRIVE_PIN_KEYS = { 'rr': 'pin_drive_rr', } +DRIVE_NEUTRAL_KEYS = { + 'fl': 'drive_pwm_neutral_fl', + 'fr': 'drive_pwm_neutral_fr', + 'cl': 'drive_pwm_neutral_cl', + 'cr': 'drive_pwm_neutral_cr', + 'rl': 'drive_pwm_neutral_rl', + 'rr': 'drive_pwm_neutral_rr', +} + ACTION_SETTINGS = { 'steer_left': { 'kind': 'steer', @@ -94,7 +103,10 @@ class PCA9685Direct: try: import smbus except ImportError as exc: - raise RuntimeError('smbus ist nicht installiert.') from exc + try: + import smbus2 as smbus + except ImportError as inner_exc: + raise RuntimeError('smbus ist nicht installiert.') from inner_exc self.address = address self.bus = smbus.SMBus(busnum) @@ -176,7 +188,10 @@ class MotorTester: self.config = _load_config(self.config_path) self.pwm.set_pwm_freq(50) self.steer_pwm_range = int(self.config['steer_pwm_range']) - self.drive_pwm_neutral = int(self.config['drive_pwm_neutral']) + self.drive_pwm_neutral = { + wheel: int(self.config[DRIVE_NEUTRAL_KEYS[wheel]]) + for wheel in WHEELS + } self.drive_pwm_range = int(self.config['drive_pwm_range']) def _steer_pin(self, wheel): @@ -188,10 +203,12 @@ class MotorTester: def _drive_pin(self, wheel): return int(self.config[DRIVE_PIN_KEYS[wheel]]) - def set_drive_neutral_pwm(self, pwm_value): - duty_cycle = int(pwm_value) + def _drive_neutral(self, wheel): + return int(self.drive_pwm_neutral[wheel]) + + def set_drive_neutral_pwm(self, values): for wheel in WHEELS: - self.pwm.set_pwm(self._drive_pin(wheel), 0, duty_cycle) + self.pwm.set_pwm(self._drive_pin(wheel), 0, int(values[wheel])) time.sleep(0.03) def center_all_steering(self): @@ -201,7 +218,7 @@ class MotorTester: def stop_all_driving(self): for wheel in WHEELS: - self.pwm.set_pwm(self._drive_pin(wheel), 0, self.drive_pwm_neutral) + self.pwm.set_pwm(self._drive_pin(wheel), 0, self._drive_neutral(wheel)) time.sleep(0.03) def set_steering_angle(self, wheel, angle): @@ -237,12 +254,12 @@ class MotorTester: def test_driving(self, wheel, speed, duration): self.pwm.set_pwm(self._steer_pin(wheel), 0, self._steer_neutral(wheel)) duty_cycle = int( - self.drive_pwm_neutral + + self._drive_neutral(wheel) + speed / 100.0 * self.drive_pwm_range * WHEEL_DIRECTIONS[wheel] ) self.pwm.set_pwm(self._drive_pin(wheel), 0, duty_cycle) time.sleep(duration) - self.pwm.set_pwm(self._drive_pin(wheel), 0, self.drive_pwm_neutral) + self.pwm.set_pwm(self._drive_pin(wheel), 0, self._drive_neutral(wheel)) def execute(self, wheel, action): if wheel not in WHEELS: @@ -418,30 +435,61 @@ def save_steering_neutral_values(values, config_path=None): def get_drive_neutral_value(config_path=None): tester = MotorTester(config_path=config_path) + values = {} + for wheel in WHEELS: + key = DRIVE_NEUTRAL_KEYS[wheel] + values[wheel] = { + 'wheel': wheel, + 'wheel_label': WHEEL_LABELS[wheel], + 'key': key, + 'value': int(tester.config[key]), + 'pin': tester._drive_pin(wheel), + } return { 'config_path': tester.config_path, - 'value': int(tester.config['drive_pwm_neutral']), - 'pins': {wheel: tester._drive_pin(wheel) for wheel in WHEELS}, + 'values': values, } -def preview_drive_neutral_value(pwm_value, config_path=None): +def preview_drive_neutral_value(values, config_path=None): tester = MotorTester(config_path=config_path) - value = int(pwm_value) + preview_values = {} + for wheel in WHEELS: + if wheel not in values: + raise ValueError(f'Fahr-Neutralwert fehlt: {DRIVE_NEUTRAL_KEYS[wheel]}') + preview_values[wheel] = int(values[wheel]) + tester.center_all_steering() - tester.set_drive_neutral_pwm(value) + tester.set_drive_neutral_pwm(preview_values) return { - 'value': value, - 'pins': {wheel: tester._drive_pin(wheel) for wheel in WHEELS}, + 'values': { + wheel: { + 'wheel': wheel, + 'wheel_label': WHEEL_LABELS[wheel], + 'key': DRIVE_NEUTRAL_KEYS[wheel], + 'value': preview_values[wheel], + 'pin': tester._drive_pin(wheel), + } + for wheel in WHEELS + }, } -def save_drive_neutral_value(pwm_value, config_path=None): +def save_drive_neutral_value(values, config_path=None): tester = MotorTester(config_path=config_path) - value = int(pwm_value) - _update_config_values(tester.config_path, {'drive_pwm_neutral': value}) + updates = {} + for wheel in WHEELS: + key = DRIVE_NEUTRAL_KEYS[wheel] + if key not in values: + raise ValueError(f'Fahr-Neutralwert fehlt: {key}') + updates[key] = int(values[key]) + + _update_config_values(tester.config_path, updates) tester.config = _load_config(tester.config_path) - tester.drive_pwm_neutral = int(tester.config['drive_pwm_neutral']) + tester.drive_pwm_neutral = { + wheel: int(tester.config[DRIVE_NEUTRAL_KEYS[wheel]]) + for wheel in WHEELS + } tester.center_all_steering() tester.set_drive_neutral_pwm(tester.drive_pwm_neutral) return get_drive_neutral_value(config_path=tester.config_path) diff --git a/ExoMy_Software-master/scripts/config_drive_motor_neutral.py b/ExoMy_Software-master/scripts/config_drive_motor_neutral.py index 5877ddb..c8db433 100644 --- a/ExoMy_Software-master/scripts/config_drive_motor_neutral.py +++ b/ExoMy_Software-master/scripts/config_drive_motor_neutral.py @@ -4,31 +4,29 @@ import time import os config_filename = '../config/exomy.yaml' +WHEELS = ('fl', 'fr', 'cl', 'cr', 'rl', 'rr') def get_driving_pins(): - pin_list = [] with open(config_filename, 'r') as file: param_dict = yaml.load(file) - for key, value in param_dict.items(): - if('pin_drive_' in str(key)): - pin_list.append(value) - return pin_list + return [param_dict['pin_drive_' + wheel] for wheel in WHEELS] -def get_drive_pwm_neutral(): - +def get_drive_pwm_neutral_values(): with open(config_filename, 'r') as file: param_dict = yaml.load(file) - for key, value in param_dict.items(): - if('drive_pwm_neutral' in str(key)): - return value - - default_value = 300 - print('The parameter drive_pwm_neutral could not be found in the exomy.yaml \n') - print('It was set to the default value: '+ default_value + '\n') - return default_value + values = {} + for wheel in WHEELS: + key = 'drive_pwm_neutral_' + wheel + if key not in param_dict: + print('The parameter ' + key + ' could not be found in the exomy.yaml \n') + print('It was set to the default value: 300\n') + values[wheel] = 300 + else: + values[wheel] = param_dict[key] + return values if __name__ == "__main__": print( @@ -62,7 +60,7 @@ On each motor you have to turn the correction screw until the motor really stand pwm = Adafruit_PCA9685.PCA9685() ''' - The drive_pwm_neutral value is determined from the exomy.yaml file. + The drive_pwm_neutral values are determined from the exomy.yaml file. But it can be also calculated from the values of the PWM board and motors, like shown in the following calculation: @@ -83,11 +81,11 @@ On each motor you have to turn the correction screw until the motor really stand value = int(duty_cycle*4096.0) # 307 ''' - value = get_drive_pwm_neutral() + value_dict = get_drive_pwm_neutral_values() pin_list = get_driving_pins() - for pin in pin_list: - pwm.set_pwm(pin, 0, value) + for index, pin in enumerate(pin_list): + pwm.set_pwm(pin, 0, value_dict[WHEELS[index]]) time.sleep(0.1) raw_input('Press any button if you are done to complete configuration\n') diff --git a/ExoMy_Software-master/scripts/exomy-wifi-fallback.service b/ExoMy_Software-master/scripts/exomy-wifi-fallback.service new file mode 100644 index 0000000..548a010 --- /dev/null +++ b/ExoMy_Software-master/scripts/exomy-wifi-fallback.service @@ -0,0 +1,13 @@ +[Unit] +Description=ExoMy WLAN Fallback Manager +After=NetworkManager.service +Wants=NetworkManager.service + +[Service] +Type=simple +ExecStart=/usr/local/bin/exomy-wifi-fallback-manager.sh +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/ExoMy_Software-master/scripts/exomy_admin_api.py b/ExoMy_Software-master/scripts/exomy_admin_api.py index 9404878..bc0a925 100644 --- a/ExoMy_Software-master/scripts/exomy_admin_api.py +++ b/ExoMy_Software-master/scripts/exomy_admin_api.py @@ -72,6 +72,41 @@ def read_memory_usage(): return 'unbekannt' +def read_undervoltage_status(): + stdout, _, returncode = run_command(['vcgencmd', 'get_throttled']) + if returncode != 0 or not stdout or '=' not in stdout: + return { + 'text': 'unbekannt', + 'state': 'unknown', + } + + try: + throttled_value = int(stdout.split('=', 1)[1].strip(), 16) + except ValueError: + return { + 'text': 'unbekannt', + 'state': 'unknown', + } + + undervoltage_now = bool(throttled_value & 0x1) + undervoltage_occurred = bool(throttled_value & 0x10000) + + if undervoltage_now: + return { + 'text': 'Ja, aktuell', + 'state': 'active', + } + if undervoltage_occurred: + return { + 'text': 'Früher erkannt', + 'state': 'past', + } + return { + 'text': 'Nein', + 'state': 'clear', + } + + def format_uptime(): try: with open('/proc/uptime', 'r', encoding='utf-8') as handle: @@ -100,11 +135,25 @@ def format_disk_free(): def get_ip_addresses(): + stdout, _, returncode = run_command(['ip', '-4', '-o', 'addr', 'show', 'dev', 'wlan0', 'scope', 'global']) + if returncode == 0 and stdout: + for line in stdout.splitlines(): + parts = line.split() + if 'inet' in parts: + inet_index = parts.index('inet') + if inet_index + 1 < len(parts): + return parts[inet_index + 1].split('/')[0] + stdout, _, returncode = run_command(['hostname', '-I']) if returncode != 0 or not stdout: return 'unbekannt' - ips = [item for item in stdout.split() if item] - return ', '.join(ips) if ips else 'unbekannt' + + ipv4_addresses = [] + for item in stdout.split(): + if item.count('.') == 3 and not item.startswith('172.17.'): + ipv4_addresses.append(item) + + return ipv4_addresses[0] if ipv4_addresses else 'unbekannt' def get_wifi_status(): @@ -149,11 +198,14 @@ def get_motor_test_container_status(): def collect_status(): + undervoltage = read_undervoltage_status() return { 'status': 'Bereit', 'system': { 'wifi': get_wifi_status(), 'ips': get_ip_addresses(), + 'undervoltage': undervoltage['text'], + 'undervoltage_state': undervoltage['state'], 'cpu_temperature': read_cpu_temperature(), 'cpu_usage': read_cpu_usage(), 'memory_usage': read_memory_usage(), @@ -315,17 +367,17 @@ def get_drive_neutral_status(): try: result = get_drive_neutral_value() return 200, { - 'status': 'Fahr-Neutralwert geladen', + 'status': 'Fahr-Neutralwerte geladen', 'drive_neutral': result, 'container_status': container_status, } except FileNotFoundError: return 500, {'status': 'Motor-Konfiguration fehlt'} except Exception: - return 500, {'status': 'Fahr-Neutralwert konnte nicht geladen werden'} + return 500, {'status': 'Fahr-Neutralwerte konnten nicht geladen werden'} -def preview_drive_neutral_action(value): +def preview_drive_neutral_action(values): from admin_motor_test import preview_drive_neutral_value container_status = get_motor_test_container_status() @@ -336,9 +388,9 @@ def preview_drive_neutral_action(value): } try: - result = preview_drive_neutral_value(int(value)) + result = preview_drive_neutral_value(values) return 200, { - 'status': f"Fahr-Neutralwert: PWM {result['value']}", + 'status': 'Fahr-Neutralwerte angefahren', 'drive_neutral': result, 'container_status': container_status, } @@ -350,7 +402,7 @@ def preview_drive_neutral_action(value): return 500, {'status': 'Fahr-Vorschau fehlgeschlagen'} -def save_drive_neutral_action(value): +def save_drive_neutral_action(values): from admin_motor_test import save_drive_neutral_value container_status = get_motor_test_container_status() @@ -361,9 +413,9 @@ def save_drive_neutral_action(value): } try: - result = save_drive_neutral_value(int(value)) + result = save_drive_neutral_value(values) return 200, { - 'status': 'Fahr-Neutralwert gespeichert', + 'status': 'Fahr-Neutralwerte gespeichert', 'drive_neutral': result, 'container_status': container_status, } @@ -374,7 +426,7 @@ def save_drive_neutral_action(value): except FileNotFoundError: return 500, {'status': 'Motor-Konfiguration fehlt'} except Exception: - return 500, {'status': 'Fahr-Neutralwert konnte nicht gespeichert werden'} + return 500, {'status': 'Fahr-Neutralwerte konnten nicht gespeichert werden'} class Handler(http.server.BaseHTTPRequestHandler): @@ -431,7 +483,12 @@ class Handler(http.server.BaseHTTPRequestHandler): self.write_json(400, {'status': 'Ungültige Anfrage'}) return - status_code, response = preview_drive_neutral_action(payload.get('value', 0)) + values = payload.get('values') + if not isinstance(values, dict): + self.write_json(400, {'status': 'Fahr-Neutralwerte fehlen'}) + return + + status_code, response = preview_drive_neutral_action(values) self.write_json(status_code, response) return @@ -449,7 +506,12 @@ class Handler(http.server.BaseHTTPRequestHandler): self.write_json(400, {'status': 'Ungültige Anfrage'}) return - status_code, response = save_drive_neutral_action(payload.get('value', 0)) + values = payload.get('values') + if not isinstance(values, dict): + self.write_json(400, {'status': 'Fahr-Neutralwerte fehlen'}) + return + + status_code, response = save_drive_neutral_action(values) self.write_json(status_code, response) return diff --git a/ExoMy_Software-master/scripts/wifi_fallback_manager.sh b/ExoMy_Software-master/scripts/wifi_fallback_manager.sh new file mode 100644 index 0000000..e33f1a1 --- /dev/null +++ b/ExoMy_Software-master/scripts/wifi_fallback_manager.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail + +WIFI_IFACE="wlan0" +PRIMARY_CONN="netplan-wlan0-eskimue.de" +SECONDARY_CONN="4pi" +FALLBACK_AP_CONN="CUNO-AP" +CHECK_INTERVAL=20 + +get_active_connection() { + nmcli -t -f GENERAL.CONNECTION device show "$WIFI_IFACE" 2>/dev/null | head -n 1 | cut -d: -f2- +} + +activate_connection() { + local connection_name="$1" + nmcli --wait 15 connection up "$connection_name" ifname "$WIFI_IFACE" >/dev/null 2>&1 +} + +ensure_best_connection() { + local active_connection + active_connection="$(get_active_connection)" + + if [[ "$active_connection" == "$PRIMARY_CONN" ]]; then + return + fi + + if activate_connection "$PRIMARY_CONN"; then + return + fi + + active_connection="$(get_active_connection)" + if [[ "$active_connection" == "$SECONDARY_CONN" ]]; then + return + fi + + if activate_connection "$SECONDARY_CONN"; then + return + fi + + active_connection="$(get_active_connection)" + if [[ "$active_connection" != "$FALLBACK_AP_CONN" ]]; then + activate_connection "$FALLBACK_AP_CONN" || true + fi +} + +while true; do + ensure_best_connection + sleep "$CHECK_INTERVAL" +done diff --git a/ExoMy_Software-master/src/f710_joy_node.py b/ExoMy_Software-master/src/f710_joy_node.py new file mode 100644 index 0000000..bf9d9a7 --- /dev/null +++ b/ExoMy_Software-master/src/f710_joy_node.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python +import errno +import os +import select +import struct +import time + +import rospy +from sensor_msgs.msg import Joy + + +DEVICE_PATH = '/dev/input/js0' +AXIS_COUNT = 8 +BUTTON_COUNT = 12 +PUBLISH_RATE_HZ = 20.0 + +JS_EVENT_BUTTON = 0x01 +JS_EVENT_AXIS = 0x02 +JS_EVENT_INIT = 0x80 +EVENT_SIZE = struct.calcsize('IhBB') + + +def open_device(): + while not rospy.is_shutdown(): + try: + fd = os.open(DEVICE_PATH, os.O_RDONLY | os.O_NONBLOCK) + rospy.loginfo('Joystick verbunden: %s', DEVICE_PATH) + return fd + except OSError as exc: + if exc.errno != errno.ENOENT: + rospy.logwarn('Joystick kann nicht geoeffnet werden: %s', exc) + rospy.sleep(1.0) + return None + + +def close_device(fd): + if fd is None: + return + try: + os.close(fd) + except OSError: + pass + + +if __name__ == '__main__': + rospy.init_node('f710_joy_node') + + publisher = rospy.Publisher('/joy', Joy, queue_size=5) + rate = rospy.Rate(PUBLISH_RATE_HZ) + + axes = [0.0] * AXIS_COUNT + buttons = [0] * BUTTON_COUNT + joystick_fd = None + + while not rospy.is_shutdown(): + if joystick_fd is None: + joystick_fd = open_device() + axes = [0.0] * AXIS_COUNT + buttons = [0] * BUTTON_COUNT + if joystick_fd is None: + break + + try: + readable, _, _ = select.select([joystick_fd], [], [], 0.0) + if readable: + event = os.read(joystick_fd, EVENT_SIZE) + while event and len(event) == EVENT_SIZE: + _, value, event_type, number = struct.unpack('IhBB', event) + event_type &= ~JS_EVENT_INIT + + if event_type == JS_EVENT_AXIS and number < len(axes): + axes[number] = max(-1.0, min(1.0, value / 32767.0)) + elif event_type == JS_EVENT_BUTTON and number < len(buttons): + buttons[number] = 1 if value else 0 + + try: + event = os.read(joystick_fd, EVENT_SIZE) + except OSError as exc: + if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK): + break + raise + + message = Joy() + message.header.stamp = rospy.Time.now() + message.axes = list(axes) + message.buttons = list(buttons) + publisher.publish(message) + rate.sleep() + + except OSError as exc: + if exc.errno in (errno.ENODEV, errno.EIO, errno.ENXIO, errno.EBADF): + rospy.logwarn('Joystick getrennt, warte auf Neuverbindung.') + else: + rospy.logwarn('Joystick-Lesefehler: %s', exc) + close_device(joystick_fd) + joystick_fd = None + time.sleep(1.0) + + close_device(joystick_fd) diff --git a/ExoMy_Software-master/src/joystick_parser_node.py b/ExoMy_Software-master/src/joystick_parser_node.py index 45fd1f0..ead333b 100644 --- a/ExoMy_Software-master/src/joystick_parser_node.py +++ b/ExoMy_Software-master/src/joystick_parser_node.py @@ -14,6 +14,8 @@ locomotion_mode = LocomotionMode.ACKERMANN.value motors_enabled = True AXIS_DEADZONE = 0.1 last_start_button_pressed = False +last_webgui_time = None +WEBGUI_PRIORITY_TIMEOUT = 2.0 VELOCITY_EXPO = 2.0 @@ -23,6 +25,7 @@ CONTROLLER_FUNCTION_MAPS = { "x_axis": 0, "y_axis": 1, "invert_x_axis": True, + "invert_y_axis": False, "X_button": 0, "Y_button": 3, "A_button": 1, @@ -34,7 +37,8 @@ CONTROLLER_FUNCTION_MAPS = { "logitech-F710": { "x_axis": 0, "y_axis": 1, - "invert_x_axis": False, + "invert_x_axis": True, + "invert_y_axis": True, "X_button": 0, "Y_button": 3, "A_button": 1, @@ -47,6 +51,7 @@ CONTROLLER_FUNCTION_MAPS = { "x_axis": 0, "y_axis": 1, "invert_x_axis": False, + "invert_y_axis": False, "X_button": 2, "Y_button": 3, "A_button": 0, @@ -94,6 +99,15 @@ def callback(data): global locomotion_mode global motors_enabled global last_start_button_pressed + global last_webgui_time + + is_webgui = data.header.frame_id == "webgui" + now = rospy.Time.now() + + if is_webgui: + last_webgui_time = now + elif last_webgui_time is not None and (now - last_webgui_time).to_sec() < WEBGUI_PRIORITY_TIMEOUT: + return rover_cmd = RoverCommand() @@ -118,6 +132,8 @@ def callback(data): if controller_function_map["invert_x_axis"]: x *= -1 + if controller_function_map["invert_y_axis"]: + y *= -1 # Reading out button data to set locomotion mode # X Button diff --git a/ExoMy_Software-master/src/motors.py b/ExoMy_Software-master/src/motors.py index 8c442c1..fec59e4 100644 --- a/ExoMy_Software-master/src/motors.py +++ b/ExoMy_Software-master/src/motors.py @@ -53,7 +53,7 @@ class Motors(): self.pins['steer'][self.RR] = rospy.get_param("pin_steer_rr") # PWM characteristics - self.pwm = Adafruit_PCA9685.PCA9685() + self.pwm = Adafruit_PCA9685.PCA9685(busnum=1) self.pwm.set_pwm_freq(50) # Hz self.steering_pwm_neutral = [None] * 6 @@ -67,7 +67,13 @@ class Motors(): self.steering_pwm_range = rospy.get_param("steer_pwm_range") self.driving_pwm_low_limit = 100 - self.driving_pwm_neutral = rospy.get_param("drive_pwm_neutral") + self.driving_pwm_neutral = [None] * 6 + self.driving_pwm_neutral[self.FL] = rospy.get_param("drive_pwm_neutral_fl") + self.driving_pwm_neutral[self.FR] = rospy.get_param("drive_pwm_neutral_fr") + self.driving_pwm_neutral[self.CL] = rospy.get_param("drive_pwm_neutral_cl") + self.driving_pwm_neutral[self.CR] = rospy.get_param("drive_pwm_neutral_cr") + self.driving_pwm_neutral[self.RL] = rospy.get_param("drive_pwm_neutral_rl") + self.driving_pwm_neutral[self.RR] = rospy.get_param("drive_pwm_neutral_rr") self.driving_pwm_upper_limit = 500 self.driving_pwm_range = rospy.get_param("drive_pwm_range") @@ -111,14 +117,11 @@ class Motors(): def setDriving(self, driving_command): # Loop through pin dictionary. The items key is the wheel_name and the value the pin. for wheel_name, motor_pin in self.pins['drive'].items(): - duty_cycle = int(self.driving_pwm_neutral + + duty_cycle = int(self.driving_pwm_neutral[wheel_name] + driving_command[wheel_name]/100.0 * self.driving_pwm_range * self.wheel_directions[wheel_name]) self.pwm.set_pwm(motor_pin, 0, duty_cycle) def stopMotors(self): - # Set driving wheels to neutral position to stop them - duty_cycle = int(self.driving_pwm_neutral) - for wheel_name, motor_pin in self.pins['drive'].items(): - self.pwm.set_pwm(motor_pin, 0, duty_cycle) + self.pwm.set_pwm(motor_pin, 0, int(self.driving_pwm_neutral[wheel_name])) diff --git a/ExoMy_Software-master/src/rover.py b/ExoMy_Software-master/src/rover.py index ed759b6..d69d5f1 100644 --- a/ExoMy_Software-master/src/rover.py +++ b/ExoMy_Software-master/src/rover.py @@ -24,6 +24,7 @@ class Rover(): self.wheel_ry = 20.3 self.wheel_fx = 16.0 self.wheel_fy = 20.3 + self.point_turn_max_angle = 45 max_steering_angle = 45 self.ackermann_r_max = 250 @@ -152,10 +153,14 @@ class Rover(): return steering_angles if(self.locomotion_mode == LocomotionMode.POINT_TURN.value): - point_turn_angle = int(math.degrees( - math.atan((self.wheel_rx + self.wheel_fx) / self.wheel_ry))) - point_turn_angle_center = int(math.degrees( - math.atan((((self.wheel_rx + self.wheel_fx) / 2) - self.wheel_fx) / (self.wheel_ry / 2)))) + raw_point_turn_angle = math.degrees( + math.atan((self.wheel_rx + self.wheel_fx) / self.wheel_ry)) + raw_point_turn_angle_center = math.degrees( + math.atan((((self.wheel_rx + self.wheel_fx) / 2) - self.wheel_fx) / (self.wheel_ry / 2))) + + point_turn_angle = int(min(self.point_turn_max_angle, raw_point_turn_angle)) + center_scale = 0.0 if raw_point_turn_angle == 0 else abs(raw_point_turn_angle_center / raw_point_turn_angle) + point_turn_angle_center = int(point_turn_angle * center_scale) steering_angles[self.FL] = point_turn_angle steering_angles[self.FR] = -point_turn_angle diff --git a/exomy-setup-status.md b/exomy-setup-status.md index f21d451..72cfeea 100644 --- a/exomy-setup-status.md +++ b/exomy-setup-status.md @@ -1,10 +1,10 @@ # ExoMy Setup-Status -Stand: 2026-05-18 +Stand: 2026-05-21 ## Raspberry Pi -- Hostname: `cuno` +- Hostname: `ExoMyCuno` - Benutzer: `pi` - LAN-IP: `192.168.1.83` - WLAN-IP: `192.168.1.9` @@ -30,6 +30,7 @@ Stand: 2026-05-18 - Bekannte WLANs: - `eskimue.de` - `4pi` +- Passwort `4pi`: `st89Saf6H86n` - Fallback-Access-Point: - SSID: `CUNO` - Passwort: `astr0cun042` @@ -46,6 +47,10 @@ Stand: 2026-05-18 - Wenn `eskimue.de` erreichbar ist, verbindet sich der Pi bevorzugt damit. - Wenn `eskimue.de` nicht erreichbar ist, aber `4pi` sichtbar ist, verbindet sich der Pi mit `4pi`. - Wenn keines der beiden WLANs erreichbar ist, soll `CUNO` als eigener Access Point einspringen. +- Ein Host-Dienst prueft laufend die Reihenfolge: + - erst `eskimue.de` + - dann `4pi` + - sonst `CUNO` - Das alte WLAN-Profil `FRITZ!Box 6660 Cable AI` ist nicht mehr auf Autoverbindung gesetzt. ## Aktueller Aufbau