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 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 @@
Speichern
-
-
-
-
- -
- -
- +
-
-
-
- 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.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