Kaera umschaltbar

This commit is contained in:
2026-05-25 15:30:56 +02:00
parent 1855a2446b
commit 8c8bfd9e5b
7 changed files with 452 additions and 111 deletions
@@ -0,0 +1,6 @@
{
"fps": 10,
"height": 972,
"profile": "1296x972",
"width": 1296
}
+134 -57
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CUNO // Admin</title>
<link rel="stylesheet" href="./app.css?v=20260522104500">
<link rel="stylesheet" href="./app.css?v=20260525132000">
<link rel="stylesheet" href="./vendor/leaflet/leaflet.css">
<script src="./vendor/roslib.min.js"></script>
<script src="./vendor/leaflet/leaflet.js"></script>
@@ -56,6 +56,7 @@
<div class="admin-shell">
<div class="admin-section-tabs" role="tablist" aria-label="Admin-Bereiche">
<button id="admin_tab_system" class="admin-section-tab is_active" role="tab" aria-selected="true" onclick="switchAdminSection('system')">Admin</button>
<button id="admin_tab_camera" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('camera')">Kamera</button>
<button id="admin_tab_drive" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('drive')">Geschwindigkeit / Weg</button>
<button id="admin_tab_map" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('map')">Karte</button>
<button id="admin_tab_gps" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('gps')">GPS</button>
@@ -190,6 +191,74 @@
</div>
</section>
<section id="admin_panel_camera" class="admin-section-panel" role="tabpanel">
<div class="admin-section-grid">
<div class="admin-col">
<div class="panel camera-panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Kamera-Vorschau</span>
<span class="p-tag">Live Stream</span>
</div>
<div class="copy-text camera-copy">
Die Vorschau kommt direkt vom Kameradienst auf Port <code>8081</code>. Nach einem Profilwechsel startet der Dienst kurz neu, das Bild setzt dann automatisch wieder ein.
</div>
<div class="camera-preview-stage">
<img id="camera_preview_image" class="camera-preview-image" alt="Kamera-Vorschau" src="" loading="eager">
</div>
<div class="camera-preview-meta">
<div class="sr">
<span class="sr-led led-ok"></span>
<span class="sr-key">Profil</span>
<span id="camera_profile_label" class="sr-val">--</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Auflösung</span>
<span id="camera_resolution_label" class="sr-val">--</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Bildrate</span>
<span id="camera_fps_label" class="sr-val">--</span>
</div>
</div>
</div>
</div>
<div class="admin-col">
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Stream-Aufl&ouml;sung</span>
<span class="p-tag">Konfiguration</span>
</div>
<div class="dropdown-row">
<label>Profil</label>
<select id="camera_profile_select" class="ctrl-select" onchange="setCameraProfile(this.value)">
<option value="640x480">640 x 480 &middot; 4:3 &middot; gr&ouml;&szlig;tes Sichtfeld</option>
<option value="1024x768">1024 x 768 &middot; 4:3 &middot; mehr Details</option>
<option value="1296x972">1296 x 972 &middot; 4:3 &middot; noch mehr Details</option>
</select>
<span id="camera_profile_status" class="ctrl-status"></span>
</div>
<div class="copy-text camera-copy">
Die neue Aufl&ouml;sung wird gespeichert und der Kameradienst automatisch neu gestartet. Der Videostream ist dabei f&uuml;r einen kurzen Moment unterbrochen.
</div>
<div class="act-grid act-grid_single">
<button class="act-btn subtle full" onclick="refreshCameraPreview()">Vorschau neu laden</button>
</div>
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Dienst</span><div class="ss-hdr-line"></div></div>
<div class="sr">
<span class="sr-led led-ok"></span>
<span class="sr-key">Kamera</span>
<strong id="camera_service_state" class="sr-val service_state state_unknown">-</strong>
</div>
</div>
</div>
</div>
</section>
<section id="admin_panel_drive" class="admin-section-panel" role="tabpanel">
<div class="admin-section-grid">
<div class="admin-col">
@@ -423,25 +492,11 @@
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Vertikale Genauigkeit</span><span id="gps_diag_vdop" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Für Fix genutzt</span><span id="gps_diag_sat_used" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Im Himmelsplot</span><span id="gps_diag_sat_visible" class="sr-val">--</span></div>
<div class="copy-text gps-copy gps-copy_compact">
<div class="copy-text gps-copy">
Für eine gute Fix-Qualität sind niedrige DOP-Werte und hohe, gleichmäßig verteilte Satelliten sinnvoll. Der Himmelsplot zeigt nur Satelliten, die der Empfänger per GSV mit Richtung und Höhe meldet.
</div>
</div>
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">GPS-Rohdaten</span>
<span class="p-tag">NMEA</span>
</div>
<div class="copy-text gps-copy gps-copy_compact">
Letzte direkt empfangene NMEA-Sätze des Empfängers. Die Ansicht scrollt intern und ist für Diagnose bei Abschattung, Drift und fehlerhaften Fixen gedacht.
</div>
<div class="neutral-toolbar">
<button class="button buttonA" onclick="copyGpsRawData()">Rohdaten kopieren</button>
</div>
<textarea id="gps_raw_output" class="gps-raw-output" readonly spellcheck="false">--</textarea>
</div>
</div>
</div>
</section>
@@ -561,6 +616,7 @@
<script type="text/javascript">
var adminApiBase = window.location.protocol + '//' + window.location.hostname + ':8082';
var cameraPreviewBase = window.location.protocol + '//' + window.location.hostname + ':8081/stream.mjpg';
var AUTH_TOKEN = 'Y3Vubw==';
var activeAdminSection = 'system';
var ros = null;
@@ -678,7 +734,6 @@
utcTime: null,
lastFixAgeS: null,
satellites: [],
rawSentences: [],
selectedSatellitePrn: null,
plottedSatellites: []
};
@@ -708,6 +763,7 @@
setText('info_disk', '--');
setUndervoltageState('info_undervoltage', '--', 'unknown');
setServiceState('service_camera', '-');
setServiceState('camera_service_state', '-');
setServiceState('service_admin_api', '-');
setServiceState('service_exomy', '-');
var bar = document.getElementById('bar-temp-adm');
@@ -750,7 +806,6 @@
gpsDiagnosticsState.utcTime = null;
gpsDiagnosticsState.lastFixAgeS = null;
gpsDiagnosticsState.satellites = [];
gpsDiagnosticsState.rawSentences = [];
updateGpsDiagnosticsPanel();
}
@@ -831,31 +886,37 @@
function switchAdminSection(sectionName) {
activeAdminSection = sectionName;
var systemTab = document.getElementById('admin_tab_system');
var cameraTab = document.getElementById('admin_tab_camera');
var driveTab = document.getElementById('admin_tab_drive');
var mapTab = document.getElementById('admin_tab_map');
var gpsTab = document.getElementById('admin_tab_gps');
var motorTab = document.getElementById('admin_tab_motor');
var systemPanel = document.getElementById('admin_panel_system');
var cameraPanel = document.getElementById('admin_panel_camera');
var drivePanel = document.getElementById('admin_panel_drive');
var mapPanel = document.getElementById('admin_panel_map');
var gpsPanel = document.getElementById('admin_panel_gps');
var motorPanel = document.getElementById('admin_panel_motor');
var isSystem = sectionName === 'system';
var isCamera = sectionName === 'camera';
var isDrive = sectionName === 'drive';
var isMap = sectionName === 'map';
var isGps = sectionName === 'gps';
var isMotor = sectionName === 'motor';
systemTab.classList.toggle('is_active', isSystem);
cameraTab.classList.toggle('is_active', isCamera);
driveTab.classList.toggle('is_active', isDrive);
mapTab.classList.toggle('is_active', isMap);
gpsTab.classList.toggle('is_active', isGps);
motorTab.classList.toggle('is_active', isMotor);
systemTab.setAttribute('aria-selected', isSystem ? 'true' : 'false');
cameraTab.setAttribute('aria-selected', isCamera ? 'true' : 'false');
driveTab.setAttribute('aria-selected', isDrive ? 'true' : 'false');
mapTab.setAttribute('aria-selected', isMap ? 'true' : 'false');
gpsTab.setAttribute('aria-selected', isGps ? 'true' : 'false');
motorTab.setAttribute('aria-selected', isMotor ? 'true' : 'false');
systemPanel.classList.toggle('is_active', isSystem);
cameraPanel.classList.toggle('is_active', isCamera);
drivePanel.classList.toggle('is_active', isDrive);
mapPanel.classList.toggle('is_active', isMap);
gpsPanel.classList.toggle('is_active', isGps);
@@ -867,6 +928,9 @@
if (roverMapState.map) roverMapState.map.invalidateSize();
}, 80);
}
if (isCamera) {
refreshCameraPreview();
}
if (isGps) {
window.setTimeout(drawGpsSkyPlot, 80);
}
@@ -1020,47 +1084,9 @@
setGpsQualityState('gps_diag_vdop', gpsDiagnosticsState.vdop);
setText('gps_diag_sat_used', gpsDiagnosticsState.satellitesUsed);
setText('gps_diag_sat_visible', gpsDiagnosticsState.satellitesVisible);
setGpsRawOutput(gpsDiagnosticsState.rawSentences);
drawGpsSkyPlot();
}
function setGpsRawOutput(sentences) {
var node = document.getElementById('gps_raw_output');
if (!node) return;
if (!Array.isArray(sentences) || !sentences.length) {
node.value = '--';
return;
}
var wasNearBottom = (node.scrollTop + node.clientHeight + 24) >= node.scrollHeight;
node.value = sentences.join('\n');
if (wasNearBottom) node.scrollTop = node.scrollHeight;
}
async function copyGpsRawData() {
var node = document.getElementById('gps_raw_output');
if (!node) return;
var text = String(node.value || '').trim();
if (!text || text === '--') {
setAdminStatus('Keine GPS-Rohdaten zum Kopieren');
return;
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
node.focus();
node.select();
document.execCommand('copy');
node.setSelectionRange(node.value.length, node.value.length);
}
setAdminStatus('GPS-Rohdaten kopiert');
window.setTimeout(function() { setAdminStatus('Bereit'); }, 1800);
} catch (error) {
setAdminStatus('Kopieren fehlgeschlagen');
}
}
function getSelectedSatellite() {
if (!gpsDiagnosticsState.satellites.length) return null;
var selected = gpsDiagnosticsState.satellites.find(function(item) {
@@ -1227,7 +1253,6 @@
gpsDiagnosticsState.utcTime = data.utc_time || null;
gpsDiagnosticsState.lastFixAgeS = typeof data.last_fix_age_s === 'number' ? data.last_fix_age_s : null;
gpsDiagnosticsState.satellites = Array.isArray(data.satellites) ? data.satellites : [];
gpsDiagnosticsState.rawSentences = Array.isArray(data.raw_sentences) ? data.raw_sentences : [];
if (gpsDiagnosticsState.selectedSatellitePrn) {
var stillExists = gpsDiagnosticsState.satellites.some(function(item) {
return item.prn === gpsDiagnosticsState.selectedSatellitePrn;
@@ -2211,6 +2236,7 @@
setText('info_uptime', data.system && data.system.uptime);
setText('info_disk', data.system && data.system.disk_free);
setServiceState('service_camera', data.services && data.services.camera);
setServiceState('camera_service_state', data.services && data.services.camera);
setServiceState('service_admin_api', data.services && data.services.admin_api);
setServiceState('service_exomy', data.services && data.services.exomy);
}
@@ -2304,6 +2330,52 @@
}
}
function refreshCameraPreview() {
var image = document.getElementById('camera_preview_image');
if (!image) return;
image.src = cameraPreviewBase + '?t=' + Date.now();
}
function renderCameraConfig(data) {
var camera = data && data.camera ? data.camera : {};
var selectedProfile = camera.selected_profile || '';
var select = document.getElementById('camera_profile_select');
if (select && selectedProfile) select.value = selectedProfile;
setText('camera_profile_label', selectedProfile || '--');
setText('camera_resolution_label', (camera.width && camera.height) ? (camera.width + ' x ' + camera.height) : '--');
setText('camera_fps_label', camera.fps ? (camera.fps + ' fps') : '--');
}
async function fetchCameraConfig() {
try {
var response = await fetch(adminApiBase + '/api/camera-config');
if (!response.ok) return;
renderCameraConfig(await response.json());
} catch (e) {}
}
async function setCameraProfile(profileId) {
var statusEl = document.getElementById('camera_profile_status');
if (statusEl) statusEl.textContent = 'Speichere…';
try {
var response = await fetch(adminApiBase + '/api/camera-config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({profile: profileId})
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || 'Fehler');
renderCameraConfig(data);
if (statusEl) statusEl.textContent = data.status || 'Gespeichert';
window.setTimeout(fetchStatus, 1500);
window.setTimeout(refreshCameraPreview, 2200);
window.setTimeout(fetchCameraConfig, 2500);
window.setTimeout(function() { if (statusEl) statusEl.textContent = ''; }, 3500);
} catch (e) {
if (statusEl) statusEl.textContent = 'Fehler';
}
}
async function runRumble() {
try {
var response = await fetch(adminApiBase + '/api/rumble', { method: 'POST' });
@@ -2696,6 +2768,8 @@
if (gpsCanvas) gpsCanvas.addEventListener('click', onGpsSkyCanvasClick);
if (window.location.hash === '#motor') {
switchAdminSection('motor');
} else if (window.location.hash === '#camera') {
switchAdminSection('camera');
} else if (window.location.hash === '#drive') {
switchAdminSection('drive');
} else if (window.location.hash === '#gps') {
@@ -2707,6 +2781,7 @@
updatePageConnectionState();
window.setInterval(updateClock, 1000);
fetchStatus();
fetchCameraConfig();
fetchDelay();
fetchSpeedLimit();
fetchMotorTestStatus();
@@ -2718,7 +2793,9 @@
applyWaypointPlanningState();
renderDriveEstimator();
updateGpsDiagnosticsPanel();
refreshCameraPreview();
window.setInterval(fetchStatus, 10000);
window.setInterval(fetchCameraConfig, 10000);
window.setInterval(fetchDelay, 5000);
window.setInterval(fetchSpeedLimit, 5000);
window.setInterval(fetchMotorTestStatus, 4000);
+77 -32
View File
@@ -995,30 +995,65 @@ body.page-admin .admin-shell{
padding:12px; width:100%;
}
body.page-admin .admin-section-tabs{
display:flex; gap:6px; flex-shrink:0;
display:flex; gap:0; flex-shrink:0;
padding:8px 8px 0;
border-bottom:1px solid rgba(120,255,205,.18);
background:linear-gradient(180deg, rgba(9,18,24,.94), rgba(7,13,18,.88));
border-radius:14px 14px 0 0;
box-shadow:inset 0 -1px 0 rgba(255,255,255,.02);
}
body.page-admin .admin-section-tab{
font-family:var(--disp); font-size:11px; font-weight:700; letter-spacing:1px; text-transform:uppercase;
padding:10px 16px; border:1px solid rgba(120,255,205,.24);
background:rgba(0,255,145,.04); color:rgba(215,255,238,.88);
cursor:pointer; transition:all .15s;
padding:10px 16px;
margin:0 4px -1px 0;
border:1px solid rgba(120,255,205,.18);
border-bottom-color:rgba(120,255,205,.18);
border-radius:10px 10px 0 0;
background:linear-gradient(180deg, rgba(10,22,28,.92), rgba(7,14,20,.96));
color:rgba(215,255,238,.76);
cursor:pointer;
transition:all .15s;
position:relative;
}
body.page-admin .admin-section-tab::after{
content:'';
position:absolute;
left:12px;
right:12px;
bottom:0;
height:2px;
background:transparent;
transition:background .15s ease;
}
body.page-admin .admin-section-tab:hover{
border-color:rgba(180,255,225,.48);
background:rgba(0,255,145,.08);
border-color:rgba(180,255,225,.36);
background:linear-gradient(180deg, rgba(16,34,42,.95), rgba(8,18,24,.98));
color:#fff;
}
body.page-admin .admin-section-tab.is_active{
border-color:rgba(180,255,225,.72);
background:linear-gradient(180deg, rgba(20,120,82,.3), rgba(6,40,28,.95));
border-color:rgba(180,255,225,.62);
border-bottom-color:#0b1218;
background:linear-gradient(180deg, rgba(20,120,82,.24), rgba(11,18,24,1) 68%);
color:#fff;
box-shadow:0 0 14px rgba(120,255,205,.16), inset 0 0 14px rgba(180,255,225,.08);
box-shadow:
0 -1px 0 rgba(255,255,255,.03) inset,
0 0 0 1px rgba(0,255,145,.04),
0 10px 22px rgba(0,0,0,.16);
z-index:2;
}
body.page-admin .admin-section-tab.is_active::after{
background:linear-gradient(90deg, rgba(120,255,205,0), rgba(120,255,205,.92), rgba(120,255,205,0));
}
body.page-admin .admin-section-panel{
flex:1; min-height:0; overflow-y:auto; display:none;
}
body.page-admin .admin-section-panel.is_active{
display:block;
border:1px solid rgba(120,255,205,.16);
border-top:none;
border-radius:0 0 16px 16px;
background:linear-gradient(180deg, rgba(9,16,22,.98), rgba(7,13,18,.98));
box-shadow:0 18px 38px rgba(0,0,0,.16);
}
body.page-admin .admin-section-grid{
min-height:100%;
@@ -1092,7 +1127,13 @@ body.page-admin .motor-admin-tab .steering_neutral_adjust{ padding:4px 10px !imp
}
@media (max-width:479px){
body.page-admin .hdr-badge, body.page-admin .hdr-clk{ display:none; }
body.page-admin .admin-section-tab{ flex:1; text-align:center; padding:10px 8px; }
body.page-admin .admin-section-tabs{ flex-wrap:wrap; gap:0; }
body.page-admin .admin-section-tab{
flex:1 1 calc(50% - 4px);
text-align:center;
padding:10px 8px;
margin:0 4px -1px 0;
}
body.page-admin .motor-admin-tab .tab-row{ grid-template-columns:1fr; }
body.page-admin .motor-admin-tab .tab-box + .tab-box{ border-left:none; border-top:1px solid rgba(0,255,145,.06); }
body.page-admin .drive-estimator-toolbar{ flex-direction:column; align-items:stretch; }
@@ -1115,6 +1156,7 @@ body.page-admin .act-btn.warm:hover{ background:rgba(255,140,0,.12); color:#FF8C
body.page-admin .act-btn.danger{ border-color:rgba(255,51,51,.35); color:rgba(255,51,51,.7); background:rgba(255,51,51,.04); }
body.page-admin .act-btn.danger:hover{ background:rgba(255,51,51,.12); color:#FF3333; border-color:rgba(255,51,51,.7); }
body.page-admin .act-btn.full{ grid-column:span 2; }
body.page-admin .act-grid_single{ grid-template-columns:1fr; }
body.page-admin .act-btn.subtle{ border-color:rgba(0,255,145,.1); color:rgba(0,255,145,.3); }
body.page-admin .act-btn.subtle:hover{ color:rgba(0,255,145,.6); border-color:rgba(0,255,145,.35); }
body.page-admin .act-btn.is_active{
@@ -1147,6 +1189,31 @@ body.page-admin .ctrl-select{
}
body.page-admin .ctrl-select:focus{ border-color:rgba(0,255,145,.5); }
body.page-admin .ctrl-status{ font-size:9px; letter-spacing:1.2px; color:rgba(225,255,242,.92); white-space:nowrap; min-width:64px; text-align:right; }
body.page-admin .camera-copy{
font-size:10px; color:rgba(205,255,235,.86); letter-spacing:.4px; line-height:1.6;
padding:8px 12px; border-bottom:1px solid rgba(0,255,145,.05);
}
body.page-admin .camera-preview-stage{
padding:14px 12px 12px;
background:
radial-gradient(circle at top, rgba(30,90,140,.24), transparent 55%),
linear-gradient(180deg, rgba(7,17,28,.96), rgba(4,10,18,.98));
border-bottom:1px solid rgba(0,255,145,.05);
}
body.page-admin .camera-preview-image{
display:block;
width:100%;
aspect-ratio:16/9;
object-fit:contain;
background:#03070c;
border:1px solid rgba(120,255,205,.16);
border-radius:14px;
box-shadow:0 14px 28px rgba(0,0,0,.28), inset 0 0 0 1px rgba(255,255,255,.03);
}
body.page-admin .camera-preview-meta{
display:grid;
gap:0;
}
body.page-admin .drive-estimator-copy{
font-size:10px; color:rgba(205,255,235,.86); letter-spacing:.4px; line-height:1.6;
padding:8px 12px; border-bottom:1px solid rgba(0,255,145,.05);
@@ -1292,28 +1359,6 @@ body.page-admin .gps-copy{
padding:10px 12px;
border-bottom:1px solid rgba(0,255,145,.05);
}
body.page-admin .gps-copy_compact{
border-bottom:none;
}
body.page-admin .gps-raw-output{
width:100%;
min-height:220px;
max-height:320px;
resize:vertical;
overflow:auto;
margin-top:14px;
padding:12px 14px;
border:1px solid rgba(0,255,145,.18);
border-radius:12px;
background:#07111c;
color:#b8ffe1;
font:12px/1.45 Consolas, "Courier New", monospace;
box-shadow:inset 0 0 0 1px rgba(255,255,255,.02);
}
body.page-admin .gps-raw-output:focus{
outline:none;
border-color:rgba(0,255,145,.28);
}
body.page-admin .gps-sky-stage{
padding:18px;
position:relative;
+1 -1
View File
@@ -3,7 +3,7 @@
<node name="motors" pkg="exomy" type="motor_node.py" respawn="true" output="screen" />
<node name="joystick" pkg="exomy" type="joystick_parser_node.py" respawn="true" output="screen" />
<node name="gps" pkg="exomy" type="gps_node.py" respawn="true" output="screen">
<param name="port" value="/dev/ttyUSB0"/>
<param name="port" value="auto"/>
<param name="baud" value="4800"/>
<param name="frame_id" value="gps"/>
</node>
@@ -21,7 +21,38 @@ DRIVE_ESTIMATOR_CALIBRATION_FILE = os.path.join(
ROUTE_LIBRARY_FILE = os.path.join(
os.path.dirname(__file__), '..', 'config', 'route_library.json'
)
CAMERA_SETTINGS_FILE = os.path.join(
os.path.dirname(__file__), '..', 'config', 'camera_settings.json'
)
MOTOR_TEST_LOCK = threading.Lock()
CAMERA_PROFILES = [
{
'id': '640x480',
'label': '640 x 480',
'description': '4:3, klein und sparsam',
'width': 640,
'height': 480,
'fps': 10,
},
{
'id': '1024x768',
'label': '1024 x 768',
'description': '4:3, großes Sichtfeld mit mehr Details',
'width': 1024,
'height': 768,
'fps': 10,
},
{
'id': '1296x972',
'label': '1296 x 972',
'description': '4:3, großes Sichtfeld mit noch mehr Details',
'width': 1296,
'height': 972,
'fps': 10,
},
]
CAMERA_PROFILE_MAP = {profile['id']: profile for profile in CAMERA_PROFILES}
DEFAULT_CAMERA_PROFILE_ID = '1296x972'
class ReusableThreadingTCPServer(socketserver.ThreadingTCPServer):
@@ -285,6 +316,77 @@ def set_speed_limit(percent):
return percent
def get_default_camera_profile():
return dict(CAMERA_PROFILE_MAP[DEFAULT_CAMERA_PROFILE_ID])
def normalize_camera_profile(profile_id):
profile = CAMERA_PROFILE_MAP.get(str(profile_id or '').strip())
if profile is None:
raise ValueError('Unbekanntes Kamera-Profil')
return dict(profile)
def read_camera_settings():
default_profile = get_default_camera_profile()
try:
with open(CAMERA_SETTINGS_FILE, 'r', encoding='utf-8') as handle:
payload = json.load(handle)
except (FileNotFoundError, json.JSONDecodeError, OSError, ValueError, TypeError):
return default_profile
selected_profile_id = str(payload.get('profile', '')).strip()
if selected_profile_id in CAMERA_PROFILE_MAP:
return dict(CAMERA_PROFILE_MAP[selected_profile_id])
try:
width = int(payload.get('width', default_profile['width']))
height = int(payload.get('height', default_profile['height']))
fps = int(payload.get('fps', default_profile['fps']))
except (TypeError, ValueError):
return default_profile
for profile in CAMERA_PROFILES:
if profile['width'] == width and profile['height'] == height and profile['fps'] == fps:
return dict(profile)
return default_profile
def write_camera_settings(profile):
profile = dict(profile)
os.makedirs(os.path.dirname(CAMERA_SETTINGS_FILE), exist_ok=True)
payload = {
'profile': profile['id'],
'width': profile['width'],
'height': profile['height'],
'fps': profile['fps'],
}
with open(CAMERA_SETTINGS_FILE, 'w', encoding='utf-8') as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write('\n')
def get_camera_config():
profile = read_camera_settings()
return {
'status': 'Bereit',
'camera': {
'selected_profile': profile['id'],
'width': profile['width'],
'height': profile['height'],
'fps': profile['fps'],
'profiles': [dict(item) for item in CAMERA_PROFILES],
}
}
def set_camera_profile(profile_id):
profile = normalize_camera_profile(profile_id)
write_camera_settings(profile)
restart_camera_service()
return profile
def rumble_controller(duration_ms=400):
# Linux FF constants
EV_FF = 0x15
@@ -856,6 +958,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
if self.path == '/api/speed-limit':
self.write_json(200, {'speed_limit_percent': get_speed_limit()})
return
if self.path == '/api/camera-config':
self.write_json(200, get_camera_config())
return
if self.path == '/api/status':
self.write_json(200, collect_status())
return
@@ -932,6 +1037,35 @@ class Handler(http.server.BaseHTTPRequestHandler):
self.write_json(500, {'status': str(exc)})
return
if self.path == '/api/camera-config':
try:
content_length = int(self.headers.get('Content-Length', '0'))
except ValueError:
content_length = 0
raw_body = self.rfile.read(content_length) if content_length > 0 else b'{}'
try:
payload = json.loads(raw_body.decode('utf-8'))
except (UnicodeDecodeError, json.JSONDecodeError):
self.write_json(400, {'status': 'Ungültige Anfrage'})
return
try:
profile = set_camera_profile(payload.get('profile'))
self.write_json(200, {
'status': 'Kameraauflösung gespeichert, Kameradienst startet neu',
'camera': {
'selected_profile': profile['id'],
'width': profile['width'],
'height': profile['height'],
'fps': profile['fps'],
'profiles': [dict(item) for item in CAMERA_PROFILES],
}
})
except ValueError as exc:
self.write_json(400, {'status': str(exc)})
except Exception as exc:
self.write_json(500, {'status': str(exc)})
return
if self.path == '/api/drive-estimator/calibration':
try:
content_length = int(self.headers.get('Content-Length', '0'))
@@ -1,5 +1,8 @@
#!/usr/bin/env python3
import http.server
import json
import os
import urllib.parse
import socketserver
import subprocess
import threading
@@ -9,10 +12,40 @@ BOUNDARY = b'--frame'
LATEST_JPEG = None
LATEST_LOCK = threading.Lock()
WIDTH = 1280
HEIGHT = 720
FPS = 10
PORT = 8081
CONFIG_FILE = os.path.join(os.path.dirname(__file__), '..', 'config', 'camera_settings.json')
DEFAULT_CAMERA_SETTINGS = {
'width': 1280,
'height': 720,
'fps': 10,
}
def load_camera_settings():
settings = dict(DEFAULT_CAMERA_SETTINGS)
try:
with open(CONFIG_FILE, 'r', encoding='utf-8') as handle:
raw_settings = json.load(handle)
except (FileNotFoundError, json.JSONDecodeError, OSError, ValueError, TypeError):
return settings
try:
width = int(raw_settings.get('width', settings['width']))
height = int(raw_settings.get('height', settings['height']))
fps = int(raw_settings.get('fps', settings['fps']))
except (TypeError, ValueError):
return settings
settings['width'] = max(320, min(2592, width))
settings['height'] = max(240, min(1944, height))
settings['fps'] = max(1, min(30, fps))
return settings
CAMERA_SETTINGS = load_camera_settings()
WIDTH = CAMERA_SETTINGS['width']
HEIGHT = CAMERA_SETTINGS['height']
FPS = CAMERA_SETTINGS['fps']
def camera_reader():
@@ -65,14 +98,18 @@ class ReusableThreadingTCPServer(socketserver.ThreadingTCPServer):
class Handler(http.server.BaseHTTPRequestHandler):
def normalized_path(self):
return urllib.parse.urlsplit(self.path).path
def do_HEAD(self):
if self.path in ('/', '/index.html'):
path = self.normalized_path()
if path in ('/', '/index.html'):
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.end_headers()
return
if self.path == '/stream.mjpg':
if path == '/stream.mjpg':
self.send_response(200)
self.send_header('Age', '0')
self.send_header('Cache-Control', 'no-cache, private')
@@ -84,7 +121,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
self.send_error(404)
def do_GET(self):
if self.path in ('/', '/index.html'):
path = self.normalized_path()
if path in ('/', '/index.html'):
body = b'<html><body><img src="/stream.mjpg" /></body></html>'
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
@@ -93,7 +131,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
self.wfile.write(body)
return
if self.path != '/stream.mjpg':
if path != '/stream.mjpg':
self.send_error(404)
return
+47 -6
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python
import errno
import glob
import json
import os
import select
@@ -12,7 +13,7 @@ from sensor_msgs.msg import NavSatFix, NavSatStatus
from std_msgs.msg import String
PORT = '/dev/ttyUSB0'
PORT = 'auto'
BAUD = 4800
FRAME_ID = 'gps'
READ_SIZE = 512
@@ -114,8 +115,47 @@ def configure_serial(fd, baud):
termios.tcflush(fd, termios.TCIFLUSH)
def open_device(port, baud):
def resolve_port_candidates(port_config):
if port_config is None:
port_config = PORT
config_text = str(port_config).strip()
if not config_text or config_text.lower() == 'auto':
raw_candidates = [
'/dev/serial/by-id/*',
'/dev/serial/by-path/*',
'/dev/ttyUSB*',
'/dev/ttyACM*',
]
else:
raw_candidates = [item.strip() for item in config_text.split(',') if item.strip()]
candidates = []
seen = set()
for candidate in raw_candidates:
matches = sorted(glob.glob(candidate)) if any(char in candidate for char in '*?[') else [candidate]
for match in matches:
if match in seen:
continue
seen.add(match)
candidates.append(match)
return candidates
def open_device(port_config, baud, baud_rate):
last_signature = None
while not rospy.is_shutdown():
candidates = resolve_port_candidates(port_config)
signature = tuple(candidates)
if not candidates:
if signature != last_signature:
rospy.loginfo('GPS wartet auf Geraet. Suchmuster: %s', port_config)
last_signature = signature
rospy.sleep(1.0)
continue
try:
for port in candidates:
try:
fd = os.open(port, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
configure_serial(fd, baud)
@@ -123,10 +163,11 @@ def open_device(port, baud):
return fd
except OSError as exc:
if exc.errno != errno.ENOENT:
rospy.logwarn('GPS kann nicht geoeffnet werden: %s', exc)
rospy.sleep(1.0)
rospy.logwarn('GPS kann nicht geoeffnet werden (%s): %s', port, exc)
except termios.error as exc:
rospy.logwarn('GPS-Port kann nicht konfiguriert werden: %s', exc)
rospy.logwarn('GPS-Port kann nicht konfiguriert werden (%s): %s', port, exc)
finally:
last_signature = signature
rospy.sleep(1.0)
return None
@@ -440,7 +481,7 @@ if __name__ == '__main__':
while not rospy.is_shutdown():
if serial_fd is None:
serial_fd = open_device(port, baud)
serial_fd = open_device(port, baud, baud_rate)
buffer = bytearray()
state.connected = serial_fd is not None
publish_fix(fix_publisher, overlay_publisher, diagnostics_publisher, state, frame_id)