OAuth2 Password-Grant + Refresh-Token on-the-fly. Holt sich automatisch einen neuen Access-Token wenn der alte abläuft (3h Gültigkeit). Module: NAMain (Indoor), NAModule1 (Outdoor), NAModule2 (Wind), NAModule3 (Regen), NAModule4 (Extra Indoor). Slot-responsive: 1x1 — Outdoor-Temp prominent + Mini-Status 4x1 — Indoor | Outdoor | Wind/Regen kompakt (3 Spalten) 1x4 — Vertikale Liste aller Module 2x2 — Indoor-Card (mit CO2-Bar) + Outdoor-Card + Wind/Regen-Bereich 4x4 — Volle Ansicht mit Min/Max, Windrose, Timestamps Config-Optionen: client_id/client_secret/username/password (secrets via Admin-UI) station_filter (substring-match für Multi-Setup) show_indoor/outdoor/wind/rain/compass/secondary (bools) co2_thresholds (ppm, default ok@600,warn@1000,alert@1500) temp_unit (C/F), wind_unit (kmh/ms) Plus: - plugins/NETATMO.md: vollständige Doku (Setup, Optionen, Diagnose) - config.netatmo.example.json: copy-paste Beispiel-Config - layout.py: netatmo default-size = 4x4 (4 Sub-Cards brauchen Platz) - README.md: Plugin-Tabelle erweitert Verifiziert: /api/plugins.json listet netatmo, Auto-Loader erkennt es, Admin-UI zeigt das Config-Form.
192 lines
6.0 KiB
Python
192 lines
6.0 KiB
Python
"""Grid-Layout: 4x4-Grid mit Pack-Algorithmus und Konflikt-Detection.
|
|
|
|
Display: 800x480 Pixel.
|
|
Grid: 4x4 Zellen, jede Zelle 200x120 Pixel.
|
|
|
|
Item-Geometrie:
|
|
(x, y, w, h) in Zelleneinheiten, x/y ∈ [0..GRID_COLS-1], w/h ∈ [1..GRID_COLS]
|
|
(x+w) ≤ GRID_COLS, (y+h) ≤ GRID_ROWS
|
|
|
|
Regeln:
|
|
- Items dürfen sich nicht überlappen
|
|
- Wenn mehrere Items die gleiche Zelle belegen wollen → Konflikt
|
|
- Pack-Algorithmus: greedy, scan-line, größte zuerst
|
|
"""
|
|
from __future__ import annotations
|
|
from dataclasses import dataclass, field, asdict
|
|
from typing import Optional
|
|
|
|
|
|
GRID_COLS = 4
|
|
GRID_ROWS = 4
|
|
CELL_W = 200 # px
|
|
CELL_H = 120 # px
|
|
DISPLAY_W = GRID_COLS * CELL_W # 800
|
|
DISPLAY_H = GRID_ROWS * CELL_H # 480
|
|
|
|
# Standard-Größen die im UI als Buttons angeboten werden
|
|
SIZE_PRESETS = [
|
|
(1, 1), (2, 1), (1, 2), (2, 2),
|
|
(4, 1), (1, 4), (2, 4), (4, 2),
|
|
(3, 1), (1, 3), (3, 2), (2, 3), (3, 3),
|
|
(4, 4),
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class Item:
|
|
id: str
|
|
plugin: str
|
|
x: int = 0
|
|
y: int = 0
|
|
w: int = 1
|
|
h: int = 1
|
|
|
|
def to_dict(self):
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict) -> "Item":
|
|
return cls(
|
|
id=str(d.get("id", _new_id())),
|
|
plugin=str(d.get("plugin", "")),
|
|
x=int(d.get("x", 0)),
|
|
y=int(d.get("y", 0)),
|
|
w=int(d.get("w", 1)),
|
|
h=int(d.get("h", 1)),
|
|
)
|
|
|
|
def bounds(self) -> tuple[int, int, int, int]:
|
|
"""Returns (x, y, w, h)."""
|
|
return self.x, self.y, self.w, self.h
|
|
|
|
def pixels(self) -> tuple[int, int, int, int]:
|
|
"""Returns (x, y, width, height) in pixels."""
|
|
return self.x * CELL_W, self.y * CELL_H, self.w * CELL_W, self.h * CELL_H
|
|
|
|
|
|
def _new_id() -> str:
|
|
import secrets
|
|
return secrets.token_hex(4)
|
|
|
|
|
|
def cells_occupied(item: Item) -> set[tuple[int, int]]:
|
|
"""Set of (col, row) cells this item covers."""
|
|
return {(item.x + dx, item.y + dy) for dx in range(item.w) for dy in range(item.h)}
|
|
|
|
|
|
def find_overlaps(items: list[Item]) -> list[tuple[str, str]]:
|
|
"""Returns list of (id_a, id_b) pairs that overlap."""
|
|
overlaps = []
|
|
for i, a in enumerate(items):
|
|
cells_a = cells_occupied(a)
|
|
for b in items[i+1:]:
|
|
if cells_a & cells_occupied(b):
|
|
overlaps.append((a.id, b.id))
|
|
return overlaps
|
|
|
|
|
|
def find_out_of_bounds(items: list[Item]) -> list[str]:
|
|
"""Returns list of item IDs that are outside the grid."""
|
|
bad = []
|
|
for it in items:
|
|
if it.x < 0 or it.y < 0 or it.x + it.w > GRID_COLS or it.y + it.h > GRID_ROWS:
|
|
bad.append(it.id)
|
|
return bad
|
|
|
|
|
|
def pack(items: list[Item], order: Optional[list[str]] = None) -> list[Item]:
|
|
"""Auto-pack items into a 4x4 grid.
|
|
|
|
Strategy: greedy first-fit, sort by area descending (largest first).
|
|
Items that don't fit are placed in a 'trash row' below the grid (y=GRID_ROWS,
|
|
h=1) so they are visible to the user as overflowing.
|
|
|
|
`order` (optional): explicit ordering by item id.
|
|
"""
|
|
# Sort: largest area first; tie-break by id for determinism
|
|
indexed = list(items)
|
|
if order:
|
|
priority = {id_: i for i, id_ in enumerate(order)}
|
|
indexed.sort(key=lambda it: (priority.get(it.id, 9999), -it.w * it.h, it.id))
|
|
else:
|
|
indexed.sort(key=lambda it: (-it.w * it.h, it.id))
|
|
|
|
taken: set[tuple[int, int]] = set()
|
|
result: list[Item] = []
|
|
overflow_row: list[Item] = []
|
|
|
|
for it in indexed:
|
|
# clamp size to grid
|
|
it.w = max(1, min(GRID_COLS, it.w))
|
|
it.h = max(1, min(GRID_ROWS, it.h))
|
|
# try to place
|
|
placed = False
|
|
for y in range(GRID_ROWS - it.h + 1):
|
|
for x in range(GRID_COLS - it.w + 1):
|
|
cells = {(x + dx, y + dy) for dx in range(it.w) for dy in range(it.h)}
|
|
if not (cells & taken):
|
|
it.x, it.y = x, y
|
|
taken |= cells
|
|
result.append(it)
|
|
placed = True
|
|
break
|
|
if placed: break
|
|
if not placed:
|
|
# couldn't fit → overflow marker
|
|
it.x = 0
|
|
it.y = GRID_ROWS # off-screen
|
|
overflow_row.append(it)
|
|
result.append(it)
|
|
return result
|
|
|
|
|
|
def first_fit(item: Item, others: list[Item]) -> Item | None:
|
|
"""Place the item at the first free scan-line position without moving others.
|
|
|
|
Returns the item with x/y set, or None if no placement fits.
|
|
Used by /api/layout/add so a new widget does not reshuffle the grid.
|
|
"""
|
|
w = max(1, min(GRID_COLS, item.w))
|
|
h = max(1, min(GRID_ROWS, item.h))
|
|
for y in range(GRID_ROWS - h + 1):
|
|
for x in range(GRID_COLS - w + 1):
|
|
candidate = Item(item.id, item.plugin, x, y, w, h)
|
|
cells = cells_occupied(candidate)
|
|
if all(not (cells & cells_occupied(o)) for o in others):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def auto_size_for_plugin(plugin_name: str) -> tuple[int, int]:
|
|
"""Default size when user adds a new item."""
|
|
presets = {
|
|
"clock": (2, 2),
|
|
"clock_wordclock": (2, 2),
|
|
"weather": (2, 2),
|
|
"netatmo": (4, 4), # zeigt 4 Sub-Cards; ab 2x2 sinnvoll, 4x4 ideal
|
|
"system": (2, 2),
|
|
"minimax": (2, 2),
|
|
"spotify": (2, 1),
|
|
"strava": (2, 1),
|
|
"gmail": (1, 1),
|
|
"hello": (1, 1),
|
|
}
|
|
return presets.get(plugin_name, (2, 2))
|
|
|
|
|
|
def render_grid_layout(items: list[Item]) -> dict:
|
|
"""Render each item to its (x, y, w, h) pixel-box on the 800x480 display."""
|
|
boxes = []
|
|
for it in items:
|
|
px, py, pw, ph = it.pixels()
|
|
# Overflow items still get rendered as small placeholder, not visible
|
|
boxes.append({
|
|
"id": it.id,
|
|
"plugin": it.plugin,
|
|
"x": px, "y": py, "w": pw, "h": ph,
|
|
"out_of_bounds": it.y >= GRID_ROWS,
|
|
})
|
|
return {"display_w": DISPLAY_W, "display_h": DISPLAY_H, "grid_cols": GRID_COLS,
|
|
"grid_rows": GRID_ROWS, "cell_w": CELL_W, "cell_h": CELL_H, "items": boxes}
|