User Tools

Site Tools


ionpy:project_rodemap
no way to compare when less than two revisions

Differences

This shows you the differences between two versions of the page.


—ionpy:project_rodemap [2026/02/28 09:36] (current) – created dominik
Line 1: Line 1:
 +====== ionpy Pro OS — Entwicklungs-Fahrplan & Opus-Einstieg ======
  
 +Erstellt: 2026-02-28\\
 +Basis: Architektur-Analyse & Konzept-Session mit Claude Sonnet\\
 +Nächster Schritt: Umsetzung mit Claude Opus
 +
 +----
 +
 +===== Inhaltsverzeichnis =====
 +
 +  - Kontext & Ausgangslage
 +  - Was in dieser Session entschieden wurde
 +  - Gesamtarchitektur (Zielzustand)
 +  - Fahrplan: Phase für Phase
 +  - Prompts für Claude Opus
 +  - Offene Fragen & Entscheidungen
 +
 +----
 +
 +====== 1. Kontext & Ausgangslage ======
 +
 +ionpy ist ein Python/FastAPI Backend mit Vue 3 Frontend zur Steuerung und Visualisierung von Laborgeräten. Die Architektur folgt dem Muster:
 +
 +<code>
 +Transport → Framing → Device (Entity-ORM) → EventBus → WebSocket → Vue Frontend
 +</code>
 +
 +**Was bereits sehr gut funktioniert:**
 +  * Deklaratives Entity-ORM (vergleichbar Django Models)
 +  * Dynamisches Treiber-Loading via importlib
 +  * Transport/Framing Stack mit Factory & Registry
 +  * GridStack-basiertes Widget-Dashboard
 +  * uPlot Charts mit Plugin-Architektur
 +  * Workspace-Serialisierung (JSON, pro Widget exportState())
 +
 +**Was fehlt und gebaut werden muss:**
 +  * Projekt-Konzept als übergeordnete Klammer
 +  * Persistenz (SQLite, strukturiert)
 +  * Device-Anlegen über die UI (aktuell nur YAML)
 +  * Alarm-Routing (Berechnung läuft, aber nirgendwo hin)
 +  * Session-Management (existiert, aber nicht wirklich genutzt)
 +
 +----
 +
 +====== 2. Was in dieser Session entschieden wurde ======
 +
 +===== 2.1 Das Projekt-Konzept =====
 +
 +Die drei bisher getrennten Systeme — Config (YAML), Session (Backend), Workspace (Frontend) — werden zu einem **Projekt** zusammengefasst.
 +
 +Ein Projekt ist ein Experiment-Container. Alles was zu einem Messaufbau gehört, lebt zusammen:
 +
 +<code>
 +projects/
 +└── akku-charakterisierung/
 +    ├── project.json       ← Metadaten, Klammer
 +    ├── devices.json       ← Welche Geräte, wie angeschlossen
 +    ├── workspace.json     ← UI-Layout (bestehendes Format)
 +    ├── data.db            ← EINE SQLite DB für das gesamte Projekt
 +    ├── scripts/           ← Automations-Scripte
 +    │   └── charge_test.py
 +    └── exports/           ← Alle Exports, egal welche Session
 +        └── PSU_01_session1.csv
 +</code>
 +
 +===== 2.2 Die JSON-Strukturen =====
 +
 +==== project.json ====
 +
 +<code json>
 +{
 +  "schema_version": 1,
 +  "id": "a3f8c2d1-4b5e-4f6a-9c2d-1e7f8b3a2c5d",
 +  "name": "Akku-Charakterisierung 18650",
 +  "description": "Kapazitätsmessung nach Lagerung bei verschiedenen Temperaturen",
 +  "created": "2026-01-15T09:23:00",
 +  "last_opened": "2026-02-28T08:12:00",
 +  "author": "Marcus",
 +  "tags": ["battery", "18650", "characterization"],
 +  "active_session": "b7e2a1f4-3c8d-4e9b-a2f5-6d1c8e4b3a7f",
 +  "files": {
 +    "devices": "devices.json",
 +    "workspace": "workspace.json"
 +  },
 +  "notes": "Zellen wurden 30 Tage bei 10°C gelagert."
 +}
 +</code>
 +
 +**Warum schema_version?** Spätere Format-Änderungen können automatisch migriert werden.\\
 +**Warum UUID als id?** "Copy devices from Project XY" referenziert über ID, nicht über Namen.\\
 +**Warum files-Block?** Flexibilität — theoretisch kann jedes Projekt eine andere Workspace-Datei haben.
 +
 +==== devices.json ====
 +
 +<code json>
 +{
 +  "schema_version": 1,
 +  "devices": [
 +    {
 +      "id": "PSU_01",
 +      "driver": "rd6012",
 +      "enabled": true,
 +      "alias": "Lade-PSU",
 +      "color": "#0ea5e9",
 +      "icon": "power-plug",
 +      "location": "Rack links, Shelf 2",
 +      "auto_start": true,
 +      "transport": {
 +        "type": "serial",
 +        "port": "COM3",
 +        "baudrate": 115200,
 +        "port_hint": {
 +          "vid": "0x1A86",
 +          "pid": "0x7523",
 +          "description": "USB-Serial CH340"
 +        }
 +      },
 +      "polling_interval_ms": 500,
 +      "params": {
 +        "modbus_address": 1
 +      },
 +      "logging": {
 +        "override_level": null
 +      }
 +    }
 +  ]
 +}
 +</code>
 +
 +**Wichtige Entscheidungen in dieser Struktur:**
 +  * ''id'' ist intern und unveränderlich (wird in DB referenziert)
 +  * ''alias'' ist was der User sieht und ändern darf
 +  * ''color'' und ''icon'' gehören zur Device-Definition, nicht zum Workspace
 +  * ''port_hint'' mit VID/PID ist die Lösung für Portabilität auf anderen Rechnern
 +  * ''params'' ist ein freies Objekt für treiberspezifische Konfiguration (Modbus-Adresse etc.)
 +
 +===== 2.3 Das Datenbank-Schema =====
 +
 +**Entscheidung: Eine DB pro Projekt, eine Tabelle pro Device.**
 +
 +Begründung:
 +  * Eine DB → Session-übergreifende Abfragen ohne JOIN über Dateien
 +  * Eine Tabelle pro Device → direkte Spalten statt EAV oder JSON-Blobs
 +  * Jede Spalte = eine Entity → maximal einfache, schnelle Abfragen
 +  * Dynamisches Schema → Spalten werden beim Treiber-Start angelegt/ergänzt
 +
 +<code sql>
 +-- Wird beim ersten Start des Projekts angelegt
 +CREATE TABLE sessions (
 +    id          TEXT PRIMARY KEY,
 +    name        TEXT NOT NULL,
 +    created     REAL NOT NULL,
 +    stopped     REAL,
 +    status      TEXT DEFAULT 'running',
 +    conditions  TEXT,    -- JSON blob: Temperatur, Luftfeuchte, Notizen
 +    notes       TEXT
 +);
 +
 +-- Zeitmarker für Chart-Annotation und Daten-Segmentierung
 +CREATE TABLE markers (
 +    id          INTEGER PRIMARY KEY AUTOINCREMENT,
 +    timestamp   REAL NOT NULL,
 +    session_id  TEXT NOT NULL,
 +    device_id   TEXT,            -- NULL = gilt für alle Devices
 +    label       TEXT NOT NULL,   -- z.B. "Ladung Start", "Entladung Start"
 +    color       TEXT DEFAULT '#f59e0b',
 +    notes       TEXT
 +);
 +
 +-- Alarm-Verlauf
 +CREATE TABLE alarms (
 +    id          INTEGER PRIMARY KEY AUTOINCREMENT,
 +    timestamp   REAL NOT NULL,
 +    session_id  TEXT NOT NULL,
 +    device_id   TEXT NOT NULL,
 +    entity_id   TEXT NOT NULL,
 +    old_state   TEXT,
 +    new_state   TEXT,
 +    value       REAL,
 +    acknowledged INTEGER DEFAULT 0,
 +    ack_time    REAL,
 +    ack_by      TEXT
 +);
 +
 +-- Export-Historie
 +CREATE TABLE exports (
 +    id          INTEGER PRIMARY KEY AUTOINCREMENT,
 +    created     REAL NOT NULL,
 +    session_id  TEXT,            -- NULL = ganzes Projekt
 +    format      TEXT,
 +    filename    TEXT,
 +    device_id   TEXT,
 +    entity_id   TEXT
 +);
 +
 +-- Device-Tabellen werden DYNAMISCH angelegt:
 +-- CREATE TABLE device_PSU_01 (
 +--     id          INTEGER PRIMARY KEY AUTOINCREMENT,
 +--     timestamp   REAL NOT NULL,
 +--     session_id  TEXT NOT NULL,
 +--     u_out       REAL,
 +--     i_out       REAL,
 +--     p_out       REAL,
 +--     output      INTEGER,
 +--     alarm_state TEXT DEFAULT 'NORMAL'
 +-- );
 +-- CREATE INDEX idx_PSU_01 ON device_PSU_01(session_id, timestamp);
 +</code>
 +
 +**Abfrage-Beispiele:**
 +
 +<code sql>
 +-- Chart: 100 Spannungswerte einer Session
 +SELECT timestamp, u_out, i_out
 +FROM device_PSU_01
 +WHERE session_id = 'abc123'
 +AND timestamp BETWEEN 1706000000 AND 1706003600
 +ORDER BY timestamp;
 +
 +-- Session-Vergleich: Max-Spannung pro Session
 +SELECT session_id, MAX(u_out), MIN(u_out), AVG(i_out)
 +FROM device_PSU_01
 +GROUP BY session_id;
 +
 +-- Daten zwischen zwei Markern (Lade-Segment)
 +SELECT d.timestamp, d.u_out, d.i_out
 +FROM device_PSU_01 d
 +WHERE d.session_id = 'abc123'
 +AND d.timestamp BETWEEN
 +    (SELECT timestamp FROM markers WHERE label='Ladung Start' AND session_id='abc123')
 +    AND
 +    (SELECT timestamp FROM markers WHERE label='Ladung Ende' AND session_id='abc123');
 +</code>
 +
 +----
 +
 +====== 3. Gesamtarchitektur (Zielzustand) ======
 +
 +<code>
 +┌─────────────────────────────────────────────────────────────┐
 +│                      Vue 3 Frontend                         │
 +│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐  │
 +│  │ Projekt- │ │ Device-  │ │Dashboard │ │  AlarmView /  │  │
 +│  │ Manager  │ │ Wizard   │ │(wie jetzt│ │  HistoryView  │  │
 +│  └──────────┘ └──────────┘ └──────────┘ └───────────────┘  │
 +│                    globalStore (reactive)                   │
 +│                         │ WebSocket                        │
 +├─────────────────────────┼───────────────────────────────────┤
 +│                    FastAPI Server                           │
 +│  ┌──────────────┐  ┌────────────┐  ┌─────────────────────┐ │
 +│  │  REST API    │  │  WS Route  │  │  Static Files       │ │
 +│  │ /api/projects│  │            │  │                     │ │
 +│  └──────┬───────┘  └─────┬──────┘  └─────────────────────┘ │
 +│         └────────────────┘                                  │
 +│              SystemEngine                                   │
 +│    ┌──────────────┬───────────────┬────────────────┐        │
 +│  ProjectManager  DeviceManager  AlarmManager       │        │
 +│    │              │               │                │        │
 +│  project.json  devices.json    EventBus            │        │
 +│  sessions      Treiber-Loading  (Topic-Filter)     │        │
 +│    │                                               │        │
 +│  ProjectDatabase (SQLite)                          │        │
 +│    ├── sessions                                    │        │
 +│    ├── markers                                     │        │
 +│    ├── alarms                                      │        │
 +│    └── device_XXX (dynamisch)                      │        │
 +└─────────────────────────────────────────────────────────────┘
 +</code>
 +
 +----
 +
 +====== 4. Fahrplan: Phase für Phase ======
 +
 +===== Phase 1: SQLite Backend (Fundament) =====
 +
 +**Warum zuerst?** Ohne Persistenz ergibt das Projekt-Konzept keinen Sinn. Die DB-Struktur muss stehen bevor der ProjectManager gebaut wird, weil ''data.db'' in den Projekt-Ordner gehört.
 +
 +**Geschätzter Aufwand: 2 Tage**
 +
 +^ Aufgabe ^ Datei ^ Details ^
 +| ProjectDatabase Klasse | ''core/project_database.py'' | Persistente Connection, WAL-Mode, Batch-Insert |
 +| ensure_device_table() | ''core/project_database.py'' | Dynamisches Schema, ALTER TABLE für neue Entities |
 +| Storage Worker in Engine | ''core/engine.py'' | Parallel zum Cache-Worker, Batch alle 500ms |
 +| Marker-API | ''core/project_database.py'' | add_marker(), get_markers() |
 +| History REST Endpoint | ''web/rest.py'' | GET /api/devices/{id}/history mit Zeitbereich |
 +
 +**Kern-Klasse:**
 +
 +<code python>
 +# core/project_database.py
 +
 +import aiosqlite
 +import asyncio
 +import logging
 +import time
 +import os
 +from typing import Optional
 +
 +class ProjectDatabase:
 +    """
 +    Eine SQLite-Datenbank pro Projekt.
 +    Eine Tabelle pro Device, Spalten = Entities.
 +    Dynamisches Schema-Management.
 +    """
 +    
 +    def __init__(self, project_path: str):
 +        self.db_path = os.path.join(project_path, "data.db")
 +        self._conn: Optional[aiosqlite.Connection] = None
 +        self._batch: list = []
 +        self._flush_interval = 0.5
 +        self.logger = logging.getLogger("ProjectDatabase")
 +    
 +    async def initialize(self):
 +        self._conn = await aiosqlite.connect(self.db_path)
 +        self._conn.row_factory = aiosqlite.Row
 +        # WAL-Mode: bessere Concurrent-Performance, kein Full-Lock beim Schreiben
 +        await self._conn.execute("PRAGMA journal_mode=WAL")
 +        await self._conn.execute("PRAGMA synchronous=NORMAL")
 +        await self._create_base_schema()
 +        asyncio.create_task(self._flush_loop())
 +        self.logger.info(f"Datenbank geöffnet: {self.db_path}")
 +    
 +    async def _create_base_schema(self):
 +        await self._conn.executescript("""
 +            CREATE TABLE IF NOT EXISTS sessions (
 +                id       TEXT PRIMARY KEY,
 +                name     TEXT NOT NULL,
 +                created  REAL NOT NULL,
 +                stopped  REAL,
 +                status   TEXT DEFAULT 'running',
 +                conditions TEXT,
 +                notes    TEXT
 +            );
 +            CREATE TABLE IF NOT EXISTS markers (
 +                id         INTEGER PRIMARY KEY AUTOINCREMENT,
 +                timestamp  REAL NOT NULL,
 +                session_id TEXT NOT NULL,
 +                device_id  TEXT,
 +                label      TEXT NOT NULL,
 +                color      TEXT DEFAULT '#f59e0b',
 +                notes      TEXT
 +            );
 +            CREATE TABLE IF NOT EXISTS alarms (
 +                id           INTEGER PRIMARY KEY AUTOINCREMENT,
 +                timestamp    REAL NOT NULL,
 +                session_id   TEXT NOT NULL,
 +                device_id    TEXT NOT NULL,
 +                entity_id    TEXT NOT NULL,
 +                old_state    TEXT,
 +                new_state    TEXT,
 +                value        REAL,
 +                acknowledged INTEGER DEFAULT 0,
 +                ack_time     REAL,
 +                ack_by       TEXT
 +            );
 +            CREATE TABLE IF NOT EXISTS exports (
 +                id         INTEGER PRIMARY KEY AUTOINCREMENT,
 +                created    REAL NOT NULL,
 +                session_id TEXT,
 +                format     TEXT,
 +                filename   TEXT,
 +                device_id  TEXT,
 +                entity_id  TEXT
 +            );
 +        """)
 +        await self._conn.commit()
 +    
 +    async def ensure_device_table(self, device_id: str, entities: dict):
 +        """
 +        Erstellt Device-Tabelle wenn sie nicht existiert.
 +        Fügt fehlende Spalten hinzu wenn Device erweitert wurde.
 +        """
 +        safe_id = device_id.replace("-", "_").replace(".", "_")
 +        table = f"device_{safe_id}"
 +        
 +        base_cols = [
 +            "id INTEGER PRIMARY KEY AUTOINCREMENT",
 +            "timestamp REAL NOT NULL",
 +            "session_id TEXT NOT NULL",
 +            "alarm_state TEXT DEFAULT 'NORMAL'"
 +        ]
 +        
 +        entity_cols = []
 +        for eid, entity in entities.items():
 +            t = entity.__class__.__name__
 +            if t == "NumericEntity":
 +                entity_cols.append(f"{eid} REAL")
 +            elif t == "ToggleEntity":
 +                entity_cols.append(f"{eid} INTEGER")
 +            else:
 +                entity_cols.append(f"{eid} TEXT")
 +        
 +        all_cols = base_cols + entity_cols
 +        await self._conn.execute(
 +            f"CREATE TABLE IF NOT EXISTS {table} ({', '.join(all_cols)})"
 +        )
 +        await self._conn.execute(
 +            f"CREATE INDEX IF NOT EXISTS idx_{safe_id} ON {table}(session_id, timestamp)"
 +        )
 +        
 +        # Fehlende Spalten ergänzen (Treiber wurde erweitert)
 +        async with self._conn.execute(f"PRAGMA table_info({table})") as cur:
 +            existing = {row[1] async for row in cur}
 +        
 +        for eid, entity in entities.items():
 +            if eid not in existing:
 +                col_type = "REAL" if entity.__class__.__name__ == "NumericEntity" else "TEXT"
 +                await self._conn.execute(
 +                    f"ALTER TABLE {table} ADD COLUMN {eid} {col_type}"
 +                )
 +                self.logger.info(f"Neue Spalte: {table}.{eid}")
 +        
 +        await self._conn.commit()
 +    
 +    def queue_snapshot(self, device_id: str, session_id: str,
 +                        timestamp: float, values: dict,
 +                        alarm_state: str = "NORMAL"):
 +        """Fügt Snapshot zur Batch-Queue hinzu. Nicht-blockierend."""
 +        self._batch.append({
 +            "device_id": device_id,
 +            "session_id": session_id,
 +            "timestamp": timestamp,
 +            "values": values,
 +            "alarm_state": alarm_state
 +        })
 +    
 +    async def _flush_loop(self):
 +        """Schreibt alle 500ms gesammelte Snapshots in einem Batch."""
 +        while True:
 +            await asyncio.sleep(self._flush_interval)
 +            if not self._batch:
 +                continue
 +            batch, self._batch = self._batch, []
 +            try:
 +                for snap in batch:
 +                    safe_id = snap["device_id"].replace("-","_").replace(".","_")
 +                    table = f"device_{safe_id}"
 +                    cols = ["timestamp","session_id","alarm_state"] + list(snap["values"].keys())
 +                    vals = [snap["timestamp"], snap["session_id"], snap["alarm_state"]] + list(snap["values"].values())
 +                    ph = ",".join(["?"]*len(cols))
 +                    await self._conn.execute(
 +                        f"INSERT INTO {table} ({','.join(cols)}) VALUES ({ph})", vals
 +                    )
 +                await self._conn.commit()
 +            except Exception as e:
 +                self.logger.error(f"Flush-Fehler: {e}")
 +    
 +    async def query_device(self, device_id: str, entities: list,
 +                            session_id: str = None,
 +                            start: float = None, end: float = None,
 +                            limit: int = None) -> list:
 +        """
 +        Liest Messwerte aus einer Device-Tabelle.
 +        entities: Liste der gewünschten Entity-IDs (leere Liste = alle)
 +        """
 +        safe_id = device_id.replace("-","_").replace(".","_")
 +        table = f"device_{safe_id}"
 +        
 +        cols = ", ".join(["timestamp"] + entities) if entities else "*"
 +        where = []
 +        params = []
 +        
 +        if session_id:
 +            where.append("session_id = ?")
 +            params.append(session_id)
 +        if start:
 +            where.append("timestamp >= ?")
 +            params.append(start)
 +        if end:
 +            where.append("timestamp <= ?")
 +            params.append(end)
 +        
 +        sql = f"SELECT {cols} FROM {table}"
 +        if where:
 +            sql += " WHERE " + " AND ".join(where)
 +        sql += " ORDER BY timestamp"
 +        if limit:
 +            sql += f" LIMIT {limit}"
 +        
 +        async with self._conn.execute(sql, params) as cur:
 +            rows = await cur.fetchall()
 +            return [dict(r) for r in rows]
 +    
 +    async def add_marker(self, session_id: str, label: str,
 +                          timestamp: float = None, device_id: str = None,
 +                          color: str = "#f59e0b", notes: str = None):
 +        ts = timestamp or time.time()
 +        await self._conn.execute(
 +            "INSERT INTO markers (timestamp, session_id, device_id, label, color, notes) VALUES (?,?,?,?,?,?)",
 +            (ts, session_id, device_id, label, color, notes)
 +        )
 +        await self._conn.commit()
 +    
 +    async def close(self):
 +        if self._batch:
 +            await self._flush_loop()
 +        if self._conn:
 +            await self._conn.close()
 +</code>
 +
 +----
 +
 +===== Phase 2: ProjectManager Backend =====
 +
 +**Warum jetzt?** SQLite steht, jetzt kann die Klammer drumherum gebaut werden.
 +
 +**Geschätzter Aufwand: 2-3 Tage**
 +
 +^ Aufgabe ^ Datei ^ Details ^
 +| ProjectManager Klasse | ''core/project_manager.py'' | Ordner, JSONs, Sessions verwalten |
 +| Engine umbauen | ''core/engine.py'' | Nimmt Project statt config_path |
 +| DeviceManager umbauen | ''core/device_manager.py'' | Liest devices.json statt YAML |
 +| Migrations-Script | ''tools/migrate_config.py'' | YAML → Projekt-Struktur konvertieren |
 +| REST Endpoints | ''web/rest.py'' | Projekt CRUD, Session Start/Stop |
 +
 +**Wichtig:** Der bestehende ''_spawn_device()'' bleibt unverändert — er bekommt weiterhin ein Dict. Nur die Quelle ändert sich von YAML zu JSON.
 +
 +**Migration von YAML zu Projekt:**
 +
 +<code python>
 +# tools/migrate_config.py
 +"""
 +Konvertiert eine bestehende config.yaml in ein ionpy-Projekt.
 +Aufruf: python -m tools.migrate_config --config config.yaml --output projects/mein-projekt
 +"""
 +
 +import yaml
 +import json
 +import os
 +import uuid
 +from datetime import datetime
 +
 +def migrate(config_path: str, output_path: str, project_name: str):
 +    with open(config_path, 'r') as f:
 +        old_config = yaml.safe_load(f)
 +    
 +    os.makedirs(output_path, exist_ok=True)
 +    os.makedirs(os.path.join(output_path, "scripts"), exist_ok=True)
 +    os.makedirs(os.path.join(output_path, "exports"), exist_ok=True)
 +    
 +    # project.json
 +    project = {
 +        "schema_version": 1,
 +        "id": str(uuid.uuid4()),
 +        "name": project_name,
 +        "created": datetime.now().isoformat(),
 +        "last_opened": datetime.now().isoformat(),
 +        "files": {
 +            "devices": "devices.json",
 +            "workspace": "workspace.json"
 +        }
 +    }
 +    
 +    # devices.json — aus YAML-Devices konvertieren
 +    devices = []
 +    for dev in old_config.get("devices", []):
 +        devices.append({
 +            "id": dev.get("id"),
 +            "driver": dev.get("driver"),
 +            "enabled": dev.get("enabled", True),
 +            "alias": dev.get("alias", dev.get("id")),
 +            "color": dev.get("color", "#0ea5e9"),
 +            "auto_start": dev.get("auto_start", True),
 +            "transport": dev.get("transport", {}),
 +            "polling_interval_ms": dev.get("polling_interval_ms", 1000),
 +            "params": {k: v for k, v in dev.items()
 +                      if k not in ["id","driver","enabled","alias","color",
 +                                   "auto_start","transport","polling_interval_ms"]}
 +        })
 +    
 +    with open(os.path.join(output_path, "project.json"), 'w') as f:
 +        json.dump(project, f, indent=2)
 +    
 +    with open(os.path.join(output_path, "devices.json"), 'w') as f:
 +        json.dump({"schema_version": 1, "devices": devices}, f, indent=2)
 +    
 +    print(f"Projekt angelegt: {output_path}")
 +    print(f"Bitte workspace.json manuell in {output_path} kopieren.")
 +</code>
 +
 +----
 +
 +===== Phase 3: Minimale Projekt-UI =====
 +
 +**Motto: Es muss funktionieren, nicht schön sein.**
 +
 +**Geschätzter Aufwand: 1 Tag**
 +
 +Ein Modal beim Start das fragt "Welches Projekt?":
 +
 +<code>
 +┌─────────────────────────────────────────┐
 +│  ionpy Pro OS — Projekt wählen          │
 +├─────────────────────────────────────────┤
 +│  ► Akku-Charakterisierung               │
 +│    Zuletzt geöffnet: heute 08:12        │
 +│                                         │
 +│  ► LED-Treiber Test                     │
 +│    Zuletzt geöffnet: 15.02.2026         │
 +│                                         │
 +│  + Neues Projekt anlegen                │
 +└─────────────────────────────────────────┘
 +</code>
 +
 +Neue REST Endpoints dafür:
 +
 +<code>
 +GET  /api/projects              → alle Projekte auflisten
 +POST /api/projects              → neues Projekt anlegen
 +POST /api/projects/{id}/open    → Projekt aktivieren (Engine reload)
 +GET  /api/projects/active       → aktuell geladenes Projekt
 +</code>
 +
 +**In dieser Phase:** Devices werden noch per JSON-Editor im Browser hinzugefügt (Übergangslösung). Das ist nicht komfortabel, aber es entblockt die weitere Entwicklung.
 +
 +----
 +
 +===== Phase 4: Device-Wizard =====
 +
 +**Geschätzter Aufwand: 3-4 Tage**
 +
 +Das ist der wichtigste UX-Schritt. Der Workflow:
 +
 +<code>
 +Schritt 1: Treiber wählen
 +  → Dropdown aus GET /api/config/drivers (bereits vorhanden!)
 +  → Zeigt Beschreibung aus dem Docstring
 +  → Zeigt welche Transporte unterstützt werden (STACK_BLUEPRINTS)
 +
 +Schritt 2: Instanz konfigurieren
 +  → ID, Alias, Farbe (immer gleich)
 +  → Transport-Typ wählen (Serial / TCP / ...)
 +  → Wenn Serial: Port-Dropdown aus GET /api/inventory/tools/serial
 +  → Wenn Serial: port_hint wird automatisch aus VID/PID gesetzt
 +  → Treiber-spezifische params (dynamisch aus driver.parameters)
 +
 +Schritt 3: Verbindung testen
 +  → "Verbinden" Button → sofortiges Feedback
 +  → Zeigt ersten Messwert wenn erfolgreich
 +
 +Schritt 4: Speichern
 +  → Landet in devices.json
 +  → Device wird live gespawnt (kein Server-Neustart nötig!)
 +</code>
 +
 +**"Copy devices from Project XY":**
 +
 +<code>
 +GET /api/projects → Liste aller Projekte
 +→ User wählt Quell-Projekt
 +→ GET /api/projects/{id}/devices → devices.json des Quell-Projekts
 +→ Checkbox-Liste: welche Devices übernehmen?
 +→ POST /api/projects/active/devices/import
 +→ Ports müssen ggf. angepasst werden (Dialog)
 +</code>
 +
 +----
 +
 +===== Phase 5: Quick-Wins (parallel zu jeder Phase machbar) =====
 +
 +Diese Aufgaben sind unabhängig und können jederzeit zwischendurch erledigt werden:
 +
 +^ Aufgabe ^ Aufwand ^ Datei ^
 +| Exponential Backoff beim Reconnect | 1h | ''devices/transport/base.py'' |
 +| Store.js State als Map (Race-Condition) | 1h | ''static/js/store.js'' |
 +| System-Status Endpoint (/api/system/status) | 2h | ''web/rest.py'' |
 +| EventBus Topic-Filtering | 2h | ''core/event_bus.py'' |
 +| EnumEntity + BitmaskEntity | 3h | ''structures/entities/logic.py'' |
 +
 +**Exponential Backoff (fast fertig, nur einsetzen):**
 +
 +<code python>
 +async def reconnect(self, max_retries: int = 10,
 +                    initial_delay: float = 2.0,
 +                    max_delay: float = 60.0,
 +                    backoff_factor: float = 1.5) -> bool:
 +    delay = initial_delay
 +    for attempt in range(1, max_retries + 1):
 +        try:
 +            await self.disconnect()
 +            if self.check_hardware_exists():
 +                if await self.connect():
 +                    self.logger.info(f"Wiederverbunden nach {attempt} Versuch(en)")
 +                    return True
 +        except Exception as e:
 +            self.logger.debug(f"Reconnect #{attempt} Fehler: {e}")
 +        
 +        self.logger.info(f"Versuch {attempt} fehlgeschlagen. Nächster in {delay:.1f}s")
 +        await asyncio.sleep(delay)
 +        delay = min(delay * backoff_factor, max_delay)
 +    
 +    return False
 +</code>
 +
 +----
 +
 +===== Phase 6: AlarmManager =====
 +
 +**Warum hier?** Alarm-Berechnung läuft bereits in ''_evaluate_alarms()''. Die Hälfte ist schon da.
 +
 +**Geschätzter Aufwand: 3-4 Tage**
 +
 +Was hinzukommt:
 +  * ''core/alarm_manager.py'' — AlarmEvent, Active-Alarms, History, Callbacks
 +  * Verknüpfung mit ''_evaluate_alarms()'' in den Entity-Klassen
 +  * Alarms in ''ProjectDatabase.alarms'' speichern
 +  * WebSocket-Event wenn Alarm auftritt (neuer Message-Type)
 +  * Frontend AlarmView: aktive Alarme, History, Quittierung
 +  * Marker in DB setzen wenn Alarm ausgelöst wird ("ALARM: OVP PSU_01")
 +
 +----
 +
 +===== Phase 7: Session-Export =====
 +
 +**Geschätzter Aufwand: 2 Tage**
 +
 +<code>
 +GET /api/projects/active/sessions/{id}/export?format=csv&device=PSU_01
 +GET /api/projects/active/sessions/{id}/export?format=json
 +GET /api/projects/active/export?format=csv   ← ganzes Projekt
 +</code>
 +
 +Export landet automatisch in ''projects/xy/exports/'' und wird in der ''exports'' Tabelle geloggt.
 +
 +----
 +
 +===== Phase 8: Automation/Scripting =====
 +
 +**Achtung:** Den ''exec()''-basierten Ansatz aus der Roadmap-Analyse mit Vorsicht angehen.
 +
 +**Empfohlene Reihenfolge:**
 +  - Erst: **Rule-Engine** (YAML-basiert, Condition → Action) — sicher, einfach
 +  - Dann: **Python-Scripting** via exec() — nur für lokale, vertrauenswürdige Umgebung
 +
 +Scripts gehören in ''projects/xy/scripts/'' und sind Teil des Projekts.
 +
 +----
 +
 +====== 5. Prompts für Claude Opus ======
 +
 +Diese Prompts bauen aufeinander auf. Immer den relevanten Code als Attachment beifügen.
 +
 +===== Prompt 1: ProjectDatabase =====
 +
 +<code>
 +Ich entwickle "ionpy" - ein Python/FastAPI Backend zur Laborgerätesteuerung 
 +mit Vue 3 Frontend.
 +
 +Ich habe mich entschieden ein Projekt-Konzept einzuführen. Ein Projekt ist 
 +ein Ordner der alles enthält: devices.json (Gerätedefinitionen), workspace.json 
 +(UI-Layout) und data.db (SQLite für alle Messdaten).
 +
 +Ich hänge dir an:
 +- core/engine.py (die SystemEngine)
 +- core/device_manager.py
 +- structures/entities/ (das Entity-ORM)
 +
 +Bitte implementiere die Klasse ProjectDatabase in core/project_database.py 
 +mit folgenden Anforderungen:
 +
 +1. Eine SQLite-Datei pro Projekt (data.db im Projekt-Ordner)
 +2. Eine Tabelle pro Device - Spalten sind die Entity-IDs des Treibers
 +3. Methode ensure_device_table(device_id, entities_dict) die die Tabelle 
 +   dynamisch anlegt und bei neuen Entities ALTER TABLE ADD COLUMN ausführt
 +4. Batch-Insert via asyncio: queue_snapshot() ist nicht-blockierend, ein 
 +   _flush_loop() Task schreibt alle 500ms gesammelte Snapshots in einem 
 +   executemany()-Aufruf
 +5. WAL-Mode und PRAGMA synchronous=NORMAL für Performance
 +6. Basis-Tabellen: sessions, markers, alarms, exports (Schema siehe unten)
 +7. Methode query_device() mit optionalem Filter auf session_id, Zeitbereich,
 +   entity-Liste und LIMIT
 +8. Methode add_marker() für Chart-Annotationen
 +
 +Das Schema für die Basis-Tabellen: [sessions/markers/alarms/exports Schema hier einfügen]
 +
 +Bitte keinen neuen Connection-Handler pro Query - eine persistente Connection 
 +für die gesamte Laufzeit.
 +</code>
 +
 +===== Prompt 2: ProjectManager =====
 +
 +<code>
 +Ich habe jetzt die ProjectDatabase (core/project_database.py - angehängt).
 +
 +Bitte implementiere den ProjectManager in core/project_manager.py.
 +
 +Er verwaltet:
 +- projects/ Ordner-Struktur (ein Unterordner pro Projekt)
 +- project.json (Metadaten, schema_version, UUID, name, active_session)
 +- devices.json (Gerätedefinitionen, ersetzt die bisherige config.yaml)
 +- Schnittstelle zur ProjectDatabase
 +
 +Das Format von project.json und devices.json:
 +[JSONs hier einfügen]
 +
 +Wichtige Methoden:
 +- list_projects() → alle Projekte im projects/ Ordner
 +- create_project(name, description) → legt Ordner und JSONs an
 +- open_project(project_id) → lädt project.json und devices.json
 +- add_device(device_dict) → fügt Device zu devices.json hinzu und 
 +  validiert Pflichtfelder (id, driver, transport.type)
 +- remove_device(device_id) → entfernt aus devices.json
 +- start_session(name, conditions=None) → legt Session in DB an, 
 +  setzt active_session in project.json
 +- stop_session() → setzt stopped-Timestamp und status='completed'
 +- get_config_for_engine() → gibt das Dict zurück das SystemEngine 
 +  bisher als config_path erwartet hat (für Rückwärtskompatibilität)
 +
 +Bestehender Code den du NICHT verändern sollst:
 +- device_manager._spawn_device() bekommt weiterhin ein Dict
 +- Alles in structures/ bleibt unverändert
 +- Die YAML-basierte Config soll noch als Fallback funktionieren
 +</code>
 +
 +===== Prompt 3: Engine umbauen =====
 +
 +<code>
 +Ich habe jetzt ProjectManager und ProjectDatabase implementiert.
 +Ich hänge dir an: core/engine.py (aktueller Stand), core/project_manager.py
 +
 +Bitte passe die SystemEngine in core/engine.py an:
 +
 +1. __init__ nimmt jetzt optional einen project: ProjectManager Parameter
 +   - Wenn project übergeben: devices aus project.devices_path laden
 +   - Wenn nicht (Legacy): weiterhin config_path verwenden
 +   
 +2. Einen _storage_worker Task hinzufügen der parallel zum _cache_worker läuft:
 +   - Lauscht auf den EventBus
 +   - Ruft project.db.queue_snapshot() auf für jeden Sample
 +   - Filtert WaveformSamples heraus (die werden nicht in die DB geschrieben)
 +   - Nur aktiv wenn eine Session läuft (session_manager.is_recording)
 +
 +3. Bei engine.start(): ensure_device_table() für jedes Device aufrufen
 +   nachdem die Devices geladen wurden
 +
 +4. engine.stop(): project.db.close() aufrufen
 +
 +Wichtig: Der bestehende _cache_worker und der EventBus bleiben unverändert.
 +Der Storage Worker ist ein zusätzlicher Subscriber.
 +</code>
 +
 +===== Prompt 4: REST Endpoints für Projekt-Management =====
 +
 +<code>
 +Ich habe jetzt ProjectManager, ProjectDatabase und die angepasste Engine.
 +Angehängt: web/rest.py (aktuell), core/project_manager.py
 +
 +Bitte füge folgende REST Endpoints zu web/rest.py hinzu:
 +
 +Projekt-Management:
 +GET  /api/projects              → list_projects() 
 +POST /api/projects              → create_project(name, description)
 +GET  /api/projects/active       → aktuell geladenes Projekt
 +POST /api/projects/{id}/open    → Projekt wechseln (Engine neu initialisieren)
 +
 +Device-Management im aktiven Projekt:
 +GET    /api/projects/active/devices         → devices.json
 +POST   /api/projects/active/devices         → add_device()
 +DELETE /api/projects/active/devices/{id}    → remove_device()
 +
 +Session-Management:
 +GET  /api/projects/active/sessions         → alle Sessions aus DB
 +POST /api/projects/active/sessions/start   → start_session(name, conditions)
 +POST /api/projects/active/sessions/stop    → stop_session()
 +
 +Marker:
 +POST /api/projects/active/markers → add_marker(label, timestamp?, device_id?, color?)
 +
 +History (für Chart-Daten):
 +GET /api/projects/active/devices/{device_id}/history
 +    Query-Parameter: session_id, start (Unix-Timestamp), end, entities (kommagetrennt), limit
 +
 +Fehlerbehandlung:
 +- 404 wenn Projekt nicht gefunden
 +- 409 wenn Device-ID bereits existiert  
 +- 400 wenn Pflichtfelder fehlen
 +- 500 mit aussagekräftiger Fehlermeldung bei DB-Fehlern
 +
 +Alle Endpoints die das aktive Projekt benötigen sollen eine
 +HTTPException(503, "Kein Projekt geladen") werfen wenn kein Projekt aktiv ist.
 +</code>
 +
 +===== Prompt 5: Migration und CLI-Tool =====
 +
 +<code>
 +Bitte erstelle tools/migrate_config.py
 +
 +Das Script konvertiert eine bestehende ionpy config.yaml in die neue 
 +Projekt-Struktur.
 +
 +Aufruf: python -m tools.migrate_config --config config.yaml --name "Mein Projekt" --output projects/
 +
 +Was es tun soll:
 +1. config.yaml einlesen
 +2. Projekt-Ordner anlegen (projects/{slug-des-namens}/)
 +3. project.json generieren (mit neuer UUID, Datum, name)
 +4. devices.json generieren:
 +   - Jeder YAML-Device wird zu einem JSON-Device
 +   - transport-Block bleibt wie er ist
 +   - Alle unbekannten Felder landen in params{}
 +   - port_hint wird als leeres Objekt angelegt (muss manuell befüllt werden)
 +5. Hinweis ausgeben: "Bitte workspace.json manuell kopieren"
 +6. Validierung: Warnung wenn ein Device kein 'driver' Feld hat
 +
 +Das Format von devices.json und project.json ist hier definiert:
 +[JSONs einfügen]
 +</code>
 +
 +===== Prompt 6: Device-Wizard Frontend =====
 +
 +<code>
 +Ich habe jetzt das Backend für das Projekt-Management vollständig.
 +Angehängt: static/index.html (komplett), die aktuellen REST Endpoints
 +
 +Bitte implementiere den Device-Wizard als neues Vue 3 Widget.
 +Er soll als Modal über dem Dashboard erscheinen.
 +
 +Schritt 1 — Treiber wählen:
 +- Lädt GET /api/config/drivers
 +- Zeigt Liste mit Treibername und Beschreibung (aus driver.description)
 +- Zeigt welche Transport-Typen der Treiber unterstützt (aus STACK_BLUEPRINTS)
 +
 +Schritt 2 — Instanz konfigurieren:
 +- Pflichtfelder: ID (Text, wird zu device_id), Alias, Farbe (Color-Picker)
 +- Transport-Typ Dropdown (die Keys aus STACK_BLUEPRINTS des gewählten Treibers)
 +- Wenn transport.type == "serial": 
 +    Lädt GET /api/inventory/tools/serial
 +    Zeigt Port-Dropdown mit Beschreibungen
 +- Wenn transport.type == "tcp": Host und Port Felder
 +- Treiber-spezifische params aus driver.parameters dynamisch als Formular-Felder
 +
 +Schritt 3 — Test:
 +- "Verbindung testen" Button
 +- POST /api/projects/active/devices mit {test: true} Flag
 +- Zeigt Erfolg/Fehler und wenn erfolgreich den ersten Messwert
 +
 +Schritt 4 — Speichern:
 +- POST /api/projects/active/devices (ohne test Flag)
 +- Device erscheint sofort im Geräte-Menü
 +
 +Style: Exakt gleicher Dark-Theme Stil wie der Rest der App 
 +(Farben aus den CSS-Variablen). Kein zusätzliches CSS-Framework.
 +
 +Wichtig: Kein <form> Tag verwenden, nur @click/@input Handler.
 +</code>
 +
 +===== Prompt 7: Projekt-Auswahl beim Start =====
 +
 +<code>
 +Bitte füge dem Frontend (index.html) einen Projekt-Auswahl Dialog hinzu.
 +
 +Er soll beim ersten Laden erscheinen wenn kein Projekt aktiv ist
 +(GET /api/projects/active gibt 503 zurück).
 +
 +Das Modal zeigt:
 +- Liste aller Projekte (GET /api/projects)
 +  Pro Eintrag: Name, Beschreibung, "Zuletzt geöffnet" Datum
 +  Klick → POST /api/projects/{id}/open → Dashboard lädt
 +- Button "Neues Projekt anlegen"
 +  → Formular: Name (Pflicht), Beschreibung (optional)  
 +  → POST /api/projects → öffnet das neue Projekt direkt
 +
 +Das Modal kann NICHT geschlossen werden ohne ein Projekt zu wählen.
 +Es ist kein Widget - es ist ein Fullscreen-Overlay das den Rest der App blockiert.
 +
 +Wenn ein Projekt aktiv ist: Projekt-Name im Header anzeigen 
 +(neben dem "ionpy PRO" Schriftzug).
 +</code>
 +
 +----
 +
 +====== 6. Offene Fragen & Entscheidungen ======
 +
 +Diese Punkte wurden in der Konzept-Session bewusst offen gelassen:
 +
 +^ Frage ^ Empfehlung ^ Priorität ^
 +| Port-Portabilität: Was wenn COM3 auf neuem Rechner nicht existiert? | VID/PID in port_hint implementieren, Server sucht automatisch | Phase 4 |
 +| Treiber-Versionierung: Was wenn Treiber sich ändert und alte Session-Daten nicht mehr passen? | driver_version in devices_snapshot der Session speichern | Phase 2 |
 +| Downsampling: Wann werden historische Daten zu groß? | Retention-Policy als Hintergrund-Task, erst wenn nötig | Später |
 +| Automation: exec() oder Rule-Engine zuerst? | Rule-Engine (YAML-basiert) zuerst, sicherer | Phase 8 |
 +| Waveform-Daten in DB? | Nein, Waveforms in eigene Dateien (zu groß für SQLite-Rows) | Offen |
 +| Multi-User / Concurrent Access? | Aktuell kein Ziel, WAL-Mode gibt aber schon etwas Sicherheit | Nicht geplant |
 +
 +----
 +
 +//Erstellt im Rahmen der ionpy Architektur-Session, 2026-02-28//\\
 +//Nächster Schritt: Implementierung mit Claude Opus, beginnend mit Phase 1 (ProjectDatabase)//
ionpy/project_rodemap.txt · Last modified: by dominik

Donate Powered by PHP Valid HTML5 Valid CSS Driven by DokuWiki