Alles verbessert

This commit is contained in:
2026-05-21 19:30:33 +02:00
parent 5dccc8985e
commit 33b71416ff
19 changed files with 855 additions and 229 deletions
+146 -26
View File
@@ -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
<!-- Add icon library -->
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.13.1/css/all.css">
---
<!-- Add font awesome icons -->
<p>
<img src="https://github.com/esa-prl/ExoMy/wiki/images/social_media_icons/discord-brands.svg" width="20px">
<a href="https://discord.gg/gZk62gg"> Join the Community!</a>
</p>
<p>
<img src="https://github.com/esa-prl/ExoMy/wiki/images/social_media_icons/twitter-square-brands.svg" width="20px">
<a href="https://twitter.com/exomy_rover"> @ExoMy_Rover</a>
</p>
<p>
<img src="https://github.com/esa-prl/ExoMy/wiki/images/social_media_icons/instagram-square-brands.svg" width="20px">
<a href="https://www.instagram.com/exomy_rover/"> @ExoMy_Rover</a>
</p>
## 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)
@@ -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
@@ -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
+5 -7
View File
@@ -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"]
+40 -5
View File
@@ -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
+84 -65
View File
@@ -138,7 +138,7 @@
</div>
<p class="admin_copy motor_test_neutral_copy">
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`.
</p>
<div class="motor_test_neutral_toolbar">
@@ -146,22 +146,7 @@
<button id="drive_neutral_save_button" class="button buttonA" onclick="saveDriveNeutralValue()">Speichern</button>
</div>
<article class="drive_neutral_card" id="drive_neutral_card">
<div class="steering_neutral_card_header">
<span class="info_label">Drive PWM Neutral</span>
<strong>Alle Fahrmotoren</strong>
</div>
<div class="steering_neutral_value_row drive_neutral_value_row">
<button class="button buttonWarm steering_neutral_adjust" onclick="adjustDriveNeutralValue(-5)">-</button>
<strong id="drive_neutral_value" class="steering_neutral_value">-</strong>
<button class="button buttonA steering_neutral_adjust" onclick="adjustDriveNeutralValue(5)">+</button>
</div>
<div class="steering_neutral_meta">
<span id="drive_neutral_pins">Pins: -</span>
</div>
</article>
<div id="drive_neutral_grid" class="steering_neutral_grid"></div>
</div>
</section>
</div>
@@ -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(
'<article class="steering_neutral_card steering_neutral_card_compact' + (isDirty ? ' is_dirty' : '') + '">' +
'<div class="steering_neutral_compact_row">' +
'<div class="steering_neutral_card_header steering_neutral_card_header_compact">' +
'<strong class="steering_neutral_title">' + entry.wheel_label + '</strong>' +
'</div>' +
'<div class="steering_neutral_value_row steering_neutral_value_row_compact">' +
'<button class="button buttonWarm steering_neutral_adjust" onclick="adjustDriveNeutralValue(\'' + wheel + '\', -' + STEERING_NEUTRAL_STEP + ')">-</button>' +
'<strong class="steering_neutral_value">' + entry.value + '</strong>' +
'<button class="button buttonA steering_neutral_adjust" onclick="adjustDriveNeutralValue(\'' + wheel + '\', ' + STEERING_NEUTRAL_STEP + ')">+</button>' +
'</div>' +
'</div>' +
'</article>'
);
});
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();
+39
View File
@@ -89,6 +89,10 @@
<span class="info_label">CPU-Temperatur</span>
<strong id="info_temp">-</strong>
</div>
<div class="info_card">
<span class="info_label">Unterspannung</span>
<strong id="info_undervoltage" class="state_unknown">-</strong>
</div>
<div class="info_card">
<span class="info_label">Uptime</span>
<strong id="info_uptime">-</strong>
@@ -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);
+155 -57
View File
@@ -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
</div>
<div class="c-xhair">
<div class="xc tl"></div><div class="xc tr"></div>
<div class="xc bl"></div><div class="xc br"></div>
<svg viewBox="-90 -90 180 180" xmlns="http://www.w3.org/2000/svg" width="180" height="180">
<!-- äußerer rotierender Strichelring -->
<circle class="xh-rot" cx="0" cy="0" r="82" fill="none" stroke="rgba(0,255,145,.2)" stroke-width="1" stroke-dasharray="4 10"/>
<!-- mittlerer Ring, Gegenrichtung -->
<circle class="xh-rot-rev" cx="0" cy="0" r="60" fill="none" stroke="rgba(0,255,145,.15)" stroke-width=".75" stroke-dasharray="2 16"/>
<!-- innerer fester Ring -->
<circle cx="0" cy="0" r="16" fill="none" stroke="rgba(0,255,145,.3)" stroke-width=".75"/>
<!-- Kreuzlinien mit Lücke in der Mitte -->
<line x1="-86" y1="0" x2="-19" y2="0" stroke="rgba(0,255,145,.6)" stroke-width="1"/>
<line x1="19" y1="0" x2="86" y2="0" stroke="rgba(0,255,145,.6)" stroke-width="1"/>
<line x1="0" y1="-86" x2="0" y2="-19" stroke="rgba(0,255,145,.6)" stroke-width="1"/>
<line x1="0" y1="19" x2="0" y2="86" stroke="rgba(0,255,145,.6)" stroke-width="1"/>
<!-- Skalenstriche bei r=60 -->
<line x1="-60" y1="-6" x2="-60" y2="6" stroke="rgba(0,255,145,.45)" stroke-width=".75"/>
<line x1="60" y1="-6" x2="60" y2="6" stroke="rgba(0,255,145,.45)" stroke-width=".75"/>
<line x1="-6" y1="-60" x2="6" y2="-60" stroke="rgba(0,255,145,.45)" stroke-width=".75"/>
<line x1="-6" y1="60" x2="6" y2="60" stroke="rgba(0,255,145,.45)" stroke-width=".75"/>
<!-- Skalenstriche bei r=38 -->
<line x1="-38" y1="-3.5" x2="-38" y2="3.5" stroke="rgba(0,255,145,.3)" stroke-width=".5"/>
<line x1="38" y1="-3.5" x2="38" y2="3.5" stroke="rgba(0,255,145,.3)" stroke-width=".5"/>
<line x1="-3.5" y1="-38" x2="3.5" y2="-38" stroke="rgba(0,255,145,.3)" stroke-width=".5"/>
<line x1="-3.5" y1="38" x2="3.5" y2="38" stroke="rgba(0,255,145,.3)" stroke-width=".5"/>
<!-- Eckklammern -->
<path d="M-74,-58 L-74,-74 L-58,-74" fill="none" stroke="rgba(0,255,145,.75)" stroke-width="1.5"/>
<path d="M58,-74 L74,-74 L74,-58" fill="none" stroke="rgba(0,255,145,.75)" stroke-width="1.5"/>
<path d="M-74,58 L-74,74 L-58,74" fill="none" stroke="rgba(0,255,145,.75)" stroke-width="1.5"/>
<path d="M58,74 L74,74 L74,58" fill="none" stroke="rgba(0,255,145,.75)" stroke-width="1.5"/>
<!-- pulsierender Ring -->
<circle class="xh-pulse" cx="0" cy="0" r="6" fill="none" stroke="rgba(0,255,145,.7)" stroke-width="1"/>
<!-- Mittelpunkt -->
<circle cx="0" cy="0" r="2.5" fill="rgba(0,255,145,.9)"/>
<circle cx="0" cy="0" r="1" fill="rgba(255,255,255,.7)"/>
</svg>
</div>
<div class="c-scanlines"></div>
<div class="c-vignette"></div>
</div>
</div>
@@ -633,6 +643,10 @@ header {
<span class="slbl">IP Adressen</span>
<span class="sval b" id="stat-ips">--</span>
</div>
<div class="srow warn">
<span class="slbl">Unterspannung</span>
<span class="sval b" id="stat-undervoltage">--</span>
</div>
<div class="srow info">
<span class="slbl">Uptime Pi</span>
<span class="sval b" id="stat-uptime">--</span>
@@ -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));
+9
View File
@@ -455,6 +455,15 @@ h2 {
color: #8a6b35;
}
@keyframes statusBlink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
}
.admin-page {
color: #1b2730;
overflow-y: auto;
@@ -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)
@@ -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')
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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
+10 -7
View File
@@ -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]))
+9 -4
View File
@@ -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
+7 -2
View File
@@ -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