Benutzer öffnet: http://[server-ip]:8000/mobile
↓
FastAPI liefert mobile.html
↓
Vue 3 App startet (ES Modules)
↓
store.js → WebSocket /ws
↓
Gleiche Daten wie Desktop-App
----
===== Technologie-Stack =====
^ Komponente ^ Technologie ^ Begründung ^
| Framework | Vue 3 (Composition) | Bereits vorhanden, kein Overhead |
| Charts | uPlot | Bereits vorhanden, sehr performant |
| State | store.js | 1:1 wiederverwendet |
| Icons | MDI | Bereits vorhanden |
| CSS | theme.css Variablen | Bereits vorhanden |
| PWA | Web App Manifest | Kein Framework nötig |
| Offline | Service Worker | Nur Static Assets cachen |
| Deployment | FastAPI Static | Eine neue Route |
----
===== Blocker-Analyse =====
==== Einziger echter Blocker: Server-Route ====
**Datei:** ''web/server.py''\\
**Aufwand:** 5 Minuten\\
**Priorität:** 🔴 Muss zuerst
# web/server.py – nach der bestehenden read_index() Route ergänzen:
@app.get("/mobile")
async def read_mobile():
"""Einstiegspunkt für die PWA Mobile App."""
mobile_path = os.path.join(static_dir, "mobile.html")
if os.path.exists(mobile_path):
return FileResponse(mobile_path)
return JSONResponse(
status_code=404,
content={"error": "mobile.html nicht gefunden. "
"Bitte in static/ ablegen."}
)
# Service Worker muss vom Root-Scope aus erreichbar sein
# damit er alle /mobile/* Requests cachen kann
@app.get("/sw.js")
async def serve_service_worker():
sw_path = os.path.join(static_dir, "mobile", "sw.js")
if os.path.exists(sw_path):
return FileResponse(
sw_path,
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/"}
)
return JSONResponse(status_code=404, content={"error": "sw.js nicht gefunden"})
Alle anderen Backend-Endpoints (''GET /api/devices'', ''POST /api/control'',
''POST /api/emergency'', ''WebSocket /ws'') funktionieren bereits ohne
Änderungen.
----
====== PHASE 0: Vorbereitung ======
//(~0.5 Tage)//
===== 0.1 Dateistruktur anlegen =====
static/
├── index.html ✅ Desktop – unverändert
├── mobile.html 🆕 PWA Entry Point
└── mobile/
├── manifest.json 🆕 PWA Manifest (Homescreen Install)
├── sw.js 🆕 Service Worker (Offline Cache)
├── app.js 🆕 Vue Root App
├── views/
│ ├── overview.js 🆕 Geräteübersicht (Startseite)
│ ├── device_detail.js 🆕 Einzelgerät Detail
│ └── alarms.js 🆕 Alarm-Feed
└── components/
├── device_card.js 🆕 Gerätekarte für Übersicht
├── sensor_tile.js 🆕 Einzelner Messwert
├── mini_chart.js 🆕 uPlot Sparkline (60s Verlauf)
└── connection_banner.js 🆕 WS-Status Anzeige
Icons werden benötigt (PNG, beliebig erstellen oder aus Emoji):
static/images/
├── icon-192.png 🆕 App Icon 192x192px (für Android)
├── icon-512.png 🆕 App Icon 512x512px (für Splash Screen)
└── badge-72.png 🆕 Notification Badge 72x72px (optional)
===== 0.2 PWA Manifest =====
**Datei:** ''static/mobile/manifest.json'' (NEU)
{
"name": "ionpy Pro",
"short_name": "ionpy",
"description": "Hardware Monitor & Control",
"start_url": "/mobile",
"scope": "/mobile",
"display": "standalone",
"background_color": "#18181b",
"theme_color": "#18181b",
"orientation": "any",
"icons": [
{
"src": "/static/images/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/images/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"categories": ["utilities", "productivity"]
}
===== 0.3 Service Worker (Basis) =====
**Datei:** ''static/mobile/sw.js'' (NEU)
/**
* ionpy PWA Service Worker
* Strategie: Cache-First für statische Assets,
* Network-Only für API und WebSocket
*/
const CACHE_NAME = 'ionpy-mobile-v1';
// Diese Dateien werden beim Install gecacht
// → App lädt auch ohne Netzwerk (nur UI, keine Live-Daten)
const STATIC_ASSETS = [
'/mobile',
'/static/mobile/app.js',
'/static/mobile/views/overview.js',
'/static/mobile/views/device_detail.js',
'/static/mobile/views/alarms.js',
'/static/mobile/components/device_card.js',
'/static/mobile/components/sensor_tile.js',
'/static/mobile/components/mini_chart.js',
'/static/mobile/components/connection_banner.js',
'/static/vendor/vue.global.prod.js',
'/static/vendor/uPlot.iife.min.js',
'/static/vendor/uPlot.min.css',
'/static/vendor/mdi/css/materialdesignicons.min.css',
'/static/vendor/fonts/jetbrains-mono.css',
'/static/css/theme.css',
'/static/js/store.js',
'/static/images/icon-192.png',
];
// INSTALL: Assets vorab cachen
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// ACTIVATE: Alte Caches aufräumen
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys
.filter(k => k !== CACHE_NAME)
.map(k => caches.delete(k))
)
).then(() => self.clients.claim())
);
});
// FETCH: Cache-Strategie
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// API-Calls IMMER live – nie aus Cache!
if (url.pathname.startsWith('/api/')) return;
// WebSocket kann der SW sowieso nicht abfangen
if (url.protocol === 'ws:' || url.protocol === 'wss:') return;
// Statische Assets: Cache-First
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
// Nicht im Cache → Netzwerk und danach cachen
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(c => c.put(event.request, clone));
}
return response;
});
})
);
});
----
====== PHASE 1: Entry Point & Navigation ======
//(~1 Tag)//
===== 1.1 mobile.html – Entry Point =====
**Datei:** ''static/mobile.html'' (NEU)
**Wichtige Implementierungshinweise für KI:**
* ''viewport'' mit ''user-scalable=no'' – verhindert ungewolltes Zoomen
* ''height: 100dvh'' statt ''100vh'' – ''dvh'' respektiert den
mobilen Browser-Chrome (Adressleiste die ein/ausblendet)
* ''env(safe-area-inset-*)'' für Notch und Home-Indicator (iPhone)
* ''touch-action: manipulation'' eliminiert den 300ms Tap-Delay
* Alle Vendor-Dateien aus ''/static/vendor/'' – identisch zur Desktop-App
ionpy Mobile
===== 1.2 app.js – Vue Root & Navigation =====
**Datei:** ''static/mobile/app.js'' (NEU)
**Hinweise für KI:**
* Kein Vue Router – Navigation über ''ref'' und ''v-if''
(hält die Bundle-Size minimal)
* ''initStore()'' aus ''store.js'' ist identisch zur Desktop-App
* ''globalStore'' ist reaktiv – alle Views updaten automatisch
* Bottom-Navigation mit Badge für aktive Alarme
* ''selectedDeviceId'' steuert ob Overview oder Detail sichtbar ist
// static/mobile/app.js
import { globalStore, initStore } from '/static/js/store.js';
import OverviewView from '/static/mobile/views/overview.js';
import DeviceDetailView from '/static/mobile/views/device_detail.js';
import AlarmsView from '/static/mobile/views/alarms.js';
import ConnectionBanner from '/static/mobile/components/connection_banner.js';
const { createApp, ref, computed, onMounted } = Vue;
createApp({
components: {
OverviewView, DeviceDetailView, AlarmsView, ConnectionBanner
},
setup() {
// Navigation State
const activeTab = ref('overview');
const selectedDeviceId = ref(null);
// Gerätedetail öffnen
const openDevice = (deviceId) => {
selectedDeviceId.value = deviceId;
};
// Zurück zur Übersicht
const goBack = () => {
selectedDeviceId.value = null;
};
// Alarm-Badge Zähler
const activeAlarmCount = computed(() => {
let count = 0;
Object.values(globalStore.devices).forEach(dev => {
(dev.state || []).forEach(s => {
if (s.alarm_state && s.alarm_state !== 'NORMAL') count++;
});
});
return count;
});
// Tab wechseln (setzt auch Device-Detail zurück)
const switchTab = (tab) => {
activeTab.value = tab;
selectedDeviceId.value = null;
};
// Globaler Notaus
const emergencyStop = async () => {
if (confirm('⚠️ NOTAUS\nAlle Hardware-Ausgänge abschalten?')) {
try {
await fetch('/api/emergency', { method: 'POST' });
} catch (e) {
alert('Fehler: ' + e.message);
}
}
};
// Connectivity
const isOnline = ref(navigator.onLine);
window.addEventListener('online', () => isOnline.value = true);
window.addEventListener('offline', () => isOnline.value = false);
onMounted(() => {
initStore(); // ← Gleicher Store wie Desktop
});
// Bottom Navigation Tabs
const tabs = [
{ id: 'overview', icon: 'view-dashboard', label: 'Geräte' },
{ id: 'alarms', icon: 'bell-alert', label: 'Alarme' },
];
return {
store: globalStore,
activeTab, selectedDeviceId, tabs, activeAlarmCount,
isOnline, openDevice, goBack, switchTab, emergencyStop
};
},
template: `
ionpy MOBILE
`
}).mount('#app');
----
====== PHASE 2: Core Views ======
//(~3 Tage)//
===== 2.1 connection_banner.js – Verbindungsstatus =====
**Datei:** ''static/mobile/components/connection_banner.js'' (NEU)\\
**Aufwand:** 0.5 Tage
**Hinweis für KI:** Zeigt einen farbigen Banner wenn WS getrennt
oder kein Netzwerk vorhanden. Verschwindet automatisch wenn
Verbindung wiederhergestellt.
export default {
props: {
wsConnected: { type: Boolean, default: false },
isOnline: { type: Boolean, default: true }
},
computed: {
banner() {
if (!this.isOnline) return {
show: true,
color: '#7f1d1d',
icon: 'wifi-off',
text: 'Kein Netzwerk – Live-Daten nicht verfügbar'
};
if (!this.wsConnected) return {
show: true,
color: '#78350f',
icon: 'lan-disconnect',
text: 'Verbindung getrennt – Stelle Verbindung wieder her...'
};
return { show: false };
}
},
template: `
{{ banner.text }}
`
};
===== 2.2 overview.js – Geräteübersicht =====
**Datei:** ''static/mobile/views/overview.js'' (NEU)\\
**Aufwand:** 1 Tag
**Hinweis für KI:**
* Responsiv: 1 Spalte auf Phone, 2 Spalten auf Tablet (>768px)
* Key-Metrics: Die ersten 3 Numeric-Entities nach ''ui.order'' sortiert
* Touch-Feedback: ''pointerdown / pointerup'' für sofortiges visuelles Feedback
* Alarm-Farben direkt in den Metric-Werten anzeigen
import { globalStore } from '/static/js/store.js';
import DeviceCard from '/static/mobile/components/device_card.js';
export default {
components: { DeviceCard },
emits: ['open-device'],
setup(_, { emit }) {
const { computed, ref } = Vue;
// Responsiv
const isTablet = ref(window.innerWidth >= 768);
window.addEventListener('resize', () => {
isTablet.value = window.innerWidth >= 768;
});
const devices = computed(() => {
return Object.values(globalStore.devices)
.map(dev => {
const stateEnt = dev.state?.find(s => s.sensor === 'state');
const status = stateEnt?.value ?? 'OFFLINE';
const statusColor = {
ONLINE: '#10b981',
ERROR: '#ef4444',
CONNECTING: '#f59e0b',
RECOVERING: '#f59e0b'
}[status] ?? '#71717a';
// Top 3 Messwerte für die Karte
const keyMetrics = (dev.meta?.entities ?? [])
.filter(e =>
e.ui?.ui_type === 'number' &&
e.mode !== 'WRITE_ONLY' &&
e.channel !== 'System'
)
.sort((a, b) => (a.ui?.order ?? 99) - (b.ui?.order ?? 99))
.slice(0, 3)
.map(e => {
const s = dev.state?.find(x => x.sensor === e.id);
return {
name: e.name,
unit: e.unit ?? '',
value: s?.value != null
? Number(s.value).toFixed(e.accuracy ?? 1)
: '--',
alarm: s?.alarm_state ?? 'NORMAL'
};
});
return {
id: dev.meta.device_id,
name: dev.meta.alias || dev.meta.device_id,
icon: dev.meta.icon || 'developer-board',
status,
statusColor,
keyMetrics
};
})
.sort((a, b) => a.name.localeCompare(b.name));
});
return { devices, isTablet, emit };
},
template: `
Keine Geräte verfügbar
Warte auf Verbindung...
`
};
===== 2.3 device_card.js – Gerätekarte =====
**Datei:** ''static/mobile/components/device_card.js'' (NEU)\\
**Aufwand:** 0.5 Tage
export default {
props: {
device: { type: Object, required: true }
},
setup(props) {
const { ref } = Vue;
const pressed = ref(false);
const alarmColor = (alarm) => {
if (alarm === 'HIHI' || alarm === 'LOLO') return '#ef4444';
if (alarm === 'HI' || alarm === 'LO') return '#f59e0b';
return '#ffffff';
};
return { pressed, alarmColor };
},
template: `
{{ device.status }}
{{ m.value }}
Keine Messwerte
`
};
===== 2.4 device_detail.js – Gerät Detail =====
**Datei:** ''static/mobile/views/device_detail.js'' (NEU)\\
**Aufwand:** 1.5 Tage
**Hinweis für KI:**
* Zeigt ALLE Entities des Geräts gruppiert
* Für ''toggle'' Entities mit ''READ_WRITE'': EIN/AUS Button
* Für ''number'' Entities: Wert + Einheit + Alarm-Farbe
* ''sendControl()'' nutzt den bestehenden ''POST /api/control'' Endpoint
* Mini-Chart (uPlot) für die ersten numerischen Entities
* Tabs für Kanäle (Ch1, Ch2, System) falls vorhanden
import { globalStore } from '/static/js/store.js';
import MiniChart from '/static/mobile/components/mini_chart.js';
export default {
components: { MiniChart },
props: {
deviceId: { type: String, required: true }
},
setup(props) {
const { computed, ref } = Vue;
const device = computed(() => globalStore.devices[props.deviceId]);
const currentState = computed(() => {
const s = device.value?.state?.find(s => s.sensor === 'state');
return s?.value ?? 'OFFLINE';
});
const statusColor = computed(() => ({
ONLINE: '#10b981',
ERROR: '#ef4444',
CONNECTING: '#f59e0b',
RECOVERING: '#f59e0b'
}[currentState.value] ?? '#71717a'));
// Kanäle ermitteln
const channels = computed(() => {
if (!device.value?.meta?.entities) return ['Ch1'];
const chs = [...new Set(
device.value.meta.entities
.filter(e => e.mode !== 'WRITE_ONLY')
.map(e => e.channel || 'Ch1')
)];
return chs.sort((a, b) => {
if (a.toLowerCase() === 'system') return 1;
if (b.toLowerCase() === 'system') return -1;
return a.localeCompare(b);
});
});
const activeChannel = ref('');
// Aktiven Kanal initialisieren
const initChannel = () => {
if (!activeChannel.value && channels.value.length > 0) {
activeChannel.value = channels.value[0];
}
};
// Entities für aktiven Kanal, gruppiert
const groupedEntities = computed(() => {
initChannel();
if (!device.value?.meta?.entities) return [];
const visible = device.value.meta.entities.filter(e =>
(e.channel || 'Ch1') === activeChannel.value &&
e.mode !== 'WRITE_ONLY' &&
e.ui?.ui_type !== 'table' &&
e.ui?.ui_type !== 'waveform' &&
!e.ui?.hidden
);
const map = {};
visible.forEach(e => {
const g = e.ui?.group || 'Allgemein';
if (!map[g]) map[g] = {
name: g, order: e.ui?.group_order ?? 99, entities: []
};
map[g].entities.push(e);
});
return Object.values(map)
.sort((a, b) => a.order - b.order)
.map(g => ({
...g,
entities: g.entities.sort(
(a, b) => (a.ui?.order ?? 99) - (b.ui?.order ?? 99)
)
}));
});
// Wert aus State holen
const getValue = (entityId) => {
const s = device.value?.state?.find(s => s.sensor === entityId);
return s?.value ?? null;
};
const getAlarmState = (entityId) => {
const s = device.value?.state?.find(s => s.sensor === entityId);
return s?.alarm_state ?? 'NORMAL';
};
const formatValue = (entity) => {
const val = getValue(entity.id);
if (val === null) return '--';
if (entity.ui?.ui_type === 'toggle') {
return val ? 'AN' : 'AUS';
}
if (typeof val === 'number') {
return Number(val).toFixed(entity.accuracy ?? 2);
}
return String(val);
};
const alarmColor = (entityId) => {
const state = getAlarmState(entityId);
if (state === 'HIHI' || state === 'LOLO') return '#ef4444';
if (state === 'HI' || state === 'LO') return '#f59e0b';
return '#ffffff';
};
const isTrue = (val) => {
if (val === true || val === 1 || val === '1') return true;
if (typeof val === 'string') {
return ['on','true','an'].includes(val.toLowerCase());
}
return false;
};
// Steuern
const sendControl = async (entityId, value) => {
// Optimistic Update
const s = device.value?.state?.find(s => s.sensor === entityId);
if (s) s.value = value;
await fetch('/api/control', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: props.deviceId,
key: entityId,
value: value
})
});
};
const toggleConnection = async () => {
const target = currentState.value === 'ONLINE'
? 'OFFLINE' : 'ONLINE';
await sendControl('state', target);
};
// Erste numerische Entities für Mini-Charts
const chartEntities = computed(() => {
if (!device.value?.meta?.entities) return [];
return device.value.meta.entities
.filter(e =>
e.ui?.ui_type === 'number' &&
e.mode !== 'WRITE_ONLY' &&
e.channel !== 'System'
)
.sort((a, b) => (a.ui?.order ?? 99) - (b.ui?.order ?? 99))
.slice(0, 2); // Max 2 Charts auf Mobile
});
return {
device, currentState, statusColor, channels, activeChannel,
groupedEntities, getValue, formatValue, alarmColor, isTrue,
sendControl, toggleConnection, chartEntities
};
},
template: `
{{ currentState }}
{{ ent.name }}
{{ getValue(ent.id) !== null
? Number(getValue(ent.id)).toFixed(ent.accuracy ?? 2)
: '--' }}
{{ ent.unit }}
{{ group.name }}
Gerät nicht gefunden
`
};
===== 2.5 mini_chart.js – uPlot Sparkline =====
**Datei:** ''static/mobile/components/mini_chart.js'' (NEU)\\
**Aufwand:** 0.5 Tage
**Hinweis für KI:**
* Hört auf ''ws-message'' CustomEvents (gleicher Mechanismus wie Desktop)
* ResizeObserver für responsive Breite – kein fixer Wert
* Kein Cursor, keine Legende – nur die Kurve
* ''onUnmounted'' muss EventListener UND ResizeObserver trennen
export default {
props: {
deviceId: { type: String, required: true },
entityId: { type: String, required: true },
color: { type: String, default: '#0ea5e9' },
windowMs: { type: Number, default: 60000 },
height: { type: Number, default: 50 }
},
setup(props) {
const { ref, onMounted, onUnmounted } = Vue;
const chartEl = ref(null);
let uplot = null;
let resizeObserver = null;
const data = [[], []];
const buildChart = (width) => {
if (!chartEl.value || width <= 0) return;
if (uplot) { uplot.destroy(); uplot = null; }
const opts = {
width,
height: props.height,
legend: { show: false },
cursor: { show: false },
select: { show: false },
scales: { x: { time: true }, y: { auto: true } },
axes: [{ show: false }, { show: false }],
series: [
{},
{
stroke: props.color,
width: 2,
fill: props.color + '20'
}
],
padding: [2, 0, 2, 0]
};
uplot = new uPlot(opts, data, chartEl.value);
};
const handleWsMessage = (e) => {
try {
const sample = JSON.parse(e.detail);
if (sample.device_id !== props.deviceId) return;
if (sample.entity_id !== props.entityId) return;
if (typeof sample.value !== 'number') return;
if (sample.type === 'waveform') return;
const now = Date.now() / 1000;
const cutoff = now - (props.windowMs / 1000);
data[0].push(now);
data[1].push(sample.value);
// Alte Werte entfernen
while (data[0].length > 0 && data[0][0] < cutoff) {
data[0].shift();
data[1].shift();
}
if (uplot && data[0].length > 1) {
uplot.setData(data, true);
}
} catch { /* Ignore parse errors */ }
};
onMounted(async () => {
await Vue.nextTick();
if (!chartEl.value) return;
resizeObserver = new ResizeObserver(entries => {
const w = Math.floor(entries[0].contentRect.width);
if (!uplot && w > 0) {
buildChart(w);
} else if (uplot && w > 0) {
uplot.setSize({ width: w, height: props.height });
}
});
resizeObserver.observe(chartEl.value);
window.addEventListener('ws-message', handleWsMessage);
});
onUnmounted(() => {
window.removeEventListener('ws-message', handleWsMessage);
if (resizeObserver) resizeObserver.disconnect();
if (uplot) uplot.destroy();
});
return { chartEl };
},
template: ``
};
===== 2.6 alarms.js – Alarm Feed =====
**Datei:** ''static/mobile/views/alarms.js'' (NEU)\\
**Aufwand:** 0.5 Tage
import { globalStore } from '/static/js/store.js';
export default {
setup() {
const { computed } = Vue;
const allAlarms = computed(() => {
const result = [];
Object.values(globalStore.devices).forEach(dev => {
(dev.state || []).forEach(s => {
if (!s.alarm_state || s.alarm_state === 'NORMAL') return;
const ent = dev.meta?.entities?.find(e => e.id === s.sensor);
result.push({
deviceId: dev.meta.device_id,
deviceName: dev.meta.alias || dev.meta.device_id,
entityId: s.sensor,
entityName: ent?.name || s.sensor,
value: s.value,
unit: ent?.unit || '',
alarm: s.alarm_state,
timestamp: s.timestamp
});
});
});
// Kritische zuerst
const priority = { HIHI: 0, LOLO: 1, HI: 2, LO: 3 };
return result.sort((a, b) =>
(priority[a.alarm] ?? 9) - (priority[b.alarm] ?? 9)
);
});
const alarmStyle = (alarm) => {
const crit = alarm === 'HIHI' || alarm === 'LOLO';
return {
background: crit ? '#450a0a' : '#431407',
border: `1px solid ${crit ? '#991b1b' : '#78350f'}`,
color: crit ? '#fca5a5' : '#fed7aa',
borderRadius: '10px',
padding: '12px 14px',
marginBottom: '8px'
};
};
const formatTime = (ts) => {
if (!ts) return '--';
const d = new Date(ts * 1000);
return d.toLocaleTimeString('de-DE', { hour12: false }) +
'.' + String(d.getMilliseconds()).padStart(3, '0');
};
return { allAlarms, alarmStyle, formatTime };
},
template: `
Keine aktiven Alarme
{{ alarm.deviceName }} › {{ alarm.entityName }}
{{ alarm.alarm }} –
{{ alarm.value }} {{ alarm.unit }}
{{ formatTime(alarm.timestamp) }}
`
};
----
====== PHASE 3: PWA Install & Offline ======
//(~0.5 Tage – fast geschenkt)//
Nach Phase 2 ist die App bereits **voll funktionsfähig** im Browser.
Phase 3 macht sie installierbar.
===== 3.1 iOS Homescreen =====
Auf iPhone/iPad: **Safari → Teilen-Button → Zum Home-Bildschirm**
Dafür müssen die '''' Tags in ''mobile.html'' korrekt gesetzt
sein (bereits in Phase 1 enthalten).
===== 3.2 Android Install Banner =====
Chrome auf Android zeigt automatisch einen Install-Banner wenn:
* Manifest vorhanden
* Service Worker registriert
* HTTPS oder localhost
Kein zusätzlicher Code nötig.
===== 3.3 HTTPS für produktiven Einsatz =====
**Hinweis für KI:** Für den Einsatz im internen Netz
(nicht localhost) braucht PWA auf iOS zwingend HTTPS.
Optionen:
* **nginx Reverse Proxy** mit self-signed Cert
* **Caddy** (automatisches HTTPS, einfachste Option)
* **mkcert** für lokale CA im Netzwerk
# Caddy Beispiel (Caddyfile):
# ionpy.local {
# reverse_proxy localhost:8000
# }
# → Caddy holt automatisch ein lokales Cert
----
====== PHASE 4: Erweiterungen (Nice-to-have) ======
===== 4.1 Wake Lock – Bildschirm bleibt an =====
**Aufwand:** 30 Minuten\\
**Bestes Aufwand/Nutzen Verhältnis im ganzen Projekt**
// In app.js setup() ergänzen:
let wakeLock = null;
const enableWakeLock = async () => {
if (!('wakeLock' in navigator)) return;
try {
wakeLock = await navigator.wakeLock.request('screen');
} catch (e) {
console.warn('Wake Lock nicht verfügbar:', e);
}
};
// Neu anfordern wenn Tab wieder sichtbar wird
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
await enableWakeLock();
}
});
onMounted(() => {
initStore();
enableWakeLock(); // Sofort aktivieren
});
===== 4.2 Vibrations-Alarm =====
**Aufwand:** 30 Minuten
// In alarms.js oder store.js – bei neuem Alarm auslösen:
const vibrateOnNewAlarm = (alarmState) => {
if (!('vibrate' in navigator)) return; // iOS unterstützt das nicht
if (alarmState === 'HIHI' || alarmState === 'LOLO') {
navigator.vibrate([500, 100, 500, 100, 500]); // Kritisch
} else if (alarmState === 'HI' || alarmState === 'LO') {
navigator.vibrate(200); // Warnung
}
};
===== 4.3 Vollbild-Einzelwert (Distanz-Monitoring) =====
**Aufwand:** 0.5 Tage\\
**Usecase:** Tablet liegt auf dem Tisch – Wert lesbar aus 3m Entfernung
// static/mobile/views/sensor_fullscreen.js (NEU)
// Wird durch langen Tap auf einen Sensor-Tile geöffnet
// Schließt durch Tap irgendwo
// clamp(min, preferred, max) sorgt für skalierenden Text
// fontSize: 'clamp(80px, 25vw, 220px)'
===== 4.4 Swipe-Gesten =====
**Aufwand:** 0.5 Tage
// Swipe von links → Detail schließen (wie native App)
// In device_detail.js:
const setupSwipeBack = (onSwipe) => {
let startX = 0;
const onTouchStart = e => { startX = e.touches[0].clientX; };
const onTouchEnd = e => {
const delta = e.changedTouches[0].clientX - startX;
if (delta > 80 && startX < 50) onSwipe();
};
return { onTouchStart, onTouchEnd };
};
===== 4.5 QR-Code Scanner =====
**Aufwand:** 1 Tag\\
**Usecase:** QR-Code klebt am Gerät → Handy draufhalten → Detail öffnet sich sofort
// Benötigt jsQR Library (oder BarcodeDetector API auf Android Chrome)
// QR-Format: "ionpy://device/DPS5005_1"
// → Parsen → openDevice(deviceId) aufrufen
===== 4.6 Handy als Sensor-Device =====
**Aufwand:** 2-3 Tage\\
**Beschreibung:** Handy sendet eigene Sensordaten als ionpy-Device ans Backend.
Separat dokumentiert in der Kamera/Sensor-Roadmap.
===== 4.7 Push Notifications =====
**Aufwand:** 2 Tage\\
**Voraussetzung:** HTTPS + Service Worker (bereits vorhanden)
Alarm-Benachrichtigung auch wenn App geschlossen ist.
Braucht Backend-seitigen Web-Push Service
(''pywebpush'' Library).
===== 4.8 Dark/Light Theme Toggle =====
**Aufwand:** 0.5 Tage\\
Systemtheme via ''prefers-color-scheme'' Media Query +
manueller Toggle. Theme in ''localStorage'' speichern.
===== 4.9 Capacitor Wrapper (echter App Store) =====
**Aufwand:** 2-3 Tage Setup\\
Wenn die PWA stabil läuft kann sie mit Capacitor in eine
native iOS/Android App verpackt werden.
Der Vue-Code bleibt 1:1 identisch.
npm install @capacitor/core @capacitor/cli
npx cap init ionpy com.ionpy.mobile
npx cap add ios
npx cap add android
npx cap copy
npx cap open ios # Öffnet Xcode
npx cap open android # Öffnet Android Studio
----
====== Gesamtübersicht Zeitplan PWA ======
^ Phase ^ Inhalt ^ Tage ^ Ergebnis ^
| 0 | Vorbereitung + SW | 0.5 | Dateistruktur steht |
| 1 | Entry Point + Navigation | 1.0 | App startet im Browser |
| 2 | Core Views | 3.0 | Vollständig nutzbare App |
| 3 | PWA Install | 0.5 | Auf Homescreen installierbar |
| **MVP** | | **~5 Tage** | **Produktionsreif** |
| 4.1 | Wake Lock | 0.5h | Bildschirm bleibt an |
| 4.2 | Vibration | 0.5h | Alarm-Feedback |
| 4.3 | Vollbild-Sensor | 0.5 | Distanz-Monitoring |
| 4.4 | Swipe-Gesten | 0.5 | Nativer App-Feel |
| 4.5 | QR-Scanner | 1.0 | Geräte-Shortcut |
| 4.6 | Handy als Sensor | 3.0 | Rückkanal aktiv |
| 4.7 | Push Notifications | 2.0 | Alarme im Hintergrund |
**Unabhängigkeit:** Die PWA hat **keine Abhängigkeiten** vom
Wizard-Backlog. Kann sofort parallel entwickelt werden.
----
//Ende PWA Roadmap//\\
//Code-Beispiele sind Implementierungshinweise – kein fertiger Produktionscode//