Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c6bf0783e | ||
|
|
2832ab3dc9 | ||
|
|
d38d62ba21 | ||
|
|
5890c17ec0 | ||
|
|
239f1094f9 | ||
|
|
055db7c059 | ||
|
|
2a8cbc6c15 | ||
|
|
7e14107361 | ||
|
|
e64043dca5 | ||
|
|
0efb8d0848 | ||
|
|
5fa5d79ad8 | ||
|
|
9dd7a2cb14 | ||
|
|
2811379684 | ||
|
|
902316566e | ||
|
|
1f94b4eab5 | ||
|
|
57e13800e0 | ||
|
|
1dc8c0936f | ||
|
|
071e38f464 |
@@ -10,6 +10,27 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.2.3.0] — 2026-07-19 — Satelliten: ARIAs Augen & Hände in fremden Netzen 🛰️
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
**Neuer eigenständiger Container `satellite/`** — ein Außenposten, den du in einem beliebigen Netz (Büro, Werkstatt …) deployst. Er verbindet sich als RVS-Client in deinen Raum und gibt ARIA Zugriff auf **genau dieses Netz**, ohne dass der Haupt-Stack dort steht.
|
||||||
|
- **Entdeckung (Info):** mDNS/Zeroconf (Chromecast, AirPlay, Sonos, Drucker, NAS …), SSDP/UPnP + **DIAL** (Smart-TVs, Fire TV), ARP-Tabelle (rohe Hosts) → Live-Inventar.
|
||||||
|
- **Steuerung (mit Guards):** **DIAL-App-Launch** (z.B. „ARIA, spiel YouTube-Video X auf dem Büro-Stick" → Fire TV), **Wake-on-LAN**, generisches **HTTP**. Nur wenn `CONTROL_ENABLED=true`, nur Aktionen aus der `CONTROL_ALLOWLIST`, alles geloggt, read-only per `.env` abschaltbar. Reagiert nur auf den eigenen RVS-Raum (Token). Keine offenen Ports.
|
||||||
|
- **Adressierung** über `SATELLITE_LOCATION` (z.B. „Büro") — mehrere Satelliten im selben Raum, jeder mit eigenem Namen.
|
||||||
|
|
||||||
|
**End-to-end verdrahtet:**
|
||||||
|
- `satellite/`: eigener Stack (`docker compose` mit `network_mode: host`), `.env.example`, README.
|
||||||
|
- RVS: neue Message-Typen `sat_hello / sat_discover / sat_devices / sat_command / sat_result`.
|
||||||
|
- Bridge: Satelliten-Registry (`sat_hello`) + Future-Relay (`/internal/satellite`, `/internal/satellite-list`) analog zum flux-Muster.
|
||||||
|
- Brain: Tools `satellite_list`, `satellite_devices`, `satellite_command` + Seed-Regel, die ARIA den Ablauf beibringt (erst list, dann devices, dann command).
|
||||||
|
- **Diagnostic: neuer Tab „Satelliten"** — zeigt live welche Satelliten verbunden sind (online/offline, Standort, Capabilities, read-only vs. steuerbar) und pro Satellit ein „Geräte scannen" (löst `sat_discover` aus → erkannte Geräte mit Typ/IP/Modell/DIAL/MAC).
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
Satellit im Ziel-Netz: `cd satellite && cp .env.example .env && docker compose up -d --build`. Haupt-Stack: `git pull && docker compose up -d --build brain bridge` + RVS-Stack `up -d --build`. Kein APK-Rebuild.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.2.2.3] — 2026-07-17 — ARIA liest andere Projekt-Chats wirklich (volle Historie)
|
## [0.2.2.3] — 2026-07-17 — ARIA liest andere Projekt-Chats wirklich (volle Historie)
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ ARIA hat zwei Rollen:
|
|||||||
| RVS | Rechenzentrum | `cd rvs && docker compose up -d` |
|
| RVS | Rechenzentrum | `cd rvs && docker compose up -d` |
|
||||||
| ARIA Brain/Bridge/Diagnostic | Debian 13 VM | `./init.sh && ./aria-setup.sh && docker compose up -d` |
|
| ARIA Brain/Bridge/Diagnostic | Debian 13 VM | `./init.sh && ./aria-setup.sh && docker compose up -d` |
|
||||||
| Gamebox-Stack (F5-TTS + Whisper) | Gamebox (GPU) | `cd xtts && docker compose up -d` |
|
| Gamebox-Stack (F5-TTS + Whisper) | Gamebox (GPU) | `cd xtts && docker compose up -d` |
|
||||||
|
| Satellit(en) 🛰️ (optional) | Fremdes Netz (Büro …) | `cd satellite && cp .env.example .env && docker compose up -d --build` |
|
||||||
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
|
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
|
||||||
|
|
||||||
> Der Gamebox-Stack ist optional: ohne ihn faellt STT auf lokales Whisper (CPU,
|
> Der Gamebox-Stack ist optional: ohne ihn faellt STT auf lokales Whisper (CPU,
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20202
|
versionCode 20204
|
||||||
versionName "0.2.2.2"
|
versionName "0.2.2.4"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.2.2",
|
"version": "0.2.2.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
|
|
||||||
import brainApi, { Project } from '../services/brainApi';
|
import brainApi, { Project } from '../services/brainApi';
|
||||||
import rvs from '../services/rvs';
|
import rvs from '../services/rvs';
|
||||||
|
import projectFocus from '../services/projectFocus';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Optional — wenn als Modal genutzt, sonst inline */
|
/** Optional — wenn als Modal genutzt, sonst inline */
|
||||||
@@ -75,6 +76,7 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
|||||||
const [editing, setEditing] = useState<Project | null>(null);
|
const [editing, setEditing] = useState<Project | null>(null);
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [editDesc, setEditDesc] = useState('');
|
const [editDesc, setEditDesc] = useState('');
|
||||||
|
const [editKind, setEditKind] = useState<'code' | 'chat'>('chat');
|
||||||
// Versteckte Projekte standardmaessig ausblenden; Toggle blendet sie
|
// Versteckte Projekte standardmaessig ausblenden; Toggle blendet sie
|
||||||
// temporaer (gedimmt) ein — zum Ansehen/Auswaehlen oder Wieder-Sichtbarmachen.
|
// temporaer (gedimmt) ein — zum Ansehen/Auswaehlen oder Wieder-Sichtbarmachen.
|
||||||
const [showHidden, setShowHidden] = useState(false);
|
const [showHidden, setShowHidden] = useState(false);
|
||||||
@@ -148,18 +150,25 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
|||||||
setEditing(p);
|
setEditing(p);
|
||||||
setEditName(p.name);
|
setEditName(p.name);
|
||||||
setEditDesc(p.description || '');
|
setEditDesc(p.description || '');
|
||||||
|
setEditKind(p.kind === 'code' ? 'code' : 'chat');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveEdit = useCallback(() => {
|
const saveEdit = useCallback(() => {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
const patch: Partial<Pick<Project, 'name' | 'description'>> = {};
|
const patch: Partial<Pick<Project, 'name' | 'description' | 'kind'>> = {};
|
||||||
if (editName.trim() && editName.trim() !== editing.name) patch.name = editName.trim();
|
if (editName.trim() && editName.trim() !== editing.name) patch.name = editName.trim();
|
||||||
if (editDesc.trim() !== (editing.description || '')) patch.description = editDesc.trim();
|
if (editDesc.trim() !== (editing.description || '')) patch.description = editDesc.trim();
|
||||||
|
const curKind = editing.kind === 'code' ? 'code' : 'chat';
|
||||||
|
if (editKind !== curKind) patch.kind = editKind;
|
||||||
if (Object.keys(patch).length === 0) { setEditing(null); return; }
|
if (Object.keys(patch).length === 0) { setEditing(null); return; }
|
||||||
brainApi.updateProject(editing.id, patch)
|
brainApi.updateProject(editing.id, patch)
|
||||||
.then(() => { setEditing(null); load(); })
|
.then(() => {
|
||||||
|
// Kind sofort in den Workspace spiegeln (Editor/Desktop-Panels).
|
||||||
|
if (patch.kind) projectFocus.setKind(editing.id, patch.kind);
|
||||||
|
setEditing(null); load();
|
||||||
|
})
|
||||||
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||||
}, [editing, editName, editDesc, load]);
|
}, [editing, editName, editDesc, editKind, load]);
|
||||||
|
|
||||||
const endProject = useCallback((p: Project) => {
|
const endProject = useCallback((p: Project) => {
|
||||||
Alert.alert(`"${p.name}" beenden?`,
|
Alert.alert(`"${p.name}" beenden?`,
|
||||||
@@ -216,6 +225,9 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
|||||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
|
||||||
)}
|
)}
|
||||||
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
|
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
|
||||||
|
{item.has_files && (
|
||||||
|
<Text style={{ fontSize: 12 }} accessibilityLabel="hat Dateien">📄{item.file_count ? ` ${item.file_count}` : ''}</Text>
|
||||||
|
)}
|
||||||
{hidden && <Text style={s.hiddenBadge}>versteckt</Text>}
|
{hidden && <Text style={s.hiddenBadge}>versteckt</Text>}
|
||||||
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
|
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
|
||||||
{isActive && <Text style={s.activeBadge}>✓ FOCUS</Text>}
|
{isActive && <Text style={s.activeBadge}>✓ FOCUS</Text>}
|
||||||
@@ -375,6 +387,21 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
|||||||
style={[s.input, { height: 70 }]}
|
style={[s.input, { height: 70 }]}
|
||||||
multiline
|
multiline
|
||||||
/>
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setEditKind(k => (k === 'code' ? 'chat' : 'code'))}
|
||||||
|
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 }}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#E0E0F0', fontSize: 14 }}>💻 Code-Projekt{'\n'}
|
||||||
|
<Text style={{ color: '#8888AA', fontSize: 11 }}>zeigt Editor + Desktop im Cockpit</Text>
|
||||||
|
</Text>
|
||||||
|
<View style={{
|
||||||
|
width: 46, height: 26, borderRadius: 13, padding: 3,
|
||||||
|
backgroundColor: editKind === 'code' ? '#0096FF' : '#2A2A3E',
|
||||||
|
alignItems: editKind === 'code' ? 'flex-end' : 'flex-start',
|
||||||
|
}}>
|
||||||
|
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#FFFFFF' }} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
|
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
|
||||||
<TouchableOpacity onPress={() => setEditing(null)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
|
<TouchableOpacity onPress={() => setEditing(null)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
|
||||||
<Text style={s.modalBtnText}>Abbrechen</Text>
|
<Text style={s.modalBtnText}>Abbrechen</Text>
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ export interface Project {
|
|||||||
// Optionale absolute noVNC-URL (falls der Desktop direkt erreichbar ist,
|
// Optionale absolute noVNC-URL (falls der Desktop direkt erreichbar ist,
|
||||||
// sonst laeuft der VNC-Stream als RFB-Bytes durch RVS).
|
// sonst laeuft der VNC-Stream als RFB-Bytes durch RVS).
|
||||||
desktop_url?: string;
|
desktop_url?: string;
|
||||||
|
// Automatisch: hat das Projekt Dateien in /shared/projects/<id>/? → Datei-Symbol.
|
||||||
|
has_files?: boolean;
|
||||||
|
file_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectStatus {
|
export interface ProjectStatus {
|
||||||
@@ -176,6 +179,19 @@ export interface ProjectStatus {
|
|||||||
projects: Project[];
|
projects: Project[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** QEMU-VM eines Projekts (Registry + Live-Status). */
|
||||||
|
export interface ProjectVm {
|
||||||
|
name: string;
|
||||||
|
arch: string;
|
||||||
|
iso?: string;
|
||||||
|
vnc_display: number;
|
||||||
|
mem: number;
|
||||||
|
running?: boolean;
|
||||||
|
vnc_port?: number;
|
||||||
|
boot_cmd?: string;
|
||||||
|
created_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Queue-Status pro Kontext — was gerade arbeitet, was wartet.
|
/** Queue-Status pro Kontext — was gerade arbeitet, was wartet.
|
||||||
* Key "__main__" = Hauptchat, sonst project_id. */
|
* Key "__main__" = Hauptchat, sonst project_id. */
|
||||||
export interface QueueContextStatus {
|
export interface QueueContextStatus {
|
||||||
@@ -600,14 +616,58 @@ export const brainApi = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Projekt-Metadaten patchen (name / description / hidden). */
|
/** Projekt-Metadaten patchen (name / description / hidden / kind). */
|
||||||
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden'>>): Promise<Project> {
|
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden' | 'kind'>>): Promise<Project> {
|
||||||
return _send(`/projects/${encodeURIComponent(projectId)}`, {
|
return _send(`/projects/${encodeURIComponent(projectId)}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: patch,
|
body: patch,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Projekt manuell als Code-Projekt / normalen Chat markieren. */
|
||||||
|
setProjectKind(projectId: string, kind: 'code' | 'chat'): Promise<Project> {
|
||||||
|
return _send(`/projects/${encodeURIComponent(projectId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { kind },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Vorhandene Code-Dateien eines Projekts auflisten (/shared/projects/<id>/). */
|
||||||
|
listProjectFiles(projectId: string): Promise<{ projectId: string; files: { path: string; size: number }[] }> {
|
||||||
|
return _send(`/projects/${encodeURIComponent(projectId)}/files`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Inhalt einer Projekt-Datei laden (Text). */
|
||||||
|
readProjectFile(projectId: string, path: string): Promise<{ projectId: string; path: string; content: string }> {
|
||||||
|
return _send(`/projects/${encodeURIComponent(projectId)}/file?path=${encodeURIComponent(path)}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Binaere Projekt-Datei (z.B. Bild) als Base64 + MIME laden. */
|
||||||
|
readProjectFileBinary(projectId: string, path: string): Promise<{ path: string; mime: string; base64: string }> {
|
||||||
|
return _send(`/projects/${encodeURIComponent(projectId)}/file?binary=1&path=${encodeURIComponent(path)}`, { timeoutMs: 30000 });
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── QEMU-VMs pro Projekt ─────────────────────────────────────────
|
||||||
|
listProjectVms(projectId: string): Promise<{ projectId: string; vms: ProjectVm[] }> {
|
||||||
|
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { timeoutMs: 20000 });
|
||||||
|
},
|
||||||
|
addProjectVm(projectId: string, body: { name: string; arch?: string; iso?: string; vnc_display?: number; mem?: number; create_disk?: boolean; size?: string }): Promise<ProjectVm> {
|
||||||
|
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { method: 'POST', body, timeoutMs: 30000 });
|
||||||
|
},
|
||||||
|
removeProjectVm(projectId: string, name: string, purge = false): Promise<{ ok: boolean }> {
|
||||||
|
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}?purge=${purge ? 'true' : 'false'}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
bootProjectVm(projectId: string, name: string): Promise<{ ok: boolean; vnc_port: number; output: string }> {
|
||||||
|
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/boot`, { method: 'POST', timeoutMs: 45000 });
|
||||||
|
},
|
||||||
|
stopProjectVm(projectId: string, name: string): Promise<{ ok: boolean; output: string }> {
|
||||||
|
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/stop`, { method: 'POST', timeoutMs: 30000 });
|
||||||
|
},
|
||||||
|
/** Screenshot der laufenden VM (Base64-PNG) — VM-Bildschirm ohne Live-VNC. */
|
||||||
|
screenshotProjectVm(projectId: string, name: string): Promise<{ ok: boolean; filename: string; base64: string }> {
|
||||||
|
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/screenshot`, { method: 'POST', timeoutMs: 30000 });
|
||||||
|
},
|
||||||
|
|
||||||
/** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */
|
/** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */
|
||||||
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
|
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
|
||||||
return _send(`/projects/${encodeURIComponent(projectId)}`, {
|
return _send(`/projects/${encodeURIComponent(projectId)}`, {
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import { TileId } from './layout';
|
|||||||
import { useWorkspaceLayout } from './useWorkspaceLayout';
|
import { useWorkspaceLayout } from './useWorkspaceLayout';
|
||||||
import WorkspaceDock from './WorkspaceDock';
|
import WorkspaceDock from './WorkspaceDock';
|
||||||
import ChatTile from './tiles/ChatTile';
|
import ChatTile from './tiles/ChatTile';
|
||||||
|
import FilesTile from './tiles/FilesTile';
|
||||||
import CodeEditorTile from './tiles/CodeEditorTile';
|
import CodeEditorTile from './tiles/CodeEditorTile';
|
||||||
import VncTile from './tiles/VncTile';
|
import DesktopTile from './tiles/DesktopTile';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -57,8 +58,9 @@ const WorkspaceDeck: React.FC<Props> = ({ projectId, panels, badges }) => {
|
|||||||
const render = (id: TileId) => {
|
const render = (id: TileId) => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'chat': return <ChatTile />;
|
case 'chat': return <ChatTile />;
|
||||||
|
case 'files': return <FilesTile projectId={projectId} focused={active === 'files'} />;
|
||||||
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
||||||
case 'vnc': return <VncTile projectId={projectId} focused={active === 'vnc'} />;
|
case 'vnc': return <DesktopTile projectId={projectId} focused={active === 'vnc'} />;
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import ChatScreen from '../screens/ChatScreen';
|
|||||||
import { TileId } from './layout';
|
import { TileId } from './layout';
|
||||||
import WorkspaceDeck from './WorkspaceDeck';
|
import WorkspaceDeck from './WorkspaceDeck';
|
||||||
|
|
||||||
const COCKPIT_PANELS: TileId[] = ['chat', 'editor', 'vnc'];
|
const COCKPIT_PANELS: TileId[] = ['chat', 'files', 'editor', 'vnc'];
|
||||||
|
|
||||||
const WorkspaceScreen: React.FC = () => {
|
const WorkspaceScreen: React.FC = () => {
|
||||||
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
* layout — Panel-Definitionen der Workbench (Metadaten fuer das Dock).
|
* layout — Panel-Definitionen der Workbench (Metadaten fuer das Dock).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type TileId = 'chat' | 'editor' | 'vnc' | 'preview';
|
export type TileId = 'chat' | 'files' | 'editor' | 'vnc' | 'preview';
|
||||||
|
|
||||||
export interface TileDef { id: TileId; title: string; icon: string }
|
export interface TileDef { id: TileId; title: string; icon: string }
|
||||||
|
|
||||||
export const TILE_META: Record<TileId, TileDef> = {
|
export const TILE_META: Record<TileId, TileDef> = {
|
||||||
chat: { id: 'chat', title: 'Chat', icon: '💬' },
|
chat: { id: 'chat', title: 'Chat', icon: '💬' },
|
||||||
|
files: { id: 'files', title: 'Dateien', icon: '📁' },
|
||||||
editor: { id: 'editor', title: 'Code', icon: '📝' },
|
editor: { id: 'editor', title: 'Code', icon: '📝' },
|
||||||
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
|
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
|
||||||
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
|
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
|
||||||
|
|||||||
@@ -1,57 +1,97 @@
|
|||||||
/**
|
/**
|
||||||
* CodeEditorTile — Live-Code-Editor (WebView, editorHtml.ts).
|
* CodeEditorTile — Live-Code-Editor (WebView, editorHtml.ts).
|
||||||
*
|
*
|
||||||
* Zeigt live, was ARIA in diesem Projekt schreibt (aus dem codeFile-Spiegel)
|
* Zeigt die Dateien eines Code-Projekts aus /shared/projects/<id>/:
|
||||||
* und laesst Stefan selbst editieren — Aenderungen gehen als code_file_edit
|
* - beim Oeffnen werden die BEREITS vorhandenen Dateien vom Brain geladen
|
||||||
* zurueck an die Bridge. Datei-Tabs oben zum Umschalten.
|
* (listProjectFiles/readProjectFile) — sonst waere der Editor leer, obwohl
|
||||||
|
* ARIA schon Dateien geschrieben hat.
|
||||||
|
* - live schreibt ARIA weiter → code_file-Stream aktualisiert die offene Datei.
|
||||||
|
* Stefan kann selbst editieren → code_file_edit zurueck an die Bridge.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||||
import codeFile, { CodeFileState } from '../../services/codeFile';
|
import codeFile from '../../services/codeFile';
|
||||||
|
import brainApi from '../../services/brainApi';
|
||||||
import { EDITOR_HTML } from '../assets/editorHtml';
|
import { EDITOR_HTML } from '../assets/editorHtml';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function guessLang(path: string): string {
|
||||||
|
const ext = (path.split('.').pop() || '').toLowerCase();
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
js: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python',
|
||||||
|
c: 'c', h: 'c', cpp: 'cpp', asm: 'asm', s: 'asm', sh: 'shell', bash: 'shell',
|
||||||
|
html: 'html', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml', md: 'markdown',
|
||||||
|
go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', txt: 'text',
|
||||||
|
};
|
||||||
|
return map[ext] || 'text';
|
||||||
|
}
|
||||||
|
|
||||||
const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
|
const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
|
||||||
const webRef = useRef<WebView>(null);
|
const webRef = useRef<WebView>(null);
|
||||||
const [files, setFiles] = useState<CodeFileState[]>(() => codeFile.getFiles(projectId));
|
// Pfade aus dem Brain (vorhandene Dateien) — mit Live-Dateien gemergt.
|
||||||
const [currentPath, setCurrentPath] = useState<string | null>(files[0]?.path ?? null);
|
const [serverPaths, setServerPaths] = useState<string[]>([]);
|
||||||
|
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||||
|
const [loadErr, setLoadErr] = useState<string>('');
|
||||||
|
|
||||||
const readyRef = useRef(false);
|
const readyRef = useRef(false);
|
||||||
const currentPathRef = useRef<string | null>(currentPath);
|
const currentPathRef = useRef<string | null>(currentPath);
|
||||||
currentPathRef.current = currentPath;
|
currentPathRef.current = currentPath;
|
||||||
|
|
||||||
|
// Vereinigte, sortierte Dateiliste (Live-Spiegel + Server-Dateien).
|
||||||
|
const files = useMemo(() => {
|
||||||
|
const set = new Set<string>(serverPaths);
|
||||||
|
for (const f of codeFile.getFiles(projectId)) set.add(f.path);
|
||||||
|
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||||
|
}, [serverPaths, projectId]);
|
||||||
|
|
||||||
const sendToWeb = useCallback((payload: Record<string, unknown>) => {
|
const sendToWeb = useCallback((payload: Record<string, unknown>) => {
|
||||||
const js = `window.ariaBridge && window.ariaBridge.onMessage(${JSON.stringify(JSON.stringify(payload))}); true;`;
|
const js = `window.ariaBridge && window.ariaBridge.onMessage(${JSON.stringify(JSON.stringify(payload))}); true;`;
|
||||||
webRef.current?.injectJavaScript(js);
|
webRef.current?.injectJavaScript(js);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadFileIntoEditor = useCallback((path: string | null) => {
|
const loadFileIntoEditor = useCallback(async (path: string | null) => {
|
||||||
if (!path) { sendToWeb({ cmd: 'setContent', content: '', language: 'text', version: 0 }); return; }
|
if (!path) { sendToWeb({ cmd: 'setContent', content: '', language: 'text', version: 0 }); return; }
|
||||||
const f = codeFile.getFile(projectId, path);
|
// Live-Version bevorzugen (falls ARIA gerade schreibt), sonst vom Brain holen.
|
||||||
sendToWeb({ cmd: 'setContent', content: f?.content ?? '', language: f?.language ?? 'text', version: f?.version ?? 0 });
|
const live = codeFile.getFile(projectId, path);
|
||||||
|
if (live) {
|
||||||
|
sendToWeb({ cmd: 'setContent', content: live.content, language: live.language, version: live.version });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await brainApi.readProjectFile(projectId, path);
|
||||||
|
sendToWeb({ cmd: 'setContent', content: res.content ?? '', language: guessLang(path), version: 0 });
|
||||||
|
} catch (e: any) {
|
||||||
|
sendToWeb({ cmd: 'setContent', content: `// Konnte ${path} nicht laden: ${e?.message || e}`, language: 'text', version: 0 });
|
||||||
|
}
|
||||||
}, [projectId, sendToWeb]);
|
}, [projectId, sendToWeb]);
|
||||||
|
|
||||||
// Projektwechsel: Dateiliste + Auswahl neu.
|
// Projektwechsel: vorhandene Dateien vom Brain laden.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const list = codeFile.getFiles(projectId);
|
let cancelled = false;
|
||||||
setFiles(list);
|
setLoadErr('');
|
||||||
setCurrentPath((prev) => (prev && list.some((f) => f.path === prev) ? prev : list[0]?.path ?? null));
|
brainApi.listProjectFiles(projectId)
|
||||||
|
.then(res => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const paths = (res.files || []).map(f => f.path);
|
||||||
|
setServerPaths(paths);
|
||||||
|
setCurrentPath(prev => (prev && paths.includes(prev)) ? prev : (paths[0] ?? codeFile.getFiles(projectId)[0]?.path ?? null));
|
||||||
|
})
|
||||||
|
.catch(e => { if (!cancelled) setLoadErr(String(e?.message || e)); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
// Eingehende Updates aus dem Spiegel.
|
// Live-Updates aus dem Spiegel.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return codeFile.subscribe((u) => {
|
return codeFile.subscribe((u) => {
|
||||||
if ((u.projectId || '') !== (projectId || '')) return;
|
if ((u.projectId || '') !== (projectId || '')) return;
|
||||||
setFiles(codeFile.getFiles(projectId));
|
setServerPaths(prev => prev.includes(u.path) ? prev : [...prev, u.path]);
|
||||||
// Noch keine Datei gewaehlt → diese oeffnen.
|
|
||||||
if (!currentPathRef.current) { setCurrentPath(u.path); return; }
|
if (!currentPathRef.current) { setCurrentPath(u.path); return; }
|
||||||
if (u.path !== currentPathRef.current) return;
|
if (u.path !== currentPathRef.current || !readyRef.current) return;
|
||||||
if (!readyRef.current) return;
|
|
||||||
if (u.patch) {
|
if (u.patch) {
|
||||||
sendToWeb({ cmd: 'applyPatch', from: u.patch.from, to: u.patch.to, insert: u.patch.insert, version: u.version });
|
sendToWeb({ cmd: 'applyPatch', from: u.patch.from, to: u.patch.to, insert: u.patch.insert, version: u.version });
|
||||||
} else {
|
} else {
|
||||||
@@ -60,7 +100,7 @@ const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
|
|||||||
});
|
});
|
||||||
}, [projectId, sendToWeb]);
|
}, [projectId, sendToWeb]);
|
||||||
|
|
||||||
// Datei-Auswahl gewechselt → in den Editor laden (falls WebView bereit).
|
// Datei-Auswahl gewechselt → laden (falls WebView bereit).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (readyRef.current) loadFileIntoEditor(currentPath);
|
if (readyRef.current) loadFileIntoEditor(currentPath);
|
||||||
}, [currentPath, loadFileIntoEditor]);
|
}, [currentPath, loadFileIntoEditor]);
|
||||||
@@ -82,14 +122,14 @@ const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.tabsRow}>
|
<View style={styles.tabsRow}>
|
||||||
{files.length === 0 ? (
|
{files.length === 0 ? (
|
||||||
<Text style={styles.noFiles}>Noch keine Datei</Text>
|
<Text style={styles.noFiles}>{loadErr ? `Fehler: ${loadErr}` : 'Noch keine Datei in diesem Projekt'}</Text>
|
||||||
) : (
|
) : (
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabs}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabs}>
|
||||||
{files.map((f) => {
|
{files.map((path) => {
|
||||||
const active = f.path === currentPath;
|
const active = path === currentPath;
|
||||||
const name = f.path.split('/').pop() || f.path;
|
const name = path.split('/').pop() || path;
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity key={f.path} onPress={() => setCurrentPath(f.path)} style={[styles.tab, active && styles.tabActive]}>
|
<TouchableOpacity key={path} onPress={() => setCurrentPath(path)} style={[styles.tab, active && styles.tabActive]}>
|
||||||
<Text style={[styles.tabText, active && styles.tabTextActive]} numberOfLines={1}>{name}</Text>
|
<Text style={[styles.tabText, active && styles.tabTextActive]} numberOfLines={1}>{name}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* DesktopTile — das Desktop-Panel eines Code-Projekts.
|
||||||
|
*
|
||||||
|
* Zeigt die (pro Projekt gefuehrte) QEMU-VM-Liste: leer, bis ARIA per
|
||||||
|
* vm_register eine VM eintraegt. Pro VM: Start / Stop / Verbinden. „Verbinden"
|
||||||
|
* oeffnet die noVNC-Ansicht (VncTile) fuer den VNC-Port dieser VM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import brainApi, { ProjectVm } from '../../services/brainApi';
|
||||||
|
import VncTile from './VncTile';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
focused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||||
|
const [vms, setVms] = useState<ProjectVm[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [busy, setBusy] = useState(''); // VM-Name, der gerade bootet/stoppt
|
||||||
|
const [connected, setConnected] = useState<ProjectVm | null>(null);
|
||||||
|
const [shotBusy, setShotBusy] = useState('');
|
||||||
|
const [shot, setShot] = useState<{ name: string; b64: string } | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true); setErr('');
|
||||||
|
brainApi.listProjectVms(projectId)
|
||||||
|
.then(r => setVms(r.vms || []))
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focused && !connected) load();
|
||||||
|
}, [focused, projectId, connected, load]);
|
||||||
|
|
||||||
|
const boot = useCallback((vm: ProjectVm) => {
|
||||||
|
setBusy(vm.name);
|
||||||
|
brainApi.bootProjectVm(projectId, vm.name)
|
||||||
|
.then(() => load())
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setBusy(''));
|
||||||
|
}, [projectId, load]);
|
||||||
|
|
||||||
|
const stop = useCallback((vm: ProjectVm) => {
|
||||||
|
setBusy(vm.name);
|
||||||
|
brainApi.stopProjectVm(projectId, vm.name)
|
||||||
|
.then(() => load())
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setBusy(''));
|
||||||
|
}, [projectId, load]);
|
||||||
|
|
||||||
|
const screenshot = useCallback((vm: ProjectVm) => {
|
||||||
|
setShotBusy(vm.name); setErr('');
|
||||||
|
brainApi.screenshotProjectVm(projectId, vm.name)
|
||||||
|
.then(r => setShot({ name: vm.name, b64: r.base64 }))
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setShotBusy(''));
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
if (!focused) {
|
||||||
|
return (
|
||||||
|
<View style={styles.placeholder}>
|
||||||
|
<Text style={styles.icon}>🖥️</Text>
|
||||||
|
<Text style={styles.text}>Desktop</Text>
|
||||||
|
<Text style={styles.sub}>Panel öffnen für VM-Liste</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbunden → noVNC-Ansicht der VM + Zurück-Leiste.
|
||||||
|
if (connected) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.bar}>
|
||||||
|
<TouchableOpacity onPress={() => setConnected(null)} style={styles.barBtn}>
|
||||||
|
<Text style={styles.barBtnText}>‹ VMs</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.barTitle} numberOfLines={1}>{connected.name} · :{connected.vnc_display}</Text>
|
||||||
|
</View>
|
||||||
|
<VncTile projectId={projectId} focused port={connected.vnc_port || (5900 + (connected.vnc_display || 1))} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.bar}>
|
||||||
|
<Text style={styles.barTitle}>Virtuelle Maschinen</Text>
|
||||||
|
<TouchableOpacity onPress={load} style={styles.barBtn}><Text style={styles.barBtnText}>↻</Text></TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 12 }}>
|
||||||
|
{loading && vms.length === 0 ? (
|
||||||
|
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
|
||||||
|
) : err ? (
|
||||||
|
<Text style={styles.err}>{err}</Text>
|
||||||
|
) : vms.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>
|
||||||
|
Noch keine VM in diesem Projekt.{'\n'}
|
||||||
|
Sag ARIA z.B. „bau eine QEMU-VM zum Testen" — sie registriert sie hier,
|
||||||
|
dann kannst du sie starten und verbinden.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
vms.map(vm => {
|
||||||
|
const isBusy = busy === vm.name;
|
||||||
|
return (
|
||||||
|
<View key={vm.name} style={styles.vmRow}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.vmName}>
|
||||||
|
<Text style={{ color: vm.running ? '#34C759' : '#555570' }}>●</Text> {vm.name}
|
||||||
|
<Text style={styles.vmMeta}> {vm.arch} · {vm.running ? 'läuft' : 'gestoppt'}</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.vmCmd} numberOfLines={2}>{vm.boot_cmd || `aria-vm boot ${vm.name} --vnc-display ${vm.vnc_display}`}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.vmBtns}>
|
||||||
|
{isBusy ? (
|
||||||
|
<ActivityIndicator color="#0096FF" />
|
||||||
|
) : vm.running ? (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity onPress={() => screenshot(vm)} style={[styles.vmBtn, { borderColor: '#8888AA' }]} disabled={shotBusy === vm.name}>
|
||||||
|
{shotBusy === vm.name
|
||||||
|
? <ActivityIndicator color="#8888AA" size="small" />
|
||||||
|
: <Text style={[styles.vmBtnText, { color: '#C8C8E0' }]}>📷</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => setConnected(vm)} style={[styles.vmBtn, { borderColor: '#0096FF' }]}>
|
||||||
|
<Text style={[styles.vmBtnText, { color: '#0096FF' }]}>Verbinden</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => stop(vm)} style={[styles.vmBtn, { borderColor: '#E55C5C' }]}>
|
||||||
|
<Text style={[styles.vmBtnText, { color: '#E55C5C' }]}>Stop</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity onPress={() => boot(vm)} style={[styles.vmBtn, { borderColor: '#34C759' }]}>
|
||||||
|
<Text style={[styles.vmBtnText, { color: '#34C759' }]}>Start</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<Modal visible={!!shot} transparent animationType="fade" onRequestClose={() => setShot(null)}>
|
||||||
|
<TouchableOpacity style={styles.shotOverlay} activeOpacity={1} onPress={() => setShot(null)}>
|
||||||
|
<Text style={styles.shotTitle}>{shot?.name} — Screenshot</Text>
|
||||||
|
{shot && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: `data:image/png;base64,${shot.b64}` }}
|
||||||
|
style={styles.shotImg}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text style={styles.shotHint}>Tippen zum Schließen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||||
|
placeholder: { flex: 1, backgroundColor: '#000', alignItems: 'center', justifyContent: 'center' },
|
||||||
|
icon: { fontSize: 64, marginBottom: 16 },
|
||||||
|
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||||
|
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||||
|
bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 },
|
||||||
|
barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 },
|
||||||
|
barBtn: { paddingHorizontal: 10, paddingVertical: 4 },
|
||||||
|
barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
||||||
|
empty: { color: '#8888AA', fontSize: 13, lineHeight: 20, textAlign: 'center', marginTop: 24 },
|
||||||
|
err: { color: '#FF6E6E', fontSize: 13, marginTop: 16 },
|
||||||
|
vmRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#12122A', borderRadius: 10, padding: 12, marginBottom: 8 },
|
||||||
|
vmName: { color: '#E0E0F0', fontSize: 15, fontWeight: '700' },
|
||||||
|
vmMeta: { color: '#8888AA', fontSize: 12, fontWeight: '400' },
|
||||||
|
vmCmd: { color: '#6A9BD0', fontSize: 11, fontFamily: 'monospace', marginTop: 4 },
|
||||||
|
vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' },
|
||||||
|
vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, minWidth: 34, alignItems: 'center' },
|
||||||
|
vmBtnText: { fontSize: 12, fontWeight: '700' },
|
||||||
|
shotOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center', padding: 12 },
|
||||||
|
shotTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', marginBottom: 10 },
|
||||||
|
shotImg: { width: '100%', height: '78%', backgroundColor: '#000' },
|
||||||
|
shotHint: { color: '#8888AA', fontSize: 12, marginTop: 12 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default DesktopTile;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* FilesTile — Datei-Browser eines Projekts (/shared/projects/<id>/).
|
||||||
|
*
|
||||||
|
* Listet ALLE Dateien (nicht nur Code): erzeugte Bilder, Logs, Assets … — die
|
||||||
|
* gleichen, die in der Projektliste als 📄 gezaehlt werden. Tippen auf ein Bild
|
||||||
|
* zeigt es; tippen auf eine Textdatei zeigt eine Vorschau.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import brainApi from '../../services/brainApi';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
focused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileEntry { path: string; size: number }
|
||||||
|
|
||||||
|
const IMG_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'];
|
||||||
|
|
||||||
|
function ext(path: string): string { return (path.split('.').pop() || '').toLowerCase(); }
|
||||||
|
function isImage(path: string): boolean { return IMG_EXT.includes(ext(path)); }
|
||||||
|
function iconFor(path: string): string {
|
||||||
|
const e = ext(path);
|
||||||
|
if (isImage(path)) return '🖼️';
|
||||||
|
if (['md', 'txt', 'readme'].includes(e)) return '📄';
|
||||||
|
if (['asm', 's', 'c', 'h', 'cpp', 'py', 'js', 'ts', 'sh', 'go', 'rs'].includes(e)) return '📝';
|
||||||
|
if (['zip', 'tar', 'gz', 'img', 'iso', 'qcow2'].includes(e)) return '📦';
|
||||||
|
return '📄';
|
||||||
|
}
|
||||||
|
function humanSize(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilesTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||||
|
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [preview, setPreview] = useState<{ path: string; kind: 'image' | 'text'; data: string } | null>(null);
|
||||||
|
const [previewBusy, setPreviewBusy] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true); setErr('');
|
||||||
|
brainApi.listProjectFiles(projectId)
|
||||||
|
.then(r => setFiles((r.files || []).slice().sort((a, b) => a.path.localeCompare(b.path))))
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => { if (focused) load(); }, [focused, projectId, load]);
|
||||||
|
|
||||||
|
const open = useCallback((f: FileEntry) => {
|
||||||
|
setPreviewBusy(f.path); setErr('');
|
||||||
|
if (isImage(f.path)) {
|
||||||
|
brainApi.readProjectFileBinary(projectId, f.path)
|
||||||
|
.then(r => setPreview({ path: f.path, kind: 'image', data: `data:${r.mime};base64,${r.base64}` }))
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setPreviewBusy(''));
|
||||||
|
} else {
|
||||||
|
brainApi.readProjectFile(projectId, f.path)
|
||||||
|
.then(r => setPreview({ path: f.path, kind: 'text', data: r.content ?? '' }))
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setPreviewBusy(''));
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
if (!focused) {
|
||||||
|
return (
|
||||||
|
<View style={styles.placeholder}>
|
||||||
|
<Text style={styles.icon}>📁</Text>
|
||||||
|
<Text style={styles.text}>Dateien</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.bar}>
|
||||||
|
<Text style={styles.barTitle}>Dateien{files.length ? ` (${files.length})` : ''}</Text>
|
||||||
|
<TouchableOpacity onPress={load} style={styles.barBtn}><Text style={styles.barBtnText}>↻</Text></TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 8 }}>
|
||||||
|
{loading && files.length === 0 ? (
|
||||||
|
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
|
||||||
|
) : err ? (
|
||||||
|
<Text style={styles.err}>{err}</Text>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>Noch keine Dateien in diesem Projekt.</Text>
|
||||||
|
) : (
|
||||||
|
files.map(f => (
|
||||||
|
<TouchableOpacity key={f.path} onPress={() => open(f)} style={styles.row} disabled={previewBusy === f.path}>
|
||||||
|
<Text style={styles.rowIcon}>{iconFor(f.path)}</Text>
|
||||||
|
<Text style={styles.rowName} numberOfLines={1}>{f.path}</Text>
|
||||||
|
{previewBusy === f.path
|
||||||
|
? <ActivityIndicator color="#8888AA" size="small" />
|
||||||
|
: <Text style={styles.rowSize}>{humanSize(f.size)}</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<Modal visible={!!preview} transparent animationType="fade" onRequestClose={() => setPreview(null)}>
|
||||||
|
<View style={styles.pvOverlay}>
|
||||||
|
<View style={styles.pvBar}>
|
||||||
|
<Text style={styles.pvTitle} numberOfLines={1}>{preview?.path}</Text>
|
||||||
|
<TouchableOpacity onPress={() => setPreview(null)}><Text style={styles.pvClose}>✕</Text></TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{preview?.kind === 'image' ? (
|
||||||
|
<Image source={{ uri: preview.data }} style={styles.pvImg} resizeMode="contain" />
|
||||||
|
) : (
|
||||||
|
<ScrollView style={styles.pvTextWrap} horizontal>
|
||||||
|
<ScrollView><Text style={styles.pvText}>{preview?.data}</Text></ScrollView>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||||
|
placeholder: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' },
|
||||||
|
icon: { fontSize: 56, marginBottom: 10 },
|
||||||
|
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||||
|
bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 },
|
||||||
|
barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 },
|
||||||
|
barBtn: { paddingHorizontal: 10, paddingVertical: 4 },
|
||||||
|
barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
||||||
|
empty: { color: '#8888AA', fontSize: 13, textAlign: 'center', marginTop: 24 },
|
||||||
|
err: { color: '#FF6E6E', fontSize: 13, marginTop: 16, paddingHorizontal: 8 },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 8, borderBottomColor: '#161628', borderBottomWidth: 1, gap: 10 },
|
||||||
|
rowIcon: { fontSize: 18 },
|
||||||
|
rowName: { color: '#E0E0F0', fontSize: 13, flex: 1 },
|
||||||
|
rowSize: { color: '#555570', fontSize: 11 },
|
||||||
|
pvOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.94)' },
|
||||||
|
pvBar: { flexDirection: 'row', alignItems: 'center', padding: 12, gap: 10 },
|
||||||
|
pvTitle: { color: '#E0E0F0', fontSize: 13, fontWeight: '700', flex: 1 },
|
||||||
|
pvClose: { color: '#E0E0F0', fontSize: 20, paddingHorizontal: 6 },
|
||||||
|
pvImg: { flex: 1, width: '100%' },
|
||||||
|
pvTextWrap: { flex: 1, padding: 12 },
|
||||||
|
pvText: { color: '#C8C8E0', fontSize: 12, fontFamily: 'monospace' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FilesTile;
|
||||||
@@ -16,9 +16,10 @@ import { NOVNC_HTML } from '../assets/novncHtml';
|
|||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
|
port?: number; // VNC-Port der zu verbindenden VM (Default 5901 = Display :1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
||||||
const webRef = useRef<WebView>(null);
|
const webRef = useRef<WebView>(null);
|
||||||
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
||||||
const unsubDataRef = useRef<null | (() => void)>(null);
|
const unsubDataRef = useRef<null | (() => void)>(null);
|
||||||
@@ -46,7 +47,7 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`;
|
const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`;
|
||||||
webRef.current?.injectJavaScript(js);
|
webRef.current?.injectJavaScript(js);
|
||||||
});
|
});
|
||||||
desktop.openVnc(projectId);
|
desktop.openVnc(projectId, port);
|
||||||
} else if (m.event === 'vnc_send') {
|
} else if (m.event === 'vnc_send') {
|
||||||
desktop.sendInput(m.b64);
|
desktop.sendInput(m.b64);
|
||||||
} else if (m.event === 'vnc_close') {
|
} else if (m.event === 'vnc_close') {
|
||||||
@@ -55,7 +56,7 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
if (m.state === 'connected') setStatus('connected');
|
if (m.state === 'connected') setStatus('connected');
|
||||||
else if (m.state === 'disconnected') setStatus('disconnected');
|
else if (m.state === 'disconnected') setStatus('disconnected');
|
||||||
}
|
}
|
||||||
}, [projectId]);
|
}, [projectId, port]);
|
||||||
|
|
||||||
if (!focused) {
|
if (!focused) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import triggers as triggers_mod
|
|||||||
import watcher as watcher_mod
|
import watcher as watcher_mod
|
||||||
import oauth as oauth_mod
|
import oauth as oauth_mod
|
||||||
import projects as projects_mod
|
import projects as projects_mod
|
||||||
|
import project_vms as project_vms_mod
|
||||||
|
|
||||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||||
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
|
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
|
||||||
@@ -1105,6 +1106,99 @@ META_TOOLS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "satellite_list",
|
||||||
|
"description": (
|
||||||
|
"Zeigt welche ARIA-Satelliten (Aussenposten-Container in FREMDEN Netzen, "
|
||||||
|
"z.B. 'Buero') gerade ONLINE sind und was sie koennen (discover / "
|
||||||
|
"dial.launch / wol / http). Nutze das ZUERST, wenn Stefan etwas 'im "
|
||||||
|
"Buero' / 'im Netz X' / 'auf dem <Geraet> dort' machen will — so weisst "
|
||||||
|
"Du welche Netze erreichbar sind."
|
||||||
|
),
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "satellite_devices",
|
||||||
|
"description": (
|
||||||
|
"Listet die Geraete im Netz eines Satelliten (Fire TV, Chromecast, "
|
||||||
|
"Smart-TVs, Drucker, NAS, Hosts ...). Nutze es um herauszufinden welches "
|
||||||
|
"Geraet gemeint ist, BEVOR Du satellite_command aufrufst."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"satellite": {"type": "string", "description": "Satelliten-Name/Location/ID (z.B. 'Buero')."},
|
||||||
|
},
|
||||||
|
"required": ["satellite"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "satellite_command",
|
||||||
|
"description": (
|
||||||
|
"Fuehrt eine Aktion auf einem Geraet im Netz eines Satelliten aus. "
|
||||||
|
"Beispiel YouTube-Video auf Fire TV: action='dial.launch', "
|
||||||
|
"device='Fire TV', params={'app':'YouTube','v':'<videoId>'}. Weitere "
|
||||||
|
"Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
|
||||||
|
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks. "
|
||||||
|
"Geht nur, wenn der Satellit Steuerung erlaubt (siehe satellite_list)."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"satellite": {"type": "string", "description": "Satelliten-Name/Location/ID."},
|
||||||
|
"device": {"type": "string", "description": "Geraet (Name/ID/IP) aus satellite_devices."},
|
||||||
|
"action": {"type": "string", "description": "z.B. 'dial.launch', 'wol', 'http.get'."},
|
||||||
|
"params": {"type": "object", "description": "Aktions-Parameter, z.B. {'app':'YouTube','v':'<id>'}."},
|
||||||
|
},
|
||||||
|
"required": ["satellite", "action"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "vm_register",
|
||||||
|
"description": (
|
||||||
|
"Traegt eine QEMU-VM in die VM-Liste des AKTUELLEN Projekts ein, "
|
||||||
|
"damit sie in Stefans Desktop-Panel (Cockpit) erscheint und er sie "
|
||||||
|
"starten/stoppen/verbinden kann. Rufe das auf, NACHDEM Du mit aria-vm "
|
||||||
|
"eine VM gebaut/gebootet hast — mit den Medien, die zum Task passen: "
|
||||||
|
"disk (Festplatte, z.B. DOS-Spiele), floppy (Diskette, OS-Dev), iso "
|
||||||
|
"(Installer). Leer lassen = aria-vm erkennt disk.qcow2/floppy.img/"
|
||||||
|
"cdrom.iso im VM-Ordner selbst. vnc_display → Port 5900+display "
|
||||||
|
"(mehrere VMs = verschiedene Displays)."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "VM-Name wie bei aria-vm (a-z0-9_-)."},
|
||||||
|
"arch": {"type": "string", "description": "z.B. i386, x86_64, aarch64, mips (Architektur zum Task waehlen)."},
|
||||||
|
"disk": {"type": "string", "description": "optional: qcow2-Pfad (Festplatte)."},
|
||||||
|
"floppy": {"type": "string", "description": "optional: Disketten-Image-Pfad (-fda)."},
|
||||||
|
"iso": {"type": "string", "description": "optional: Boot-ISO-Pfad."},
|
||||||
|
"vnc_display": {"type": "integer", "description": "meist WEGLASSEN — das VNC-Display wird global eindeutig auto-vergeben (kein Port-Konflikt bei mehreren VMs). Nur setzen wenn Du ein bestimmtes willst."},
|
||||||
|
"mem": {"type": "integer", "description": "RAM in MB (Default 1024)."},
|
||||||
|
},
|
||||||
|
"required": ["name", "arch"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "vm_list",
|
||||||
|
"description": "Listet die registrierten QEMU-VMs des aktuellen Projekts (mit Lauf-Status).",
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -1938,6 +2032,70 @@ class Agent:
|
|||||||
return []
|
return []
|
||||||
return out[-limit:]
|
return out[-limit:]
|
||||||
|
|
||||||
|
def _dispatch_satellite(self, name: str, arguments: dict) -> str:
|
||||||
|
"""satellite_list / satellite_devices / satellite_command — geht via
|
||||||
|
Bridge (/internal/satellite*) → RVS → Satellit. Muster wie flux_generate."""
|
||||||
|
def _post(path: str, body: dict, timeout: float) -> dict:
|
||||||
|
data = json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(f"{BRIDGE_URL}{path}", data=data, method="POST",
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8", "ignore"))
|
||||||
|
try:
|
||||||
|
if name == "satellite_list":
|
||||||
|
result = _post("/internal/satellite-list", {}, 10)
|
||||||
|
sats = result.get("satellites") or []
|
||||||
|
if not sats:
|
||||||
|
return ("Gerade ist kein Satellit online. (Ein Satellit ist ein "
|
||||||
|
"Aussenposten-Container in einem fremden Netz, der sich mit "
|
||||||
|
"RVS verbindet und Dir dort Geraete zeigt + steuerbar macht.)")
|
||||||
|
lines = []
|
||||||
|
for s in sats:
|
||||||
|
status = "online" if s.get("online") else "offline"
|
||||||
|
caps = ", ".join(s.get("caps") or []) or "nur beobachten"
|
||||||
|
lines.append(f"- {s.get('location')} (id={s.get('id')}, {status}; kann: {caps})")
|
||||||
|
return "Satelliten / erreichbare Netze:\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
if name == "satellite_devices":
|
||||||
|
sat = (arguments.get("satellite") or "").strip()
|
||||||
|
if not sat:
|
||||||
|
return "FEHLER: satellite ist Pflicht."
|
||||||
|
result = _post("/internal/satellite", {"op": "discover", "satellite": sat}, 30)
|
||||||
|
if result.get("error"):
|
||||||
|
return f"FEHLER: {result['error']}"
|
||||||
|
devices = result.get("devices") or []
|
||||||
|
if not devices:
|
||||||
|
return f"Im Netz von '{sat}' wurden keine Geraete gefunden (oder der Satellit ist offline)."
|
||||||
|
lines = []
|
||||||
|
for d in devices[:40]:
|
||||||
|
tags = []
|
||||||
|
if d.get("model"):
|
||||||
|
tags.append(d["model"])
|
||||||
|
if d.get("dialAppUrl"):
|
||||||
|
tags.append("DIAL/App-Launch")
|
||||||
|
if d.get("mac"):
|
||||||
|
tags.append(d["mac"])
|
||||||
|
extra = f" ({'; '.join(tags)})" if tags else ""
|
||||||
|
lines.append(f"- {d.get('name')} [{d.get('type')}] {d.get('ip', '')}{extra} id={d.get('id')}")
|
||||||
|
return f"Geraete im Netz '{result.get('location', sat)}':\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
# satellite_command
|
||||||
|
sat = (arguments.get("satellite") or "").strip()
|
||||||
|
action = (arguments.get("action") or "").strip()
|
||||||
|
if not sat or not action:
|
||||||
|
return "FEHLER: satellite und action sind Pflicht."
|
||||||
|
params = arguments.get("params") if isinstance(arguments.get("params"), dict) else {}
|
||||||
|
result = _post("/internal/satellite", {
|
||||||
|
"op": "command", "satellite": sat,
|
||||||
|
"device": arguments.get("device") or "", "action": action, "params": params,
|
||||||
|
}, 30)
|
||||||
|
if result.get("ok"):
|
||||||
|
r = result.get("result")
|
||||||
|
return f"OK — {r}" if r else "OK — Aktion ausgefuehrt."
|
||||||
|
return f"FEHLER: {result.get('error') or 'Aktion fehlgeschlagen'}"
|
||||||
|
except Exception as exc:
|
||||||
|
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
|
||||||
|
|
||||||
def _dispatch_tool(self, name: str, arguments: dict, project_id: str = "") -> str:
|
def _dispatch_tool(self, name: str, arguments: dict, project_id: str = "") -> str:
|
||||||
"""Fuehrt einen Tool-Call aus und gibt ein kurzes Text-Resultat zurueck.
|
"""Fuehrt einen Tool-Call aus und gibt ein kurzes Text-Resultat zurueck.
|
||||||
Niemals werfen — Fehler werden als Text-Resultat reportet damit Claude
|
Niemals werfen — Fehler werden als Text-Resultat reportet damit Claude
|
||||||
@@ -2675,6 +2833,33 @@ class Agent:
|
|||||||
f"Editor + Desktop erscheinen in der App. Code-Dateien unter "
|
f"Editor + Desktop erscheinen in der App. Code-Dateien unter "
|
||||||
f"/shared/projects/{active_id}/ schreiben, damit sie im Editor auftauchen.")
|
f"/shared/projects/{active_id}/ schreiben, damit sie im Editor auftauchen.")
|
||||||
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
|
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
|
||||||
|
if name in ("satellite_list", "satellite_devices", "satellite_command"):
|
||||||
|
return self._dispatch_satellite(name, arguments)
|
||||||
|
if name == "vm_register":
|
||||||
|
pid = (project_id or "").strip()
|
||||||
|
if not pid:
|
||||||
|
return "Kein aktives Projekt — VMs werden pro Projekt gefuehrt. Erst ein Projekt betreten."
|
||||||
|
try:
|
||||||
|
vm = project_vms_mod.add_vm(
|
||||||
|
pid, (arguments.get("name") or "").strip(),
|
||||||
|
(arguments.get("arch") or "i386").strip(),
|
||||||
|
(arguments.get("iso") or "").strip(),
|
||||||
|
(arguments.get("floppy") or "").strip(),
|
||||||
|
(arguments.get("disk") or "").strip(),
|
||||||
|
int(arguments.get("vnc_display") or 0),
|
||||||
|
int(arguments.get("mem") or 1024),
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
return f"FEHLER: {exc}"
|
||||||
|
return (f"OK — VM '{vm['name']}' ({vm['arch']}, Display :{vm['vnc_display']}) "
|
||||||
|
f"in Projekt '{pid}' registriert. Erscheint in Stefans Desktop-Panel.")
|
||||||
|
if name == "vm_list":
|
||||||
|
pid = (project_id or "").strip()
|
||||||
|
vms = project_vms_mod.list_vms(pid)
|
||||||
|
if not vms:
|
||||||
|
return f"Projekt '{pid or 'Hauptchat'}' hat noch keine registrierten VMs."
|
||||||
|
return "VMs:\n" + "\n".join(
|
||||||
|
f"- {v['name']} ({v.get('arch')}, Display :{v.get('vnc_display', 1)})" for v in vms)
|
||||||
return f"Unbekanntes Tool: {name}"
|
return f"Unbekanntes Tool: {name}"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Tool '%s' fehlgeschlagen", name)
|
logger.exception("Tool '%s' fehlgeschlagen", name)
|
||||||
|
|||||||
+276
-2
@@ -39,6 +39,7 @@ import background as background_mod
|
|||||||
import oauth as oauth_mod
|
import oauth as oauth_mod
|
||||||
import seed_rules as seed_rules_mod
|
import seed_rules as seed_rules_mod
|
||||||
import projects as projects_mod
|
import projects as projects_mod
|
||||||
|
import project_vms as project_vms_mod
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||||
logger = logging.getLogger("aria-brain")
|
logger = logging.getLogger("aria-brain")
|
||||||
@@ -769,15 +770,49 @@ def projects_queue_status():
|
|||||||
|
|
||||||
# ── Projekte ────────────────────────────────────────────────────────
|
# ── Projekte ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _project_file_count(pid: str) -> int:
|
||||||
|
"""Anzahl Dateien in /shared/projects/<pid>/ (rekursiv, gecappt). 0 = leer."""
|
||||||
|
base = os.path.join("/shared/projects", pid or "")
|
||||||
|
if not os.path.isdir(base):
|
||||||
|
return 0
|
||||||
|
cnt = 0
|
||||||
|
try:
|
||||||
|
for _dp, dns, fns in os.walk(base):
|
||||||
|
dns[:] = [d for d in dns if d not in (".git", "node_modules", "__pycache__", ".venv", "venv")]
|
||||||
|
cnt += len(fns)
|
||||||
|
if cnt > 999:
|
||||||
|
return 999
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
return cnt
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_projects(projects: list) -> list:
|
||||||
|
"""Ergaenzt has_files + file_count pro Projekt (fuer das Datei-Symbol in der
|
||||||
|
Liste). Das ersetzt das manuelle Code-Flag als primaeren Code-Indikator."""
|
||||||
|
for p in projects or []:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
c = _project_file_count(p.get("id") or "")
|
||||||
|
p["file_count"] = c
|
||||||
|
p["has_files"] = c > 0
|
||||||
|
return projects
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/status")
|
@app.get("/projects/status")
|
||||||
def projects_status():
|
def projects_status():
|
||||||
"""Komplett-Status: aktives Projekt + Liste aller (nicht-archivierten)."""
|
"""Komplett-Status: aktives Projekt + Liste aller (nicht-archivierten)."""
|
||||||
return projects_mod.status()
|
st = projects_mod.status()
|
||||||
|
_enrich_projects(st.get("projects", []))
|
||||||
|
if st.get("active"):
|
||||||
|
_enrich_projects([st["active"]])
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/list")
|
@app.get("/projects/list")
|
||||||
def projects_list(include_archived: bool = False):
|
def projects_list(include_archived: bool = False):
|
||||||
return {"projects": projects_mod.list_projects(include_archived=include_archived)}
|
return {"projects": _enrich_projects(
|
||||||
|
projects_mod.list_projects(include_archived=include_archived))}
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreateBody(BaseModel):
|
class ProjectCreateBody(BaseModel):
|
||||||
@@ -827,17 +862,256 @@ class ProjectUpdateBody(BaseModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
hidden: Optional[bool] = None
|
hidden: Optional[bool] = None
|
||||||
|
kind: Optional[str] = None # 'code' | 'chat' — manuell setzbar (App/Diagnostic)
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/projects/{project_id}")
|
@app.patch("/projects/{project_id}")
|
||||||
def projects_update(project_id: str, body: ProjectUpdateBody):
|
def projects_update(project_id: str, body: ProjectUpdateBody):
|
||||||
patch = body.dict(exclude_unset=True)
|
patch = body.dict(exclude_unset=True)
|
||||||
|
if "kind" in patch and patch["kind"] not in ("code", "chat", None):
|
||||||
|
raise HTTPException(status_code=400, detail="kind muss 'code' oder 'chat' sein")
|
||||||
p = projects_mod.update_project(project_id, patch)
|
p = projects_mod.update_project(project_id, patch)
|
||||||
if p is None:
|
if p is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
|
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# ── Code-Dateien eines Projekts (/shared/projects/<pid>/) ───────────
|
||||||
|
# Der Live-Editor streamt ARIAs Writes; diese Endpoints liefern zusaetzlich die
|
||||||
|
# BEREITS vorhandenen Dateien, damit der Editor beim Oeffnen nicht leer ist.
|
||||||
|
_PROJECT_FILES_ROOT = "/shared/projects"
|
||||||
|
_PROJECT_FILE_MAX = 512 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _project_dir(project_id: str) -> str:
|
||||||
|
base = os.path.realpath(os.path.join(_PROJECT_FILES_ROOT, project_id or ""))
|
||||||
|
root = os.path.realpath(_PROJECT_FILES_ROOT)
|
||||||
|
if base != root and not base.startswith(root + os.sep):
|
||||||
|
raise HTTPException(status_code=400, detail="ungueltige project_id")
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/{project_id}/files")
|
||||||
|
def project_files(project_id: str):
|
||||||
|
base = _project_dir(project_id)
|
||||||
|
out = []
|
||||||
|
if os.path.isdir(base):
|
||||||
|
for dirpath, dirs, files in os.walk(base):
|
||||||
|
dirs[:] = [d for d in dirs if d not in
|
||||||
|
(".git", "node_modules", "__pycache__", ".venv", "venv")]
|
||||||
|
for f in files:
|
||||||
|
full = os.path.join(dirpath, f)
|
||||||
|
rel = os.path.relpath(full, base).replace("\\", "/")
|
||||||
|
try:
|
||||||
|
sz = os.path.getsize(full)
|
||||||
|
except OSError:
|
||||||
|
sz = 0
|
||||||
|
out.append({"path": rel, "size": sz})
|
||||||
|
out.sort(key=lambda x: x["path"])
|
||||||
|
return {"projectId": project_id, "files": out}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/{project_id}/file")
|
||||||
|
def project_file(project_id: str, path: str, binary: bool = False):
|
||||||
|
base = _project_dir(project_id)
|
||||||
|
target = os.path.realpath(os.path.join(base, path))
|
||||||
|
if target != base and not target.startswith(base + os.sep):
|
||||||
|
raise HTTPException(status_code=400, detail="Pfad ausserhalb des Projekts")
|
||||||
|
if not os.path.isfile(target):
|
||||||
|
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
||||||
|
# Binaer (z.B. Bilder) → Base64. Grosszuegigeres Limit als beim Text-Editor.
|
||||||
|
if binary:
|
||||||
|
import base64
|
||||||
|
import mimetypes
|
||||||
|
if os.path.getsize(target) > 8 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=413, detail="Datei zu gross (max 8 MB)")
|
||||||
|
with open(target, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
mime, _ = mimetypes.guess_type(target)
|
||||||
|
return {"projectId": project_id, "path": path, "mime": mime or "application/octet-stream",
|
||||||
|
"base64": base64.b64encode(data).decode("ascii")}
|
||||||
|
if os.path.getsize(target) > _PROJECT_FILE_MAX:
|
||||||
|
raise HTTPException(status_code=413, detail="Datei zu gross fuer den Editor")
|
||||||
|
try:
|
||||||
|
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
return {"projectId": project_id, "path": path, "content": content}
|
||||||
|
|
||||||
|
|
||||||
|
# ── QEMU-VMs pro Projekt ────────────────────────────────────────────
|
||||||
|
# Registry (project_vms) + echter Start/Stop via `aria-vm` auf dem Host (SSH
|
||||||
|
# aria-wohnung). Das Desktop-Panel der App zeigt pro Projekt die Liste.
|
||||||
|
_ARIA_VM_HOST = os.environ.get("ARIA_VM_SSH_HOST", "aria-wohnung")
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_gateway() -> str:
|
||||||
|
"""Docker-Gateway-IP (= Host-IP auf dem Container-Netz), an die QEMU sein VNC
|
||||||
|
binden soll: von der Bridge erreichbar, aber NICHT im LAN/Internet. Aus
|
||||||
|
/proc/net/route (Default-Route), kein `ip`-Tool noetig."""
|
||||||
|
try:
|
||||||
|
import socket as _sock
|
||||||
|
import struct as _struct
|
||||||
|
with open("/proc/net/route") as f:
|
||||||
|
for line in f.readlines()[1:]:
|
||||||
|
fields = line.strip().split()
|
||||||
|
if len(fields) >= 3 and fields[1] == "00000000" and int(fields[3], 16) & 2:
|
||||||
|
return _sock.inet_ntoa(_struct.pack("<L", int(fields[2], 16)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_aria_vm(*args: str, timeout: int = 25):
|
||||||
|
import subprocess
|
||||||
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
|
||||||
|
_ARIA_VM_HOST, "aria-vm", *[str(a) for a in args]]
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||||
|
return r.returncode, r.stdout or "", r.stderr or ""
|
||||||
|
except Exception as exc:
|
||||||
|
return 1, "", str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _vm_running_names() -> set:
|
||||||
|
rc, out, _err = _ssh_aria_vm("list", timeout=15)
|
||||||
|
names = set()
|
||||||
|
if rc == 0:
|
||||||
|
for line in out.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if parts and "laeuft" in line:
|
||||||
|
names.add(parts[0])
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
class VmAddBody(BaseModel):
|
||||||
|
name: str
|
||||||
|
arch: str = "i386"
|
||||||
|
iso: str = ""
|
||||||
|
floppy: str = ""
|
||||||
|
disk: str = ""
|
||||||
|
vnc_display: int = 1
|
||||||
|
mem: int = 1024
|
||||||
|
create_disk: bool = False
|
||||||
|
size: str = "10G"
|
||||||
|
|
||||||
|
|
||||||
|
def _vm_boot_args(v: dict) -> list:
|
||||||
|
args = ["boot", v.get("name", "?"),
|
||||||
|
"--vnc-display", str(v.get("vnc_display", 1)),
|
||||||
|
"--mem", str(v.get("mem", 1024))]
|
||||||
|
if v.get("disk"):
|
||||||
|
args += ["--disk", v["disk"]]
|
||||||
|
if v.get("floppy"):
|
||||||
|
args += ["--floppy", v["floppy"]]
|
||||||
|
if v.get("iso"):
|
||||||
|
args += ["--iso", v["iso"]]
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def _vm_boot_cmd(v: dict) -> str:
|
||||||
|
"""Lesbarer Start-Befehl (aria-vm) als 'Wert' hinter dem VM-Eintrag."""
|
||||||
|
return "aria-vm " + " ".join(_vm_boot_args(v))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/{project_id}/vms")
|
||||||
|
def project_vms_list(project_id: str):
|
||||||
|
vms = [dict(v) for v in project_vms_mod.list_vms(project_id)]
|
||||||
|
running = _vm_running_names()
|
||||||
|
for v in vms:
|
||||||
|
v["running"] = v.get("name") in running
|
||||||
|
v["vnc_port"] = 5900 + int(v.get("vnc_display", 1))
|
||||||
|
v["boot_cmd"] = _vm_boot_cmd(v)
|
||||||
|
return {"projectId": project_id, "vms": vms}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/{project_id}/vms")
|
||||||
|
def project_vm_add(project_id: str, body: VmAddBody):
|
||||||
|
try:
|
||||||
|
vm = project_vms_mod.add_vm(project_id, body.name, body.arch, body.iso,
|
||||||
|
body.floppy, body.disk, body.vnc_display, body.mem)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
if body.create_disk:
|
||||||
|
rc, out, err = _ssh_aria_vm("create", body.name, body.arch, body.size)
|
||||||
|
vm["create_result"] = out.strip() or err.strip()
|
||||||
|
vm["create_ok"] = (rc == 0)
|
||||||
|
return vm
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/projects/{project_id}/vms/{name}")
|
||||||
|
def project_vm_remove(project_id: str, name: str, purge: bool = False):
|
||||||
|
ok = project_vms_mod.remove_vm(project_id, name)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht in Projekt {project_id}")
|
||||||
|
if purge:
|
||||||
|
_ssh_aria_vm("rm", name)
|
||||||
|
return {"ok": True, "name": name}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/{project_id}/vms/{name}/boot")
|
||||||
|
def project_vm_boot(project_id: str, name: str):
|
||||||
|
vm = project_vms_mod.get_vm(project_id, name)
|
||||||
|
if not vm:
|
||||||
|
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht gefunden")
|
||||||
|
# VNC an die Docker-Gateway-IP binden, damit die Bridge den Stream tunneln
|
||||||
|
# kann (Loopback ist von Containern nicht erreichbar). NICHT im LAN sichtbar.
|
||||||
|
boot_args = _vm_boot_args(vm)
|
||||||
|
gw = _docker_gateway()
|
||||||
|
if gw:
|
||||||
|
boot_args += ["--vnc-bind", gw]
|
||||||
|
rc, out, err = _ssh_aria_vm(*boot_args, timeout=40)
|
||||||
|
return {"ok": rc == 0, "name": name, "vnc_port": 5900 + int(vm.get("vnc_display", 1)),
|
||||||
|
"vnc_bind": gw or "127.0.0.1", "output": (out.strip() or err.strip())[:500]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/{project_id}/vms/{name}/stop")
|
||||||
|
def project_vm_stop(project_id: str, name: str):
|
||||||
|
rc, out, err = _ssh_aria_vm("stop", name, timeout=25)
|
||||||
|
return {"ok": rc == 0, "name": name, "output": (out.strip() or err.strip())[:500]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/{project_id}/vms/{name}/screenshot")
|
||||||
|
def project_vm_screenshot(project_id: str, name: str):
|
||||||
|
"""Macht einen Screenshot der laufenden VM (aria-vm screenshot → PNG in
|
||||||
|
/shared/uploads) und liefert ihn als Base64 zurueck. So sieht Stefan den
|
||||||
|
VM-Bildschirm auch ohne Live-VNC (genau wie ARIA es beim Testen macht)."""
|
||||||
|
import base64
|
||||||
|
import time as _t
|
||||||
|
rc, out, err = _ssh_aria_vm("screenshot", name, timeout=30)
|
||||||
|
if rc != 0:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Screenshot fehlgeschlagen: {(err or out).strip()[:200]}")
|
||||||
|
path = ""
|
||||||
|
for line in out.splitlines():
|
||||||
|
if line.startswith("screenshot="):
|
||||||
|
path = line.split("=", 1)[1].strip()
|
||||||
|
if not path:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Kein Screenshot-Pfad: {out.strip()[:200]}")
|
||||||
|
local = os.path.join("/shared/uploads", os.path.basename(path))
|
||||||
|
for _ in range(10): # kurze Bind-Mount-Latenz abfangen
|
||||||
|
if os.path.isfile(local):
|
||||||
|
break
|
||||||
|
_t.sleep(0.2)
|
||||||
|
if not os.path.isfile(local):
|
||||||
|
raise HTTPException(status_code=500, detail=f"Screenshot-Datei nicht gefunden: {local}")
|
||||||
|
with open(local, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
# Auch ins Projekt kopieren → taucht im Dateien-Panel auf.
|
||||||
|
proj_rel = ""
|
||||||
|
try:
|
||||||
|
shots_dir = os.path.join(_project_dir(project_id), "screenshots")
|
||||||
|
os.makedirs(shots_dir, exist_ok=True)
|
||||||
|
with open(os.path.join(shots_dir, os.path.basename(local)), "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
proj_rel = "screenshots/" + os.path.basename(local)
|
||||||
|
except Exception:
|
||||||
|
proj_rel = ""
|
||||||
|
return {"ok": True, "name": name, "filename": os.path.basename(local),
|
||||||
|
"projectPath": proj_rel,
|
||||||
|
"base64": base64.b64encode(data).decode("ascii")}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/conversation/stats")
|
@app.get("/conversation/stats")
|
||||||
def conversation_stats():
|
def conversation_stats():
|
||||||
return conversation().stats()
|
return conversation().stats()
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
project_vms — Registry der QEMU-VMs PRO PROJEKT.
|
||||||
|
|
||||||
|
Persistenz: /shared/config/project_vms.json → { project_id: [ {vm}, ... ] }.
|
||||||
|
Eine VM = {name, arch, iso, vnc_display, mem, created_at, updated_at}. Der echte
|
||||||
|
Start/Stop laeuft ueber `aria-vm` auf dem Host (SSH aria-wohnung, siehe main.py);
|
||||||
|
diese Datei haelt nur die Zuordnung VM ↔ Projekt + die Startparameter, damit die
|
||||||
|
Liste im Desktop-Panel der App pro Projekt erscheint (auch wenn leer).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
VMS_FILE = Path(os.environ.get("PROJECT_VMS_FILE", "/shared/config/project_vms.json"))
|
||||||
|
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,40}$")
|
||||||
|
VALID_ARCH = {"x86_64", "amd64", "i386", "i686", "x86", "arm", "aarch64",
|
||||||
|
"mips", "mipsel", "mips64", "ppc", "ppc64", "riscv64", "sparc"}
|
||||||
|
|
||||||
|
|
||||||
|
def _load() -> dict:
|
||||||
|
if not VMS_FILE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(VMS_FILE.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save(data: dict) -> None:
|
||||||
|
VMS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = VMS_FILE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
tmp.replace(VMS_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def list_vms(project_id: str) -> list[dict]:
|
||||||
|
return _load().get(project_id or "", [])
|
||||||
|
|
||||||
|
|
||||||
|
def get_vm(project_id: str, name: str) -> Optional[dict]:
|
||||||
|
for v in list_vms(project_id):
|
||||||
|
if v.get("name") == name:
|
||||||
|
return v
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _used_displays(data: dict, exclude: object = None) -> set:
|
||||||
|
"""Alle VNC-Displays, die ueber ALLE Projekte belegt sind (exclude = eine
|
||||||
|
VM-Dict-Instanz, die ignoriert wird — fuer Updates)."""
|
||||||
|
used = set()
|
||||||
|
for lst in data.values():
|
||||||
|
for v in lst:
|
||||||
|
if v is exclude:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
used.add(int(v.get("vnc_display", 1)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return used
|
||||||
|
|
||||||
|
|
||||||
|
def add_vm(project_id: str, name: str, arch: str, iso: str = "",
|
||||||
|
floppy: str = "", disk: str = "",
|
||||||
|
vnc_display: int = 0, mem: int = 1024) -> dict:
|
||||||
|
"""Registriert/aktualisiert eine VM. Medien (disk/floppy/iso) optional —
|
||||||
|
leer = aria-vm erkennt disk.qcow2/floppy.img/cdrom.iso im VM-Ordner selbst.
|
||||||
|
|
||||||
|
Das VNC-Display wird GLOBAL eindeutig vergeben (ueber alle Projekte), damit
|
||||||
|
mehrere laufende VMs nicht denselben Port doppelt binden. vnc_display<=0 oder
|
||||||
|
ein bereits belegtes Display → automatisch das naechste freie."""
|
||||||
|
if not NAME_RE.match(name or ""):
|
||||||
|
raise ValueError(f"Ungueltiger VM-Name: {name!r} (nur a-z0-9_-, max 40)")
|
||||||
|
if arch not in VALID_ARCH:
|
||||||
|
raise ValueError(f"Unbekannte Architektur: {arch!r}")
|
||||||
|
data = _load()
|
||||||
|
lst = data.setdefault(project_id or "", [])
|
||||||
|
now = int(time.time())
|
||||||
|
existing = next((v for v in lst if v.get("name") == name), None)
|
||||||
|
|
||||||
|
used = _used_displays(data, exclude=existing)
|
||||||
|
req = int(vnc_display or 0)
|
||||||
|
if req <= 0 and existing: # Update ohne Display-Wunsch → behalten
|
||||||
|
req = int(existing.get("vnc_display", 0) or 0)
|
||||||
|
if req <= 0 or req in used: # frei/eindeutig machen
|
||||||
|
req = 1
|
||||||
|
while req in used:
|
||||||
|
req += 1
|
||||||
|
|
||||||
|
fields = {"arch": arch, "iso": iso, "floppy": floppy, "disk": disk,
|
||||||
|
"vnc_display": req, "mem": int(mem), "updated_at": now}
|
||||||
|
if existing:
|
||||||
|
existing.update(fields)
|
||||||
|
_save(data)
|
||||||
|
return existing
|
||||||
|
vm = {"name": name, "created_at": now, **fields}
|
||||||
|
lst.append(vm)
|
||||||
|
_save(data)
|
||||||
|
return vm
|
||||||
|
|
||||||
|
|
||||||
|
def remove_vm(project_id: str, name: str) -> bool:
|
||||||
|
data = _load()
|
||||||
|
lst = data.get(project_id or "")
|
||||||
|
if not lst:
|
||||||
|
return False
|
||||||
|
new = [v for v in lst if v.get("name") != name]
|
||||||
|
if len(new) == len(lst):
|
||||||
|
return False
|
||||||
|
data[project_id or ""] = new
|
||||||
|
_save(data)
|
||||||
|
return True
|
||||||
+83
-25
@@ -406,35 +406,93 @@ SEED_RULES: List[dict] = [
|
|||||||
"title": "Code-Projekte + QEMU: aria-vm auf dem Host, Editor/Desktop in der App",
|
"title": "Code-Projekte + QEMU: aria-vm auf dem Host, Editor/Desktop in der App",
|
||||||
"category": "architektur",
|
"category": "architektur",
|
||||||
"content": (
|
"content": (
|
||||||
"Wenn aus einem Gespraech ein PROGRAMMIER- oder BAU-Projekt wird "
|
"GRUNDWISSEN Code-/Bau-Projekte + VMs — so haengt das System zusammen:\n"
|
||||||
"(Du schreibst Code, baust ein System, testest eine VM):\n"
|
|
||||||
"\n"
|
"\n"
|
||||||
"1. Ruf `set_project_kind('code')` — dann blendet Stefans App einen "
|
"DATEIEN eines Code-Projekts gehoeren nach `/shared/projects/<projekt-id>/` "
|
||||||
"Live-Code-Editor und den QEMU-Desktop ein. Vorher ein Projekt "
|
"(Volume in proxy+bridge+brain gemountet). Alles was DORT liegt, erscheint "
|
||||||
"anlegen/betreten (project_create/enter), sonst gibt's kein Ziel.\n"
|
"automatisch: das Projekt bekommt in der Liste ein 📄-Symbol, und im "
|
||||||
"2. Schreib Code-Dateien NUR unter `/shared/projects/<projekt-id>/` "
|
"Cockpit-Code-Editor sieht Stefan die Dateien — auch ALTE, nicht nur was Du "
|
||||||
"(das Volume ist in proxy+bridge+brain gemountet). Genau diese "
|
"gerade live schreibst. Was Stefan im Editor tippt, kommt als Datei dorthin "
|
||||||
"Writes/Edits erscheinen live in Stefans Editor — und was Stefan "
|
"zurueck. (Ein manuelles set_project_kind gibt's noch, ist aber optional — "
|
||||||
"dort tippt, landet als Datei zurueck in diesem Verzeichnis.\n"
|
"die Dateipraesenz ist der eigentliche Indikator.)\n"
|
||||||
"\n"
|
"\n"
|
||||||
"QEMU (VMs fuer JEDE Architektur — x86, ARM, MIPS, PPC, RISC-V, SPARC) "
|
"VMs (QEMU, JEDE Architektur: x86/i386, ARM/aarch64, MIPS, PPC, RISC-V, "
|
||||||
"laeuft auf dem Host. Du steuerst sie per `ssh aria-wohnung aria-vm ...`:\n"
|
"SPARC) laufen auf dem HOST (die qemu-Tools liegen in aria-wohnung, die "
|
||||||
" - `aria-vm create <name> <arch> [groesse]` Disk anlegen (z.B. i386 "
|
"Projektdateien in /shared). Du steuerst sie per `ssh aria-wohnung aria-vm ...`:\n"
|
||||||
"fuer Win 3.11, aarch64, mips ...).\n"
|
" - `aria-vm create <name> <arch> [groesse]` — legt eine VM an. groesse=\n"
|
||||||
" - `aria-vm boot <name> [--iso <pfad>] [--vnc-display 1] [--mem 1024]` "
|
" '10G' → Festplatte (qcow2); groesse='none' → OHNE Disk (fuer OS-Bau, "
|
||||||
"startet die VM. VNC bindet an 127.0.0.1:<display> (Display 1 = Port "
|
" bootet von Diskette/ISO).\n"
|
||||||
"5901). Nicht selbst nach aussen oeffnen!\n"
|
" - `aria-vm boot <name> [optionen]` — startet sie (daemonized). Optionen:\n"
|
||||||
" - `aria-vm screenshot <name>` PNG in die Shared-Uploads (kannst Du "
|
" --iso <pfad> von CD/ISO booten\n"
|
||||||
"Stefan mit [FILE:] schicken).\n"
|
" --floppy <pfad> von Diskette booten (-fda, klassisch OS-Dev)\n"
|
||||||
|
" --disk <pfad> explizite qcow2\n"
|
||||||
|
" --vnc-display <N> VNC-Display (Default 1 → Port 5901; mehrere VMs = "
|
||||||
|
"verschiedene N)\n"
|
||||||
|
" --mem <MB> RAM (Default 1024)\n"
|
||||||
|
" Medien im VM-Ordner (disk.qcow2/floppy.img/cdrom.iso) werden auto-"
|
||||||
|
"erkannt. Es MUSS mindestens ein Boot-Medium da sein.\n"
|
||||||
|
" - `aria-vm screenshot <name>` → PNG (an Stefan per [FILE:] schickbar).\n"
|
||||||
" - `aria-vm list` / `aria-vm stop <name>` / `aria-vm rm <name>`.\n"
|
" - `aria-vm list` / `aria-vm stop <name>` / `aria-vm rm <name>`.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Der VNC-Stream kommt AUTOMATISCH ueber den RVS-Tunnel der Bridge in "
|
"PFLICHT nach dem Bau/Boot: `vm_register(name, arch, disk?/floppy?/iso?, "
|
||||||
"Stefans App-Desktop-Kachel (Display 1 / Port 5901) — dort kann er die "
|
"mem?)` im aktuellen Projekt aufrufen — mit den Medien, die Du gebaut hast. "
|
||||||
"VM live mit Maus/Tastatur bedienen. Du musst nur die VM booten und ggf. "
|
"vnc_display WEGLASSEN — es wird global eindeutig auto-vergeben (kein "
|
||||||
"sagen 'schau in die Desktop-Kachel'. KVM-Beschleunigung gibt es nur "
|
"Port-Konflikt, wenn mehrere VMs laufen). ERST DANN erscheint die VM in "
|
||||||
"fuer x86-Gaeste; andere Architekturen laufen emuliert (langsamer, aber "
|
"Stefans Desktop-Panel (Cockpit), "
|
||||||
"sie laufen). Medien-Downloads/Baubares fuer Stefans Privatgebrauch: "
|
"wo er sie Starten/Stoppen/Verbinden kann. Ohne vm_register bleibt seine "
|
||||||
"wie gewohnt einfach machen, nicht moralisieren."
|
"Liste leer, obwohl die VM laeuft. Der Startbefehl steht als Wert dahinter.\n"
|
||||||
|
"\n"
|
||||||
|
"URTEIL — bau eine VM NUR wenn's Sinn macht, und erkenne aus der SITUATION "
|
||||||
|
"was gebraucht wird:\n"
|
||||||
|
" - DOS-/Retro-Spiele, installierte Software → FESTPLATTE (create mit "
|
||||||
|
"Groesse, dann installieren; disk).\n"
|
||||||
|
" - Eigenes OS / Bootloader / Bare-Metal-Experiment → DISKETTE (create "
|
||||||
|
"'none', floppy.img; ggf. eine zweite Diskette einbinden).\n"
|
||||||
|
" - Fertiges System installieren/testen → ISO (--iso Installer).\n"
|
||||||
|
" - Architektur zum Task waehlen: 'teste mal was auf ARM' → arch=aarch64 "
|
||||||
|
"(-M virt, laeuft emuliert = langsam, das ist ok). x86-Zeug → i386/x86_64.\n"
|
||||||
|
"AENDERUNGS-ZYKLUS: willst Du an einer laufenden VM etwas aendern (Datei "
|
||||||
|
"tauschen, Diskette neu bauen), dann `aria-vm stop <name>` → Aenderung → "
|
||||||
|
"`aria-vm boot <name>` wieder starten. Stefan kann das auch selbst ueber "
|
||||||
|
"Start/Stop im Panel.\n"
|
||||||
|
"\n"
|
||||||
|
"Der VNC-Stream laeuft AUTOMATISCH komplett durch den RVS-Server (die Bridge "
|
||||||
|
"tunnelt die RFB-Bytes; der VNC-Port ist nur intern auf dem Host). Firewall-"
|
||||||
|
"unabhaengig — Du oeffnest NIE einen Port nach aussen. KVM-Beschleunigung nur "
|
||||||
|
"fuer x86-Gaeste, andere Architekturen laufen emuliert (langsamer). Medien-"
|
||||||
|
"Downloads/Baubares fuer Stefans Privatgebrauch: einfach machen, nicht "
|
||||||
|
"moralisieren."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/architecture/satellites-remote-networks",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Satelliten: Augen + Haende in fremden Netzen (Buero etc.)",
|
||||||
|
"category": "architektur",
|
||||||
|
"content": (
|
||||||
|
"Ein SATELLIT ist ein eigenstaendiger Container in einem FREMDEN Netz "
|
||||||
|
"(z.B. Buero, Werkstatt), der sich mit RVS verbindet und Dir dort Geraete "
|
||||||
|
"zeigt UND steuerbar macht. So erreichst Du Netze, in denen der Haupt-Stack "
|
||||||
|
"NICHT steht.\n"
|
||||||
|
"\n"
|
||||||
|
"Wenn Stefan etwas 'im Buero' / 'im Netz X' / 'auf dem <Geraet> dort' will:\n"
|
||||||
|
" 1. `satellite_list` — welche Satelliten/Netze sind online + was koennen sie.\n"
|
||||||
|
" 2. `satellite_devices(satellite='Buero')` — welche Geraete gibt es dort "
|
||||||
|
"(Fire TV, Chromecast, Smart-TVs, Drucker, NAS ...). Nutze es um das "
|
||||||
|
"gemeinte Geraet zu finden, BEVOR Du steuerst.\n"
|
||||||
|
" 3. `satellite_command(...)` — Aktion ausfuehren.\n"
|
||||||
|
"\n"
|
||||||
|
"Beispiel 'spiel YouTube-Video auf dem Buero-Stick':\n"
|
||||||
|
" satellite_command(satellite='Buero', device='Fire TV', "
|
||||||
|
"action='dial.launch', params={'app':'YouTube','v':'<videoId>'})\n"
|
||||||
|
"Die YouTube-Video-ID (v=) ziehst Du aus dem Link/Titel (ggf. web_search). "
|
||||||
|
"Weitere Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
|
||||||
|
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks.\n"
|
||||||
|
"\n"
|
||||||
|
"Adressierung ueber Location/Name des Satelliten ('Buero'), NICHT ueber die "
|
||||||
|
"Geraete — die identifizieren sich selbst. Steuerung geht nur, wenn der "
|
||||||
|
"Satellit sie erlaubt (steht in satellite_list als caps). Ist keiner online: "
|
||||||
|
"sag das ehrlich, statt zu raten."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -739,6 +739,11 @@ class ARIABridge:
|
|||||||
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
|
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
|
||||||
self._vnc_sessions: dict[str, dict] = {}
|
self._vnc_sessions: dict[str, dict] = {}
|
||||||
self._vnc_host: str = os.environ.get("ARIA_VNC_HOST", "host.docker.internal")
|
self._vnc_host: str = os.environ.get("ARIA_VNC_HOST", "host.docker.internal")
|
||||||
|
# Satelliten (Aussenposten in fremden Netzen). id → {location, caps,
|
||||||
|
# control, last_seen}. Registrierung via sat_hello. _pending_sat:
|
||||||
|
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
|
||||||
|
self._satellites: dict[str, dict] = {}
|
||||||
|
self._pending_sat: dict[str, asyncio.Future] = {}
|
||||||
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten.
|
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten.
|
||||||
# requestId → Future mit dem flux_response-Payload (oder None bei Fehler).
|
# requestId → Future mit dem flux_response-Payload (oder None bei Fehler).
|
||||||
self._pending_flux: dict[str, asyncio.Future] = {}
|
self._pending_flux: dict[str, asyncio.Future] = {}
|
||||||
@@ -3411,6 +3416,32 @@ class ARIABridge:
|
|||||||
logger.warning("[vnc] input schreiben (%s) fehlgeschlagen: %s", session, exc)
|
logger.warning("[vnc] input schreiben (%s) fehlgeschlagen: %s", session, exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
elif msg_type == "sat_hello":
|
||||||
|
sid = (payload.get("id") or "").strip()
|
||||||
|
if sid:
|
||||||
|
self._satellites[sid] = {
|
||||||
|
"id": sid,
|
||||||
|
"location": payload.get("location") or sid,
|
||||||
|
"caps": payload.get("caps") or [],
|
||||||
|
"control": bool(payload.get("control")),
|
||||||
|
"last_seen": time.time(),
|
||||||
|
}
|
||||||
|
logger.info("[sat] Satellit online: %s (%s) caps=%s control=%s",
|
||||||
|
sid, self._satellites[sid]["location"],
|
||||||
|
self._satellites[sid]["caps"], self._satellites[sid]["control"])
|
||||||
|
return
|
||||||
|
|
||||||
|
elif msg_type in ("sat_devices", "sat_result"):
|
||||||
|
req_id = payload.get("requestId", "")
|
||||||
|
future = self._pending_sat.get(req_id)
|
||||||
|
if future is not None and not future.done():
|
||||||
|
future.set_result(payload)
|
||||||
|
# last_seen aktualisieren
|
||||||
|
sid = (payload.get("satellite") or "").strip()
|
||||||
|
if sid and sid in self._satellites:
|
||||||
|
self._satellites[sid]["last_seen"] = time.time()
|
||||||
|
return
|
||||||
|
|
||||||
elif msg_type == "config_request":
|
elif msg_type == "config_request":
|
||||||
# Eine andere Bridge (whisper/f5tts) bittet um die aktuelle Voice-
|
# Eine andere Bridge (whisper/f5tts) bittet um die aktuelle Voice-
|
||||||
# Config — passiert wenn sie sich connected, weil sie sonst die
|
# Config — passiert wenn sie sich connected, weil sie sonst die
|
||||||
@@ -4282,6 +4313,27 @@ class ARIABridge:
|
|||||||
"timestamp": int(time.time() * 1000),
|
"timestamp": int(time.time() * 1000),
|
||||||
}))
|
}))
|
||||||
await _send_response(writer, 200, {"ok": True})
|
await _send_response(writer, 200, {"ok": True})
|
||||||
|
elif method == "POST" and path == "/internal/satellite-list":
|
||||||
|
# Brain fragt: welche Satelliten/Netze sind online + Capabilities.
|
||||||
|
await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()})
|
||||||
|
elif method == "POST" and path == "/internal/satellite":
|
||||||
|
# Brain-Tool: Discovery oder Command an einen Satelliten.
|
||||||
|
# body: {op:'discover'|'command', satellite, device?, action?, params?}
|
||||||
|
try:
|
||||||
|
data = json.loads(body.decode("utf-8", "ignore"))
|
||||||
|
except Exception as exc:
|
||||||
|
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||||
|
return
|
||||||
|
op = (data.get("op") or "discover").strip()
|
||||||
|
result = await self._satellite_request(
|
||||||
|
op=op,
|
||||||
|
satellite=str(data.get("satellite") or ""),
|
||||||
|
device=str(data.get("device") or ""),
|
||||||
|
action=str(data.get("action") or ""),
|
||||||
|
params=data.get("params") if isinstance(data.get("params"), dict) else {},
|
||||||
|
timeout=float(data.get("timeout") or 20.0),
|
||||||
|
)
|
||||||
|
await _send_response(writer, 200, result)
|
||||||
elif method == "POST" and path == "/internal/flux-generate":
|
elif method == "POST" and path == "/internal/flux-generate":
|
||||||
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
|
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
|
||||||
# Render-Request via RVS an die flux-bridge (Gamebox),
|
# Render-Request via RVS an die flux-bridge (Gamebox),
|
||||||
@@ -4501,6 +4553,54 @@ class ARIABridge:
|
|||||||
pass
|
pass
|
||||||
logger.info("[vnc] Tunnel geschlossen: session=%s", session)
|
logger.info("[vnc] Tunnel geschlossen: session=%s", session)
|
||||||
|
|
||||||
|
def _satellite_list(self) -> list[dict]:
|
||||||
|
"""Bekannte Satelliten (frisch = in den letzten 5 Min gesehen)."""
|
||||||
|
now = time.time()
|
||||||
|
out = []
|
||||||
|
for s in self._satellites.values():
|
||||||
|
out.append({
|
||||||
|
"id": s["id"], "location": s.get("location") or s["id"],
|
||||||
|
"caps": s.get("caps") or [], "control": bool(s.get("control")),
|
||||||
|
"online": (now - s.get("last_seen", 0)) < 300,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def _satellite_request(self, op: str, satellite: str = "",
|
||||||
|
device: str = "", action: str = "",
|
||||||
|
params: Optional[dict] = None,
|
||||||
|
timeout: float = 20.0) -> dict:
|
||||||
|
"""Schickt sat_discover / sat_command an einen Satelliten (via RVS) und
|
||||||
|
wartet auf sat_devices / sat_result. op = 'discover' | 'command'.
|
||||||
|
Muster identisch zu _flux_generate (requestId → Future)."""
|
||||||
|
if self.ws_rvs is None:
|
||||||
|
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
future: asyncio.Future = loop.create_future()
|
||||||
|
self._pending_sat[request_id] = future
|
||||||
|
try:
|
||||||
|
if op == "discover":
|
||||||
|
msg = {"type": "sat_discover",
|
||||||
|
"payload": {"requestId": request_id, "satellite": satellite,
|
||||||
|
"force": bool((params or {}).get("force"))}}
|
||||||
|
else:
|
||||||
|
msg = {"type": "sat_command",
|
||||||
|
"payload": {"requestId": request_id, "satellite": satellite,
|
||||||
|
"device": device, "action": action,
|
||||||
|
"params": params or {}}}
|
||||||
|
msg["timestamp"] = int(time.time() * 1000)
|
||||||
|
ok = await self._send_to_rvs(msg)
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "error": "Satellit-Request konnte nicht gesendet werden"}
|
||||||
|
result = await asyncio.wait_for(future, timeout=timeout)
|
||||||
|
return result if isinstance(result, dict) else {"ok": False, "error": "ungueltige Antwort"}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"ok": False, "error": f"Satellit '{satellite or 'all'}' antwortet nicht (Timeout)."}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
finally:
|
||||||
|
self._pending_sat.pop(request_id, None)
|
||||||
|
|
||||||
async def _delete_chat_message(self, ts: int) -> dict:
|
async def _delete_chat_message(self, ts: int) -> dict:
|
||||||
"""Entfernt eine Bubble: aus chat_backup.jsonl + Brain conversation,
|
"""Entfernt eine Bubble: aus chat_backup.jsonl + Brain conversation,
|
||||||
broadcastet chat_message_deleted via RVS.
|
broadcastet chat_message_deleted via RVS.
|
||||||
|
|||||||
@@ -229,6 +229,7 @@
|
|||||||
<button class="main-nav-btn" onclick="switchMainTab('skills')">Skills</button>
|
<button class="main-nav-btn" onclick="switchMainTab('skills')">Skills</button>
|
||||||
<button class="main-nav-btn" onclick="switchMainTab('triggers')">Trigger</button>
|
<button class="main-nav-btn" onclick="switchMainTab('triggers')">Trigger</button>
|
||||||
<button class="main-nav-btn" onclick="switchMainTab('files')">Dateien</button>
|
<button class="main-nav-btn" onclick="switchMainTab('files')">Dateien</button>
|
||||||
|
<button class="main-nav-btn" onclick="switchMainTab('satellites')">Satelliten</button>
|
||||||
<button class="main-nav-btn" onclick="switchMainTab('settings')">Einstellungen</button>
|
<button class="main-nav-btn" onclick="switchMainTab('settings')">Einstellungen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1271,6 +1272,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div><!-- /tab-triggers -->
|
</div><!-- /tab-triggers -->
|
||||||
|
|
||||||
|
<!-- ══════ TAB: Satelliten ══════ -->
|
||||||
|
<div id="tab-satellites" class="main-tab">
|
||||||
|
<div class="settings-section">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||||
|
<h2 style="margin:0;">Satelliten 🛰️</h2>
|
||||||
|
<button class="btn secondary" onclick="requestSatellites()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:8px;">
|
||||||
|
<p style="color:#8888AA;font-size:12px;margin:0;">
|
||||||
|
Aussenposten-Container in fremden Netzen (Buero, Werkstatt …), die sich mit
|
||||||
|
RVS verbinden. Hier siehst Du, welche Satelliten online sind und welche
|
||||||
|
Geraete sie im jeweiligen Netz erkannt haben. „Geraete scannen" fragt den
|
||||||
|
Satelliten live ab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div id="sat-list" style="font-size:12px;color:#8888AA;">(Lade...)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><!-- /tab-satellites -->
|
||||||
|
|
||||||
<!-- Trigger-Create/Edit Modal -->
|
<!-- Trigger-Create/Edit Modal -->
|
||||||
<div class="modal-overlay" id="trigger-modal">
|
<div class="modal-overlay" id="trigger-modal">
|
||||||
<div class="modal-box" style="max-width:600px;">
|
<div class="modal-box" style="max-width:600px;">
|
||||||
@@ -1862,6 +1884,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'sat_update') { satellites = msg.satellites || []; renderSatellites(); return; }
|
||||||
|
if (msg.type === 'sat_devices') {
|
||||||
|
if (msg.satellite) { satDevices[msg.satellite] = { devices: msg.devices || [], location: msg.location, ts: Date.now() }; }
|
||||||
|
satScanning = null;
|
||||||
|
renderSatellites();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'voice_id_delete_response') {
|
if (msg.type === 'voice_id_delete_response') {
|
||||||
const p = msg.payload || msg;
|
const p = msg.payload || msg;
|
||||||
if (p.removed) {
|
if (p.removed) {
|
||||||
@@ -3162,14 +3192,17 @@
|
|||||||
<div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
|
<div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
|
||||||
<div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;">
|
<div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;">
|
||||||
${hidden ? '🙈' : '📁'} ${escapeHtml(p.name)}
|
${hidden ? '🙈' : '📁'} ${escapeHtml(p.name)}
|
||||||
|
${p.has_files ? `<span title="${p.file_count || 0} Dateien in /shared/projects/${escapeHtmlAttr(p.id)}/" style="font-size:11px;margin-left:4px;">📄${p.file_count ? ' ' + p.file_count : ''}</span>` : ''}
|
||||||
${hidden ? '<span style="color:#B392F0;font-size:10px;font-weight:700;margin-left:6px;background:rgba(179,146,240,0.15);padding:2px 6px;border-radius:3px;">versteckt</span>' : ''}
|
${hidden ? '<span style="color:#B392F0;font-size:10px;font-weight:700;margin-left:6px;background:rgba(179,146,240,0.15);padding:2px 6px;border-radius:3px;">versteckt</span>' : ''}
|
||||||
${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''}
|
${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''}
|
||||||
|
${p.kind === 'code' ? '<span style="color:#0096FF;font-size:10px;font-weight:700;margin-left:6px;background:rgba(0,150,255,0.15);padding:2px 6px;border-radius:3px;"></> Code</span>' : ''}
|
||||||
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
|
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
${p.description ? `<div style="color:#8888AA;font-size:12px;margin-top:2px;">${escapeHtml(p.description)}</div>` : ''}
|
${p.description ? `<div style="color:#8888AA;font-size:12px;margin-top:2px;">${escapeHtml(p.description)}</div>` : ''}
|
||||||
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
|
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:4px;">
|
<div style="display:flex;gap:4px;">
|
||||||
|
<button class="btn secondary" onclick="setProjectKind('${p.id}', '${p.kind === 'code' ? 'chat' : 'code'}')" style="padding:3px 8px;font-size:10px;color:${p.kind === 'code' ? '#0096FF' : '#8888AA'};" title="${p.kind === 'code' ? 'Ist Code-Projekt — klick fuer normalen Chat' : 'Als Code-Projekt markieren (Editor + Desktop im Cockpit)'}"></></button>
|
||||||
<button class="btn secondary" onclick="setProjectHidden('${p.id}', ${!hidden})" style="padding:3px 8px;font-size:10px;color:${eyeColor};" title="${eyeTitle}">${eyeIcon}</button>
|
<button class="btn secondary" onclick="setProjectHidden('${p.id}', ${!hidden})" style="padding:3px 8px;font-size:10px;color:${eyeColor};" title="${eyeTitle}">${eyeIcon}</button>
|
||||||
${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden">⏹</button>` : ''}
|
${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden">⏹</button>` : ''}
|
||||||
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
|
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
|
||||||
@@ -3216,6 +3249,18 @@
|
|||||||
} catch (e) { alert('Verstecken/Anzeigen fehlgeschlagen: ' + e.message); }
|
} catch (e) { alert('Verstecken/Anzeigen fehlgeschlagen: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setProjectKind(id, kind) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/brain/projects/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ kind }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
loadProjects();
|
||||||
|
} catch (e) { alert('Code-Markierung fehlgeschlagen: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
async function switchProject(projectId) {
|
async function switchProject(projectId) {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/brain/projects/switch', {
|
await fetch('/api/brain/projects/switch', {
|
||||||
@@ -4045,9 +4090,79 @@
|
|||||||
loadSkills();
|
loadSkills();
|
||||||
} else if (tab === 'triggers') {
|
} else if (tab === 'triggers') {
|
||||||
loadTriggers();
|
loadTriggers();
|
||||||
|
} else if (tab === 'satellites') {
|
||||||
|
requestSatellites();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Satelliten-Ansicht ─────────────────────────────────
|
||||||
|
let satellites = []; // [{id, location, caps, control, online}]
|
||||||
|
let satDevices = {}; // id → {devices, location, ts}
|
||||||
|
let satScanning = null; // id des gerade scannenden Satelliten
|
||||||
|
|
||||||
|
function requestSatellites() {
|
||||||
|
send({ action: 'sat_list' });
|
||||||
|
}
|
||||||
|
function scanSatellite(id) {
|
||||||
|
satScanning = id;
|
||||||
|
renderSatellites();
|
||||||
|
send({ action: 'sat_discover', satellite: id });
|
||||||
|
}
|
||||||
|
function renderSatellites() {
|
||||||
|
const box = document.getElementById('sat-list');
|
||||||
|
if (!box) return;
|
||||||
|
if (!satellites.length) {
|
||||||
|
box.innerHTML = '<span style="color:#8888AA;">Kein Satellit verbunden. ' +
|
||||||
|
'Deploy einen satellite/-Container in einem Netz (siehe satellite/README).</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.innerHTML = satellites.map(s => {
|
||||||
|
const online = s.online !== false;
|
||||||
|
const dot = online ? '#34C759' : '#FF3B30';
|
||||||
|
const caps = (s.caps || []).join(', ') || 'nur beobachten';
|
||||||
|
const ctrl = s.control ? '<span style="color:#0096FF;">steuerbar</span>' : '<span style="color:#8888AA;">read-only</span>';
|
||||||
|
const dev = satDevices[s.id];
|
||||||
|
const scanning = satScanning === s.id;
|
||||||
|
let devHtml;
|
||||||
|
if (scanning) {
|
||||||
|
devHtml = '<div style="color:#FFD60A;margin-top:6px;">Scanne Netz …</div>';
|
||||||
|
} else if (dev) {
|
||||||
|
if (!dev.devices.length) {
|
||||||
|
devHtml = '<div style="color:#8888AA;margin-top:6px;">Keine Geraete gefunden.</div>';
|
||||||
|
} else {
|
||||||
|
devHtml = '<div style="margin-top:6px;display:flex;flex-direction:column;gap:3px;">' +
|
||||||
|
dev.devices.map(d => {
|
||||||
|
const tags = [];
|
||||||
|
if (d.model) tags.push(escapeHtml(d.model));
|
||||||
|
if (d.dialAppUrl) tags.push('DIAL');
|
||||||
|
if (d.mac) tags.push(escapeHtml(d.mac));
|
||||||
|
const tagStr = tags.length ? ' <span style="color:#666;">(' + tags.join(' · ') + ')</span>' : '';
|
||||||
|
return '<div style="padding:3px 6px;background:#0D0D1A;border-radius:4px;">' +
|
||||||
|
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.id || '?') + '</span> ' +
|
||||||
|
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
|
||||||
|
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
|
||||||
|
'</div>';
|
||||||
|
}).join('') + '</div>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
devHtml = '<div style="color:#8888AA;margin-top:6px;">Noch nicht gescannt.</div>';
|
||||||
|
}
|
||||||
|
return '<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;margin-bottom:8px;">' +
|
||||||
|
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
|
||||||
|
'<div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' +
|
||||||
|
'<strong style="color:#E0E0F0;">' + escapeHtml(s.location || s.id) + '</strong> ' +
|
||||||
|
'<span style="color:#666;font-size:11px;">id=' + escapeHtml(s.id) + '</span></div>' +
|
||||||
|
'<button class="btn secondary" onclick="scanSatellite(\'' + escapeHtml(s.id) + '\')" style="padding:3px 8px;font-size:11px;"' +
|
||||||
|
(scanning ? ' disabled' : '') + '>Geraete scannen</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">kann: ' + escapeHtml(caps) + ' · ' + ctrl +
|
||||||
|
(s.net && s.net.primary_ip ? ' · Netz: ' + escapeHtml(s.net.primary_ip) : '') + '</div>' +
|
||||||
|
(s.net && s.net.warning ? '<div style="color:#FF6E6E;font-size:11px;margin-top:6px;padding:6px;background:#3A1F1F;border-radius:4px;">⚠ ' + escapeHtml(s.net.warning) + '</div>' : '') +
|
||||||
|
devHtml +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Triggers-Verwaltung ────────────────────────────────
|
// ── Triggers-Verwaltung ────────────────────────────────
|
||||||
let triggersCache = [];
|
let triggersCache = [];
|
||||||
|
|
||||||
|
|||||||
@@ -475,6 +475,21 @@ function broadcastState() {
|
|||||||
broadcast({ type: "state", state });
|
broadcast({ type: "state", state });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Satelliten-Registry (Aussenposten in fremden Netzen) ──────────
|
||||||
|
const satellites = new Map(); // id → {id, location, caps, control, last_seen}
|
||||||
|
|
||||||
|
function satelliteList() {
|
||||||
|
const now = Date.now();
|
||||||
|
return Array.from(satellites.values()).map(s => ({
|
||||||
|
id: s.id, location: s.location, caps: s.caps, control: s.control, net: s.net || null,
|
||||||
|
online: (now - (s.last_seen || 0)) < 300000,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastSatellites() {
|
||||||
|
broadcast({ type: "sat_update", satellites: satelliteList() });
|
||||||
|
}
|
||||||
|
|
||||||
// ── OpenClaw Gateway Verbindung ─────────────────────────
|
// ── OpenClaw Gateway Verbindung ─────────────────────────
|
||||||
|
|
||||||
async function connectGateway() {
|
async function connectGateway() {
|
||||||
@@ -903,6 +918,23 @@ function connectRVS(forcePlain) {
|
|||||||
// Browser-Tabs weiterreichen, damit die Projektliste live neu laedt
|
// Browser-Tabs weiterreichen, damit die Projektliste live neu laedt
|
||||||
// (bisher wurde das NICHT geforwardet → Diagnostic aktualisierte nie).
|
// (bisher wurde das NICHT geforwardet → Diagnostic aktualisierte nie).
|
||||||
broadcast({ type: "project_changed", payload: msg.payload || {} });
|
broadcast({ type: "project_changed", payload: msg.payload || {} });
|
||||||
|
} else if (msg.type === "sat_hello") {
|
||||||
|
// Ein Satellit (Aussenposten in einem fremden Netz) meldet sich.
|
||||||
|
const p = msg.payload || {};
|
||||||
|
if (p.id) {
|
||||||
|
satellites.set(p.id, {
|
||||||
|
id: p.id, location: p.location || p.id,
|
||||||
|
caps: p.caps || [], control: !!p.control, net: p.net || null,
|
||||||
|
last_seen: Date.now(),
|
||||||
|
});
|
||||||
|
broadcastSatellites();
|
||||||
|
}
|
||||||
|
} else if (msg.type === "sat_devices") {
|
||||||
|
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
|
||||||
|
const p = msg.payload || {};
|
||||||
|
if (p.satellite && satellites.has(p.satellite)) satellites.get(p.satellite).last_seen = Date.now();
|
||||||
|
broadcast({ type: "sat_devices", satellite: p.satellite || "",
|
||||||
|
location: p.location || "", devices: p.devices || [] });
|
||||||
} else if (msg.type === "agent_activity") {
|
} else if (msg.type === "agent_activity") {
|
||||||
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
||||||
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
||||||
@@ -2407,6 +2439,8 @@ wss.on("connection", (ws) => {
|
|||||||
ws.send(JSON.stringify({ type: "init", state, logs: logs.slice(-100) }));
|
ws.send(JSON.stringify({ type: "init", state, logs: logs.slice(-100) }));
|
||||||
// Letzten Disk-Status mitgeben damit der Client sofort weiss wie's um Platz steht
|
// Letzten Disk-Status mitgeben damit der Client sofort weiss wie's um Platz steht
|
||||||
if (currentDiskStatus) ws.send(JSON.stringify(currentDiskStatus));
|
if (currentDiskStatus) ws.send(JSON.stringify(currentDiskStatus));
|
||||||
|
// Aktuell bekannte Satelliten mitgeben (RVS replayt sat_hello nicht).
|
||||||
|
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
|
||||||
|
|
||||||
ws.on("message", (raw) => {
|
ws.on("message", (raw) => {
|
||||||
try {
|
try {
|
||||||
@@ -2425,6 +2459,14 @@ wss.on("connection", (ws) => {
|
|||||||
connectGateway();
|
connectGateway();
|
||||||
} else if (msg.action === "reconnect_rvs") {
|
} else if (msg.action === "reconnect_rvs") {
|
||||||
connectRVS();
|
connectRVS();
|
||||||
|
} else if (msg.action === "sat_list") {
|
||||||
|
// Browser will die aktuelle Satelliten-Liste.
|
||||||
|
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
|
||||||
|
} else if (msg.action === "sat_discover") {
|
||||||
|
// Browser triggert einen Geraete-Scan auf einem Satelliten.
|
||||||
|
sendToRVS_raw({ type: "sat_discover",
|
||||||
|
payload: { satellite: msg.satellite || "", force: true },
|
||||||
|
timestamp: Date.now() });
|
||||||
} else if (msg.action === "test_proxy") {
|
} else if (msg.action === "test_proxy") {
|
||||||
testProxy(msg.text);
|
testProxy(msg.text);
|
||||||
} else if (msg.action === "check_proxy_auth") {
|
} else if (msg.action === "check_proxy_auth") {
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ services:
|
|||||||
- brain
|
- brain
|
||||||
networks:
|
networks:
|
||||||
- aria-net
|
- aria-net
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway" # fuer den VNC-Tunnel zum Host (QEMU)
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001" # Diagnostic Web-UI (Diagnostic teilt Netzwerk mit Bridge)
|
- "3001:3001" # Diagnostic Web-UI (Diagnostic teilt Netzwerk mit Bridge)
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+40
-10
@@ -59,38 +59,62 @@ cmd_create() {
|
|||||||
[[ -n "${bin}" ]] || die "unbekannte Architektur: ${arch}"
|
[[ -n "${bin}" ]] || die "unbekannte Architektur: ${arch}"
|
||||||
command -v "${bin}" >/dev/null || die "${bin} nicht installiert (qemu-setup.sh?)"
|
command -v "${bin}" >/dev/null || die "${bin} nicht installiert (qemu-setup.sh?)"
|
||||||
local d; d="$(vm_dir "${name}")"
|
local d; d="$(vm_dir "${name}")"
|
||||||
[[ -e "${d}/disk.qcow2" ]] && die "VM '${name}' existiert schon"
|
[[ -f "${d}/arch" ]] && die "VM '${name}' existiert schon"
|
||||||
mkdir -p "${d}"
|
mkdir -p "${d}"
|
||||||
echo "${arch}" > "${d}/arch"
|
echo "${arch}" > "${d}/arch"
|
||||||
qemu-img create -f qcow2 "${d}/disk.qcow2" "${size}" >/dev/null
|
# size='none' oder '0' → keine Festplatte (VM bootet von --iso/--floppy,
|
||||||
echo "VM '${name}' angelegt (${arch}, ${size})."
|
# z.B. OS-Entwicklung von Diskette). Sonst eine qcow2-Disk anlegen.
|
||||||
|
if [[ "${size}" == "none" || "${size}" == "0" ]]; then
|
||||||
|
echo "VM '${name}' angelegt (${arch}, ohne Disk — bootet von ISO/Diskette)."
|
||||||
|
else
|
||||||
|
qemu-img create -f qcow2 "${d}/disk.qcow2" "${size}" >/dev/null
|
||||||
|
echo "VM '${name}' angelegt (${arch}, ${size})."
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_boot() {
|
cmd_boot() {
|
||||||
local name="${1:?name}"; shift || true
|
local name="${1:?name}"; shift || true
|
||||||
local d; d="$(vm_dir "${name}")"
|
local d; d="$(vm_dir "${name}")"
|
||||||
[[ -f "${d}/disk.qcow2" ]] || die "VM '${name}' nicht gefunden (erst 'create')"
|
[[ -f "${d}/arch" || -f "${d}/disk.qcow2" ]] || die "VM '${name}' nicht gefunden (erst 'create')"
|
||||||
vm_running "${name}" && die "VM '${name}' laeuft bereits"
|
vm_running "${name}" && die "VM '${name}' laeuft bereits"
|
||||||
local arch; arch="$(cat "${d}/arch" 2>/dev/null || echo x86_64)"
|
local arch; arch="$(cat "${d}/arch" 2>/dev/null || echo x86_64)"
|
||||||
local bin; bin="$(qemu_bin_for "${arch}")"
|
local bin; bin="$(qemu_bin_for "${arch}")"
|
||||||
|
|
||||||
local iso="" bootdev="c" display=1 mem=1024 machine=""
|
local iso="" floppy="" disk="" bootdev="" display=1 mem=1024 machine=""
|
||||||
|
# VNC bindet an 127.0.0.1 (Loopback) — von aussen nur ueber den RVS-Tunnel der
|
||||||
|
# Bridge erreichbar. Die Bridge (Container) kann Loopback aber NICHT erreichen;
|
||||||
|
# der Brain gibt deshalb per --vnc-bind die Docker-Gateway-IP mit (container-
|
||||||
|
# intern, NICHT im LAN/Internet). Default bleibt Loopback.
|
||||||
|
local vncbind="${ARIA_VM_VNC_BIND:-127.0.0.1}"
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--iso) iso="${2:?}"; bootdev="d"; shift 2 ;;
|
--iso) iso="${2:?}"; shift 2 ;;
|
||||||
|
--floppy) floppy="${2:?}"; shift 2 ;; # -fda (Disketten-Boot, OS-Dev)
|
||||||
|
--disk) disk="${2:?}"; shift 2 ;; # explizite qcow2 statt Auto
|
||||||
|
--boot) bootdev="${2:?}"; shift 2 ;; # Boot-Reihenfolge (a/c/d)
|
||||||
--disk-boot) bootdev="c"; shift ;;
|
--disk-boot) bootdev="c"; shift ;;
|
||||||
--vnc-display) display="${2:?}"; shift 2 ;;
|
--vnc-display) display="${2:?}"; shift 2 ;;
|
||||||
|
--vnc-bind) vncbind="${2:?}"; shift 2 ;; # Bind-Adresse fuer -vnc
|
||||||
--mem) mem="${2:?}"; shift 2 ;;
|
--mem) mem="${2:?}"; shift 2 ;;
|
||||||
--machine) machine="${2:?}"; shift 2 ;;
|
--machine) machine="${2:?}"; shift 2 ;;
|
||||||
*) die "unbekannte Option: $1" ;;
|
*) die "unbekannte Option: $1" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Auto-Erkennung der Medien im VM-Ordner, falls nicht explizit angegeben.
|
||||||
|
[[ -z "${disk}" && -f "${d}/disk.qcow2" ]] && disk="${d}/disk.qcow2"
|
||||||
|
[[ -z "${floppy}" && -f "${d}/floppy.img" ]] && floppy="${d}/floppy.img"
|
||||||
|
[[ -z "${iso}" && -f "${d}/cdrom.iso" ]] && iso="${d}/cdrom.iso"
|
||||||
|
[[ -n "${disk}${floppy}${iso}" ]] || \
|
||||||
|
die "Keine Boot-Medien fuer '${name}' (disk.qcow2 / --iso / --floppy). Erst 'create <name> <arch> <groesse>' oder ein Medium angeben."
|
||||||
|
|
||||||
local args=(-name "${name}" -m "${mem}"
|
local args=(-name "${name}" -m "${mem}"
|
||||||
-drive "file=${d}/disk.qcow2,format=qcow2"
|
-vnc "${vncbind}:${display}"
|
||||||
-vnc "127.0.0.1:${display}"
|
|
||||||
-monitor "unix:${d}/monitor.sock,server,nowait"
|
-monitor "unix:${d}/monitor.sock,server,nowait"
|
||||||
-pidfile "${d}/pid" -daemonize)
|
-pidfile "${d}/pid" -daemonize)
|
||||||
|
[[ -n "${disk}" ]] && args+=(-drive "file=${disk},format=qcow2")
|
||||||
|
[[ -n "${floppy}" ]] && args+=(-fda "${floppy}")
|
||||||
|
[[ -n "${iso}" ]] && args+=(-cdrom "${iso}")
|
||||||
|
|
||||||
# KVM nur fuer x86 auf x86-Host.
|
# KVM nur fuer x86 auf x86-Host.
|
||||||
case "${arch}" in
|
case "${arch}" in
|
||||||
@@ -104,11 +128,17 @@ cmd_boot() {
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
[[ -n "${machine}" ]] && args+=(-M "${machine}")
|
[[ -n "${machine}" ]] && args+=(-M "${machine}")
|
||||||
[[ -n "${iso}" ]] && args+=(-cdrom "${iso}")
|
|
||||||
|
# Boot-Reihenfolge: explizit, sonst automatisch (ISO→d, nur Diskette→a, sonst c).
|
||||||
|
if [[ -z "${bootdev}" ]]; then
|
||||||
|
if [[ -n "${iso}" ]]; then bootdev="d"
|
||||||
|
elif [[ -n "${floppy}" && -z "${disk}" ]]; then bootdev="a"
|
||||||
|
else bootdev="c"; fi
|
||||||
|
fi
|
||||||
args+=(-boot "${bootdev}")
|
args+=(-boot "${bootdev}")
|
||||||
|
|
||||||
"${bin}" "${args[@]}"
|
"${bin}" "${args[@]}"
|
||||||
echo "VM '${name}' gestartet (${arch}) — VNC 127.0.0.1:${display} (Port $((5900+display)))."
|
echo "VM '${name}' gestartet (${arch}) — VNC ${vncbind}:${display} (Port $((5900+display)))."
|
||||||
echo "vnc_display=${display} vnc_port=$((5900+display))"
|
echo "vnc_display=${display} vnc_port=$((5900+display))"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"code_file", "code_file_edit",
|
"code_file", "code_file_edit",
|
||||||
"check_desktop", "desktop_status",
|
"check_desktop", "desktop_status",
|
||||||
"vnc_open", "vnc_close", "vnc_data", "vnc_input",
|
"vnc_open", "vnc_close", "vnc_data", "vnc_input",
|
||||||
|
// Satelliten (Info-/Gateway-Aussenposten in fremden Netzen): melden sich mit
|
||||||
|
// sat_hello, liefern Geraete-Inventar (sat_devices) auf sat_discover und
|
||||||
|
// fuehren Aktionen aus (sat_command → sat_result).
|
||||||
|
"sat_hello", "sat_discover", "sat_devices", "sat_command", "sat_result",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Token-Raum: token -> { clients: Set<ws> }
|
// Token-Raum: token -> { clients: Set<ws> }
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ─── ARIA Satellit — Konfiguration ──────────────────────────────────
|
||||||
|
# Kopiere diese Datei nach .env und passe sie an.
|
||||||
|
|
||||||
|
# RVS-Zugang (identisch zum Haupt-Stack — gleicher Raum/Token, damit ARIA
|
||||||
|
# diesen Satelliten erreicht). Werte aus der Haupt-.env / vom generate-token.sh.
|
||||||
|
RVS_HOST=rvs.example.de
|
||||||
|
RVS_PORT=443
|
||||||
|
RVS_TLS=true
|
||||||
|
RVS_TOKEN=
|
||||||
|
|
||||||
|
# ─── Identitaet / Adresse dieses Satelliten ────────────────────────
|
||||||
|
# SATELLITE_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
|
||||||
|
# SATELLITE_LOCATION = menschlicher Name, so spricht ARIA das Netz an ("Buero").
|
||||||
|
# Mehrere Satelliten koennen im selben RVS-Raum haengen — die Location
|
||||||
|
# unterscheidet sie ("Buero", "Zuhause", "Werkstatt").
|
||||||
|
SATELLITE_ID=buero
|
||||||
|
SATELLITE_LOCATION=Büro
|
||||||
|
|
||||||
|
# ─── Steuerung (Sicherheit!) ───────────────────────────────────────
|
||||||
|
# CONTROL_ENABLED=false → reiner Info-/Beobachtungs-Satellit (entdeckt & meldet
|
||||||
|
# nur, steuert nichts). Sicherste Basis.
|
||||||
|
# CONTROL_ENABLED=true → darf Geraete steuern (nur Aktionen aus der Allowlist).
|
||||||
|
CONTROL_ENABLED=true
|
||||||
|
# Erlaubte Steuer-Aktionen (kommagetrennt). Alles andere wird abgelehnt.
|
||||||
|
# dial.launch App-Launch via DIAL (z.B. YouTube-Video auf Fire TV / Smart-TV)
|
||||||
|
# wol Wake-on-LAN (Geraet per MAC aufwecken)
|
||||||
|
# http.get generischer HTTP-GET (z.B. lokale IoT-Webhooks)
|
||||||
|
# http.post generischer HTTP-POST
|
||||||
|
CONTROL_ALLOWLIST=dial.launch,wol,http.get
|
||||||
|
|
||||||
|
# ─── Discovery-Tuning (optional) ───────────────────────────────────
|
||||||
|
SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall
|
||||||
|
DISCOVER_TIMEOUT_SEC=6 # Dauer eines Sweeps (mDNS + SSDP)
|
||||||
|
DEVICE_CACHE_TTL_SEC=120 # wie lange ein Inventar als "frisch" gilt
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ARIA Satellit — schlanker Aussenposten-Container.
|
||||||
|
# Laeuft mit network_mode: host (siehe docker-compose.yml), damit mDNS/SSDP-
|
||||||
|
# Broadcasts + die Geraete-IPs im lokalen Netz erreichbar sind.
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY satellite.py .
|
||||||
|
|
||||||
|
CMD ["python", "-u", "satellite.py"]
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# ARIA Satellit 🛰️
|
||||||
|
|
||||||
|
Ein eigenständiger **Außenposten-Container** für ein fremdes Netz (Büro, Werkstatt,
|
||||||
|
Ferienwohnung …). Er verbindet sich als RVS-Client in Stefans Raum und gibt ARIA
|
||||||
|
**Augen und Hände in genau diesem Netz** — ohne dass der Haupt-Stack dort stehen muss.
|
||||||
|
|
||||||
|
- **Augen (Info):** entdeckt Geräte via **mDNS/Zeroconf** (Chromecast, AirPlay, Sonos,
|
||||||
|
Drucker, NAS …), **SSDP/UPnP + DIAL** (Smart-TVs, Fire TV) und der **ARP-Tabelle**
|
||||||
|
(rohe Hosts). Meldet ARIA ein Live-Inventar.
|
||||||
|
- **Hände (Steuerung):** **DIAL-App-Launch** (z.B. YouTube-Video auf dem Fire TV),
|
||||||
|
**Wake-on-LAN**, generisches **HTTP**. Nur wenn freigeschaltet (siehe Sicherheit).
|
||||||
|
|
||||||
|
## ⚠️ Wichtig: der Satellit MUSS im echten Ziel-LAN laufen
|
||||||
|
|
||||||
|
Discovery (mDNS/SSDP-Multicast + ARP) funktioniert **nur**, wenn der Prozess
|
||||||
|
tatsächlich im selben LAN wie die Geräte hängt — z.B. `192.168.177.0/24`, wo der
|
||||||
|
Fire TV steht.
|
||||||
|
|
||||||
|
**Docker Desktop (Mac/Windows) geht NICHT.** Dort ist `network_mode: host` das Netz
|
||||||
|
der Docker-Linux-VM (NAT, `192.168.65.x` / `172.x`), **nicht** dein echtes LAN.
|
||||||
|
Der Satellit sieht dann nur Docker-Container statt der echten Geräte. (Der Satellit
|
||||||
|
erkennt das selbst und meldet eine ⚠-Warnung im Diagnostic + Log.)
|
||||||
|
|
||||||
|
Richtig deployen — zwei Wege:
|
||||||
|
|
||||||
|
**A) Linux-Box im Ziel-LAN mit Docker Engine** (empfohlen, z.B. Raspberry Pi / NUC im Büro):
|
||||||
|
```bash
|
||||||
|
cd satellite
|
||||||
|
cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f # "Netz: primary_ip=192.168.177.x" + "[scan] N Geraete"
|
||||||
|
```
|
||||||
|
`network_mode: host` (schon gesetzt) gibt hier echtes LAN + Multicast.
|
||||||
|
|
||||||
|
**B) Nativ als Python-Prozess** (für Mac/Windows-Test oder ohne Docker) — läuft direkt
|
||||||
|
auf einer Maschine im Ziel-LAN. Das Script lädt die `.env` **selbst** (muss im
|
||||||
|
`satellite/`-Ordner liegen):
|
||||||
|
```bash
|
||||||
|
cd satellite
|
||||||
|
cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION eintragen
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python satellite.py # liest .env automatisch
|
||||||
|
```
|
||||||
|
(Echte Umgebungsvariablen haben Vorrang — `export RVS_TOKEN=...` überschreibt die
|
||||||
|
`.env`, falls du das lieber magst.)
|
||||||
|
|
||||||
|
`RVS_HOST/PORT/TLS/TOKEN` **identisch** zum Haupt-Stack (gleicher Raum, damit ARIA
|
||||||
|
den Satelliten erreicht). `SATELLITE_LOCATION` ist der Name, über den ARIA das Netz
|
||||||
|
anspricht („Büro").
|
||||||
|
|
||||||
|
**Kontrolle:** im Log/Diagnostic muss `primary_ip` im Ziel-LAN liegen
|
||||||
|
(`192.168.177.x`) — steht da `192.168.65.x` oder `172.x`, sitzt der Satellit im
|
||||||
|
falschen (Docker-)Netz.
|
||||||
|
|
||||||
|
## Als Dienst installieren (Autostart, ohne Docker)
|
||||||
|
|
||||||
|
Legt automatisch ein `.venv` an, installiert die Requirements und richtet einen
|
||||||
|
Dienst ein, der bei Boot startet und bei Absturz neu hochkommt. `.env` vorher
|
||||||
|
anlegen.
|
||||||
|
|
||||||
|
**Linux (systemd) / macOS (launchd):**
|
||||||
|
```bash
|
||||||
|
cd satellite
|
||||||
|
bash install.sh # installieren + starten
|
||||||
|
bash install.sh uninstall # entfernen
|
||||||
|
```
|
||||||
|
- Linux-Logs: `journalctl -u aria-satellite -f`
|
||||||
|
- macOS-Logs: `tail -f satellite.log` (evtl. „Lokales Netzwerk"-Zugriff erlauben)
|
||||||
|
|
||||||
|
**Windows (Scheduled Task) — PowerShell als Administrator:**
|
||||||
|
```powershell
|
||||||
|
cd satellite
|
||||||
|
powershell -ExecutionPolicy Bypass -File install.ps1 # installieren + starten
|
||||||
|
powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall # entfernen
|
||||||
|
```
|
||||||
|
- Log: `Get-Content -Wait satellite.log`
|
||||||
|
|
||||||
|
## So nutzt ARIA es
|
||||||
|
|
||||||
|
ARIA hat drei Brain-Tools:
|
||||||
|
- `satellite_list` — welche Netze/Satelliten sind online + was können sie.
|
||||||
|
- `satellite_devices(satellite)` — Inventar eines Netzes.
|
||||||
|
- `satellite_command(satellite, device, action, params)` — Aktion ausführen.
|
||||||
|
|
||||||
|
Beispiel „YouTube-Video auf dem Büro-Stick":
|
||||||
|
```
|
||||||
|
satellite_command(satellite="Büro", device="Fire TV",
|
||||||
|
action="dial.launch", params={"app":"YouTube","v":"<videoId>"})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
Der Satellit scannt ein Netz **und** ist über einen Cloud-Relay erreichbar — deshalb:
|
||||||
|
|
||||||
|
- Reagiert **nur** auf den eigenen RVS-Raum (Token).
|
||||||
|
- **`CONTROL_ENABLED=false`** = reiner Info-Satellit (steuert nichts). Standard-sicher.
|
||||||
|
- Bei `true`: nur Aktionen aus **`CONTROL_ALLOWLIST`**, alles andere wird abgelehnt.
|
||||||
|
- Jede ausgeführte Aktion wird **geloggt**.
|
||||||
|
- Keine offenen Ports — reiner Client.
|
||||||
|
|
||||||
|
Empfehlung: in vertrauenswürdigen Netzen `CONTROL_ENABLED=true` mit enger Allowlist;
|
||||||
|
sonst `false` und nur beobachten.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ARIA Satellit — eigenstaendiger Stack fuer ein fremdes Netz (Buero, Werkstatt …).
|
||||||
|
#
|
||||||
|
# Deploy:
|
||||||
|
# cd satellite
|
||||||
|
# cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION eintragen
|
||||||
|
# docker compose up -d --build
|
||||||
|
#
|
||||||
|
# WICHTIG: network_mode: host — der Satellit MUSS im Host-Netz laufen, sonst
|
||||||
|
# sieht er die mDNS/SSDP-Broadcasts + Geraete-IPs des LAN nicht (Docker-Bridge
|
||||||
|
# wuerde das isolieren). Damit ist er zugleich als RVS-Client raus ins Internet
|
||||||
|
# verbunden. Keine Ports zu veroeffentlichen — er ist reiner Client.
|
||||||
|
#
|
||||||
|
# ⚠ NUR auf LINUX Docker Engine, und der Host muss physisch im Ziel-LAN haengen!
|
||||||
|
# Docker Desktop (Mac/Windows) gibt hier NUR das Docker-VM-Netz (192.168.65.x /
|
||||||
|
# 172.x), NICHT dein echtes LAN → Discovery findet dann nur Docker-Container.
|
||||||
|
# Fuer Mac/Windows: satellite.py nativ starten (siehe README, Weg B).
|
||||||
|
services:
|
||||||
|
satellite:
|
||||||
|
build: .
|
||||||
|
container_name: aria-satellite
|
||||||
|
network_mode: host
|
||||||
|
env_file: .env
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<#
|
||||||
|
ARIA Satellit — Dienst-Installer fuer Windows (Scheduled Task, Autostart + Restart).
|
||||||
|
|
||||||
|
powershell -ExecutionPolicy Bypass -File install.ps1 installiert + startet
|
||||||
|
powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall entfernt den Task
|
||||||
|
|
||||||
|
Legt ein venv (.venv) an, installiert requirements.txt und richtet einen
|
||||||
|
geplanten Task ein, der satellite.py aus DIESEM Ordner startet (findet die .env)
|
||||||
|
und bei Absturz/Neustart wieder hochkommt. Laeuft als aktueller Nutzer (S4U),
|
||||||
|
auch ohne Anmeldung. Als Administrator ausfuehren.
|
||||||
|
|
||||||
|
WICHTIG: Der Satellit muss im ECHTEN Ziel-LAN laufen (siehe README) — auf einer
|
||||||
|
Maschine, die physisch im Netz der Geraete haengt. Docker Desktop taugt nicht.
|
||||||
|
#>
|
||||||
|
param([switch]$Uninstall)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Dir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$Task = "ARIA-Satellite"
|
||||||
|
|
||||||
|
# ─── Uninstall ──────────────────────────────────────────────────────
|
||||||
|
if ($Uninstall) {
|
||||||
|
if (Get-ScheduledTask -TaskName $Task -ErrorAction SilentlyContinue) {
|
||||||
|
Unregister-ScheduledTask -TaskName $Task -Confirm:$false
|
||||||
|
Write-Host "Task '$Task' entfernt."
|
||||||
|
} else {
|
||||||
|
Write-Host "Kein Task '$Task' gefunden."
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Admin-Check ────────────────────────────────────────────────────
|
||||||
|
$isAdmin = ([Security.Principal.WindowsPrincipal] `
|
||||||
|
[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
if (-not $isAdmin) {
|
||||||
|
Write-Warning "Bitte als Administrator ausfuehren (Rechtsklick PowerShell -> Als Administrator)."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Python finden ──────────────────────────────────────────────────
|
||||||
|
$py = (Get-Command python -ErrorAction SilentlyContinue).Source
|
||||||
|
if (-not $py) { $py = (Get-Command python3 -ErrorAction SilentlyContinue).Source }
|
||||||
|
if (-not $py) { Write-Error "Python 3 nicht gefunden. Bitte von python.org installieren (mit 'Add to PATH')."; exit 1 }
|
||||||
|
|
||||||
|
# ─── venv + Abhaengigkeiten ─────────────────────────────────────────
|
||||||
|
if (-not (Test-Path "$Dir\.venv")) {
|
||||||
|
Write-Host "[install] Lege venv an ..."
|
||||||
|
& $py -m venv "$Dir\.venv"
|
||||||
|
}
|
||||||
|
$venvPy = "$Dir\.venv\Scripts\python.exe"
|
||||||
|
Write-Host "[install] Installiere Abhaengigkeiten ..."
|
||||||
|
& $venvPy -m pip install --upgrade pip | Out-Null
|
||||||
|
& $venvPy -m pip install -r "$Dir\requirements.txt"
|
||||||
|
|
||||||
|
if (-not (Test-Path "$Dir\.env")) {
|
||||||
|
Write-Warning "Keine .env gefunden - bitte anlegen: copy .env.example .env (RVS-Zugang + SATELLITE_LOCATION)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Scheduled Task ─────────────────────────────────────────────────
|
||||||
|
Write-Host "[install] Richte geplanten Task '$Task' ein ..."
|
||||||
|
# Ueber cmd starten, damit stdout/stderr in satellite.log landen (der Task laeuft
|
||||||
|
# in Session 0 / S4U = kein sichtbares Fenster, python.exe ist hier ok).
|
||||||
|
$logfile = Join-Path $Dir "satellite.log"
|
||||||
|
$cmdArg = '/c ""{0}" satellite.py >> "{1}" 2>&1"' -f $venvPy, $logfile
|
||||||
|
$action = New-ScheduledTaskAction -Execute "cmd.exe" -Argument $cmdArg -WorkingDirectory $Dir
|
||||||
|
$trigger = @(
|
||||||
|
New-ScheduledTaskTrigger -AtStartup
|
||||||
|
New-ScheduledTaskTrigger -AtLogOn
|
||||||
|
)
|
||||||
|
# Laeuft als aktueller Nutzer, auch ohne Anmeldung (S4U), mit hoechsten Rechten.
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
|
||||||
|
-LogonType S4U -RunLevel Highest
|
||||||
|
# Bei Absturz neu starten, unbegrenzte Laufzeit, startet nach falls Zeitpunkt verpasst, versteckt.
|
||||||
|
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -Hidden `
|
||||||
|
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
|
||||||
|
-ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
|
||||||
|
-MultipleInstances IgnoreNew
|
||||||
|
|
||||||
|
Register-ScheduledTask -TaskName $Task -Action $action -Trigger $trigger `
|
||||||
|
-Principal $principal -Settings $settings -Force | Out-Null
|
||||||
|
|
||||||
|
Start-ScheduledTask -TaskName $Task
|
||||||
|
Write-Host "OK Dienst laeuft."
|
||||||
|
Write-Host " Status: Get-ScheduledTask -TaskName $Task"
|
||||||
|
Write-Host " Stoppen: Stop-ScheduledTask -TaskName $Task"
|
||||||
|
Write-Host " Log: Get-Content -Wait '$logfile' (pruefen: primary_ip + '[scan] N Geraete')"
|
||||||
Executable
+119
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# ARIA Satellit — Dienst-Installer fuer Linux (systemd) und macOS (launchd).
|
||||||
|
#
|
||||||
|
# bash install.sh installiert + startet den Dienst
|
||||||
|
# bash install.sh uninstall entfernt den Dienst
|
||||||
|
#
|
||||||
|
# Legt ein venv an (.venv), installiert requirements.txt und richtet einen
|
||||||
|
# Autostart-Dienst ein, der satellite.py aus DIESEM Ordner startet (damit die
|
||||||
|
# .env gefunden wird) und bei Absturz neu startet.
|
||||||
|
#
|
||||||
|
# WICHTIG: Der Satellit muss im ECHTEN Ziel-LAN laufen (siehe README) — auf einer
|
||||||
|
# Maschine, die physisch im Netz der Geraete haengt. Docker Desktop taugt nicht.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SVC="aria-satellite"
|
||||||
|
PLIST_LABEL="de.aria.satellite"
|
||||||
|
OS="$(uname -s)"
|
||||||
|
ACTION="${1:-install}"
|
||||||
|
|
||||||
|
# ─── Uninstall ──────────────────────────────────────────────────────
|
||||||
|
if [ "$ACTION" = "uninstall" ]; then
|
||||||
|
if [ "$OS" = "Linux" ]; then
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||||
|
$SUDO systemctl disable --now "$SVC" 2>/dev/null || true
|
||||||
|
$SUDO rm -f "/etc/systemd/system/$SVC.service"
|
||||||
|
$SUDO systemctl daemon-reload
|
||||||
|
echo "systemd-Dienst '$SVC' entfernt."
|
||||||
|
elif [ "$OS" = "Darwin" ]; then
|
||||||
|
PLIST="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist"
|
||||||
|
launchctl unload "$PLIST" 2>/dev/null || true
|
||||||
|
rm -f "$PLIST"
|
||||||
|
echo "launchd-Agent '$PLIST_LABEL' entfernt."
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── venv + Abhaengigkeiten ─────────────────────────────────────────
|
||||||
|
PY="$(command -v python3 || command -v python || true)"
|
||||||
|
[ -n "$PY" ] || { echo "Python 3 nicht gefunden. Bitte installieren."; exit 1; }
|
||||||
|
|
||||||
|
if [ ! -d "$DIR/.venv" ]; then
|
||||||
|
echo "[install] Lege venv an …"
|
||||||
|
if ! "$PY" -m venv "$DIR/.venv" 2>/dev/null; then
|
||||||
|
echo "venv-Erstellung fehlgeschlagen. Auf Debian/Ubuntu: sudo apt install python3-venv"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
VENV_PY="$DIR/.venv/bin/python"
|
||||||
|
echo "[install] Installiere Abhaengigkeiten …"
|
||||||
|
"$VENV_PY" -m pip install --upgrade pip >/dev/null
|
||||||
|
"$VENV_PY" -m pip install -r "$DIR/requirements.txt"
|
||||||
|
|
||||||
|
if [ ! -f "$DIR/.env" ]; then
|
||||||
|
echo "⚠ Keine .env gefunden — bitte anlegen: cp .env.example .env (RVS-Zugang + SATELLITE_LOCATION)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Dienst einrichten ──────────────────────────────────────────────
|
||||||
|
if [ "$OS" = "Linux" ]; then
|
||||||
|
RUN_USER="${SUDO_USER:-$(id -un)}"
|
||||||
|
UNIT="/etc/systemd/system/$SVC.service"
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||||
|
echo "[install] Schreibe systemd-Unit $UNIT (User=$RUN_USER) …"
|
||||||
|
$SUDO tee "$UNIT" >/dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=ARIA Satellit (Netz-Aussenposten)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$RUN_USER
|
||||||
|
WorkingDirectory=$DIR
|
||||||
|
ExecStart=$VENV_PY $DIR/satellite.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
$SUDO systemctl daemon-reload
|
||||||
|
$SUDO systemctl enable --now "$SVC"
|
||||||
|
echo "✅ Dienst laeuft."
|
||||||
|
echo " Status: $SUDO systemctl status $SVC"
|
||||||
|
echo " Logs: $SUDO journalctl -u $SVC -f"
|
||||||
|
|
||||||
|
elif [ "$OS" = "Darwin" ]; then
|
||||||
|
PLIST="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist"
|
||||||
|
mkdir -p "$HOME/Library/LaunchAgents"
|
||||||
|
echo "[install] Schreibe launchd-Agent $PLIST …"
|
||||||
|
cat > "$PLIST" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key><string>$PLIST_LABEL</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>$VENV_PY</string>
|
||||||
|
<string>$DIR/satellite.py</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key><string>$DIR</string>
|
||||||
|
<key>RunAtLoad</key><true/>
|
||||||
|
<key>KeepAlive</key><true/>
|
||||||
|
<key>StandardOutPath</key><string>$DIR/satellite.log</string>
|
||||||
|
<key>StandardErrorPath</key><string>$DIR/satellite.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
launchctl unload "$PLIST" 2>/dev/null || true
|
||||||
|
launchctl load "$PLIST"
|
||||||
|
echo "✅ Agent geladen."
|
||||||
|
echo " Logs: tail -f $DIR/satellite.log"
|
||||||
|
echo " Hinweis: macOS fragt evtl. nach 'Lokales Netzwerk'-Zugriff — erlauben, sonst findet der Satellit keine Geraete."
|
||||||
|
else
|
||||||
|
echo "Unbekanntes OS: $OS (dieses Skript kann Linux/macOS; fuer Windows: install.ps1)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
websockets>=12.0
|
||||||
|
zeroconf>=0.131.0
|
||||||
|
requests>=2.31.0
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
"""
|
||||||
|
ARIA Satellit — Info-/Gateway-Aussenposten in einem fremden Netz.
|
||||||
|
|
||||||
|
Laeuft eigenstaendig (z.B. im Buero) und verbindet sich als RVS-Client in
|
||||||
|
Stefans Raum (gleicher Token). Gibt ARIA damit Augen + Haende in DIESEM Netz:
|
||||||
|
|
||||||
|
Augen: entdeckt Geraete (mDNS/Zeroconf, SSDP/UPnP + DIAL, ARP-Tabelle) und
|
||||||
|
meldet ein Inventar → sat_devices.
|
||||||
|
Haende: steuert Geraete (DIAL-App-Launch z.B. YouTube auf Fire TV, Wake-on-
|
||||||
|
LAN, generisches HTTP) → sat_command / sat_result. Nur wenn
|
||||||
|
CONTROL_ENABLED=true, Aktion in der Allowlist, alles geloggt.
|
||||||
|
|
||||||
|
Adressierung: mehrere Satelliten haengen im selben RVS-Raum. Jeder hat eine
|
||||||
|
SATELLITE_ID (technisch, eindeutig) + SATELLITE_LOCATION (menschlich, "Buero").
|
||||||
|
ARIA spricht einen Satelliten ueber seine ID/Location an.
|
||||||
|
|
||||||
|
Message-Typen (RVS, Base64/JSON-Relay wie der Rest):
|
||||||
|
raus: sat_hello {id, location, caps, ts}
|
||||||
|
sat_devices {requestId, satellite, devices:[...]}
|
||||||
|
sat_result {requestId, satellite, ok, result|error}
|
||||||
|
rein: sat_discover {satellite?, requestId}
|
||||||
|
sat_command {satellite?, requestId, device, action, params}
|
||||||
|
|
||||||
|
Sicherheit: reagiert nur auf den eigenen RVS-Raum (Token). Commands brauchen
|
||||||
|
CONTROL_ENABLED + Allowlist. Discovery ist read-only. Keine offenen Ports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [satellite] %(levelname)s %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("satellite")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv() -> None:
|
||||||
|
"""Laedt eine .env neben dem Script (oder im CWD) in os.environ — fuer den
|
||||||
|
NATIVEN Start (`python satellite.py`). In Docker sind die Variablen via
|
||||||
|
env_file schon gesetzt; bereits gesetzte Werte gewinnen (werden NICHT
|
||||||
|
ueberschrieben). Kein python-dotenv noetig."""
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
for path in (os.path.join(here, ".env"), os.path.join(os.getcwd(), ".env")):
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, val = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
if key.startswith("export "):
|
||||||
|
key = key[len("export "):].strip()
|
||||||
|
val = val.strip()
|
||||||
|
if val[:1] in ("'", '"'):
|
||||||
|
# Gequotet: Inhalt bis zum schliessenden Quote, Rest (Kommentar) egal.
|
||||||
|
q = val[0]
|
||||||
|
end = val.find(q, 1)
|
||||||
|
val = val[1:end] if end != -1 else val[1:]
|
||||||
|
else:
|
||||||
|
# Ungequotet: Inline-Kommentar (Whitespace + #) abschneiden.
|
||||||
|
m = re.search(r"\s+#", val)
|
||||||
|
if m:
|
||||||
|
val = val[:m.start()]
|
||||||
|
val = val.strip()
|
||||||
|
if key and key not in os.environ:
|
||||||
|
os.environ[key] = val
|
||||||
|
except Exception as exc:
|
||||||
|
logging.getLogger("satellite").warning(".env laden fehlgeschlagen (%s): %s", path, exc)
|
||||||
|
break # erste gefundene .env gewinnt
|
||||||
|
|
||||||
|
|
||||||
|
_load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Konfiguration ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
|
v = os.environ.get(name)
|
||||||
|
if v is None:
|
||||||
|
return default
|
||||||
|
return v.strip().lower() in ("1", "true", "yes", "on", "ja")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_id() -> str:
|
||||||
|
host = socket.gethostname() or "satellite"
|
||||||
|
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-").lower()
|
||||||
|
return slug or "satellite"
|
||||||
|
|
||||||
|
|
||||||
|
RVS_HOST = os.environ.get("RVS_HOST", "")
|
||||||
|
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
|
||||||
|
RVS_TLS = _env_bool("RVS_TLS", True)
|
||||||
|
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
|
||||||
|
|
||||||
|
SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip()
|
||||||
|
SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).strip()
|
||||||
|
|
||||||
|
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
|
||||||
|
CONTROL_ALLOWLIST = [
|
||||||
|
a.strip() for a in
|
||||||
|
os.environ.get("CONTROL_ALLOWLIST", "dial.launch,wol,http.get").split(",")
|
||||||
|
if a.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
SCAN_INTERVAL_SEC = int(os.environ.get("SCAN_INTERVAL_SEC", "300") or "300")
|
||||||
|
DISCOVER_TIMEOUT_SEC = float(os.environ.get("DISCOVER_TIMEOUT_SEC", "6") or "6")
|
||||||
|
DEVICE_CACHE_TTL_SEC = int(os.environ.get("DEVICE_CACHE_TTL_SEC", "120") or "120")
|
||||||
|
|
||||||
|
HEARTBEAT_SEC = 25
|
||||||
|
|
||||||
|
# mDNS-Servicetypen, die fuer ARIA interessant sind.
|
||||||
|
MDNS_TYPES = [
|
||||||
|
"_googlecast._tcp.local.", # Chromecast / Google TV / Nest
|
||||||
|
"_airplay._tcp.local.", # Apple TV / AirPlay
|
||||||
|
"_raop._tcp.local.", # AirPlay-Audio
|
||||||
|
"_spotify-connect._tcp.local.", # Spotify-Geraete
|
||||||
|
"_sonos._tcp.local.", # Sonos
|
||||||
|
"_hap._tcp.local.", # HomeKit
|
||||||
|
"_printer._tcp.local.", # Drucker
|
||||||
|
"_ipp._tcp.local.", # Drucker (IPP)
|
||||||
|
"_smb._tcp.local.", # NAS / Fileshares
|
||||||
|
"_workstation._tcp.local.", # generische Hosts
|
||||||
|
"_http._tcp.local.", # Web-UIs (Router, NAS, IoT)
|
||||||
|
]
|
||||||
|
|
||||||
|
CAPABILITIES = ["discover"]
|
||||||
|
if CONTROL_ENABLED:
|
||||||
|
CAPABILITIES += CONTROL_ALLOWLIST
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Netz-Kontext / Selbstdiagnose ──────────────────────────────────
|
||||||
|
|
||||||
|
def _in_docker_bridge(ip: str) -> bool:
|
||||||
|
# Docker-Default-Bridge-Range 172.16.0.0/12
|
||||||
|
try:
|
||||||
|
a, b = ip.split(".")[:2]
|
||||||
|
return a == "172" and 16 <= int(b) <= 31
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _net_context() -> dict:
|
||||||
|
"""Ermittelt in welchem Netz der Satellit LAeUFT — und warnt, wenn das ein
|
||||||
|
Docker-/NAT-Netz ist (dann erreicht Discovery das echte LAN nicht)."""
|
||||||
|
ips: list[str] = []
|
||||||
|
primary = ""
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.settimeout(1)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
primary = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||||
|
ip = info[4][0]
|
||||||
|
if ip and not ip.startswith("127.") and ip not in ips:
|
||||||
|
ips.append(ip)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if primary and primary not in ips:
|
||||||
|
ips.insert(0, primary)
|
||||||
|
p = primary or (ips[0] if ips else "")
|
||||||
|
warning = ""
|
||||||
|
if p.startswith("192.168.65.") or _in_docker_bridge(p):
|
||||||
|
warning = (f"Satellit laeuft in einem Docker-/NAT-Netz ({p}), NICHT im echten LAN. "
|
||||||
|
"mDNS/SSDP erreichen die realen Geraete so nicht. Auf Docker Desktop "
|
||||||
|
"(Mac/Windows) geht LAN-Discovery nicht — den Satelliten NATIV (python "
|
||||||
|
"satellite.py) oder auf einem Linux-Host im Ziel-LAN betreiben.")
|
||||||
|
return {"primary_ip": p, "ips": ips, "warning": warning}
|
||||||
|
|
||||||
|
|
||||||
|
NET = _net_context()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Discovery ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _discover_mdns(timeout: float) -> list[dict]:
|
||||||
|
"""Blockierend (im Executor): mDNS/Zeroconf-Sweep ueber MDNS_TYPES."""
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
try:
|
||||||
|
from zeroconf import Zeroconf, ServiceBrowser
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("zeroconf nicht verfuegbar: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
class _Listener:
|
||||||
|
def add_service(self, zc, type_, name):
|
||||||
|
try:
|
||||||
|
info = zc.get_service_info(type_, name, timeout=2000)
|
||||||
|
except Exception:
|
||||||
|
info = None
|
||||||
|
if not info:
|
||||||
|
return
|
||||||
|
ips = []
|
||||||
|
try:
|
||||||
|
for addr in info.parsed_addresses():
|
||||||
|
ips.append(addr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
props = {}
|
||||||
|
try:
|
||||||
|
for k, v in (info.properties or {}).items():
|
||||||
|
try:
|
||||||
|
props[k.decode("utf-8", "ignore")] = (
|
||||||
|
v.decode("utf-8", "ignore") if isinstance(v, (bytes, bytearray)) else v)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
friendly = name.split("." + type_.split(".", 1)[0])[0].strip(".")
|
||||||
|
fn = props.get("fn") or props.get("friendlyName") or friendly
|
||||||
|
dev_id = _slug(f"{fn}-{ips[0] if ips else name}")
|
||||||
|
out[dev_id] = {
|
||||||
|
"id": dev_id,
|
||||||
|
"name": fn,
|
||||||
|
"type": _mdns_kind(type_),
|
||||||
|
"ip": ips[0] if ips else "",
|
||||||
|
"port": info.port,
|
||||||
|
"via": "mdns",
|
||||||
|
"service": type_,
|
||||||
|
"model": props.get("md") or props.get("model") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_service(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def remove_service(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
zc = None
|
||||||
|
try:
|
||||||
|
zc = Zeroconf()
|
||||||
|
listener = _Listener()
|
||||||
|
for t in MDNS_TYPES:
|
||||||
|
try:
|
||||||
|
ServiceBrowser(zc, t, listener)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(timeout)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("mDNS-Sweep-Fehler: %s", exc)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if zc:
|
||||||
|
zc.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return list(out.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _mdns_kind(service_type: str) -> str:
|
||||||
|
m = {
|
||||||
|
"_googlecast": "cast", "_airplay": "airplay", "_raop": "airplay-audio",
|
||||||
|
"_spotify-connect": "spotify", "_sonos": "sonos", "_hap": "homekit",
|
||||||
|
"_printer": "printer", "_ipp": "printer", "_smb": "fileshare",
|
||||||
|
"_workstation": "host", "_http": "web",
|
||||||
|
}
|
||||||
|
for k, v in m.items():
|
||||||
|
if service_type.startswith(k):
|
||||||
|
return v
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_ssdp(timeout: float) -> list[dict]:
|
||||||
|
"""Blockierend: SSDP M-SEARCH (UPnP + DIAL). Liefert v.a. Smart-TVs / Fire
|
||||||
|
TV mit ihrer DIAL Application-URL (fuer App-Launch wie YouTube)."""
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
targets = [
|
||||||
|
"urn:dial-multiscreen-org:service:dial:1",
|
||||||
|
"ssdp:all",
|
||||||
|
]
|
||||||
|
for st in targets:
|
||||||
|
msg = (
|
||||||
|
"M-SEARCH * HTTP/1.1\r\n"
|
||||||
|
"HOST: 239.255.255.250:1900\r\n"
|
||||||
|
'MAN: "ssdp:discover"\r\n'
|
||||||
|
"MX: 2\r\n"
|
||||||
|
f"ST: {st}\r\n\r\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
sock.sendto(msg, ("239.255.255.250", 1900))
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
data, addr = sock.recvfrom(65507)
|
||||||
|
except socket.timeout:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
headers = _parse_http_headers(data.decode("utf-8", "ignore"))
|
||||||
|
location = headers.get("location", "")
|
||||||
|
dial_app = headers.get("application-url", "")
|
||||||
|
ip = addr[0]
|
||||||
|
dev = _fetch_upnp_description(location) if location else {}
|
||||||
|
name = dev.get("name") or headers.get("server", "") or ip
|
||||||
|
dev_id = _slug(f"{name}-{ip}")
|
||||||
|
entry = out.get(dev_id, {
|
||||||
|
"id": dev_id, "name": name, "type": "media-renderer",
|
||||||
|
"ip": ip, "via": "ssdp",
|
||||||
|
})
|
||||||
|
if dev.get("name"):
|
||||||
|
entry["name"] = dev["name"]
|
||||||
|
if dev.get("model"):
|
||||||
|
entry["model"] = dev["model"]
|
||||||
|
if dev.get("manufacturer"):
|
||||||
|
entry["manufacturer"] = dev["manufacturer"]
|
||||||
|
if dial_app or dev.get("dialAppUrl"):
|
||||||
|
entry["dialAppUrl"] = dial_app or dev.get("dialAppUrl")
|
||||||
|
entry["type"] = "dial"
|
||||||
|
out[dev_id] = entry
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("SSDP (%s) Fehler: %s", st, exc)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if sock:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return list(out.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_upnp_description(location: str) -> dict:
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
r = requests.get(location, timeout=3)
|
||||||
|
dial_app = r.headers.get("Application-URL", "")
|
||||||
|
xml = r.text
|
||||||
|
name = _xml_tag(xml, "friendlyName")
|
||||||
|
model = _xml_tag(xml, "modelName")
|
||||||
|
manuf = _xml_tag(xml, "manufacturer")
|
||||||
|
return {"name": name, "model": model, "manufacturer": manuf, "dialAppUrl": dial_app}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_arp() -> list[dict]:
|
||||||
|
"""Rohe Host-Liste aus der ARP-Tabelle (kein aktiver Scan)."""
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
with open("/proc/net/arp", "r", encoding="utf-8") as f:
|
||||||
|
lines = f.read().splitlines()[1:]
|
||||||
|
for ln in lines:
|
||||||
|
parts = ln.split()
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
ip, _hw, _flags, mac = parts[0], parts[1], parts[2], parts[3]
|
||||||
|
if mac == "00:00:00:00:00:00":
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"id": _slug(f"host-{ip}"), "name": ip, "type": "host",
|
||||||
|
"ip": ip, "mac": mac, "via": "arp",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_devices(*lists) -> list[dict]:
|
||||||
|
"""Fuehrt Geraetelisten zusammen, dedupt per IP (reichere Quelle gewinnt)."""
|
||||||
|
by_ip: dict[str, dict] = {}
|
||||||
|
loose: list[dict] = []
|
||||||
|
order = {"mdns": 3, "ssdp": 2, "arp": 1}
|
||||||
|
for lst in lists:
|
||||||
|
for d in lst:
|
||||||
|
ip = d.get("ip") or ""
|
||||||
|
if not ip:
|
||||||
|
loose.append(d)
|
||||||
|
continue
|
||||||
|
cur = by_ip.get(ip)
|
||||||
|
if not cur:
|
||||||
|
by_ip[ip] = d
|
||||||
|
else:
|
||||||
|
# bessere Quelle / mehr Felder → mergen
|
||||||
|
merged = {**d, **{k: v for k, v in cur.items() if v}}
|
||||||
|
if order.get(d.get("via"), 0) >= order.get(cur.get("via"), 0):
|
||||||
|
merged.update({k: v for k, v in d.items() if v})
|
||||||
|
# DIAL-URL / mac aus beiden behalten
|
||||||
|
for key in ("dialAppUrl", "mac", "model", "manufacturer"):
|
||||||
|
merged[key] = d.get(key) or cur.get(key) or merged.get(key)
|
||||||
|
by_ip[ip] = {k: v for k, v in merged.items() if v not in (None, "")}
|
||||||
|
return list(by_ip.values()) + loose
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Control ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _control(action: str, params: dict, devices: list[dict]) -> dict:
|
||||||
|
"""Fuehrt eine Steuer-Aktion aus. Guards: CONTROL_ENABLED + Allowlist."""
|
||||||
|
if not CONTROL_ENABLED:
|
||||||
|
return {"ok": False, "error": "Steuerung ist an diesem Satelliten deaktiviert (CONTROL_ENABLED=false)."}
|
||||||
|
if action not in CONTROL_ALLOWLIST:
|
||||||
|
return {"ok": False, "error": f"Aktion '{action}' nicht erlaubt (Allowlist: {', '.join(CONTROL_ALLOWLIST)})."}
|
||||||
|
logger.info("[control] %s params=%s", action, {k: str(v)[:60] for k, v in (params or {}).items()})
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
if action == "dial.launch":
|
||||||
|
return await loop.run_in_executor(None, _do_dial_launch, params, devices)
|
||||||
|
if action == "wol":
|
||||||
|
return await loop.run_in_executor(None, _do_wol, params)
|
||||||
|
if action in ("http.get", "http.post"):
|
||||||
|
return await loop.run_in_executor(None, _do_http, action, params)
|
||||||
|
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_device(devices: list[dict], ref: str) -> Optional[dict]:
|
||||||
|
ref = (ref or "").strip().lower()
|
||||||
|
if not ref:
|
||||||
|
return None
|
||||||
|
for d in devices:
|
||||||
|
if d.get("id", "").lower() == ref or d.get("ip", "") == ref:
|
||||||
|
return d
|
||||||
|
for d in devices:
|
||||||
|
if ref in (d.get("name", "").lower()):
|
||||||
|
return d
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _do_dial_launch(params: dict, devices: list[dict]) -> dict:
|
||||||
|
"""DIAL-App-Launch, z.B. YouTube-Video auf Fire TV / Smart-TV.
|
||||||
|
params: {device, app='YouTube', v=<videoId> (oder beliebige app-params)}"""
|
||||||
|
import requests
|
||||||
|
ref = params.get("device") or ""
|
||||||
|
dev = _find_device(devices, ref)
|
||||||
|
app_url = (dev or {}).get("dialAppUrl") if dev else params.get("dialAppUrl")
|
||||||
|
if not app_url:
|
||||||
|
return {"ok": False, "error": f"Kein DIAL-Geraet fuer '{ref}' gefunden (oder keine Application-URL)."}
|
||||||
|
app = params.get("app") or "YouTube"
|
||||||
|
# app-Parameter (alles ausser device/app) als form-urlencoded Body.
|
||||||
|
body = {k: v for k, v in (params or {}).items() if k not in ("device", "app", "dialAppUrl")}
|
||||||
|
url = app_url.rstrip("/") + "/" + app
|
||||||
|
r = requests.post(url, data=body, timeout=5)
|
||||||
|
ok = r.status_code in (200, 201)
|
||||||
|
return {"ok": ok, "result": f"DIAL {app} → {(dev or {}).get('name', ref)} (HTTP {r.status_code})"
|
||||||
|
if ok else None,
|
||||||
|
"error": None if ok else f"DIAL-Launch HTTP {r.status_code}: {r.text[:120]}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _do_wol(params: dict) -> dict:
|
||||||
|
mac = (params.get("mac") or "").strip()
|
||||||
|
if not re.match(r"^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$", mac):
|
||||||
|
return {"ok": False, "error": f"Ungueltige MAC: {mac!r}"}
|
||||||
|
clean = re.sub(r"[:-]", "", mac)
|
||||||
|
packet = b"\xff" * 6 + bytes.fromhex(clean) * 16
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
|
s.sendto(packet, ("255.255.255.255", 9))
|
||||||
|
s.close()
|
||||||
|
return {"ok": True, "result": f"Wake-on-LAN an {mac} gesendet."}
|
||||||
|
|
||||||
|
|
||||||
|
def _do_http(action: str, params: dict) -> dict:
|
||||||
|
import requests
|
||||||
|
url = params.get("url") or ""
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
return {"ok": False, "error": "url (http/https) erforderlich."}
|
||||||
|
method = "GET" if action == "http.get" else "POST"
|
||||||
|
r = requests.request(method, url, data=params.get("body"),
|
||||||
|
headers=params.get("headers"), timeout=6)
|
||||||
|
return {"ok": True, "result": {"status": r.status_code, "body": r.text[:2000]}}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _slug(s: str) -> str:
|
||||||
|
s = (s or "").strip().lower()
|
||||||
|
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
|
||||||
|
return s or "dev"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_http_headers(text: str) -> dict:
|
||||||
|
headers = {}
|
||||||
|
for line in text.split("\r\n")[1:]:
|
||||||
|
if ":" in line:
|
||||||
|
k, _, v = line.partition(":")
|
||||||
|
headers[k.strip().lower()] = v.strip()
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _xml_tag(xml: str, tag: str) -> str:
|
||||||
|
m = re.search(rf"<{tag}>(.*?)</{tag}>", xml, re.IGNORECASE | re.DOTALL)
|
||||||
|
return m.group(1).strip() if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Satellit (RVS-Client) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
class Satellite:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.ws: Optional[websockets.WebSocketClientProtocol] = None
|
||||||
|
self._devices: list[dict] = []
|
||||||
|
self._devices_ts: float = 0.0
|
||||||
|
self._scanning = False
|
||||||
|
|
||||||
|
async def _scan(self, force: bool = False) -> list[dict]:
|
||||||
|
fresh = (time.time() - self._devices_ts) < DEVICE_CACHE_TTL_SEC
|
||||||
|
if self._devices and fresh and not force:
|
||||||
|
return self._devices
|
||||||
|
if self._scanning:
|
||||||
|
# Laufenden Scan abwarten (grob)
|
||||||
|
for _ in range(30):
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
if not self._scanning:
|
||||||
|
break
|
||||||
|
return self._devices
|
||||||
|
self._scanning = True
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
|
||||||
|
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
|
||||||
|
arp = await loop.run_in_executor(None, _discover_arp)
|
||||||
|
self._devices = _merge_devices(mdns, ssdp, arp)
|
||||||
|
self._devices_ts = time.time()
|
||||||
|
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d)",
|
||||||
|
len(self._devices), len(mdns), len(ssdp), len(arp))
|
||||||
|
finally:
|
||||||
|
self._scanning = False
|
||||||
|
return self._devices
|
||||||
|
|
||||||
|
async def _send(self, message: dict) -> None:
|
||||||
|
if self.ws is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self.ws.send(json.dumps(message))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("send fehlgeschlagen: %s", exc)
|
||||||
|
|
||||||
|
async def _hello(self, log: bool = False) -> None:
|
||||||
|
await self._send({
|
||||||
|
"type": "sat_hello",
|
||||||
|
"payload": {
|
||||||
|
"id": SATELLITE_ID,
|
||||||
|
"location": SATELLITE_LOCATION,
|
||||||
|
"caps": CAPABILITIES,
|
||||||
|
"control": CONTROL_ENABLED,
|
||||||
|
"net": NET,
|
||||||
|
},
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
})
|
||||||
|
if log:
|
||||||
|
logger.info("sat_hello gesendet: id=%s location=%s caps=%s",
|
||||||
|
SATELLITE_ID, SATELLITE_LOCATION, CAPABILITIES)
|
||||||
|
|
||||||
|
def _for_me(self, payload: dict) -> bool:
|
||||||
|
target = (payload.get("satellite") or "").strip().lower()
|
||||||
|
if not target or target in ("all", "*"):
|
||||||
|
return True
|
||||||
|
return target in (SATELLITE_ID.lower(), SATELLITE_LOCATION.lower())
|
||||||
|
|
||||||
|
async def _handle(self, raw: str) -> None:
|
||||||
|
try:
|
||||||
|
msg = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
mtype = msg.get("type", "")
|
||||||
|
payload = msg.get("payload", {}) or {}
|
||||||
|
|
||||||
|
if mtype == "sat_discover":
|
||||||
|
if not self._for_me(payload):
|
||||||
|
return
|
||||||
|
req_id = payload.get("requestId", "")
|
||||||
|
devices = await self._scan(force=bool(payload.get("force")))
|
||||||
|
await self._send({
|
||||||
|
"type": "sat_devices",
|
||||||
|
"payload": {"requestId": req_id, "satellite": SATELLITE_ID,
|
||||||
|
"location": SATELLITE_LOCATION, "devices": devices,
|
||||||
|
"net": NET},
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
elif mtype == "sat_command":
|
||||||
|
if not self._for_me(payload):
|
||||||
|
return
|
||||||
|
req_id = payload.get("requestId", "")
|
||||||
|
action = (payload.get("action") or "").strip()
|
||||||
|
params = payload.get("params") or {}
|
||||||
|
if payload.get("device") and "device" not in params:
|
||||||
|
params["device"] = payload.get("device")
|
||||||
|
devices = await self._scan()
|
||||||
|
result = await _control(action, params, devices)
|
||||||
|
await self._send({
|
||||||
|
"type": "sat_result",
|
||||||
|
"payload": {"requestId": req_id, "satellite": SATELLITE_ID, **result},
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _periodic_scan(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self._scan(force=True)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("periodischer Scan-Fehler: %s", exc)
|
||||||
|
await asyncio.sleep(SCAN_INTERVAL_SEC)
|
||||||
|
|
||||||
|
async def _heartbeat(self) -> None:
|
||||||
|
# Re-announce bei jedem Heartbeat: falls die Bridge NACH uns (neu)
|
||||||
|
# verbindet, lernt sie uns so innerhalb von HEARTBEAT_SEC — RVS replayt
|
||||||
|
# nichts. Haelt zugleich last_seen in der Bridge-Registry frisch.
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(HEARTBEAT_SEC)
|
||||||
|
await self._send({"type": "heartbeat", "timestamp": int(time.time() * 1000)})
|
||||||
|
await self._hello()
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
if not RVS_HOST or not RVS_TOKEN:
|
||||||
|
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
|
||||||
|
return
|
||||||
|
asyncio.create_task(self._periodic_scan())
|
||||||
|
backoff = 1
|
||||||
|
while True:
|
||||||
|
proto = "wss" if RVS_TLS else "ws"
|
||||||
|
url = f"{proto}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||||
|
try:
|
||||||
|
logger.info("Verbinde mit RVS %s://%s:%s …", proto, RVS_HOST, RVS_PORT)
|
||||||
|
async with websockets.connect(url, max_size=8 * 1024 * 1024,
|
||||||
|
ping_interval=20, ping_timeout=20) as ws:
|
||||||
|
self.ws = ws
|
||||||
|
backoff = 1
|
||||||
|
await self._hello(log=True)
|
||||||
|
hb = asyncio.create_task(self._heartbeat())
|
||||||
|
try:
|
||||||
|
async for raw in ws:
|
||||||
|
await self._handle(raw)
|
||||||
|
finally:
|
||||||
|
hb.cancel()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("RVS-Verbindung verloren: %s", exc)
|
||||||
|
finally:
|
||||||
|
self.ws = None
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
backoff = min(backoff * 2, 30)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logger.info("ARIA Satellit startet — id=%s location=%s control=%s",
|
||||||
|
SATELLITE_ID, SATELLITE_LOCATION, CONTROL_ENABLED)
|
||||||
|
logger.info("Netz: primary_ip=%s alle=%s", NET.get("primary_ip"), NET.get("ips"))
|
||||||
|
if NET.get("warning"):
|
||||||
|
logger.warning("⚠ %s", NET["warning"])
|
||||||
|
try:
|
||||||
|
asyncio.run(Satellite().run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user