From 27bd51a8266f2e4da59b19417638bd27abe6029c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 16:12:39 +0000 Subject: [PATCH 1/8] Replace Streamlit UI with polished Material 3 web dashboard - Serve a Material Web Components SPA via FastAPI (twfarmbot_ui/server.py) - Proxy /api and /resireg to upstream services; persist sessions and garden YAML edits through /ui endpoints - Add shared layout primitives (page, section, card, metric grid) and an 8px spacing system for consistent typography and padding across tabs - Fix FARMBOT_REQUIRED=0 so the API boots without a live FarmBot - Update docs, systemd unit, and tests (including test_ui_server.py) Co-authored-by: David Seyser --- README.md | 4 +- .../src/twfarmbot_api_server/app.py | 8 +- apps/ui/pyproject.toml | 8 +- apps/ui/src/twfarmbot_ui/__main__.py | 21 +- apps/ui/src/twfarmbot_ui/app.py | 2685 ----------------- apps/ui/src/twfarmbot_ui/client.py | 8 +- apps/ui/src/twfarmbot_ui/history.py | 2 +- apps/ui/src/twfarmbot_ui/server.py | 227 ++ apps/ui/src/twfarmbot_ui/static/app.css | 711 +++++ apps/ui/src/twfarmbot_ui/static/index.html | 51 + apps/ui/src/twfarmbot_ui/static/js/api.js | 90 + apps/ui/src/twfarmbot_ui/static/js/main.js | 117 + apps/ui/src/twfarmbot_ui/static/js/state.js | 154 + apps/ui/src/twfarmbot_ui/static/js/ui.js | 162 + .../twfarmbot_ui/static/js/views/assistant.js | 374 +++ .../twfarmbot_ui/static/js/views/camera.js | 186 ++ .../static/js/views/diagnostics.js | 61 + .../twfarmbot_ui/static/js/views/garden.js | 188 ++ .../twfarmbot_ui/static/js/views/history.js | 58 + .../ui/src/twfarmbot_ui/static/js/views/io.js | 107 + .../twfarmbot_ui/static/js/views/motion.js | 98 + .../twfarmbot_ui/static/js/views/overview.js | 140 + .../twfarmbot_ui/static/js/views/settings.js | 70 + docs/architecture.md | 9 +- pyproject.toml | 4 - scripts/install_services.sh | 9 - scripts/systemd/twfarmbot-ui.service | 4 +- tests/test_assistant_tab.py | 4 +- tests/test_ui.py | 6 +- tests/test_ui_server.py | 155 + uv.lock | 420 +-- 31 files changed, 2985 insertions(+), 3156 deletions(-) delete mode 100644 apps/ui/src/twfarmbot_ui/app.py create mode 100644 apps/ui/src/twfarmbot_ui/server.py create mode 100644 apps/ui/src/twfarmbot_ui/static/app.css create mode 100644 apps/ui/src/twfarmbot_ui/static/index.html create mode 100644 apps/ui/src/twfarmbot_ui/static/js/api.js create mode 100644 apps/ui/src/twfarmbot_ui/static/js/main.js create mode 100644 apps/ui/src/twfarmbot_ui/static/js/state.js create mode 100644 apps/ui/src/twfarmbot_ui/static/js/ui.js create mode 100644 apps/ui/src/twfarmbot_ui/static/js/views/assistant.js create mode 100644 apps/ui/src/twfarmbot_ui/static/js/views/camera.js create mode 100644 apps/ui/src/twfarmbot_ui/static/js/views/diagnostics.js create mode 100644 apps/ui/src/twfarmbot_ui/static/js/views/garden.js create mode 100644 apps/ui/src/twfarmbot_ui/static/js/views/history.js create mode 100644 apps/ui/src/twfarmbot_ui/static/js/views/io.js create mode 100644 apps/ui/src/twfarmbot_ui/static/js/views/motion.js create mode 100644 apps/ui/src/twfarmbot_ui/static/js/views/overview.js create mode 100644 apps/ui/src/twfarmbot_ui/static/js/views/settings.js create mode 100644 tests/test_ui_server.py diff --git a/README.md b/README.md index 877a2e3..8655c3a 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ uv run pytest tests/ -q | Folder | Purpose | | --- | --- | -| `apps/` | Runnable applications: `ui` (Streamlit), `api_server` (FastAPI), `worker` (background jobs) | +| `apps/` | Runnable applications: `ui` (Material 3 web app), `api_server` (FastAPI), `worker` (background jobs) | | `core/` | Shared primitives: `Action`, `Point3D`, `GardenWorld`, config, logging, events | | `services/` | One service per concern, e.g. hardware gateway, watering, vision, planning, spatial, safety | | `projects/` | Isolated student / research projects | @@ -141,7 +141,7 @@ See [`docs/architecture.md`](docs/architecture.md) for the full system design, a | Folder | Distribution | Purpose | | --- | --- | --- | | `core/` | `twfarmbot-core` | Shared domain, config, logging, events | -| `apps/ui/` | `twfarmbot-ui` | Streamlit dashboard | +| `apps/ui/` | `twfarmbot-ui` | Material 3 web dashboard (FastAPI + Material Web Components) | | `apps/api_server/` | `twfarmbot-api-server` | FastAPI HTTP API | | `apps/worker/` | `twfarmbot-worker` | Background jobs / experiments | diff --git a/apps/api_server/src/twfarmbot_api_server/app.py b/apps/api_server/src/twfarmbot_api_server/app.py index 0731583..7b95dc3 100644 --- a/apps/api_server/src/twfarmbot_api_server/app.py +++ b/apps/api_server/src/twfarmbot_api_server/app.py @@ -429,11 +429,11 @@ def connect_to_farmbot(required: bool = True) -> str: so ``GET /health`` can report it. Set ``FARMBOT_REQUIRED=0`` to allow boot without a live bot (useful - for UI-only or offline dev). When ``required=True`` (the default), - a failed connection raises ``SystemExit`` so uvicorn never starts - with a dead upstream. + for UI-only or offline dev). When ``required=True`` (the default) and + the bot is required, a failed connection raises ``SystemExit`` so + uvicorn never starts with a dead upstream. """ - if not required and os.getenv("FARMBOT_REQUIRED", "1") == "0": + if os.getenv("FARMBOT_REQUIRED", "1") == "0": log.warning("FarmBot connection skipped (FARMBOT_REQUIRED=0)") app.state.farmbot_status = "skipped" return "skipped" diff --git a/apps/ui/pyproject.toml b/apps/ui/pyproject.toml index 7aa954e..ee1060c 100644 --- a/apps/ui/pyproject.toml +++ b/apps/ui/pyproject.toml @@ -6,14 +6,14 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "twfarmbot-core", - "twfarmbot-ml-utils", - "altair>=5", + "fastapi>=0.110", + "uvicorn>=0.29", "httpx>=0.27", - "streamlit>=1.30", + "ruamel-yaml>=0.18", ] [project.scripts] -twfarmbot-ui = "twfarmbot_ui.__main__:main" +twfarmbot-ui = "twfarmbot_ui.server:main" [build-system] requires = ["hatchling"] diff --git a/apps/ui/src/twfarmbot_ui/__main__.py b/apps/ui/src/twfarmbot_ui/__main__.py index 99ea192..49a7aff 100644 --- a/apps/ui/src/twfarmbot_ui/__main__.py +++ b/apps/ui/src/twfarmbot_ui/__main__.py @@ -1,25 +1,8 @@ -"""Entry point: ``twfarmbot-ui`` runs the Streamlit dashboard.""" +"""Entry point: ``twfarmbot-ui`` runs the Material 3 web dashboard.""" from __future__ import annotations -import os -import sys -from pathlib import Path - - -def main() -> None: - from twfarmbot_core.logging import configure_logging - - configure_logging() - - app_path = Path(__file__).parent / "app.py" - # Streamlit expects the form: streamlit run path/to/app.py [args] - sys.argv = ["streamlit", "run", str(app_path), *sys.argv[1:]] - port = os.getenv("TWFB_UI_PORT", "8501") - if "--server.port" not in sys.argv: - sys.argv += ["--server.port", port] - os.execvp("streamlit", sys.argv) - +from twfarmbot_ui.server import main if __name__ == "__main__": main() diff --git a/apps/ui/src/twfarmbot_ui/app.py b/apps/ui/src/twfarmbot_ui/app.py deleted file mode 100644 index 73295f1..0000000 --- a/apps/ui/src/twfarmbot_ui/app.py +++ /dev/null @@ -1,2685 +0,0 @@ -"""TWFarmBot Research UI. - -Sidebar navigation with a clean main canvas. Each tab renders a focused, -compact card-based view. Zero business logic — every read and write is -proxied through the api_server. -""" - -from __future__ import annotations - -import json -import os -import re -import time -from datetime import datetime -from pathlib import Path -from typing import Any - -import altair as alt -import streamlit as st -from ruamel.yaml import YAML -from twfarmbot_ml_utils import ( - VisionProcessor, - parse_segmentation_labels, -) - -from twfarmbot_core.actions import summarize_action - -from twfarmbot_ui.client import ApiClient, ApiResult -from twfarmbot_ui import history - -# ── config ──────────────────────────────────────────────────────────────────── - -API_URL = os.getenv("TWFB_API_URL", "http://127.0.0.1:8000") -RESIREG_URL = os.getenv("TWFB_RESIREG_URL", "http://127.0.0.1:8080") -PLAN_TIMEOUT = 60.0 # LLM planning can take longer than the default 2s API timeout - - -@st.cache_resource -def _client(base_url: str) -> ApiClient: - return ApiClient(base_url) - - -@st.cache_resource -def _image_processor(base_url: str) -> VisionProcessor: - return VisionProcessor(base_url) - - -# ── helpers ─────────────────────────────────────────────────────────────────── - - -def _num(value: Any) -> str: - try: - return f"{float(value):.1f}" - except (TypeError, ValueError): - return "—" - - -def _float(value: Any, default: float = 0.0) -> float: - try: - return float(value) - except (TypeError, ValueError): - return default - - -_NUMBER_RE = re.compile(r"^\s*-?\d+(?:[.,]\d+)?\s*$") - - -def _parse_number(value: Any) -> float | None: - """Parse a user-typed number, accepting both '.' and ',' as decimals. - - Returns ``None`` on invalid input rather than silently defaulting — a - silent fallback here would let a mistyped "1.234,5" (German thousands - style) drive the FarmBot to (0, 0, 0). Callers should treat ``None`` - as a user-visible error. - """ - if isinstance(value, (int, float)): - return float(value) - text = str(value).strip() - if not _NUMBER_RE.match(text): - return None - try: - return float(text.replace(",", ".")) - except (TypeError, ValueError): - return None - - -def _action_summary(action: dict[str, Any]) -> str: - """Return a compact, human-readable summary of an action.""" - return summarize_action(action) - - -def _format_metrics_footer(metrics: dict[str, Any] | None) -> str: - """Return a small grey markdown string with backend timing/usage stats.""" - if not metrics: - return "" - parts: list[str] = [] - total = metrics.get("total_latency_s") - if total is not None: - parts.append(f"total {total:.2f}s") - ttft = metrics.get("ttft_s") - if ttft: - parts.append(f"ttft {ttft:.2f}s") - tps = metrics.get("tokens_per_s") - if tps: - parts.append(f"{tps:.1f} tok/s") - prompt = metrics.get("prompt_tokens") - completion = metrics.get("completion_tokens") - total_tokens = metrics.get("total_tokens") - if total_tokens: - parts.append(f"tokens {prompt or 0}+{completion or 0}={total_tokens}") - resi = metrics.get("resireg_latency_s") - if resi: - parts.append(f"resireg {resi:.2f}s") - if not parts: - return "" - return " · ".join(parts) - - -def _render_assistant_metrics() -> None: - """Show the latest turn's backend timing/usage stats above the chat input.""" - metrics = st.session_state.get("assistant_metrics") or {} - if not metrics: - return - footer = _format_metrics_footer(metrics) - if not footer: - return - st.markdown( - f'
{footer}
', - unsafe_allow_html=True, - ) - - -def _render_tool_call( - name: str, args: Any, result: Any, *, show_image: bool = True -) -> None: - """Render a compact tool call; shows AI-analysis images inline if present.""" - label = f"🔧 {name}" - if name == "analyze_image" and isinstance(args, dict) and args.get("prompt"): - label += f" · '{args['prompt']}'" - elif name == "segment_image" and isinstance(args, dict) and args.get("classes"): - label += f" · '{args['classes']}'" - elif name == "visualize_image_features" and isinstance(args, dict): - label += f" · clusters={args.get('n_clusters', 6)}" - elif ( - name == "estimate_traversability" - and isinstance(args, dict) - and args.get("prompt") - ): - label += f" · '{args['prompt']}'" - elif name == "get_images" and isinstance(args, dict) and args.get("limit"): - label += f" · limit={args['limit']}" - - with st.expander(label, expanded=False): - st.json({"args": args, "result": result}) - - if not show_image or not isinstance(result, dict): - return - - if name == "analyze_image" and result.get("image_url"): - st.image(result["image_url"], use_container_width=True) - elif name == "estimate_traversability" and result.get("image_url"): - st.image(result["image_url"], use_container_width=True) - elif name in {"segment_image", "visualize_image_features"} and result.get( - "image_urls" - ): - cols = st.columns(min(len(result["image_urls"]), 3)) - for idx, url in enumerate(result["image_urls"]): - cols[idx % len(cols)].image(url, use_container_width=True) - for label_text in result.get("labels", []): - st.caption(label_text) - elif result.get("image_url"): - # Fallback for any other tool that returns a single image (e.g. take_photo). - st.image(result["image_url"], use_container_width=True) - - -def _render_proposed_actions_inline( - message: dict[str, Any], actions: list[dict[str, Any]], idx: int -) -> None: - """Render proposed actions as compact inline chat-style approval.""" - with st.container(key=f"proposal_{idx}"): - st.markdown("*I can do this:*") - for action in actions: - st.markdown(f"• {_action_summary(action)}") - approve_col, reject_col = st.columns([1, 1]) - if approve_col.button( - "✓ Approve", key=f"approve_{idx}", use_container_width=True - ): - with st.spinner("Executing actions…"): - results = _execute_proposed_actions(actions, message, wait=True) - message["approved"] = True - message["content"] += "\n\n" + _format_execution_results(results) - _persist_session() - st.rerun() - if reject_col.button("✕ Reject", key=f"reject_{idx}", use_container_width=True): - message["rejected"] = True - message["content"] += "\n\n❌ Cancelled." - _persist_session() - st.rerun() - - -_APPROVAL_WORDS = { - "yes", - "y", - "approve", - "approved", - "ok", - "okay", - "sure", - "go ahead", - "do it", - "confirm", - "confirmed", - "execute", - "run it", -} -_REJECTION_WORDS = { - "no", - "n", - "reject", - "rejected", - "cancel", - "cancelled", - "don't", - "dont", - "stop", - "abort", -} - - -def _is_approval(text: str) -> bool: - return text.strip("!.? ").lower() in _APPROVAL_WORDS - - -def _is_rejection(text: str) -> bool: - return text.strip("!.? ").lower() in _REJECTION_WORDS - - -_CONFIG_PATH = Path(os.getenv("TWFB_CONFIG", "configs/dev.yaml")) - - -def _load_config_yaml() -> tuple[YAML, Any, Path]: - """Load the project YAML while preserving comments and formatting.""" - yaml = YAML() - yaml.preserve_quotes = True - yaml.default_flow_style = False - path = Path(_CONFIG_PATH) - with path.open(encoding="utf-8") as fh: - data = yaml.load(fh) - return yaml, data, path - - -def _entity_id(name: str) -> str: - base = re.sub(r"[^a-z0-9_]+", "_", name.lower()).strip("_") - return base or "entity" - - -def _add_garden_entity(x: float, y: float, kind: str, name: str) -> None: - """Append a new entity to ``configs/dev.yaml`` and reload the world model.""" - yaml, data, path = _load_config_yaml() - spatial = data.setdefault("spatial", {}) - entities = spatial.setdefault("entities", []) - entity = { - "id": _entity_id(name), - "kind": kind, - "name": name, - "x": float(x), - "y": float(y), - "z": 0.0, - "radius_mm": 50, - "metadata": {}, - } - entities.append(entity) - with path.open("w", encoding="utf-8") as fh: - yaml.dump(data, fh) - - -def _selected_garden_points(event: Any) -> list[tuple[float, float]]: - """Extract all selected grid coordinates from an Altair on_select event.""" - if not event: - return [] - selection = event.get("selection") if isinstance(event, dict) else None - if not isinstance(selection, dict): - selection = event if isinstance(event, dict) else {} - points: list[tuple[float, float]] = [] - for value in selection.values(): - if isinstance(value, list): - for item in value: - if isinstance(item, dict): - points.append((float(item.get("x", 0)), float(item.get("y", 0)))) - return points - - -def _garden_grid( - bounds: dict[str, float], step: float = 100.0 -) -> list[dict[str, float]]: - x0 = bounds.get("x", 0) - y0 = bounds.get("y", 0) - width = bounds.get("width", 0) - height = bounds.get("height", 0) - rows: list[dict[str, float]] = [] - xi = x0 - while xi <= x0 + width: - yi = y0 - while yi <= y0 + height: - rows.append({"x": round(xi, 1), "y": round(yi, 1)}) - yi += step - xi += step - return rows - - -def _refresh_position(client: ApiClient) -> None: - xyz = client.get_position() or {} - st.session_state["pos_x"] = _num(xyz.get("x")) - st.session_state["pos_y"] = _num(xyz.get("y")) - st.session_state["pos_z"] = _num(xyz.get("z")) - - -def _refresh_health(client: ApiClient) -> None: - health = client.get_health() - if health is not None: - st.session_state["farmbot_status"] = health.get("farmbot", "?") - st.session_state["actions"] = health.get("actions", []) - - -def _refresh_messages(client: ApiClient) -> None: - st.session_state["messages"] = client.get_messages() - - -def _refresh_telemetry(client: ApiClient) -> None: - _refresh_position(client) - _refresh_health(client) - _refresh_messages(client) - - -def _time_ago(ts: float) -> str: - """Return a human-readable 'X s/min/h ago' string.""" - if not ts: - return "never" - delta = time.time() - ts - if delta < 1: - return "just now" - if delta < 60: - return f"{int(delta)} s ago" - if delta < 3600: - return f"{int(delta / 60)} min ago" - return f"{int(delta / 3600)} h ago" - - -@st.fragment(run_every=1) -def _sidebar_auto_refresh(client: ApiClient) -> None: - """Refresh position and stats in the background without interrupting analysis.""" - position_s = max(1, int(st.session_state.get("refresh_position_s", 2))) - stats_s = max(1, int(st.session_state.get("refresh_stats_s", 300))) - - now = time.time() - if now - st.session_state.get("last_position_refresh", 0) >= position_s: - _refresh_position(client) - st.session_state["last_position_refresh"] = now - if now - st.session_state.get("last_stats_refresh", 0) >= stats_s: - _refresh_health(client) - d = client.request("GET", "/status") - st.session_state["diag"] = ( - d.body.get("state", {}) if d.ok and isinstance(d.body, dict) else {} - ) - info_for_history = (st.session_state.get("diag") or {}).get( - "informational_settings", {} - ) or {} - st.session_state["history"].append( - { - "time": datetime.now().strftime("%H:%M:%S"), - "cpu": _float(info_for_history.get("cpu_usage")), - "memory": _float(info_for_history.get("memory_usage")), - "disk": _float(info_for_history.get("disk_usage")), - "wifi": _float(info_for_history.get("wifi_level_percent")), - "soc": _float(info_for_history.get("soc_temp")), - "uptime": _float(info_for_history.get("uptime")), - } - ) - st.session_state["history"] = st.session_state["history"][-60:] - st.session_state["last_stats_refresh"] = now - - fb = st.session_state.get("farmbot_status", "?") - pill_css = "ok" if fb == "connected" else ("warn" if fb == "skipped" else "bad") - st.markdown(f'● {fb}', unsafe_allow_html=True) - st.caption( - f"X {st.session_state.get('pos_x', '—')} · " - f"Y {st.session_state.get('pos_y', '—')} · " - f"Z {st.session_state.get('pos_z', '—')} mm " - f"· updated {_time_ago(st.session_state.get('last_position_refresh', 0))}" - ) - if st.button("↻ Refresh", use_container_width=True): - _refresh_telemetry(client) - st.rerun(scope="fragment") - - -@st.fragment(run_every=1) -def _camera_auto_refresh(client: ApiClient) -> None: - """Refresh the camera gallery in the background without interrupting analysis.""" - camera_s = int(st.session_state.get("refresh_camera_s", 0)) - if camera_s > 0: - now = time.time() - if now - st.session_state.get("last_camera_refresh", 0) >= camera_s: - r = client.request("GET", "/images", timeout=10.0) - if r.ok and isinstance(r.body, dict): - st.session_state["camera_images"] = r.body.get("images", []) - _persist_session() - st.session_state["last_camera_refresh"] = now - - -@st.fragment(run_every=1) -def _garden_live_pose(client: ApiClient) -> None: - """Update the robot/camera pose on the garden map in the background.""" - position_s = max(1, int(st.session_state.get("refresh_position_s", 2))) - now = time.time() - if now - st.session_state.get("last_garden_pose_refresh", 0) >= position_s: - pos = client.get_position() or {} - world = st.session_state.get("garden_world") - if world: - world["robot"] = { - "x": pos.get("x", 0), - "y": pos.get("y", 0), - "z": pos.get("z", 0), - } - cam_offset = world.get("camera", {}).get("position") or {} - world["camera"] = world.get("camera", {}) - world["camera"]["position"] = { - "x": pos.get("x", 0) + cam_offset.get("x", 0), - "y": pos.get("y", 0) + cam_offset.get("y", 0), - "z": pos.get("z", 0) + cam_offset.get("z", 0), - } - st.session_state["garden_world"] = world - st.session_state["last_garden_pose_refresh"] = now - - -TABS = [ - "Overview", - "Garden", - "Motion", - "Camera", - "I/O", - "Assistant", - "History", - "Diagnostics", - "Settings", -] - - -def _qp_tab() -> str: - """Return the tab key currently set in the URL query string.""" - raw = st.query_params.get("tab") - if isinstance(raw, list): - raw = raw[0] if raw else "" - return (raw or "").lower() - - -def _tab_from_key(key: str) -> str: - """Map a URL-safe tab key back to the display name.""" - low = key.lower() - # Legacy routes from the old Sensors / Operations tabs now live under I/O. - if low in {"sensors", "operations"}: - return "I/O" - for tab in TABS: - if tab.lower() == low: - return tab - return TABS[0] - - -def _sync_tab_url() -> None: - """Callback that updates the URL when the user switches tabs.""" - selected = st.session_state.get("nav_tab") - if selected: - st.query_params["tab"] = selected.lower() - - -def _do_move(client: ApiClient, x: float, y: float, z: float, label: str = "") -> None: - r = client.request( - "POST", "/actions", json={"kind": "move", "params": {"x": x, "y": y, "z": z}} - ) - if r.ok: - msg = f"→ {label}" if label else f"→ ({x:.0f}, {y:.0f}, {z:.0f})" - st.toast(msg, icon="➡️") - _refresh_position(client) - else: - st.error(r.error_message()) - - -def _do_pin_write( - client: ApiClient, pin: int, value: int, mode: str = "digital" -) -> None: - r = client.request( - "POST", - "/actions", - json={ - "kind": "write_pin", - "params": {"pin": pin, "value": value, "mode": mode}, - }, - ) - if r.ok: - st.toast(f"pin {pin} = {value}", icon="✏️") - else: - st.error(r.error_message()) - - -def _do_pin_pulse( - client: ApiClient, pin: int, seconds: float, mode: str = "digital" -) -> None: - r = client.request( - "POST", - "/actions", - json={ - "kind": "write_pin", - "params": {"pin": pin, "value": 1, "mode": mode, "seconds": seconds}, - }, - ) - if r.ok: - st.toast(f"pin {pin} HIGH for {seconds}s", icon="✏️") - else: - st.error(r.error_message()) - - -# ── page shell ──────────────────────────────────────────────────────────────── - -st.set_page_config( - page_title="TWFarmBot Research", - page_icon="🌾", - layout="wide", - initial_sidebar_state="expanded", -) - -st.markdown( - """ - -""", - unsafe_allow_html=True, -) - -api_url = st.session_state.setdefault("api_url", API_URL) -client = _client(api_url) -if "farmbot_status" not in st.session_state: - _refresh_health(client) - _refresh_position(client) - st.session_state.setdefault("messages", []) - -# Auto-refresh settings (persisted via the normal session save). -st.session_state.setdefault("refresh_position_s", 45) -st.session_state.setdefault("refresh_stats_s", 300) -st.session_state.setdefault("refresh_camera_s", 0) -st.session_state.setdefault("last_position_refresh", 0.0) -st.session_state.setdefault("last_stats_refresh", 0.0) -st.session_state.setdefault("last_camera_refresh", 0.0) -st.session_state.setdefault("last_garden_pose_refresh", 0.0) -st.session_state.setdefault("history", []) -st.session_state.setdefault("camera_images", []) -st.session_state.setdefault("garden_world", None) - -# ── sidebar ────────────────────────────────────────────────────────────────── - -with st.sidebar: - st.markdown( - '' - '', - unsafe_allow_html=True, - ) - # Sync the navigation radio with the URL ?tab=... query parameter so - # refreshing the browser returns to the same tab. - url_tab = _tab_from_key(_qp_tab()) - if st.session_state.get("nav_tab") != url_tab: - st.session_state["nav_tab"] = url_tab - tab = st.radio( - "Navigation", - TABS, - key="nav_tab", - on_change=_sync_tab_url, - label_visibility="collapsed", - ) - - st.divider() - _sidebar_auto_refresh(client) - - st.divider() - if st.button("🛑 ESTOP", type="primary", use_container_width=True): - r = client.request("POST", "/actions", json={"kind": "e_stop", "params": {}}) - if r.ok: - st.toast("ESTOP sent", icon="🛑") - else: - st.error(r.error_message()) - -# ── tab content ─────────────────────────────────────────────────────────────── - - -def _render_overview() -> None: - st.markdown( - '
TWFarmBot · UAS Technikum Wien
', - unsafe_allow_html=True, - ) - st.markdown("# Research overview") - - # ── Live position ────────────────────────────────────────────────────────── - row = st.columns(3) - row[0].metric("X · mm", st.session_state.get("pos_x", "—")) - row[1].metric("Y · mm", st.session_state.get("pos_y", "—")) - row[2].metric("Z · mm", st.session_state.get("pos_z", "—")) - st.caption( - f"Position updated {_time_ago(st.session_state.get('last_position_refresh', 0))}" - ) - - # ── System status ────────────────────────────────────────────────────────── - st.markdown("### System status") - st.session_state.setdefault("history", []) - - refresh_col, clear_col = st.columns([3, 1]) - with refresh_col: - refresh_clicked = st.button("🔄 Refresh status", use_container_width=True) - with clear_col: - if st.button("Clear history", use_container_width=True): - st.session_state["history"] = [] - st.rerun() - - if refresh_clicked: - _refresh_health(client) - _refresh_position(client) - d = client.request("GET", "/status") - st.session_state["diag"] = ( - d.body.get("state", {}) if d.ok and isinstance(d.body, dict) else {} - ) - info_for_history = (st.session_state.get("diag") or {}).get( - "informational_settings", {} - ) or {} - st.session_state["history"].append( - { - "time": datetime.now().strftime("%H:%M:%S"), - "cpu": _float(info_for_history.get("cpu_usage")), - "memory": _float(info_for_history.get("memory_usage")), - "disk": _float(info_for_history.get("disk_usage")), - "wifi": _float(info_for_history.get("wifi_level_percent")), - "soc": _float(info_for_history.get("soc_temp")), - "uptime": _float(info_for_history.get("uptime")), - } - ) - # Keep the last 60 samples so the chart stays readable. - st.session_state["history"] = st.session_state["history"][-60:] - st.rerun() - - payload = st.session_state.get("diag", {}) or {} - info = payload.get("informational_settings", {}) or {} - loc = payload.get("location_data", {}) or {} - - status_cols = st.columns(5) - status_cols[0].metric("FarmBot", st.session_state.get("farmbot_status", "—")) - status_cols[1].metric("Uptime", f"{_num(info.get('uptime'))} s") - status_cols[2].metric("Wi-Fi", f"{_num(info.get('wifi_level_percent'))}%") - status_cols[3].metric("Sync", info.get("sync_status", "—")) - status_cols[4].metric("Busy", "Yes" if info.get("busy") else "No") - st.caption( - f"Stats updated {_time_ago(st.session_state.get('last_stats_refresh', 0))}" - ) - - # ── Resources over time ──────────────────────────────────────────────────── - st.markdown("### Resources over time") - cpu = info.get("cpu_usage") - mem = info.get("memory_usage") - disk = info.get("disk_usage") - soc = info.get("soc_temp") - - res_cols = st.columns(4) - res_cols[0].metric("CPU", f"{cpu}%" if cpu is not None else "—") - res_cols[1].metric("Memory", f"{mem}%" if mem is not None else "—") - res_cols[2].metric("Disk", f"{disk}%" if disk is not None else "—") - res_cols[3].metric("SoC temp", f"{soc}°C" if soc is not None else "—") - - hist = st.session_state["history"] - if hist: - base = alt.Chart(alt.Data(values=hist)) - usage_lines = ( - base.transform_fold( - fold=["cpu", "memory", "disk"], - as_=["metric", "value"], - ) - .mark_line(point=True, strokeWidth=2) - .encode( - x=alt.X("time:N", title=None), - y=alt.Y("value:Q", title="Usage %", scale=alt.Scale(domain=[0, 100])), - color=alt.Color( - "metric:N", - scale=alt.Scale( - domain=["cpu", "memory", "disk"], - range=["#3f8f64", "#5b8fc7", "#c7a15b"], - ), - legend=alt.Legend(title="Metric"), - ), - ) - .properties(height=240) - ) - st.altair_chart(usage_lines, use_container_width=True) - - extra_cols = st.columns(2) - with extra_cols[0]: - wifi_chart = ( - base.mark_line(point=True, color="#5b8fc7", strokeWidth=2) - .encode( - x=alt.X("time:N", title=None), - y=alt.Y( - "wifi:Q", title="Wi-Fi %", scale=alt.Scale(domain=[0, 100]) - ), - ) - .properties(height=180) - ) - st.altair_chart(wifi_chart, use_container_width=True) - with extra_cols[1]: - soc_chart = ( - base.mark_line(point=True, color="#c75b5b", strokeWidth=2) - .encode( - x=alt.X("time:N", title=None), - y=alt.Y("soc:Q", title="SoC temp °C"), - ) - .properties(height=180) - ) - st.altair_chart(soc_chart, use_container_width=True) - else: - st.info("Click **Refresh status** to start collecting data for the charts.") - - # ── Network & details ────────────────────────────────────────────────────── - c1, c2 = st.columns(2) - with c1: - st.markdown("**Network & hardware**") - st.caption(f"Private IP: `{info.get('private_ip', '—')}`") - st.caption(f"Wi-Fi signal: {_num(info.get('wifi_level'))} dBm") - st.caption(f"Controller: {info.get('controller_version', '—')}") - st.caption(f"Firmware: {info.get('firmware_version', '—')}") - axes = loc.get("axis_states", {}) or {} - st.caption( - f"Axis states · X {axes.get('x', '—')} · Y {axes.get('y', '—')} · Z {axes.get('z', '—')}" - ) - - with c2: - st.markdown("**Recent events**") - msgs = st.session_state.get("messages", []) - if msgs: - st.code("\n".join(msgs[-10:]), language="text") - else: - st.caption("No events recorded.") - - # ── Experiment notes ─────────────────────────────────────────────────────── - st.markdown("### Experiment") - c1, c2, c3 = st.columns(3) - with c1: - st.text_input( - "Run", - key="run", - placeholder="e.g. soil-map-07", - label_visibility="collapsed", - ) - with c2: - st.text_input( - "Operator", key="op", placeholder="initials", label_visibility="collapsed" - ) - with c3: - st.text_area( - "Notes", - key="notes", - placeholder="Conditions, observations…", - height=90, - label_visibility="collapsed", - ) - - -def _render_garden() -> None: - st.markdown( - '
Spatial model · configured world state
', - unsafe_allow_html=True, - ) - st.markdown("# Garden map") - - refresh_col, _ = st.columns([1, 4]) - if refresh_col.button("↻ Refresh map", use_container_width=True): - st.session_state["garden_world"] = None - - world = st.session_state.get("garden_world") - if world is None: - result = client.request("GET", "/garden") - if not result.ok or not isinstance(result.body, dict): - st.error(f"Garden model unavailable: {result.error_message()}") - return - world = result.body - st.session_state["garden_world"] = world - _persist_session() - - _garden_live_pose(client) - - bounds = world.get("bounds", {}) - camera = world.get("camera", {}) - robot = world.get("robot", {}) - entities = world.get("entities", []) - zones = world.get("zones", []) - - metrics = st.columns(4) - metrics[0].metric("Garden X", f"{_num(bounds.get('width'))} mm") - metrics[1].metric("Garden Y", f"{_num(bounds.get('height'))} mm") - metrics[2].metric("Known objects", len(entities)) - metrics[3].metric("Mapped zones", len(zones)) - - zone_rows = [ - { - **zone["bounds"], - "x2": zone["bounds"]["x"] + zone["bounds"]["width"], - "y2": zone["bounds"]["y"] + zone["bounds"]["height"], - "kind": zone["kind"], - "name": zone["name"], - } - for zone in zones - ] - point_rows = [ - { - "x": entity["position"]["x"], - "y": entity["position"]["y"], - "kind": entity["kind"], - "name": entity["name"], - "radius_mm": entity["radius_mm"], - } - for entity in entities - ] - point_rows.extend( - [ - { - "x": robot.get("x", 0), - "y": robot.get("y", 0), - "kind": "robot", - "name": "FarmBot", - "radius_mm": 35, - }, - { - "x": camera.get("position", {}).get("x", 0), - "y": camera.get("position", {}).get("y", 0), - "kind": "camera", - "name": "Camera", - "radius_mm": 25, - }, - ] - ) - - x_min = bounds.get("x", 0) - x_max = x_min + bounds.get("width", 1) - y_min = bounds.get("y", 0) - y_max = y_min + bounds.get("height", 1) - - def _map_scale(lo: float, hi: float) -> alt.Scale: - # Clamp pan/zoom so the user cannot scroll far outside the garden. - return alt.Scale( - domain=[lo, hi], - domainMin=lo, - domainMax=hi, - clamp=True, - nice=False, - ) - - x_scale = _map_scale(x_min, x_max) - y_scale = _map_scale(y_min, y_max) - - bounds_chart = ( - alt.Chart( - alt.Data( - values=[ - { - "x": x_min, - "y": y_min, - "x2": x_max, - "y2": y_max, - } - ] - ) - ) - .mark_rect(filled=False, stroke="#888888", strokeWidth=2) - .encode( - x=alt.X("x:Q", scale=x_scale, title="X · mm"), - x2="x2:Q", - y=alt.Y("y:Q", scale=y_scale, title="Y · mm"), - y2="y2:Q", - ) - ) - zones_chart = ( - alt.Chart(alt.Data(values=zone_rows)) - .mark_rect(opacity=0.18, strokeWidth=2) - .encode( - x=alt.X("x:Q", scale=x_scale, title="X · mm"), - x2="x2:Q", - y=alt.Y("y:Q", scale=y_scale, title="Y · mm"), - y2="y2:Q", - color=alt.Color("kind:N", title="Layer"), - stroke=alt.Stroke("kind:N", legend=None), - tooltip=["name:N", "kind:N", "x:Q", "y:Q", "width:Q", "height:Q"], - ) - ) - points_chart = ( - alt.Chart(alt.Data(values=point_rows)) - .mark_point(filled=True, stroke="white", strokeWidth=1) - .encode( - x=alt.X("x:Q", scale=x_scale), - y=alt.Y("y:Q", scale=y_scale), - color=alt.Color("kind:N", title="Object"), - shape=alt.value("circle"), - size=alt.Size("radius_mm:Q", scale=alt.Scale(range=[90, 500]), legend=None), - tooltip=["name:N", "kind:N", "x:Q", "y:Q"], - ) - ) - - grid_rows = _garden_grid(bounds, step=25) - click_selection = alt.selection_point( - name="garden_click", - fields=["x", "y"], - on="click", - toggle=True, - nearest=True, - empty=False, - ) - click_layer = ( - alt.Chart(alt.Data(values=grid_rows)) - .mark_point(size=120) - .encode( - x=alt.X("x:Q", scale=x_scale), - y=alt.Y("y:Q", scale=y_scale), - opacity=alt.condition(click_selection, alt.value(0.7), alt.value(0)), - color=alt.value("#ff4b4b"), - ) - .add_params(click_selection) - ) - - map_col, details = st.columns([2.3, 1]) - with map_col: - st.altair_chart( - (bounds_chart + zones_chart + points_chart + click_layer) - .properties(height=520) - .interactive(), - width="stretch", - on_select="rerun", - key="garden_map", - ) - selected_points = _selected_garden_points(st.session_state.get("garden_map")) - with details: - if selected_points: - with st.container(border=True): - st.markdown(f"**🌱 {len(selected_points)} selected**") - kind = st.pills( - "Kind", - [ - "plant", - "obstacle", - "tool", - "marker", - "sensor", - "valve", - "custom", - ], - default="plant", - selection_mode="single", - label_visibility="collapsed", - key="garden_assign_kind", - ) - custom_kind = "" - if kind == "custom": - custom_kind = st.text_input( - "Custom kind", - placeholder="e.g. watering", - key="garden_assign_custom_kind", - ) - name = st.text_input( - "Name prefix", - placeholder="e.g. Tomato", - key="garden_assign_name", - ) - c1, c2 = st.columns(2) - if c1.button( - f"Assign {len(selected_points)}", - key="garden_assign_save", - use_container_width=True, - ): - final_kind = ( - custom_kind - if kind == "custom" and custom_kind - else (kind or "plant") - ) - if name: - for i, (px, py) in enumerate(selected_points, start=1): - _add_garden_entity(px, py, final_kind, f"{name}-{i}") - st.success(f"Added {len(selected_points)} {final_kind}(s)") - st.session_state.pop("garden_map", None) - st.rerun() - else: - st.warning("Please enter a name prefix.") - if c2.button( - "Clear", key="garden_assign_cancel", use_container_width=True - ): - st.session_state.pop("garden_map", None) - st.rerun() - st.markdown("**Live pose**") - pose = st.columns(3) - pose[0].metric("X", _num(robot.get("x"))) - pose[1].metric("Y", _num(robot.get("y"))) - pose[2].metric("Z", _num(robot.get("z"))) - st.markdown("**Camera pose**") - st.caption( - f"X {_num(camera.get('position', {}).get('x'))} · " - f"Y {_num(camera.get('position', {}).get('y'))} · " - f"Z {_num(camera.get('position', {}).get('z'))} mm" - ) - st.caption( - f"Yaw {_num(camera.get('yaw_deg'))}° · " - f"Pitch {_num(camera.get('pitch_deg'))}° · " - f"Roll {_num(camera.get('roll_deg'))}°" - ) - camera_offset = world.get("camera_offset") or {} - if camera_offset: - st.caption( - f"Offset from FarmBot: " - f"X {_num(camera_offset.get('x'))} · " - f"Y {_num(camera_offset.get('y'))} · " - f"Z {_num(camera_offset.get('z'))} mm" - ) - st.markdown("**Mapped objects**") - st.dataframe( - [{"name": item["name"], "kind": item["kind"]} for item in entities], - hide_index=True, - width="stretch", - ) - - -def _render_motion() -> None: - cur_x = _float(st.session_state.get("pos_x")) - cur_y = _float(st.session_state.get("pos_y")) - cur_z = _float(st.session_state.get("pos_z")) - - st.markdown( - '
TWFarmBot · UAS Technikum Wien
', - unsafe_allow_html=True, - ) - st.markdown("# Motion workspace") - - row = st.columns(3) - row[0].metric("X · mm", st.session_state.get("pos_x", "—")) - row[1].metric("Y · mm", st.session_state.get("pos_y", "—")) - row[2].metric("Z · mm", st.session_state.get("pos_z", "—")) - - step = float(st.segmented_control("Jog step · mm", [1, 10, 50, 100], default=10)) - - # D-pad - _, u, _ = st.columns(3) - if u.button("▲ Y+", use_container_width=True): - _do_move( - client, float(cur_x), float(cur_y + step), float(cur_z), f"Y+{step:.0f}" - ) - left, m, right = st.columns(3) - if left.button("◀ X−", use_container_width=True): - _do_move( - client, float(cur_x - step), float(cur_y), float(cur_z), f"X-{step:.0f}" - ) - if m.button("🏠 Home", use_container_width=True): - _do_move(client, 0.0, 0.0, 0.0, "Home") - if right.button("X+ ▶", use_container_width=True): - _do_move( - client, float(cur_x + step), float(cur_y), float(cur_z), f"X+{step:.0f}" - ) - _, d, _ = st.columns(3) - if d.button("▼ Y−", use_container_width=True): - _do_move( - client, float(cur_x), float(cur_y - step), float(cur_z), f"Y-{step:.0f}" - ) - - zl, zr = st.columns(2) - if zl.button("⬆ Z+", use_container_width=True): - _do_move( - client, float(cur_x), float(cur_y), float(cur_z + step), f"Z+{step:.0f}" - ) - if zr.button("⬇ Z−", use_container_width=True): - _do_move( - client, float(cur_x), float(cur_y), float(cur_z - step), f"Z-{step:.0f}" - ) - - st.divider() - with st.form("absolute"): - tx, ty, tz = st.columns(3) - gx = tx.text_input("X", value=f"{cur_x:.2f}") - gy = ty.text_input("Y", value=f"{cur_y:.2f}") - gz = tz.text_input("Z", value=f"{cur_z:.2f}") - if st.form_submit_button("Go to", use_container_width=True): - x = _parse_number(gx) - y = _parse_number(gy) - z = _parse_number(gz) - if None in (x, y, z): - st.error( - f"Invalid coordinates: X={gx!r}, Y={gy!r}, Z={gz!r}. " - "Use a plain number like '123' or '123.4' (comma also accepted)." - ) - else: - _do_move(client, float(x), float(y), float(z)) - - if st.button("Find home"): - resp = client.request( - "POST", "/actions", json={"kind": "find_home", "params": {}} - ) - if resp.ok: - st.toast("Homing queued") - else: - st.error(resp.error_message()) - - # Presets - if "presets" not in st.session_state: - resp = client.request("GET", "/positions") - st.session_state["presets"] = resp.body.get("positions", []) if resp.ok else [] - presets = st.session_state["presets"] - if presets: - st.markdown("**Locations**") - cols = st.columns(min(5, len(presets))) - for i, p in enumerate(presets): - if cols[i].button( - p.get("label", "?"), key=f"preset_{i}", use_container_width=True - ): - _do_move( - client, float(p["x"]), float(p["y"]), float(p["z"]), p["label"] - ) - - -def _render_io() -> None: - st.markdown( - '
TWFarmBot · UAS Technikum Wien
', - unsafe_allow_html=True, - ) - st.markdown("# I/O workspace") - - if "named_pins" not in st.session_state: - r = client.request("GET", "/pins") - st.session_state["named_pins"] = r.body.get("pins", []) if r.ok else [] - named = st.session_state["named_pins"] - - # ── Sensors ─────────────────────────────────────────────────────────────── - sensors = [p for p in named if p.get("kind") == "sensor"] - if sensors: - st.markdown("### 🔍 Sensors") - cols = st.columns(min(3, len(sensors))) - for i, s in enumerate(sensors): - with cols[i]: - with st.container(border=True): - mode = s.get("mode", "analog") - st.markdown( - f"**{s['label']}** {mode}", - unsafe_allow_html=True, - ) - st.caption(f"pin {s['pin']}") - if st.button("Read", key=f"sensor_{i}", use_container_width=True): - r = client.request( - "GET", - f"/pin/{s['pin']}", - params={"mode": mode}, - ) - st.session_state[f"sv_{s['pin']}"] = ( - r.body.get("value") if r.ok else "—" - ) - sensor_value = st.session_state.get(f"sv_{s['pin']}", "—") - st.markdown( - f"
{sensor_value}
", - unsafe_allow_html=True, - ) - elif named: - st.info("No sensor pins configured.") - - st.divider() - - # ── Actuators ───────────────────────────────────────────────────────────── - st.markdown("### ⚡ Actuators") - a, b = st.columns(2) - with a: - with st.container(border=True): - st.markdown("**💧 Irrigation**") - secs = st.number_input("Seconds", 0.1, 300.0, 2.0, 0.5, key="water_secs") - if st.button("Water", use_container_width=True, type="primary"): - r = client.request( - "POST", - "/actions", - json={"kind": "water", "params": {"seconds": secs}}, - ) - if r.ok: - st.success("Queued") - else: - st.error(r.error_message()) - st.caption("Runs the pump for the selected duration.") - - with b: - with st.container(border=True): - st.markdown("**🔌 Peripheral control**") - outputs = [p for p in named if p.get("kind") != "sensor"] - if not outputs: - st.info("No output pins configured.") - else: - sel = st.selectbox( - "Output", - outputs, - format_func=lambda p: f"{p['label']} · pin {p['pin']}", - label_visibility="collapsed", - ) - if sel: - mode = sel.get("mode", "digital") - st.markdown( - f"{mode}", - unsafe_allow_html=True, - ) - - if mode == "analog": - presets = sel.get("presets") or {} - if presets: - st.caption("Presets") - preset_cols = st.columns(len(presets)) - for idx, (pval, plabel) in enumerate( - sorted(presets.items(), key=lambda x: int(x[0])) - ): - if preset_cols[idx].button( - f"{plabel} ({pval})", - use_container_width=True, - key=f"preset_{sel['pin']}_{pval}", - ): - _do_pin_write(client, sel["pin"], int(pval), mode) - - val_col, btn_col = st.columns([4, 1]) - with val_col: - analog_value = st.slider( - "PWM value", - min_value=0, - max_value=255, - value=0, - key=f"analog_value_{sel['pin']}", - ) - with btn_col: - st.markdown( - "
", - unsafe_allow_html=True, - ) - if st.button("Apply", use_container_width=True): - _do_pin_write(client, sel["pin"], analog_value, mode) - else: - pulse = st.toggle("Timed pulse", value=True) - pulse_secs: float | None = None - if pulse: - pulse_secs = st.number_input( - "Seconds", - 0.1, - 300.0, - 2.0, - 0.5, - key=f"pulse_secs_{sel['pin']}", - ) - - off, on = st.columns(2) - if off.button("⏻ OFF", use_container_width=True): - _do_pin_write(client, sel["pin"], 0, mode) - if on.button("⏻ ON", use_container_width=True, type="primary"): - if pulse and pulse_secs is not None: - _do_pin_pulse(client, sel["pin"], pulse_secs, mode) - else: - _do_pin_write(client, sel["pin"], 1, mode) - - -def _render_camera() -> None: - st.markdown( - '
TWFarmBot · UAS Technikum Wien
', - unsafe_allow_html=True, - ) - st.markdown("# Camera") - - _camera_auto_refresh(client) - - capture, refresh, _ = st.columns([1, 1, 4]) - if capture.button("📷 Take photo", type="primary", use_container_width=True): - r = client.request( - "POST", "/actions", json={"kind": "take_photo", "params": {}} - ) - if r.ok: - st.toast("Capture queued", icon="📷") - # Fetch the gallery so the new capture appears as soon as it is ready. - rg = client.request("GET", "/images", timeout=10.0) - if rg.ok and isinstance(rg.body, dict): - st.session_state["camera_images"] = rg.body.get("images", []) - _persist_session() - else: - st.error(r.error_message()) - if refresh.button("↻ Refresh gallery", use_container_width=True): - r = client.request("GET", "/images", params={"refresh": "true"}, timeout=10.0) - if r.ok and isinstance(r.body, dict): - st.session_state["camera_images"] = r.body.get("images", []) - _persist_session() - else: - st.error(r.error_message()) - - images = st.session_state.get("camera_images", []) - if not images: - st.info("Refresh the gallery to load FarmBot photos.") - return - - selected = st.selectbox( - "Research image", - images, - format_func=lambda image: ( - f"{image.get('created_at', 'Unknown time')} · image {image.get('id', '—')}" - ), - ) - - # Show the source image and analysis controls side-by-side so both fit - # above the fold without scrolling. The analysis results still appear below. - img_col, ctrl_col = st.columns([1.6, 1]) - - with img_col: - st.image(selected.get("attachment_url"), use_container_width=True) - - with ctrl_col: - st.markdown("**AI analysis**") - mode = st.selectbox( - "Mode", - [ - "Open Language Similarity", - "Zero-Shot Segmentation", - "PCA Feature Visualization", - "Traversability Estimation", - ], - key=f"ai_mode_{selected.get('id', 'unknown')}", - label_visibility="collapsed", - ) - - processor = _image_processor(RESIREG_URL) - inputs: dict[str, Any] = {} - button_disabled = False - - if mode == "Open Language Similarity": - inputs["prompt"] = st.text_input( - "Target prompt", - placeholder="e.g. green leaves, dry soil, red marker", - key=f"ai_prompt_{selected.get('id', 'unknown')}", - label_visibility="collapsed", - ) - button_disabled = not inputs["prompt"].strip() - elif mode == "Zero-Shot Segmentation": - inputs["classes"] = st.text_input( - "Classes (comma-separated)", - value="plant, weed, soil, path", - key=f"ai_classes_{selected.get('id', 'unknown')}", - ) - inputs["negative"] = st.text_input( - "Background prompt (optional)", - placeholder="e.g. thing, object, stuff", - key=f"ai_negative_{selected.get('id', 'unknown')}", - ) - button_disabled = not inputs["classes"].strip() - elif mode == "PCA Feature Visualization": - inputs["n_clusters"] = st.slider( - "K-means clusters", - min_value=2, - max_value=20, - value=6, - key=f"ai_clusters_{selected.get('id', 'unknown')}", - ) - elif mode == "Traversability Estimation": - inputs["prompt"] = st.text_input( - "Traversable prompt", - placeholder="e.g. path, road, flat ground", - key=f"ai_trav_prompt_{selected.get('id', 'unknown')}", - label_visibility="collapsed", - ) - inputs["negatives"] = st.text_input( - "Background prompts (optional, comma-separated)", - placeholder="e.g. thing, object, stuff, scenery", - key=f"ai_trav_negatives_{selected.get('id', 'unknown')}", - ) - button_disabled = not inputs["prompt"].strip() - - if st.button( - "Analyze selected image", - type="primary", - use_container_width=True, - disabled=button_disabled, - ): - try: - with st.spinner("Processing image…"): - if mode == "Open Language Similarity": - result_path = processor.process( - selected["attachment_url"], - inputs["prompt"].strip(), - negatives="", - ) - st.session_state["ai_result"] = { - "image_id": selected.get("id"), - "source_url": selected.get("attachment_url"), - "paths": [str(result_path)], - "captions": [f"Similarity map · {inputs['prompt']}"], - "labels": [], - "mode": mode, - } - elif mode == "Zero-Shot Segmentation": - raw = processor.predict( - selected["attachment_url"], - api_name="/run_seg", - classes=inputs["classes"].strip(), - negative=inputs["negative"].strip(), - ) - labels = [str(raw[2]), str(raw[3])] - class_scores = parse_segmentation_labels(labels) - st.session_state["ai_result"] = { - "image_id": selected.get("id"), - "source_url": selected.get("attachment_url"), - "paths": [str(raw[0]), str(raw[1])], - "captions": ["Segmentation overlay", "Segmentation map"], - "labels": labels, - "class_scores": class_scores, - "dominant_class": ( - max(class_scores, key=class_scores.get) - if class_scores - else None - ), - "classes": inputs["classes"].strip(), - "mode": mode, - } - elif mode == "PCA Feature Visualization": - raw = processor.predict( - selected["attachment_url"], - api_name="/run_pca", - n_clusters=int(inputs["n_clusters"]), - ) - st.session_state["ai_result"] = { - "image_id": selected.get("id"), - "source_url": selected.get("attachment_url"), - "paths": [str(raw[0]), str(raw[1]), str(raw[2])], - "captions": [ - "PCA visualization 1", - "PCA visualization 2", - "PCA visualization 3", - ], - "labels": [], - "n_clusters": int(inputs["n_clusters"]), - "mode": mode, - } - elif mode == "Traversability Estimation": - result_path = processor.predict( - selected["attachment_url"], - api_name="/run_trav", - prompt=inputs["prompt"].strip(), - negatives=inputs["negatives"].strip(), - ) - st.session_state["ai_result"] = { - "image_id": selected.get("id"), - "source_url": selected.get("attachment_url"), - "paths": [str(result_path)], - "captions": [f"Traversability map · {inputs['prompt']}"], - "labels": [], - "mode": mode, - } - except Exception as exc: - st.error(f"AI processing failed: {exc}") - - result = st.session_state.get("ai_result") - if result: - st.markdown("### Analysis result") - result_cols = st.columns(len(result["paths"])) - for idx, (path, caption) in enumerate(zip(result["paths"], result["captions"])): - with result_cols[idx]: - st.image(path, caption=caption, use_container_width=True) - - st.markdown("**Raw output**") - if result.get("class_scores"): - score_cols = st.columns(len(result["class_scores"])) - for idx, (cls, score) in enumerate(result["class_scores"].items()): - score_cols[idx].metric(cls, f"{score * 100:.1f}%") - if result.get("dominant_class"): - st.caption(f"Dominant class: **{result['dominant_class']}**") - elif result.get("n_clusters"): - st.caption(f"PCA with **{result['n_clusters']}** K-means clusters") - - for label in result.get("labels", []): - st.caption(label) - - if len(images) > 1: - st.markdown("**Recent captures**") - gallery = st.columns(3) - for index, image in enumerate(images[1:7]): - image_meta = image.get("meta") or {} - gallery[index % 3].image( - image.get("attachment_url"), - caption=f"X {image_meta.get('x', '—')} · Y {image_meta.get('y', '—')}", - width=240, - ) - - -def _render_model_picker() -> str | None: - """Render provider + model selectors and return the selected model id.""" - if "assistant_providers" not in st.session_state: - r = client.request("GET", "/providers") - if r.ok and isinstance(r.body, dict): - st.session_state["assistant_providers"] = r.body.get("providers", []) - st.session_state["assistant_provider"] = r.body.get("current", "openrouter") - else: - st.session_state["assistant_providers"] = ["openrouter", "local"] - st.session_state["assistant_provider"] = "openrouter" - - providers = st.session_state["assistant_providers"] - provider_col, model_col = st.columns([1, 2]) - with provider_col: - provider = st.selectbox( - "Provider", - providers, - key="assistant_provider", - ) - - cache = st.session_state.setdefault("assistant_models_cache", {}) - if provider not in cache: - r = client.request("GET", "/models", params={"provider": provider}) - if r.ok and isinstance(r.body, dict): - cache[provider] = r.body.get("models", []) - if not st.session_state.get("assistant_model"): - st.session_state["assistant_model"] = r.body.get("current") - else: - cache[provider] = [] - - models = cache.get(provider, []) - selected_model: str | None = None - with model_col: - if models: - current = st.session_state.get("assistant_model") - # Avoid defaulting to the first option from a raw provider list, - # which may be a meta/safeguard model that cannot chat. - if current not in models: - preferred = [ - "openai/gpt-4o-mini", - "openai/gpt-4o", - "anthropic/claude-3.5-sonnet", - "anthropic/claude-3.5-haiku", - "deepseek/deepseek-v4-flash", - ] - current = next((m for m in preferred if m in models), models[0]) - st.session_state["assistant_model"] = current - index = models.index(current) - selected_model = st.selectbox( - "Model", - models, - index=index, - key="assistant_model", - ) - else: - selected_model = ( - st.text_input( - "Model", - value=st.session_state.get("assistant_model", ""), - key="assistant_model", - ) - or None - ) - return selected_model - - -def _render_assistant() -> None: - st.markdown( - '
TWFarmBot · UAS Technikum Wien
', - unsafe_allow_html=True, - ) - title_col, clear_col = st.columns([5, 1]) - with title_col: - st.markdown("# Assistant") - with clear_col: - if st.button("Clear chat", use_container_width=True): - st.session_state["assistant_messages"] = [] - _persist_session() - st.rerun() - _render_session_controls() - selected_model = _render_model_picker() - st.session_state["assistant_selected_model"] = selected_model - _render_chat() - _persist_session() - - -def _render_chat() -> None: - if "assistant_messages" not in st.session_state: - st.session_state["assistant_messages"] = [] - - for idx, msg in enumerate(st.session_state["assistant_messages"]): - if msg.get("role") == "tool": - with st.chat_message("assistant"): - _render_tool_call( - msg.get("name", "tool"), - msg.get("args"), - msg.get("result"), - show_image=True, - ) - continue - - if msg.get("role") == "user": - with st.container(key=f"user_msg_{idx}"): - with st.chat_message("user"): - st.markdown(msg["content"]) - continue - - # Render the model's reasoning as its own collapsible assistant pill, - # similar to how tool calls are shown, so the conversation flow is clear. - if msg.get("thinking"): - with st.chat_message("assistant"): - with st.expander("🧠 Thinking", expanded=False): - st.markdown(msg["thinking"]) - - with st.chat_message(msg["role"]): - st.markdown(msg["content"]) - images = msg.get("images", []) - if images: - cols = st.columns(min(len(images), 3)) - for i, image in enumerate(images): - cols[i % len(cols)].image(image.get("attachment_url"), width=220) - - proposed_actions = msg.get("proposed_actions", []) - if proposed_actions and not msg.get("approved") and not msg.get("rejected"): - _render_proposed_actions_inline(msg, proposed_actions, idx) - elif msg.get("approved"): - st.caption("Approved") - elif msg.get("rejected"): - st.caption("Rejected") - - _render_assistant_metrics() - - if prompt := st.chat_input("Ask the FarmBot assistant…"): - messages = st.session_state["assistant_messages"] - - # Natural-language approval/rejection: if the user replies "yes", - # "approve", "no", "cancel", etc. to a proposal, handle it immediately - # instead of sending it back to the model and getting a confused answer. - if messages and messages[-1].get("role") == "assistant": - last_assistant = messages[-1] - proposed = last_assistant.get("proposed_actions", []) - pending = ( - proposed - and not last_assistant.get("approved") - and not last_assistant.get("rejected") - ) - approval = _is_approval(prompt) - rejection = _is_rejection(prompt) - if approval or rejection: - if pending: - st.session_state["assistant_messages"].append( - {"role": "user", "content": prompt} - ) - with st.container(key="user_msg_current"): - with st.chat_message("user"): - st.markdown(prompt) - if approval: - with st.spinner("Executing actions…"): - results = _execute_proposed_actions( - proposed, last_assistant, wait=True - ) - last_assistant["approved"] = True - last_assistant["content"] += "\n\n" + _format_execution_results( - results - ) - else: - last_assistant["rejected"] = True - last_assistant["content"] += "\n\n❌ Cancelled." - _persist_session() - st.rerun() - else: - st.toast("No pending proposal to approve or reject.", icon="⚠️") - _persist_session() - st.rerun() - - st.session_state["assistant_messages"].append( - {"role": "user", "content": prompt} - ) - with st.container(key="user_msg_current"): - with st.chat_message("user"): - st.markdown(prompt) - - thinking = st.empty() - thinking.caption("🤖 Assistant is thinking…") - with st.chat_message("assistant"): - stream_meta: dict[str, Any] = { - "tool_calls": [], - "proposed_actions": [], - } - stream_thinking: list[str] = [] - stream_error = None - accumulated = "" - # Preserve the order of streamed text relative to tools/thinking. - # Each open segment is a placeholder that gets updated in place. - # When a tool or thinking block starts, the current text segment is - # closed so subsequent text appears *after* that block. - text_segments: list[list[Any] | None] = [] - - def _current_text_segment() -> list[Any]: - if not text_segments or text_segments[-1] is None: - ph = st.empty() - text_segments.append([ph, ""]) - seg = text_segments[-1] - assert seg is not None - return seg - - def _close_text_segment() -> None: - if text_segments and text_segments[-1] is not None: - text_segments.append(None) - - try: - for event in client.stream( - "POST", - "/chat/stream", - json={ - "messages": st.session_state["assistant_messages"], - "model": st.session_state.get("assistant_selected_model"), - }, - timeout=PLAN_TIMEOUT, - ): - etype = event.get("type") - if etype == "delta": - accumulated += event.get("content", "") - seg = _current_text_segment() - seg[1] = accumulated - seg[0].markdown(accumulated) - elif etype == "thinking": - _close_text_segment() - think_text = str(event.get("content", "")) - with st.expander("🧠 Thinking", expanded=False): - st.markdown(think_text) - stream_thinking.append(think_text) - elif etype == "tool_call": - _close_text_segment() - thinking.caption("🤖 Assistant is using tools…") - name = event.get("name") - args = event.get("args") - result = event.get("result") - st.session_state["assistant_messages"].append( - { - "role": "tool", - "name": name, - "args": args, - "result": result, - } - ) - if ( - name == "take_photo" - and isinstance(result, dict) - and result.get("status") == "ok" - ): - image = _capture_photo_image() - if image: - result["image_url"] = image.get("attachment_url") - _render_tool_call(name, args, result) - elif etype == "meta": - stream_meta["tool_calls"] = event.get("tool_calls", []) - stream_meta["proposed_actions"] = event.get( - "proposed_actions", [] - ) - stream_meta["metrics"] = event.get("metrics", {}) - if stream_meta["metrics"]: - st.session_state["assistant_metrics"] = stream_meta[ - "metrics" - ] - elif etype == "error": - stream_error = event.get("error", "stream error") - except Exception as exc: # noqa: BLE001 - stream_error = f"{type(exc).__name__}: {exc}" - - # If the stream produced nothing useful, fall back to the - # non-streaming endpoint so the chat still works even when the - # SSE path is blocked or misbehaving. - if ( - not accumulated - and not stream_meta["tool_calls"] - and not stream_meta["proposed_actions"] - ): - try: - r = client.request( - "POST", - "/chat", - json={ - "messages": st.session_state["assistant_messages"], - "model": st.session_state.get("assistant_selected_model"), - }, - timeout=PLAN_TIMEOUT, - ) - if r.ok and isinstance(r.body, dict): - accumulated = str(r.body.get("response", "")) - stream_meta["tool_calls"] = r.body.get("tool_calls", []) or [] - stream_meta["metrics"] = r.body.get("metrics", {}) or {} - if stream_meta["metrics"]: - st.session_state["assistant_metrics"] = stream_meta[ - "metrics" - ] - for tc in stream_meta["tool_calls"]: - st.session_state["assistant_messages"].append( - { - "role": "tool", - "name": tc.get("name"), - "args": tc.get("args"), - "result": tc.get("result"), - } - ) - stream_meta["proposed_actions"] = [ - { - "kind": tc["result"].get("kind", tc["name"]), - "params": tc["result"].get( - "params", tc.get("args", {}) - ), - } - for tc in stream_meta["tool_calls"] - if isinstance(tc.get("result"), dict) - and tc["result"].get("status") == "proposed" - ] - stream_thinking = [str(r.body.get("thinking", ""))] - stream_error = None - if accumulated: - seg = _current_text_segment() - seg[1] = accumulated - seg[0].markdown(accumulated) - else: - stream_error = f"Fallback failed: {r.error_message()}" - except Exception as exc: # noqa: BLE001 - stream_error = f"Fallback failed: {type(exc).__name__}: {exc}" - - thinking.empty() - if stream_error: - st.error(f"Assistant error: {stream_error}") - - if ( - accumulated - or stream_meta["tool_calls"] - or stream_meta["proposed_actions"] - ): - # Analysis images are shown inline with their tool calls above, - # so we only keep plain photo attachments on the assistant message. - photo_images = [ - {"attachment_url": img.get("attachment_url")} - for img in stream_meta.get("images", []) - if isinstance(img, dict) and img.get("attachment_url") - ] - st.session_state["assistant_messages"].append( - { - "role": "assistant", - "content": accumulated, - "thinking": "".join(stream_thinking), - "tool_calls": stream_meta["tool_calls"], - "proposed_actions": stream_meta["proposed_actions"], - "images": photo_images, - "metrics": stream_meta.get("metrics", {}), - } - ) - _persist_session() - st.rerun() - - -def _fetch_latest_image() -> dict[str, Any] | None: - """Return the most recent FarmBot image via the existing /images endpoint.""" - result = client.request( - "GET", "/images", params={"limit": "1", "refresh": "true"}, timeout=10.0 - ) - if result.ok and isinstance(result.body, dict): - images = result.body.get("images", []) - if images: - return images[0] - return None - - -def _image_is_newer(image: dict[str, Any], previous: dict[str, Any]) -> bool: - """Return True if image is strictly newer/different than previous.""" - if image.get("id") is not None and previous.get("id") is not None: - return image["id"] != previous["id"] - new_ts = image.get("created_at", "") - old_ts = previous.get("created_at", "") - if new_ts and old_ts: - return new_ts > old_ts - return True - - -def _wait_for_new_image( - previous: dict[str, Any] | None, - max_attempts: int = 15, - delay: float = 2.0, -) -> dict[str, Any] | None: - """Poll /images until an image newer than ``previous`` appears.""" - for _ in range(max_attempts): - image = _fetch_latest_image() - if image and (previous is None or _image_is_newer(image, previous)): - return image - time.sleep(delay) - return None - - -def _capture_photo_image() -> dict[str, Any] | None: - """Fetch the latest image after take_photo, polling for a fresh upload.""" - baseline = _fetch_latest_image() - for _ in range(8): - image = _fetch_latest_image() - if image: - if baseline is None or _image_is_newer(image, baseline): - return image - # If baseline is already the newest, wait briefly in case the - # just-triggered photo is still uploading. - time.sleep(1.5) - return baseline - - -def _execute_proposed_actions( - actions: list[dict[str, Any]], - message: dict[str, Any] | None = None, - *, - wait: bool = True, -) -> list[dict[str, Any]]: - """Dispatch proposed actions and return per-action results. - - By default this waits for each action to finish so the UI can give - immediate feedback. For fire-and-forget dispatch, pass ``wait=False``. - """ - will_capture = message is not None and any( - action.get("kind") == "take_photo" for action in actions - ) - previous_image = _fetch_latest_image() if will_capture else None - - results: list[dict[str, Any]] = [] - for action in actions: - r = client.request( - "POST", - "/actions", - json={"kind": action["kind"], "params": action.get("params", {})}, - params={"wait": "true" if wait else "false"}, - ) - results.append( - { - "kind": action["kind"], - "ok": r.ok, - "status": "ok" if r.ok else "error", - "detail": r.body - if isinstance(r.body, str) - else r.body.get("detail") - if isinstance(r.body, dict) - else str(r.body), - } - ) - - if will_capture: - new_image = _wait_for_new_image(previous_image) - if new_image: - message.setdefault("images", []).append(new_image) - - return results - - -def _format_execution_results(results: list[dict[str, Any]]) -> str: - """Turn per-action results into a short, human-readable summary.""" - if not results: - return "✅ Approved (no actions)." - lines: list[str] = [] - for res in results: - summary = _action_summary({"kind": res["kind"], "params": {}}) - if res.get("ok"): - lines.append(f"✅ {summary}") - else: - lines.append(f"❌ {summary} — {res.get('detail', 'unknown error')}") - return "\n".join(lines) - - -# ── session persistence ─────────────────────────────────────────────────────── - - -def _has_session_state() -> bool: - """Return True if any chat/plan state has been initialised.""" - return ( - "assistant_messages" in st.session_state - or "assistant_plan_response" in st.session_state - or "executed_plans" in st.session_state - ) - - -def _is_session_empty() -> bool: - """Return True if the current session has nothing worth saving.""" - messages = st.session_state.get("assistant_messages") or [] - plan_response = st.session_state.get("assistant_plan_response") - executed = st.session_state.get("executed_plans") or [] - return not messages and not plan_response and not executed - - -def _restore_session() -> None: - """Load the latest or URL-specified session on first app load.""" - if _has_session_state(): - return - - session_id = st.query_params.get("session") - if isinstance(session_id, list): - session_id = session_id[0] if session_id else None - - snapshot: dict[str, Any] | None = None - if session_id: - snapshot = history.load_session(session_id) - if snapshot is None: - sessions = history.list_sessions(limit=1) - if sessions: - snapshot = history.load_session(sessions[0]["session_id"]) - - if snapshot is None: - snapshot = history.empty_snapshot() - - st.session_state["assistant_session_id"] = snapshot["session_id"] - st.session_state["assistant_session_label"] = snapshot.get("label") - st.session_state["assistant_messages"] = snapshot.get("assistant_messages", []) - st.session_state["assistant_plan_request"] = snapshot.get( - "assistant_plan_request", "" - ) - st.session_state["assistant_plan_response"] = snapshot.get( - "assistant_plan_response" - ) - st.session_state["assistant_plan_status"] = snapshot.get("assistant_plan_status") - st.session_state["assistant_selected_model"] = snapshot.get( - "assistant_selected_model" - ) - st.session_state["assistant_metrics"] = snapshot.get("assistant_metrics", {}) - st.session_state["executed_plans"] = snapshot.get("executed_plans", []) - st.session_state["refresh_position_s"] = snapshot.get("refresh_position_s", 2) - st.session_state["refresh_stats_s"] = snapshot.get("refresh_stats_s", 300) - st.session_state["refresh_camera_s"] = snapshot.get("refresh_camera_s", 0) - st.session_state["camera_images"] = snapshot.get("camera_images", []) - st.session_state["garden_world"] = snapshot.get("garden_world") - - -def _persist_session() -> None: - """Save the current chat/plan state to disk.""" - if _is_session_empty(): - return - snapshot = history.empty_snapshot( - session_id=st.session_state.get("assistant_session_id") - ) - snapshot["label"] = st.session_state.get("assistant_session_label") - snapshot["created_at"] = st.session_state.get( - "assistant_session_created_at", snapshot["created_at"] - ) - snapshot["assistant_messages"] = st.session_state.get("assistant_messages", []) - snapshot["assistant_plan_request"] = st.session_state.get( - "assistant_plan_request", "" - ) - snapshot["assistant_plan_response"] = st.session_state.get( - "assistant_plan_response" - ) - snapshot["assistant_plan_status"] = st.session_state.get("assistant_plan_status") - snapshot["assistant_selected_model"] = st.session_state.get( - "assistant_selected_model" - ) - snapshot["assistant_metrics"] = st.session_state.get("assistant_metrics", {}) - snapshot["refresh_position_s"] = st.session_state.get("refresh_position_s", 2) - snapshot["refresh_stats_s"] = st.session_state.get("refresh_stats_s", 300) - snapshot["refresh_camera_s"] = st.session_state.get("refresh_camera_s", 0) - snapshot["camera_images"] = st.session_state.get("camera_images", []) - snapshot["garden_world"] = st.session_state.get("garden_world") - snapshot["executed_plans"] = st.session_state.get("executed_plans", []) - history.save_session(snapshot) - - -def _render_session_controls() -> None: - """Render session management widgets inside the Assistant tab.""" - with st.expander("🗂️ Session", expanded=False): - current_label = st.text_input( - "Session label", - value=st.session_state.get("assistant_session_label") or "", - key="assistant_session_label_input", - placeholder="e.g. watering experiment", - ) - st.session_state["assistant_session_label"] = current_label.strip() or None - - new_col, save_col = st.columns([1, 1]) - if new_col.button("New session", use_container_width=True): - _persist_session() - new_id = history.new_session_id() - st.session_state["assistant_session_id"] = new_id - st.session_state["assistant_session_label"] = None - st.session_state["assistant_messages"] = [] - st.session_state["assistant_plan_request"] = "" - st.session_state["assistant_plan_response"] = None - st.session_state["assistant_plan_status"] = None - st.session_state["executed_plans"] = [] - st.query_params.pop("session", None) - st.rerun() - if save_col.button("Save now", use_container_width=True): - _persist_session() - st.toast("Session saved", icon="💾") - - sessions = history.list_sessions(limit=20) - if sessions: - st.divider() - st.markdown("**Previous sessions**") - for sess in sessions: - if sess["session_id"] == st.session_state.get("assistant_session_id"): - continue - label = sess["label"] or sess["session_id"] - preview = sess["preview"] - c1, c2, c3 = st.columns([3, 1, 1]) - c1.caption(f"{label}" + (f" · {preview}" if preview else "")) - if c2.button( - "Load", key=f"load_sess_{sess['session_id']}", use_container_width=True - ): - snapshot = history.load_session(sess["session_id"]) - if snapshot is None: - st.error("Session not found") - continue - st.session_state["assistant_session_id"] = snapshot["session_id"] - st.session_state["assistant_session_label"] = snapshot.get("label") - st.session_state["assistant_messages"] = snapshot.get( - "assistant_messages", [] - ) - st.session_state["assistant_plan_request"] = snapshot.get( - "assistant_plan_request", "" - ) - st.session_state["assistant_plan_response"] = snapshot.get( - "assistant_plan_response" - ) - st.session_state["assistant_plan_status"] = snapshot.get( - "assistant_plan_status" - ) - st.session_state["assistant_selected_model"] = snapshot.get( - "assistant_selected_model" - ) - st.session_state["executed_plans"] = snapshot.get("executed_plans", []) - st.query_params["session"] = snapshot["session_id"] - st.rerun() - if c3.button( - "🗑", key=f"del_sess_{sess['session_id']}", use_container_width=True - ): - history.delete_session(sess["session_id"]) - st.rerun() - - -def _render_plan() -> None: - st.caption( - "Describe a task. The LLM builds a step-by-step plan; review it before running." - ) - - selected_model = _render_model_picker() - st.session_state["assistant_selected_model"] = selected_model - - if "assistant_plan_response" not in st.session_state: - st.session_state["assistant_plan_response"] = None - if "assistant_plan_status" not in st.session_state: - st.session_state["assistant_plan_status"] = None - if "assistant_plan_request" not in st.session_state: - st.session_state["assistant_plan_request"] = "" - - examples = [ - "Water the tomato zone for 90 seconds, then go home", - "Take a photo and send me the result", - "Move to x=500 y=200 z=0", - ] - cols = st.columns(len(examples)) - for col, example in zip(cols, examples): - if col.button(example, use_container_width=True, key=f"plan_ex_{example[:20]}"): - st.session_state["assistant_plan_request"] = example - st.session_state["assistant_plan_response"] = None - st.session_state["assistant_plan_status"] = None - st.rerun() - - request = st.text_area( - "Task", - value=st.session_state["assistant_plan_request"], - placeholder="e.g. water bed for 60 seconds, then home", - height=80, - label_visibility="collapsed", - ) - st.session_state["assistant_plan_request"] = request - - plan_col, _ = st.columns([1, 3]) - preview_clicked = plan_col.button( - "Preview plan", - type="primary", - use_container_width=True, - disabled=not request.strip(), - ) - - if preview_clicked and request.strip(): - with st.spinner("Asking the planner…"): - r = client.request( - "POST", - "/plan", - json={ - "request": request, - "debug": True, - "model": st.session_state.get("assistant_selected_model"), - }, - timeout=PLAN_TIMEOUT, - ) - st.session_state["assistant_plan_response"] = ( - r.body if r.ok else {"error": r.body} - ) - st.session_state["assistant_plan_status"] = r.code - _persist_session() - - response = st.session_state.get("assistant_plan_response") - status = st.session_state.get("assistant_plan_status") - - if not response: - st.info("No plan yet. Type a task above and click **Preview plan**.") - return - - with st.expander("Debug · raw response", expanded=False): - st.json(response) - - if status and status >= 400: - err_body = response.get("error", response) - st.error( - f"Planner error (HTTP {status}): " - f"{ApiResult(ok=False, code=status, body=err_body).error_message()}" - ) - return - - actions = response.get("actions", []) or [] - rationale = response.get("rationale") if isinstance(response, dict) else None - st.success(f"Plan ready · {len(actions)} action(s)") - if rationale: - st.caption(f"Model rationale: {rationale}") - - if not actions: - st.warning("The planner returned an empty plan.") - return - - st.markdown("**Proposed actions**") - for idx, action in enumerate(actions, start=1): - with st.container(border=True): - st.markdown(f"{idx}. {_action_summary(action)}") - with st.expander("Details"): - st.json(action.get("params", {})) - - run_col, clear_col = st.columns([1, 1]) - if clear_col.button("Clear", use_container_width=True): - st.session_state["assistant_plan_response"] = None - st.session_state["assistant_plan_status"] = None - _persist_session() - st.rerun() - - if run_col.button("Run plan", type="primary", use_container_width=True): - queued = 0 - failed = 0 - action_results: list[dict[str, Any]] = [] - for action in actions: - r = client.request( - "POST", - "/actions", - json={"kind": action["kind"], "params": action.get("params", {})}, - ) - action_results.append( - { - "kind": action["kind"], - "ok": r.ok, - "detail": r.error_message() if not r.ok else None, - } - ) - if r.ok: - queued += 1 - st.toast(f"Queued {action['kind']}", icon="➡️") - else: - failed += 1 - st.error(f"Failed to queue {action['kind']}: {r.error_message()}") - if failed == 0: - st.success(f"Plan queued · {queued} action(s)") - else: - st.warning(f"Plan partially queued · {queued} ok, {failed} failed") - executed = st.session_state.get("executed_plans") or [] - executed.append( - { - "request": response.get("request", ""), - "actions": actions, - "results": action_results, - "queued_at": datetime.now().isoformat(), - "status": "ok" - if failed == 0 - else ("partial" if queued > 0 else "failed"), - } - ) - st.session_state["executed_plans"] = executed - st.session_state["assistant_plan_response"] = None - st.session_state["assistant_plan_status"] = None - _persist_session() - st.rerun() - - -def _render_history() -> None: - st.markdown( - '
TWFarmBot · UAS Technikum Wien
', - unsafe_allow_html=True, - ) - st.markdown("# History") - - sessions = history.list_sessions(limit=50) - if not sessions: - st.info("No saved sessions yet. Chat and plans are saved automatically.") - return - - st.markdown("## Chat sessions") - for sess in sessions: - label = sess["label"] or sess["session_id"] - updated = ( - sess["updated_at"][:19].replace("T", " ") if sess["updated_at"] else "" - ) - c1, c2 = st.columns([4, 1]) - with c1: - st.markdown(f"**{label}**") - st.caption( - f"Updated {updated}" - + (f" · {sess['preview']}" if sess["preview"] else "") - ) - if c2.button( - "Load", key=f"hist_load_{sess['session_id']}", use_container_width=True - ): - snapshot = history.load_session(sess["session_id"]) - if snapshot is None: - st.error("Session not found") - else: - st.session_state["assistant_session_id"] = snapshot["session_id"] - st.session_state["assistant_session_label"] = snapshot.get("label") - st.session_state["assistant_messages"] = snapshot.get( - "assistant_messages", [] - ) - st.session_state["assistant_plan_request"] = snapshot.get( - "assistant_plan_request", "" - ) - st.session_state["assistant_plan_response"] = snapshot.get( - "assistant_plan_response" - ) - st.session_state["assistant_plan_status"] = snapshot.get( - "assistant_plan_status" - ) - st.session_state["assistant_selected_model"] = snapshot.get( - "assistant_selected_model" - ) - st.session_state["executed_plans"] = snapshot.get("executed_plans", []) - st.query_params["session"] = snapshot["session_id"] - st.rerun() - - executed = st.session_state.get("executed_plans") or [] - if executed: - st.markdown("## Executed plans") - for idx, plan in enumerate(reversed(executed), start=1): - with st.container(border=True): - queued_at = plan.get("queued_at", "") - ts = queued_at[:19].replace("T", " ") if queued_at else "" - status = plan.get("status", "unknown") - status_emoji = {"ok": "✅", "partial": "⚠️", "failed": "❌"}.get( - status, "❓" - ) - st.markdown( - f"{status_emoji} **Plan {idx}** · {plan.get('request', '')}" - ) - st.caption( - f"{ts} · {len(plan.get('actions', []))} action(s) · {status}" - ) - with st.expander("Actions"): - for action in plan.get("actions", []): - st.markdown(f"• {_action_summary(action)}") - results = plan.get("results", []) - if results: - with st.expander("Results"): - for res in results: - icon = "✅" if res.get("ok") else "❌" - detail = res.get("detail") - line = f"{icon} {_action_summary({'kind': res['kind'], 'params': {}})}" - if detail: - line += f" — {detail}" - st.markdown(line) - - -def _render_diagnostics() -> None: - st.markdown( - '
TWFarmBot · UAS Technikum Wien
', - unsafe_allow_html=True, - ) - st.markdown("# Diagnostics") - - if st.button("Load /status"): - d = client.request("GET", "/status") - if d.ok and isinstance(d.body, dict): - st.session_state["diag"] = d.body.get("state", {}) - else: - st.error(f"Read failed: {d.error_message()}") - - payload = st.session_state.get("diag", {}) or {} - info = payload.get("informational_settings", {}) or {} - loc = payload.get("location_data", {}) or {} - axes = loc.get("axis_states", {}) or {} - pins = payload.get("pins", {}) or {} - jobs = payload.get("jobs", {}) or {} - - if not info and not axes and not pins and not jobs: - st.info("Click 'Load /status' to fetch diagnostic state.") - return - - top = st.columns(4) - top[0].metric("Controller", info.get("controller_version", "—")) - top[1].metric("Firmware", info.get("firmware_version", "—")) - top[2].metric("Wi-Fi", f"{info.get('wifi_level_percent', '—')}%") - top[3].metric("Uptime", f"{info.get('uptime', '—')} s") - - res = st.columns(3) - with res[0]: - st.markdown( - f'
Resources
' - f'
CPU {info.get("cpu_usage", "—")}%
' - f"
Memory {info.get('memory_usage', '—')}% · Disk {info.get('disk_usage', '—')}%
" - f"
SoC {info.get('soc_temp', '—')} °C
", - unsafe_allow_html=True, - ) - with res[1]: - st.markdown( - f'
Axis state
' - f'
X {axes.get("x", "—")}
' - f"
Y {axes.get('y', '—')} · Z {axes.get('z', '—')}
" - f"
Busy: {info.get('busy', '—')}
", - unsafe_allow_html=True, - ) - with res[2]: - st.markdown( - f'
Network
' - f'
{info.get("wifi_level", "—")} dBm
' - f"
{info.get('private_ip', '—')}
" - f"
Sync: {info.get('sync_status', '—')}
", - unsafe_allow_html=True, - ) - - if pins: - st.markdown("**Pin snapshot**") - st.dataframe( - [ - {"pin": pn, "value": pd.get("value"), "mode": pd.get("mode")} - for pn, pd in pins.items() - ], - use_container_width=True, - hide_index=True, - ) - - -def _render_settings() -> None: - st.markdown( - '
TWFarmBot · UAS Technikum Wien
', - unsafe_allow_html=True, - ) - st.markdown("# Settings") - - st.markdown("**Connection**") - url = st.text_input("API URL", value=api_url) - if url != st.session_state["api_url"]: - st.session_state["api_url"] = url - st.cache_resource.clear() - st.rerun() - - st.markdown("**Auto-refresh intervals**") - pos_col, stats_col, cam_col = st.columns(3) - with pos_col: - pos_interval = st.number_input( - "Position refresh (s)", - min_value=1, - max_value=300, - value=int(st.session_state["refresh_position_s"]), - step=1, - ) - with stats_col: - stats_interval = st.number_input( - "Stats refresh (s)", - min_value=10, - max_value=3600, - value=int(st.session_state["refresh_stats_s"]), - step=10, - ) - with cam_col: - cam_interval = st.number_input( - "Camera refresh (s, 0 = off)", - min_value=0, - max_value=3600, - value=int(st.session_state.get("refresh_camera_s", 0)), - step=10, - ) - if ( - pos_interval != st.session_state["refresh_position_s"] - or stats_interval != st.session_state["refresh_stats_s"] - or cam_interval != st.session_state.get("refresh_camera_s", 0) - ): - st.session_state["refresh_position_s"] = int(pos_interval) - st.session_state["refresh_stats_s"] = int(stats_interval) - st.session_state["refresh_camera_s"] = int(cam_interval) - _persist_session() - st.rerun() - - if st.button("Health check"): - _refresh_health(client) - st.rerun() - - st.json( - { - "farmbot": st.session_state.get("farmbot_status", "?"), - "api": st.session_state["api_url"], - "actions": st.session_state.get("actions", []), - } - ) - - with st.expander("Raw action"): - with st.form("raw"): - kind = st.text_input("Kind", "move") - raw = st.text_area("Params (JSON)", '{"message":"hello"}', height=100) - if st.form_submit_button("Fire"): - try: - p = json.loads(raw) - except json.JSONDecodeError as e: - st.error(f"Bad JSON: {e}") - else: - r = client.request( - "POST", "/actions", json={"kind": kind, "params": p} - ) - st.json(r.body) - - -# ── dispatch ────────────────────────────────────────────────────────────────── - -renderers = { - "Overview": _render_overview, - "Garden": _render_garden, - "Motion": _render_motion, - "Camera": _render_camera, - "I/O": _render_io, - "Assistant": _render_assistant, - "History": _render_history, - "Diagnostics": _render_diagnostics, - "Settings": _render_settings, -} - -# Restore the latest or URL-specified chat/plan session on first load. -_restore_session() -renderers[tab]() diff --git a/apps/ui/src/twfarmbot_ui/client.py b/apps/ui/src/twfarmbot_ui/client.py index 31fbc99..341dc62 100644 --- a/apps/ui/src/twfarmbot_ui/client.py +++ b/apps/ui/src/twfarmbot_ui/client.py @@ -1,7 +1,9 @@ -"""API client used by the Streamlit app. +"""Python client for the twfarmbot api_server. -Kept separate from ``app.py`` so tests can import it without dragging in -Streamlit. +The web frontend talks to the API through the server-side proxy in +``server.py``; this client is kept for scripts and tests that need +programmatic access with the same semantics (``wait=false`` action +dispatch, SSE streaming). """ from __future__ import annotations diff --git a/apps/ui/src/twfarmbot_ui/history.py b/apps/ui/src/twfarmbot_ui/history.py index 82b4406..ee689c7 100644 --- a/apps/ui/src/twfarmbot_ui/history.py +++ b/apps/ui/src/twfarmbot_ui/history.py @@ -1,4 +1,4 @@ -"""Persistence for Streamlit UI session state. +"""Persistence for UI session state. Chat history, plan previews, and executed plans are saved as JSON files so they survive page reloads. Storage is local and intended for a single-user diff --git a/apps/ui/src/twfarmbot_ui/server.py b/apps/ui/src/twfarmbot_ui/server.py new file mode 100644 index 0000000..7dd2c76 --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/server.py @@ -0,0 +1,227 @@ +"""FastAPI server for the Material 3 web UI. + +Serves the static single-page frontend and reverse-proxies every API call: + +* ``/api/*`` → the twfarmbot api_server (``TWFB_API_URL``) +* ``/resireg/*`` → the ReSiReg vision server (``TWFB_RESIREG_URL``) +* ``/ui/*`` → small UI-local endpoints (session persistence and + garden-entity YAML writes) that used to live inside the + Streamlit process. + +Proxying keeps the frontend same-origin (no CORS on the api_server) and +keeps the upstream URLs server-side, exactly like the old Streamlit app. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Any + +import httpx +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import FileResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field +from starlette.background import BackgroundTask + +from twfarmbot_ui import history + +STATIC_DIR = Path(__file__).parent / "static" + +# Hop-by-hop headers must not be forwarded by a proxy (RFC 9110 §7.6.1). +_HOP_BY_HOP = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", + "content-length", +} + +# Generous read timeout: /chat and /plan can block on the LLM for minutes. +_PROXY_TIMEOUT = httpx.Timeout(300.0, connect=5.0) + + +class GardenEntityPayload(BaseModel): + x: float + y: float + kind: str = Field(..., min_length=1) + name: str = Field(..., min_length=1) + + +class ConfigPayload(BaseModel): + api_url: str | None = None + resireg_url: str | None = None + + +def _entity_id(name: str) -> str: + base = re.sub(r"[^a-z0-9_]+", "_", name.lower()).strip("_") + return base or "entity" + + +def add_garden_entity(x: float, y: float, kind: str, name: str) -> dict[str, Any]: + """Append a new entity to the ``TWFB_CONFIG`` YAML, preserving comments.""" + from ruamel.yaml import YAML + + yaml = YAML() + yaml.preserve_quotes = True + yaml.default_flow_style = False + path = Path(os.getenv("TWFB_CONFIG", "configs/dev.yaml")) + with path.open(encoding="utf-8") as fh: + data = yaml.load(fh) + spatial = data.setdefault("spatial", {}) + entities = spatial.setdefault("entities", []) + entity = { + "id": _entity_id(name), + "kind": kind, + "name": name, + "x": float(x), + "y": float(y), + "z": 0.0, + "radius_mm": 50, + "metadata": {}, + } + entities.append(entity) + with path.open("w", encoding="utf-8") as fh: + yaml.dump(data, fh) + return entity + + +def create_app(http_client: httpx.AsyncClient | None = None) -> FastAPI: + app = FastAPI(title="TWFarmBot UI", version="0.2.0") + app.state.api_base = os.getenv("TWFB_API_URL", "http://127.0.0.1:8000").rstrip("/") + app.state.resireg_base = os.getenv( + "TWFB_RESIREG_URL", "http://127.0.0.1:8080" + ).rstrip("/") + app.state.http = http_client or httpx.AsyncClient(timeout=_PROXY_TIMEOUT) + + @app.on_event("shutdown") + async def close_client() -> None: + await app.state.http.aclose() + + async def _proxy(request: Request, base: str, path: str) -> StreamingResponse: + client: httpx.AsyncClient = app.state.http + headers = { + k: v for k, v in request.headers.items() if k.lower() not in _HOP_BY_HOP + } + upstream = client.build_request( + request.method, + f"{base}/{path}", + params=request.query_params, + headers=headers, + content=await request.body(), + ) + try: + resp = await client.send(upstream, stream=True) + except httpx.HTTPError as err: + raise HTTPException( + status_code=502, detail=f"{type(err).__name__}: {err}" + ) from err + return StreamingResponse( + resp.aiter_raw(), + status_code=resp.status_code, + headers={ + k: v + for k, v in resp.headers.items() + if k.lower() not in _HOP_BY_HOP and k.lower() != "content-encoding" + }, + background=BackgroundTask(resp.aclose), + ) + + @app.api_route( + "/api/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"] + ) + async def api_proxy(request: Request, path: str) -> StreamingResponse: + return await _proxy(request, app.state.api_base, path) + + @app.api_route("/resireg/{path:path}", methods=["GET", "POST"]) + async def resireg_proxy(request: Request, path: str) -> StreamingResponse: + return await _proxy(request, app.state.resireg_base, path) + + # ---- UI-local endpoints --------------------------------------------- + + @app.get("/ui/config") + def get_config() -> dict[str, Any]: + return { + "api_url": app.state.api_base, + "resireg_url": app.state.resireg_base, + } + + @app.put("/ui/config") + def put_config(payload: ConfigPayload) -> dict[str, Any]: + if payload.api_url: + app.state.api_base = payload.api_url.rstrip("/") + if payload.resireg_url: + app.state.resireg_base = payload.resireg_url.rstrip("/") + return get_config() + + @app.get("/ui/sessions") + def list_sessions() -> dict[str, Any]: + return {"sessions": history.list_sessions()} + + @app.post("/ui/sessions") + def new_session() -> dict[str, Any]: + return history.empty_snapshot() + + @app.get("/ui/sessions/{session_id}") + def get_session(session_id: str) -> dict[str, Any]: + snapshot = history.load_session(session_id) + if snapshot is None: + raise HTTPException(status_code=404, detail=f"no session: {session_id}") + return snapshot + + @app.put("/ui/sessions/{session_id}") + async def put_session(session_id: str, request: Request) -> dict[str, Any]: + snapshot = await request.json() + if not isinstance(snapshot, dict): + raise HTTPException(status_code=400, detail="snapshot must be an object") + snapshot["session_id"] = session_id + history.save_session(snapshot) + return {"status": "ok", "session_id": session_id} + + @app.delete("/ui/sessions/{session_id}") + def delete_session(session_id: str) -> dict[str, Any]: + return {"deleted": history.delete_session(session_id)} + + @app.post("/ui/garden/entities") + def post_garden_entity(payload: GardenEntityPayload) -> dict[str, Any]: + try: + entity = add_garden_entity(payload.x, payload.y, payload.kind, payload.name) + except (OSError, KeyError, TypeError) as err: + raise HTTPException( + status_code=500, detail=f"config write failed: {err}" + ) from err + return {"status": "ok", "entity": entity} + + # ---- Static frontend ------------------------------------------------- + + @app.get("/", include_in_schema=False) + def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + app.mount("/", StaticFiles(directory=STATIC_DIR), name="static") + + return app + + +app = create_app() + + +def main() -> None: + from twfarmbot_core.logging import configure_logging + + configure_logging() + import uvicorn + + port = int(os.getenv("TWFB_UI_PORT", "8501")) + uvicorn.run("twfarmbot_ui.server:app", host="0.0.0.0", port=port, reload=False) + + +if __name__ == "__main__": + main() diff --git a/apps/ui/src/twfarmbot_ui/static/app.css b/apps/ui/src/twfarmbot_ui/static/app.css new file mode 100644 index 0000000..2dfa52e --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/app.css @@ -0,0 +1,711 @@ +/* ── Design tokens (8px grid) ─────────────────────────────────────────── */ + +:root { + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-full: 999px; + --sidebar-w: 280px; + --content-max: 1080px; + --chat-bar-h: 92px; + + /* Material 3 — seed #3F8F64 */ + --md-sys-color-primary: #256a4a; + --md-sys-color-on-primary: #ffffff; + --md-sys-color-primary-container: #aaf2c8; + --md-sys-color-on-primary-container: #00210f; + --md-sys-color-secondary: #4e6355; + --md-sys-color-on-secondary: #ffffff; + --md-sys-color-secondary-container: #d0e8d6; + --md-sys-color-on-secondary-container: #0b1f14; + --md-sys-color-tertiary: #3c6472; + --md-sys-color-on-tertiary: #ffffff; + --md-sys-color-tertiary-container: #bfe9fa; + --md-sys-color-on-tertiary-container: #001f28; + --md-sys-color-error: #ba1a1a; + --md-sys-color-on-error: #ffffff; + --md-sys-color-error-container: #ffdad6; + --md-sys-color-on-error-container: #410002; + --md-sys-color-background: #f6faf4; + --md-sys-color-on-background: #171d18; + --md-sys-color-surface: #f6faf4; + --md-sys-color-on-surface: #171d18; + --md-sys-color-surface-variant: #dce5db; + --md-sys-color-on-surface-variant: #404942; + --md-sys-color-outline: #707971; + --md-sys-color-outline-variant: #c5cec5; + --md-sys-color-surface-container-lowest: #ffffff; + --md-sys-color-surface-container-low: #f0f5ed; + --md-sys-color-surface-container: #eaefe7; + --md-sys-color-surface-container-high: #e4eae1; + --md-sys-color-surface-container-highest: #dee4dc; + --md-sys-color-inverse-surface: #2c322d; + --md-sys-color-inverse-on-surface: #edf2ea; + --md-ref-typeface-plain: "Roboto Flex", system-ui, sans-serif; + --md-ref-typeface-brand: "Roboto Flex", system-ui, sans-serif; + --ok-bg: #d1fae5; --ok-fg: #065f46; + --warn-bg: #fef3c7; --warn-fg: #92400e; + --bad-bg: #fee2e2; --bad-fg: #991b1b; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root { + --md-sys-color-primary: #8fd5ad; + --md-sys-color-on-primary: #003920; + --md-sys-color-primary-container: #005231; + --md-sys-color-on-primary-container: #aaf2c8; + --md-sys-color-secondary: #b5ccbb; + --md-sys-color-on-secondary: #213528; + --md-sys-color-secondary-container: #374b3e; + --md-sys-color-on-secondary-container: #d0e8d6; + --md-sys-color-tertiary: #a3cddd; + --md-sys-color-on-tertiary: #033542; + --md-sys-color-tertiary-container: #224c59; + --md-sys-color-on-tertiary-container: #bfe9fa; + --md-sys-color-error: #ffb4ab; + --md-sys-color-on-error: #690005; + --md-sys-color-error-container: #93000a; + --md-sys-color-on-error-container: #ffdad6; + --md-sys-color-background: #0f1511; + --md-sys-color-on-background: #dee4dc; + --md-sys-color-surface: #0f1511; + --md-sys-color-on-surface: #dee4dc; + --md-sys-color-surface-variant: #404942; + --md-sys-color-on-surface-variant: #c0c9c0; + --md-sys-color-outline: #8a938a; + --md-sys-color-outline-variant: #404942; + --md-sys-color-surface-container-lowest: #0a100c; + --md-sys-color-surface-container-low: #171d18; + --md-sys-color-surface-container: #1b211c; + --md-sys-color-surface-container-high: #252b26; + --md-sys-color-surface-container-highest: #303631; + --md-sys-color-inverse-surface: #dee4dc; + --md-sys-color-inverse-on-surface: #2c322d; + --ok-bg: #064e3b; --ok-fg: #a7f3d0; + --warn-bg: #78350f; --warn-fg: #fde68a; + --bad-bg: #7f1d1d; --bad-fg: #fecaca; + color-scheme: dark; + } +} + +/* ── Reset & shell ────────────────────────────────────────────────────── */ + +*, *::before, *::after { box-sizing: border-box; } + +body { + margin: 0; + font-family: var(--md-ref-typeface-plain); + font-size: 14px; + line-height: 1.5; + background: var(--md-sys-color-background); + color: var(--md-sys-color-on-background); + -webkit-font-smoothing: antialiased; +} + +#sidebar { + position: fixed; + inset: 0 auto 0 0; + width: var(--sidebar-w); + display: flex; + flex-direction: column; + padding: var(--space-6) var(--space-4); + background: var(--md-sys-color-surface-container-low); + border-right: 1px solid var(--md-sys-color-outline-variant); + overflow-y: auto; +} + +#content { + margin-left: var(--sidebar-w); + min-height: 100vh; +} + +body.has-chat-bar #content { padding-bottom: var(--chat-bar-h); } + +/* ── Sidebar ──────────────────────────────────────────────────────────── */ + +.brand { margin-bottom: var(--space-6); } +.brand .kicker { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--md-sys-color-primary); + margin: 0 0 var(--space-1); +} +.brand .title { + font-size: 20px; + font-weight: 700; + letter-spacing: -0.02em; + margin: 0; +} + +.nav-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--md-sys-color-on-surface-variant); + margin: 0 0 var(--space-2) var(--space-3); +} + +#nav { + display: flex; + flex-direction: column; + gap: var(--space-1); + flex: 1; +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-full); + cursor: pointer; + color: var(--md-sys-color-on-surface-variant); + font-size: 14px; + font-weight: 500; + user-select: none; + transition: background 0.15s; +} +.nav-item:hover { background: var(--md-sys-color-surface-container-high); } +.nav-item.active { + background: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); + font-weight: 600; +} +.nav-item md-icon { --md-icon-size: 20px; flex-shrink: 0; } + +.sidebar-footer { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding-top: var(--space-4); + margin-top: var(--space-4); + border-top: 1px solid var(--md-sys-color-outline-variant); +} + +#status-block { + padding: var(--space-3) var(--space-4); + background: var(--md-sys-color-surface-container); + border-radius: var(--radius-md); +} + +.live-pos { + font-size: 13px; + margin-top: var(--space-2); + font-variant-numeric: tabular-nums; + color: var(--md-sys-color-on-surface); +} +.pos-age { + font-size: 11px; + margin-top: var(--space-1); + color: var(--md-sys-color-on-surface-variant); +} + +.sidebar-footer md-outlined-button, +.sidebar-footer md-filled-button { width: 100%; } + +.estop { + --md-filled-button-container-color: var(--md-sys-color-error); + --md-filled-button-label-text-color: var(--md-sys-color-on-error); + --md-filled-button-hover-label-text-color: var(--md-sys-color-on-error); + --md-filled-button-focus-label-text-color: var(--md-sys-color-on-error); + --md-filled-button-pressed-label-text-color: var(--md-sys-color-on-error); + --md-filled-button-icon-color: var(--md-sys-color-on-error); + --md-filled-button-hover-icon-color: var(--md-sys-color-on-error); +} + +/* ── Page layout ────────────────────────────────────────────────────── */ + +.page { + max-width: var(--content-max); + margin: 0 auto; + padding: var(--space-6) var(--space-8) var(--space-8); +} + +.page-header { margin-bottom: var(--space-6); } + +.eyebrow { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--md-sys-color-primary); + margin: 0 0 var(--space-2); +} + +.page-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + flex-wrap: wrap; +} + +.page-title { + font-size: 28px; + font-weight: 600; + letter-spacing: -0.02em; + margin: 0; + line-height: 1.2; +} + +.page-actions { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.page-body { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.section { display: flex; flex-direction: column; gap: var(--space-4); } + +.section-title { + font-size: 16px; + font-weight: 600; + margin: 0; + color: var(--md-sys-color-on-surface); +} + +.section + .section { margin-top: var(--space-2); } + +/* ── Cards & surfaces ───────────────────────────────────────────────── */ + +.card { + background: var(--md-sys-color-surface-container-lowest); + border: 1px solid var(--md-sys-color-outline-variant); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.card-header { + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--md-sys-color-outline-variant); + background: var(--md-sys-color-surface-container-low); +} + +.card-title { + font-size: 14px; + font-weight: 600; + margin: 0; +} + +.card-body { + padding: var(--space-5); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +/* ── Metrics ────────────────────────────────────────────────────────── */ + +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--space-3); +} + +.metric { + background: var(--md-sys-color-surface-container-low); + border: 1px solid var(--md-sys-color-outline-variant); + border-radius: var(--radius-md); + padding: var(--space-4); + min-width: 0; +} + +.metric-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--md-sys-color-on-surface-variant); +} + +.metric-value { + font-size: 22px; + font-weight: 600; + margin-top: var(--space-1); + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.2; +} + +/* ── Utilities ──────────────────────────────────────────────────────── */ + +.pill { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + font-size: 12px; + font-weight: 600; + background: var(--md-sys-color-surface-container-high); + color: var(--md-sys-color-on-surface-variant); +} +.pill.ok { background: var(--ok-bg); color: var(--ok-fg); } +.pill.warn { background: var(--warn-bg); color: var(--warn-fg); } +.pill.bad { background: var(--bad-bg); color: var(--bad-fg); } + +.toolbar { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} + +.stack { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.split { + display: grid; + grid-template-columns: var(--split-ratio, 2fr 1fr); + gap: var(--space-5); + align-items: start; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--space-4); +} + +.caption { + font-size: 12px; + line-height: 1.45; + color: var(--md-sys-color-on-surface-variant); + margin: 0; +} + +.mono { font-family: "Roboto Mono", monospace; font-size: 12px; } + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + padding: var(--space-8) var(--space-4); + text-align: center; + color: var(--md-sys-color-on-surface-variant); +} +.empty-state md-icon { --md-icon-size: 32px; opacity: 0.5; } +.empty-state p { margin: 0; max-width: 360px; } + +/* ── Data display ───────────────────────────────────────────────────── */ + +pre.codeblock { + margin: 0; + background: var(--md-sys-color-surface-container); + border-radius: var(--radius-sm); + padding: var(--space-4); + font-family: "Roboto Mono", monospace; + font-size: 12px; + line-height: 1.5; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.data-table th, +.data-table td { + text-align: left; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--md-sys-color-outline-variant); +} +.data-table th { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--md-sys-color-on-surface-variant); + background: var(--md-sys-color-surface-container-low); +} +.data-table tr:last-child td { border-bottom: none; } + +details.expander { + border: 1px solid var(--md-sys-color-outline-variant); + border-radius: var(--radius-md); + background: var(--md-sys-color-surface-container-lowest); + overflow: hidden; +} +details.expander summary { + cursor: pointer; + font-size: 13px; + font-weight: 500; + padding: var(--space-3) var(--space-4); + list-style: none; +} +details.expander summary::-webkit-details-marker { display: none; } +details.expander[open] summary { + border-bottom: 1px solid var(--md-sys-color-outline-variant); + background: var(--md-sys-color-surface-container-low); +} +.expander-body { padding: var(--space-4); } + +.chart-box { position: relative; height: 220px; } +.chart-box.tall { height: 260px; } + +/* ── Form controls ──────────────────────────────────────────────────── */ + +md-outlined-text-field, +md-outlined-select { + --md-outlined-field-container-shape: var(--radius-sm); + min-width: 0; +} + +md-outlined-text-field.grow, +md-outlined-select.grow { flex: 1; } + +.field-sm { width: 120px; flex-shrink: 0; } +.field-md { width: 200px; flex-shrink: 0; } +.field-lg { min-width: 280px; flex: 1; } + +.chip-row { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--space-4); + align-items: end; +} + +/* ── Garden map ─────────────────────────────────────────────────────── */ + +svg.garden-map { + width: 100%; + aspect-ratio: 16 / 10; + max-height: 520px; + background: var(--md-sys-color-surface-container-lowest); + border: 1px solid var(--md-sys-color-outline-variant); + border-radius: var(--radius-lg); + cursor: crosshair; +} + +.garden-legend { + display: flex; + gap: var(--space-4); + flex-wrap: wrap; + font-size: 12px; + margin-top: var(--space-2); +} +.garden-legend .swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 3px; + margin-right: var(--space-2); + vertical-align: middle; +} + +/* ── Motion D-pad ───────────────────────────────────────────────────── */ + +.dpad { + display: grid; + grid-template-columns: repeat(3, 80px); + gap: var(--space-2); + width: fit-content; +} +.dpad md-filled-tonal-button, +.dpad md-filled-button { width: 80px; height: 48px; } + +/* ── Camera ─────────────────────────────────────────────────────────── */ + +img.frame { + width: 100%; + max-height: 60vh; + object-fit: contain; + border-radius: var(--radius-md); + background: var(--md-sys-color-surface-container); + border: 1px solid var(--md-sys-color-outline-variant); +} + +.gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--space-3); +} +.gallery img { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; + border-radius: var(--radius-sm); + border: 1px solid var(--md-sys-color-outline-variant); +} +.gallery figcaption { margin-top: var(--space-1); } + +/* ── Assistant chat ─────────────────────────────────────────────────── */ + +.chat-panel { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.chat-scroll { + display: flex; + flex-direction: column; + gap: var(--space-4); + min-height: 200px; +} + +.msg { + display: flex; + gap: var(--space-3); + max-width: 720px; +} +.msg .avatar { + --md-icon-size: 20px; + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + background: var(--md-sys-color-surface-container-high); + color: var(--md-sys-color-on-surface-variant); +} +.msg .bubble { min-width: 0; flex: 1; } +.msg.user { flex-direction: row-reverse; margin-left: auto; } +.msg.user .avatar { background: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); } +.msg.user .bubble { + background: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + border-radius: var(--radius-lg) var(--radius-lg) var(--radius-sm) var(--radius-lg); + padding: var(--space-3) var(--space-4); + flex: 0 1 auto; + max-width: 85%; +} +.msg .bubble > div > p { margin: 0 0 var(--space-2); } +.msg .bubble > div > p:last-child { margin-bottom: 0; } +.msg .bubble img { max-width: 100%; max-height: 50vh; border-radius: var(--radius-sm); } +.chat-images { display: flex; gap: var(--space-2); flex-wrap: wrap; margin-top: var(--space-2); } +.chat-images img { width: 200px; border-radius: var(--radius-sm); } + +.proposal-card { + background: var(--md-sys-color-surface-container-low); + border: 1px solid var(--md-sys-color-outline-variant); + border-radius: var(--radius-md); + padding: var(--space-4); + margin-top: var(--space-3); +} + +.model-picker { + display: grid; + grid-template-columns: 160px 1fr; + gap: var(--space-4); + align-items: end; +} + +.chat-input-bar { + position: fixed; + left: var(--sidebar-w); + right: 0; + bottom: 0; + z-index: 50; + background: var(--md-sys-color-surface-container-lowest); + border-top: 1px solid var(--md-sys-color-outline-variant); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06); +} + +.chat-input-inner { + max-width: calc(var(--content-max) + var(--space-16)); + margin: 0 auto; + padding: var(--space-3) var(--space-8); + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.assistant-metrics { + font-size: 11px; + color: var(--md-sys-color-on-surface-variant); + text-align: center; +} + +.chat-input-row { + display: flex; + gap: var(--space-3); + align-items: center; +} +.chat-input-row md-outlined-text-field { flex: 1; } + +.session-list-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--md-sys-color-outline-variant); +} +.session-list-item:last-child { border-bottom: none; } +.session-list-item .caption { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ── Snackbar ───────────────────────────────────────────────────────── */ + +#snackbar-host { + position: fixed; + bottom: var(--space-6); + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: var(--space-2); + z-index: 1000; + pointer-events: none; +} +.snackbar { + background: var(--md-sys-color-inverse-surface); + color: var(--md-sys-color-inverse-on-surface); + border-radius: var(--radius-sm); + padding: var(--space-3) var(--space-5); + font-size: 14px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + animation: snack-in 0.18s ease-out; + pointer-events: auto; +} +.snackbar.error { background: var(--md-sys-color-error); color: var(--md-sys-color-on-error); } +@keyframes snack-in { from { opacity: 0; transform: translateY(8px); } } + +/* ── Responsive ───────────────────────────────────────────────────────── */ + +@media (max-width: 900px) { + :root { --sidebar-w: 240px; } + .page { padding: var(--space-5) var(--space-4) var(--space-6); } + .split { grid-template-columns: 1fr; } + .model-picker { grid-template-columns: 1fr; } + .chat-input-inner { padding: var(--space-3) var(--space-4); } +} + +@media (max-width: 640px) { + :root { --sidebar-w: 0px; } + #sidebar { transform: translateX(-100%); } + #content { margin-left: 0; } + .chat-input-bar { left: 0; } +} diff --git a/apps/ui/src/twfarmbot_ui/static/index.html b/apps/ui/src/twfarmbot_ui/static/index.html new file mode 100644 index 0000000..c7736cc --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/index.html @@ -0,0 +1,51 @@ + + + + + + TWFarmBot Research + + + + + + + + + + + +
+
+ + diff --git a/apps/ui/src/twfarmbot_ui/static/js/api.js b/apps/ui/src/twfarmbot_ui/static/js/api.js new file mode 100644 index 0000000..64a55bc --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/js/api.js @@ -0,0 +1,90 @@ +// Thin fetch wrapper mirroring the old Python ApiClient semantics: +// results are {ok, code, body}, POST /actions defaults to wait=false, +// and /chat/stream is consumed as Server-Sent Events. + +async function parseBody(resp) { + const text = await resp.text(); + try { return JSON.parse(text); } catch { return text; } +} + +export function errorMessage(result) { + const body = result.body; + if (body && typeof body === "object") { + if (body.detail !== undefined) return String(body.detail); + if (body.error !== undefined) return String(body.error); + } + if (typeof body === "string") return body; + return JSON.stringify(body); +} + +export async function request(url, { method = "GET", json, params, timeoutMs = 10000 } = {}) { + const qs = params ? `?${new URLSearchParams(params)}` : ""; + const opts = { method, signal: AbortSignal.timeout(timeoutMs) }; + if (json !== undefined) { + opts.headers = { "Content-Type": "application/json" }; + opts.body = JSON.stringify(json); + } + try { + const resp = await fetch(`${url}${qs}`, opts); + return { ok: resp.ok, code: resp.status, body: await parseBody(resp) }; + } catch (err) { + return { ok: false, code: 0, body: { error: `${err.name}: ${err.message}` } }; + } +} + +export const api = (path, opts) => request(`/api${path}`, opts); +export const resireg = (path, opts) => request(`/resireg${path}`, { timeoutMs: 120000, ...opts }); +export const ui = (path, opts) => request(`/ui${path}`, opts); + +// The FarmBot executes actions on a single worker queue; like the old UI we +// dispatch fire-and-forget by default and only wait for approved proposals. +export function postAction(kind, params = {}, { wait = false } = {}) { + return api("/actions", { + method: "POST", + json: { kind, params }, + params: { wait: String(wait) }, + timeoutMs: wait ? 120000 : 10000, + }); +} + +// POST-based SSE stream; yields parsed `data:` payloads. +export async function* sse(path, json, { timeoutMs = 90000 } = {}) { + const resp = await fetch(`/api${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(json), + signal: AbortSignal.timeout(timeoutMs), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${errorMessage({ body: await parseBody(resp) })}`); + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split("\n\n"); + buffer = events.pop(); + for (const chunk of events) { + for (const line of chunk.split("\n")) { + if (line.startsWith("data: ")) yield JSON.parse(line.slice(6)); + } + } + } +} + +// ── Local UI settings (auto-refresh intervals etc.) ───────────────────── + +const SETTINGS_KEY = "twfb_ui_settings"; +const DEFAULTS = { refreshPositionS: 5, refreshStatsS: 300, refreshCameraS: 0 }; + +export function getSettings() { + try { return { ...DEFAULTS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") }; } + catch { return { ...DEFAULTS }; } +} + +export function saveSettings(patch) { + const next = { ...getSettings(), ...patch }; + localStorage.setItem(SETTINGS_KEY, JSON.stringify(next)); + return next; +} diff --git a/apps/ui/src/twfarmbot_ui/static/js/main.js b/apps/ui/src/twfarmbot_ui/static/js/main.js new file mode 100644 index 0000000..199cfa5 --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/js/main.js @@ -0,0 +1,117 @@ +import "@material/web/all.js"; +import { styles as typescaleStyles } from "@material/web/typography/md-typescale-styles.js"; + +import { postAction, errorMessage } from "./api.js"; +import { h, icon, snack, timeAgo, num } from "./ui.js"; +import * as state from "./state.js"; + +import * as overview from "./views/overview.js"; +import * as garden from "./views/garden.js"; +import * as motion from "./views/motion.js"; +import * as camera from "./views/camera.js"; +import * as io from "./views/io.js"; +import * as assistant from "./views/assistant.js"; +import * as historyView from "./views/history.js"; +import * as diagnostics from "./views/diagnostics.js"; +import * as settings from "./views/settings.js"; + +document.adoptedStyleSheets.push(typescaleStyles.styleSheet); + +const TABS = [ + { key: "overview", label: "Overview", icon: "monitoring", view: overview }, + { key: "garden", label: "Garden", icon: "psychiatry", view: garden }, + { key: "motion", label: "Motion", icon: "open_with", view: motion }, + { key: "camera", label: "Camera", icon: "photo_camera", view: camera }, + { key: "io", label: "I/O", icon: "settings_input_component", view: io }, + { key: "assistant", label: "Assistant", icon: "smart_toy", view: assistant }, + { key: "history", label: "History", icon: "history", view: historyView }, + { key: "diagnostics", label: "Diagnostics", icon: "troubleshoot", view: diagnostics }, + { key: "settings", label: "Settings", icon: "settings", view: settings }, +]; + +// Legacy routes from the old Sensors / Operations tabs now live under I/O. +const LEGACY_TABS = { sensors: "io", operations: "io", "i/o": "io" }; + +let activeTab = null; +let teardown = null; + +function tabFromUrl() { + const raw = (new URLSearchParams(location.search).get("tab") || "").toLowerCase(); + const key = LEGACY_TABS[raw] || raw; + return TABS.some((tab) => tab.key === key) ? key : TABS[0].key; +} + +async function showTab(key) { + if (key === activeTab) return; + if (typeof teardown === "function") teardown(); + activeTab = key; + const params = new URLSearchParams(location.search); + params.set("tab", key); + history.replaceState(null, "", `?${params}`); + + for (const item of document.querySelectorAll(".nav-item")) { + item.classList.toggle("active", item.dataset.tab === key); + } + document.body.classList.toggle("has-chat-bar", key === "assistant"); + const content = document.getElementById("content"); + content.replaceChildren(); + const tab = TABS.find((t) => t.key === key); + teardown = await tab.view.render(content); +} + +function buildNav() { + const nav = document.getElementById("nav"); + nav.append(h("div", { class: "nav-label" }, "Navigation")); + for (const tab of TABS) { + nav.append(h("div", { + class: "nav-item", + "data-tab": tab.key, + onClick: () => showTab(tab.key), + }, icon(tab.icon), tab.label)); + } +} + +function bindSidebar() { + const pill = document.getElementById("farmbot-pill"); + const livePos = document.getElementById("live-position"); + const posAge = document.getElementById("position-age"); + + state.on("health", (health) => { + const fb = String(health?.farmbot ?? "unknown"); + const css = fb === "connected" ? "ok" : fb.startsWith("failed") ? "bad" : "warn"; + pill.className = `pill ${css}`; + pill.textContent = `● ${fb}`; + }); + state.on("position", (pos) => { + livePos.textContent = `X ${num(pos?.x)} · Y ${num(pos?.y)} · Z ${num(pos?.z)}`; + }); + setInterval(() => { + posAge.textContent = state.store.lastPositionRefresh + ? `updated ${timeAgo(state.store.lastPositionRefresh)}` : ""; + }, 1000); + + document.getElementById("refresh-btn").addEventListener("click", () => { + state.refreshPosition(); + state.refreshHealth(); + state.refreshMessages(); + }); + document.getElementById("estop-btn").addEventListener("click", async () => { + const r = await postAction("e_stop"); + if (r.ok) snack("🛑 ESTOP sent"); + else snack(errorMessage(r), { error: true }); + }); +} + +async function boot() { + buildNav(); + bindSidebar(); + await state.initSession(); + state.refreshHealth(); + state.refreshPosition(); + state.refreshMessages(); + state.startPolling(); + showTab(tabFromUrl()); + window.addEventListener("popstate", () => showTab(tabFromUrl())); +} + +boot(); diff --git a/apps/ui/src/twfarmbot_ui/static/js/state.js b/apps/ui/src/twfarmbot_ui/static/js/state.js new file mode 100644 index 0000000..6d7e3c1 --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/js/state.js @@ -0,0 +1,154 @@ +// Shared app state: health, position, telemetry, and the persisted session +// (chat history, camera gallery, garden world) stored via /ui/sessions. + +import { api, ui, getSettings } from "./api.js"; + +export const store = { + health: null, // {status, actions, farmbot} + position: null, // {x, y, z} + lastPositionRefresh: 0, + messages: [], + diag: {}, // last GET /status state + lastStatsRefresh: 0, + telemetry: [], // ring buffer of {time, cpu, memory, disk, wifi, soc} + session: null, // persisted snapshot +}; + +const listeners = new Map(); + +export function on(event, fn) { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event).add(fn); + return () => listeners.get(event).delete(fn); +} + +export function emit(event, payload) { + for (const fn of listeners.get(event) || []) fn(payload); +} + +// ── Refresh helpers ───────────────────────────────────────────────────── + +export async function refreshPosition() { + const r = await api("/position"); + if (r.ok && r.body?.xyz) { + store.position = r.body.xyz; + store.lastPositionRefresh = Date.now(); + emit("position", store.position); + } + return r; +} + +export async function refreshHealth() { + const r = await api("/health"); + if (r.ok && typeof r.body === "object") { + store.health = r.body; + emit("health", store.health); + } + return r; +} + +export async function refreshMessages() { + const r = await api("/messages"); + if (r.ok && Array.isArray(r.body?.last_messages)) { + store.messages = r.body.last_messages.slice(-20).map(String); + emit("messages", store.messages); + } + return r; +} + +export async function refreshStatus() { + const r = await api("/status"); + if (r.ok && typeof r.body === "object") { + store.diag = r.body.state || {}; + store.lastStatsRefresh = Date.now(); + const info = store.diag.informational_settings || {}; + store.telemetry.push({ + time: new Date().toLocaleTimeString(), + cpu: parseFloat(info.cpu_usage) || 0, + memory: parseFloat(info.memory_usage) || 0, + disk: parseFloat(info.disk_usage) || 0, + wifi: parseFloat(info.wifi_level_percent) || 0, + soc: parseFloat(info.soc_temp) || 0, + }); + store.telemetry = store.telemetry.slice(-60); + emit("status", store.diag); + } + return r; +} + +// ── Session persistence (server-side JSON files) ─────────────────────── + +function blankSession() { + const now = new Date().toISOString(); + const stamp = now.slice(0, 19).replaceAll(":", "-"); + const suffix = Math.random().toString(16).slice(2, 10); + return { + session_id: `${stamp}-${suffix}`, + label: null, + created_at: now, + updated_at: now, + assistant_messages: [], + assistant_selected_model: null, + assistant_metrics: {}, + executed_plans: [], + camera_images: [], + garden_world: null, + }; +} + +export async function initSession() { + const requested = new URLSearchParams(location.search).get("session"); + if (requested) { + const r = await ui(`/sessions/${encodeURIComponent(requested)}`); + if (r.ok) { + store.session = { ...blankSession(), ...r.body }; + return; + } + } + store.session = blankSession(); +} + +// A blank unsaved session isn't persisted until it has content. +function sessionHasContent() { + const s = store.session; + return s && (s.assistant_messages.length || s.camera_images.length || + s.garden_world || s.executed_plans.length || s.label); +} + +export async function persistSession() { + if (!sessionHasContent()) return; + const s = store.session; + await ui(`/sessions/${encodeURIComponent(s.session_id)}`, { method: "PUT", json: s }); +} + +export function loadSessionSnapshot(snapshot) { + store.session = { ...blankSession(), ...snapshot }; + const params = new URLSearchParams(location.search); + params.set("session", snapshot.session_id); + history.replaceState(null, "", `?${params}`); + emit("session", store.session); +} + +export function newSession() { + store.session = blankSession(); + const params = new URLSearchParams(location.search); + params.delete("session"); + history.replaceState(null, "", params.size ? `?${params}` : location.pathname); + emit("session", store.session); +} + +// ── Background polling ────────────────────────────────────────────────── + +export function startPolling() { + setInterval(() => { + const settings = getSettings(); + const now = Date.now(); + if (now - store.lastPositionRefresh >= settings.refreshPositionS * 1000) { + refreshPosition(); + } + if (now - store.lastStatsRefresh >= settings.refreshStatsS * 1000) { + refreshHealth(); + refreshStatus(); + } + }, 1000); +} diff --git a/apps/ui/src/twfarmbot_ui/static/js/ui.js b/apps/ui/src/twfarmbot_ui/static/js/ui.js new file mode 100644 index 0000000..0bc1cc9 --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/js/ui.js @@ -0,0 +1,162 @@ +// Tiny DOM helpers + shared layout primitives for consistent page structure. + +import { marked } from "marked"; +import DOMPurify from "dompurify"; + +export function h(tag, attrs = {}, ...children) { + const el = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (value === undefined || value === null) continue; + if (key === "class") el.className = value; + else if (key.startsWith("on") && typeof value === "function") { + el.addEventListener(key.slice(2).toLowerCase(), value); + } else if (key === "html") el.innerHTML = value; + else if (key in el && key !== "style" && typeof value !== "string") el[key] = value; + else el.setAttribute(key, value); + } + for (const child of children.flat()) { + if (child === undefined || child === null) continue; + el.append(child.nodeType ? child : document.createTextNode(String(child))); + } + return el; +} + +export const icon = (name) => h("md-icon", {}, name); + +export function snack(message, { error = false, ms = 3500 } = {}) { + const host = document.getElementById("snackbar-host"); + const bar = h("div", { class: `snackbar${error ? " error" : ""}` }, message); + host.append(bar); + setTimeout(() => bar.remove(), ms); +} + +export function md(text) { + return DOMPurify.sanitize(marked.parse(String(text ?? ""))); +} + +// ── Layout primitives ─────────────────────────────────────────────────── + +/** Standard page shell: header + scrollable body with consistent spacing. */ +export function page(title, eyebrow = "TWFarmBot · UAS Technikum Wien", { actions, bodyClass = "" } = {}) { + const body = h("div", { class: `page-body${bodyClass ? ` ${bodyClass}` : ""}` }); + const header = h("header", { class: "page-header" }, + h("p", { class: "eyebrow" }, eyebrow), + h("div", { class: "page-title-row" }, + h("h1", { class: "page-title" }, title), + actions ? h("div", { class: "page-actions" }, actions) : null)); + return { root: h("div", { class: "page" }, header, body), body }; +} + +/** Section with a consistent title and vertical rhythm. */ +export function section(title, ...children) { + return h("section", { class: "section" }, + title ? h("h2", { class: "section-title" }, title) : null, + ...children); +} + +/** Elevated surface card; optional title rendered as card header. */ +export function card(title, ...children) { + const inner = title + ? [h("div", { class: "card-header" }, h("h3", { class: "card-title" }, title)), h("div", { class: "card-body" }, ...children)] + : children; + return h("div", { class: "card" }, ...inner); +} + +/** Horizontal button / control row. */ +export function toolbar(...children) { + return h("div", { class: "toolbar" }, ...children); +} + +/** Responsive metric grid (auto-fits 2–5 columns). */ +export function metricRow(pairs) { + return h("div", { class: "metric-grid" }, + pairs.map(([label, value]) => metric(label, value))); +} + +export function metric(label, value) { + return h("div", { class: "metric" }, + h("div", { class: "metric-label" }, label), + h("div", { class: "metric-value" }, value ?? "—")); +} + +/** Two-column layout for map + sidebar, camera + controls, etc. */ +export function split(main, side, { ratio = "2fr 1fr" } = {}) { + return h("div", { class: "split", style: `--split-ratio:${ratio}` }, main, side); +} + +export function stack(...children) { + return h("div", { class: "stack" }, ...children); +} + +export function emptyState(message, { iconName = "info" } = {}) { + return h("div", { class: "empty-state" }, icon(iconName), h("p", {}, message)); +} + +export function expander(label, ...children) { + return h("details", { class: "expander" }, h("summary", {}, label), h("div", { class: "expander-body" }, ...children)); +} + +export function jsonBlock(data) { + return h("pre", { class: "codeblock" }, JSON.stringify(data, null, 2)); +} + +export function dataTable(headers, rows) { + return h("table", { class: "data-table" }, + h("thead", {}, h("tr", {}, headers.map((head) => h("th", {}, head)))), + h("tbody", {}, rows.map((row) => h("tr", {}, row.map((cell) => h("td", {}, cell ?? "—")))))); +} + +// Back-compat alias used by a few views. +export const pageHeader = (title, eyebrow) => { + const { root } = page(title, eyebrow); + return [root.querySelector(".eyebrow"), root.querySelector(".page-title")]; +}; + +// ── Formatting helpers ──────────────────────────────────────────────── + +export const num = (value) => { + const f = parseFloat(value); + return Number.isFinite(f) ? f.toFixed(1) : "—"; +}; + +export const flt = (value, fallback = 0) => { + const f = parseFloat(value); + return Number.isFinite(f) ? f : fallback; +}; + +export function parseNumber(value) { + if (typeof value === "number") return value; + const text = String(value).trim(); + if (!/^-?\d+(?:[.,]\d+)?$/.test(text)) return null; + return parseFloat(text.replace(",", ".")); +} + +export function timeAgo(tsMs) { + if (!tsMs) return "never"; + const delta = (Date.now() - tsMs) / 1000; + if (delta < 2) return "just now"; + if (delta < 60) return `${Math.floor(delta)}s ago`; + if (delta < 3600) return `${Math.floor(delta / 60)}min ago`; + return `${(delta / 3600).toFixed(1)}h ago`; +} + +const ACTION_ICONS = { + move: "➡️", move_path: "🧭", water: "💧", find_home: "🏠", take_photo: "📷", + read_pin: "📖", write_pin: "✏️", mount_tool: "🔧", dismount_tool: "🔧", e_stop: "🛑", +}; + +export function actionSummary(action) { + const kind = action.kind || "action"; + const params = action.params || {}; + const prefix = `${ACTION_ICONS[kind] || "🛠️"} ${kind}`; + switch (kind) { + case "move": return `${prefix} → (${num(params.x)}, ${num(params.y)}, ${num(params.z)})`; + case "move_path": return `${prefix} · ${(params.waypoints || []).length} waypoint(s)`; + case "water": return `${prefix} · ${params.seconds ?? "—"}s`; + case "find_home": return `${prefix} (axis=${params.axis || "all"})`; + case "read_pin": return `${prefix} ${params.pin ?? "—"} (${params.mode || "digital"})`; + case "write_pin": return `${prefix} ${params.pin ?? "—"} = ${params.value ?? "—"}`; + case "mount_tool": return `${prefix} ${params.tool_name ?? "—"}`; + default: return prefix; + } +} diff --git a/apps/ui/src/twfarmbot_ui/static/js/views/assistant.js b/apps/ui/src/twfarmbot_ui/static/js/views/assistant.js new file mode 100644 index 0000000..98af915 --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/js/views/assistant.js @@ -0,0 +1,374 @@ +import { api, ui, sse, postAction, errorMessage } from "../api.js"; +import { h, icon, snack, md, page, card, toolbar, expander, jsonBlock, actionSummary } from "../ui.js"; +import * as state from "../state.js"; + +const CHAT_TIMEOUT_MS = 90000; +const APPROVAL_WORDS = new Set(["yes", "y", "approve", "approved", "ok", "okay", "sure", + "go ahead", "do it", "confirm", "confirmed", "execute", "run it"]); +const REJECTION_WORDS = new Set(["no", "n", "reject", "rejected", "cancel", "cancelled", + "don't", "dont", "stop", "abort"]); + +const normalize = (text) => text.replace(/[!.? ]+$/g, "").trim().toLowerCase(); +const isApproval = (text) => APPROVAL_WORDS.has(normalize(text)); +const isRejection = (text) => REJECTION_WORDS.has(normalize(text)); + +function metricsFooter(metrics) { + if (!metrics) return ""; + const parts = []; + if (metrics.total_latency_s != null) parts.push(`total ${metrics.total_latency_s.toFixed(2)}s`); + if (metrics.ttft_s) parts.push(`ttft ${metrics.ttft_s.toFixed(2)}s`); + if (metrics.tokens_per_s) parts.push(`${metrics.tokens_per_s.toFixed(1)} tok/s`); + if (metrics.total_tokens) { + parts.push(`tokens ${metrics.prompt_tokens || 0}+${metrics.completion_tokens || 0}=${metrics.total_tokens}`); + } + if (metrics.resireg_latency_s) parts.push(`resireg ${metrics.resireg_latency_s.toFixed(2)}s`); + return parts.join(" · "); +} + +function toolCallLabel(name, args = {}) { + let label = name; + if (name === "analyze_image" && args.prompt) label += ` · ${args.prompt}`; + else if (name === "segment_image" && args.classes) label += ` · ${args.classes}`; + else if (name === "visualize_image_features") label += ` · ${args.n_clusters ?? 6} clusters`; + else if (name === "estimate_traversability" && args.prompt) label += ` · ${args.prompt}`; + return label; +} + +function toolCallEl(name, args, result) { + const parts = [expander(toolCallLabel(name, args || {}), jsonBlock({ args, result }))]; + if (result && typeof result === "object") { + const urls = result.image_urls || (result.image_url ? [result.image_url] : []); + if (urls.length) parts.push(h("div", { class: "chat-images" }, urls.map((src) => h("img", { src, alt: "" })))); + } + return h("div", {}, ...parts); +} + +async function fetchLatestImage() { + const r = await api("/images", { params: { limit: "1", refresh: "true" }, timeoutMs: 15000 }); + return ((r.ok && r.body?.images) || [])[0] || null; +} + +function imageIsNewer(image, previous) { + if (!previous) return true; + if (image.id != null && previous.id != null) return image.id !== previous.id; + return (image.created_at || "") > (previous.created_at || ""); +} + +async function waitForNewImage(previous, { attempts = 15, delayMs = 2000 } = {}) { + for (let i = 0; i < attempts; i++) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + const image = await fetchLatestImage(); + if (image && imageIsNewer(image, previous)) return image; + } + return null; +} + +async function executeProposedActions(actions, message) { + const willCapture = message && actions.some((a) => a.kind === "take_photo"); + const previousImage = willCapture ? await fetchLatestImage() : null; + const results = []; + for (const action of actions) { + const r = await postAction(action.kind, action.params || {}, { wait: true }); + results.push({ kind: action.kind, ok: r.ok, detail: typeof r.body === "string" ? r.body : r.body?.detail }); + } + if (willCapture) { + const image = await waitForNewImage(previousImage); + if (image) (message.images = message.images || []).push({ attachment_url: image.attachment_url }); + } + return results; +} + +function formatExecutionResults(results) { + if (!results.length) return "✅ Approved (no actions)."; + return results.map((res) => res.ok + ? `✅ ${actionSummary({ kind: res.kind })}` + : `❌ ${actionSummary({ kind: res.kind })} — ${res.detail || "error"}`).join("\n"); +} + +export async function render(container) { + const session = state.store.session; + const chatScroll = h("div", { class: "chat-scroll" }); + const metricsBar = h("div", { class: "assistant-metrics" }); + const sessionBox = h("div"); + const pickerBox = h("div", { class: "model-picker" }); + let busy = false; + + const clearBtn = h("md-outlined-button", { + onClick: async () => { + session.assistant_messages = []; + await state.persistSession(); + drawMessages(); + }, + }, icon("mop"), "Clear chat"); + + const { root, body } = page("Assistant", "TWFarmBot · UAS Technikum Wien", { actions: clearBtn }); + body.append( + sessionBox, + pickerBox, + h("div", { class: "chat-panel" }, chatScroll), + ); + container.append(root); + + // Fixed chat input bar — attached to body so it spans the full main area. + const input = h("md-outlined-text-field", { label: "Message", placeholder: "Ask the FarmBot assistant…" }); + const sendBtn = h("md-filled-button", {}, icon("send"), "Send"); + const chatBar = h("div", { class: "chat-input-bar" }, + h("div", { class: "chat-input-inner" }, + metricsBar, + h("div", { class: "chat-input-row" }, input, sendBtn))); + document.body.append(chatBar); + + const send = async () => { + const prompt = input.value.trim(); + if (!prompt) return; + input.value = ""; + await sendPrompt(prompt); + }; + sendBtn.addEventListener("click", send); + input.addEventListener("keydown", (e) => { if (e.key === "Enter") send(); }); + + function messageEl(message) { + if (message.role === "tool") { + return h("div", { class: "msg" }, + h("md-icon", { class: "avatar" }, "build"), + h("div", { class: "bubble" }, toolCallEl(message.name, message.args, message.result))); + } + if (message.role === "user") { + return h("div", { class: "msg user" }, + h("md-icon", { class: "avatar" }, "person"), + h("div", { class: "bubble", html: md(message.content) })); + } + const bubble = h("div", { class: "bubble" }); + if (message.thinking) bubble.append(expander("Thinking", h("div", { html: md(message.thinking) }))); + bubble.append(h("div", { html: md(message.content) })); + if (message.images?.length) { + bubble.append(h("div", { class: "chat-images" }, + message.images.map((img) => h("img", { src: img.attachment_url, alt: "" })))); + } + const proposals = message.proposed_actions || []; + if (proposals.length && !message.approved && !message.rejected) { + bubble.append(h("div", { class: "proposal-card" }, + h("p", { class: "caption", style: "margin:0 0 8px" }, "Proposed actions:"), + ...proposals.map((a) => h("p", { class: "caption", style: "margin:0" }, actionSummary(a))), + toolbar( + h("md-filled-button", { onClick: () => resolveProposal(message, true) }, icon("check"), "Approve"), + h("md-outlined-button", { onClick: () => resolveProposal(message, false) }, icon("close"), "Reject")))); + } else if (message.approved) { + bubble.append(h("p", { class: "caption" }, "Approved")); + } else if (message.rejected) { + bubble.append(h("p", { class: "caption" }, "Rejected")); + } + return h("div", { class: "msg" }, h("md-icon", { class: "avatar" }, "smart_toy"), bubble); + } + + function drawMessages() { + chatScroll.replaceChildren(...session.assistant_messages.map(messageEl)); + metricsBar.textContent = metricsFooter(session.assistant_metrics); + chatScroll.lastElementChild?.scrollIntoView({ behavior: "smooth", block: "end" }); + } + + async function resolveProposal(message, approved) { + if (approved) { + const results = await executeProposedActions(message.proposed_actions, message); + message.approved = true; + message.content += `\n\n${formatExecutionResults(results)}`; + } else { + message.rejected = true; + message.content += "\n\n❌ Cancelled."; + } + await state.persistSession(); + drawMessages(); + } + + async function sendPrompt(prompt) { + if (busy) return; + const messages = session.assistant_messages; + const last = messages[messages.length - 1]; + const pending = last?.role === "assistant" && (last.proposed_actions || []).length + && !last.approved && !last.rejected; + + if (isApproval(prompt) || isRejection(prompt)) { + if (pending) { + messages.push({ role: "user", content: prompt }); + drawMessages(); + await resolveProposal(last, isApproval(prompt)); + return; + } + snack("No pending proposal to approve or reject."); + return; + } + + busy = true; + messages.push({ role: "user", content: prompt }); + drawMessages(); + + const liveBubble = h("div", { class: "bubble" }); + const liveMsg = h("div", { class: "msg" }, + h("md-icon", { class: "avatar" }, "smart_toy"), + h("div", { class: "stack" }, + h("p", { class: "caption", style: "margin:0" }, "Thinking…"), + liveBubble)); + chatScroll.append(liveMsg); + + let textDiv = null; + let accumulated = ""; + let segment = ""; + const meta = { tool_calls: [], proposed_actions: [], metrics: {} }; + const thinkingParts = []; + let streamError = null; + const newSegment = () => { textDiv = null; segment = ""; }; + const appendText = (chunk) => { + accumulated += chunk; + segment += chunk; + if (!textDiv) { textDiv = h("div"); liveBubble.append(textDiv); } + textDiv.innerHTML = md(segment); + liveMsg.scrollIntoView({ behavior: "smooth", block: "end" }); + }; + + try { + for await (const event of sse("/chat/stream", + { messages, model: session.assistant_selected_model }, + { timeoutMs: CHAT_TIMEOUT_MS })) { + if (event.type === "delta") appendText(event.content || ""); + else if (event.type === "thinking") { + newSegment(); + thinkingParts.push(String(event.content || "")); + liveBubble.append(expander("Thinking", h("div", { html: md(event.content) }))); + } else if (event.type === "tool_call") { + newSegment(); + const { name, args, result } = event; + messages.push({ role: "tool", name, args, result }); + if (name === "take_photo" && result?.status === "ok") { + const image = await fetchLatestImage(); + if (image) result.image_url = image.attachment_url; + } + liveBubble.append(toolCallEl(name, args, result)); + } else if (event.type === "meta") { + meta.tool_calls = event.tool_calls || []; + meta.proposed_actions = event.proposed_actions || []; + meta.metrics = event.metrics || {}; + } else if (event.type === "error") { + streamError = event.error || "stream error"; + } + } + } catch (err) { + streamError = `${err.name}: ${err.message}`; + } + + if (!accumulated && !meta.tool_calls.length && !meta.proposed_actions.length) { + const r = await api("/chat", { + method: "POST", + json: { messages, model: session.assistant_selected_model }, + timeoutMs: CHAT_TIMEOUT_MS, + }); + if (r.ok && typeof r.body === "object") { + accumulated = String(r.body.response || ""); + meta.tool_calls = r.body.tool_calls || []; + meta.metrics = r.body.metrics || {}; + for (const tc of meta.tool_calls) { + messages.push({ role: "tool", name: tc.name, args: tc.args, result: tc.result }); + } + meta.proposed_actions = meta.tool_calls + .filter((tc) => tc.result?.status === "proposed") + .map((tc) => ({ kind: tc.result.kind ?? tc.name, params: tc.result.params ?? tc.args ?? {} })); + thinkingParts.splice(0, thinkingParts.length, String(r.body.thinking || "")); + streamError = null; + } else if (!streamError) { + streamError = `Fallback failed: ${errorMessage(r)}`; + } + } + + if (Object.keys(meta.metrics).length) session.assistant_metrics = meta.metrics; + if (streamError) snack(`Assistant error: ${streamError}`, { error: true, ms: 8000 }); + if (accumulated || meta.tool_calls.length || meta.proposed_actions.length) { + messages.push({ + role: "assistant", content: accumulated, thinking: thinkingParts.join(""), + tool_calls: meta.tool_calls, proposed_actions: meta.proposed_actions, + images: [], metrics: meta.metrics, + }); + } + busy = false; + await state.persistSession(); + drawMessages(); + drawSessionBox(); + } + + async function drawPicker() { + const provRes = await api("/providers"); + const providers = (provRes.ok && provRes.body?.providers) || ["openrouter", "local"]; + let provider = (provRes.ok && provRes.body?.current) || providers[0]; + const providerSelect = h("md-outlined-select", { label: "Provider" }, + providers.map((p) => h("md-select-option", { value: p, selected: p === provider }, + h("div", { slot: "headline" }, p)))); + const modelBox = h("div"); + pickerBox.replaceChildren(providerSelect, modelBox); + + async function loadModels() { + const r = await api("/models", { params: { provider } }); + const models = (r.ok && r.body?.models) || []; + let current = session.assistant_selected_model || (r.ok && r.body?.current) || null; + if (models.length) { + if (!models.includes(current)) { + const preferred = ["openai/gpt-4o-mini", "openai/gpt-4o", "anthropic/claude-3.5-sonnet"]; + current = preferred.find((m) => models.includes(m)) || models[0]; + } + session.assistant_selected_model = current; + const modelSelect = h("md-outlined-select", { label: "Model", class: "grow" }, + models.map((m) => h("md-select-option", { value: m, selected: m === current }, + h("div", { slot: "headline" }, m)))); + modelSelect.addEventListener("change", () => { session.assistant_selected_model = modelSelect.value; }); + modelBox.replaceChildren(modelSelect); + } else { + modelBox.replaceChildren(h("md-outlined-text-field", { + label: "Model", value: current || "", class: "grow", + onInput: (e) => { session.assistant_selected_model = e.target.value || null; }, + })); + } + } + providerSelect.addEventListener("change", () => { provider = providerSelect.value; loadModels(); }); + await loadModels(); + } + + async function drawSessionBox() { + const labelField = h("md-outlined-text-field", { + label: "Session label", value: session.label || "", class: "grow", + onInput: (e) => { session.label = e.target.value.trim() || null; }, + }); + const listBox = h("div", { class: "stack" }); + sessionBox.replaceChildren(expander("Session", + toolbar(labelField, + h("md-outlined-button", { + onClick: async () => { await state.persistSession(); state.newSession(); location.reload(); }, + }, icon("add"), "New"), + h("md-outlined-button", { + onClick: async () => { await state.persistSession(); snack("Session saved"); }, + }, icon("save"), "Save")), + listBox)); + + const r = await ui("/sessions"); + const sessions = ((r.ok && r.body?.sessions) || []) + .filter((s) => s.session_id !== session.session_id).slice(0, 20); + listBox.replaceChildren(...sessions.map((s) => h("div", { class: "session-list-item" }, + h("span", { class: "caption" }, (s.label || s.session_id) + (s.preview ? ` · ${s.preview}` : "")), + h("md-text-button", { + onClick: async () => { + const res = await ui(`/sessions/${encodeURIComponent(s.session_id)}`); + if (!res.ok) { snack("Session not found", { error: true }); return; } + state.loadSessionSnapshot(res.body); + location.reload(); + }, + }, "Load"), + h("md-icon-button", { + onClick: async () => { + await ui(`/sessions/${encodeURIComponent(s.session_id)}`, { method: "DELETE" }); + drawSessionBox(); + }, + }, icon("delete"))))); + } + + drawMessages(); + drawSessionBox(); + drawPicker(); + + return () => chatBar.remove(); +} diff --git a/apps/ui/src/twfarmbot_ui/static/js/views/camera.js b/apps/ui/src/twfarmbot_ui/static/js/views/camera.js new file mode 100644 index 0000000..661695e --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/js/views/camera.js @@ -0,0 +1,186 @@ +import { api, resireg, postAction, errorMessage, getSettings } from "../api.js"; +import { h, icon, snack, page, section, card, toolbar, split, stack, metricRow, emptyState } from "../ui.js"; +import * as state from "../state.js"; + +function parseSegmentationLabels(labels) { + const out = {}; + for (const label of labels) { + for (const part of String(label).split(",")) { + const match = part.match(/(.+?)\s*\(\s*([0-9]*\.?[0-9]+)\s*%\s*\)/); + if (match) out[match[1].trim()] = Math.round(parseFloat(match[2]) * 10) / 1000; + } + } + return out; +} + +async function visionChat(imageUrl, text) { + const r = await resireg("/v1/chat/completions", { + method: "POST", + json: { + model: "SimonSchwaiger/resireg_mini", + messages: [{ role: "user", content: [ + { type: "text", text }, { type: "image_url", image_url: { url: imageUrl } }, + ] }], + }, + }); + if (!r.ok) throw new Error(errorMessage(r)); + const content = r.body.choices[0].message.content; + return typeof content === "string" ? JSON.parse(content) : content; +} + +const MODES = [ + "Open Language Similarity", + "Zero-Shot Segmentation", + "PCA Feature Visualization", + "Traversability Estimation", +]; + +export async function render(container) { + const { root, body } = page("Camera"); + const galleryArea = h("div"); + const resultArea = h("div"); + let aiResult = null; + + body.append( + toolbar( + h("md-filled-button", { + onClick: async () => { + const r = await postAction("take_photo"); + if (r.ok) { snack("Capture queued"); loadGallery(); } + else snack(errorMessage(r), { error: true }); + }, + }, icon("photo_camera"), "Take photo"), + h("md-outlined-button", { onClick: () => loadGallery(true) }, icon("refresh"), "Refresh gallery")), + galleryArea, + resultArea, + ); + container.append(root); + + async function loadGallery(refresh = false) { + const r = await api("/images", { params: refresh ? { refresh: "true" } : undefined, timeoutMs: 15000 }); + if (r.ok && Array.isArray(r.body?.images)) { + state.store.session.camera_images = r.body.images; + state.persistSession(); + draw(); + } else snack(errorMessage(r), { error: true }); + } + + function draw() { + const images = state.store.session?.camera_images || []; + if (!images.length) { + galleryArea.replaceChildren(emptyState("Refresh the gallery to load FarmBot photos.", { iconName: "photo_library" })); + return; + } + let selected = images[0]; + const select = h("md-outlined-select", { label: "Research image", class: "field-lg" }, + images.map((img, i) => h("md-select-option", { value: String(i), selected: i === 0 }, + h("div", { slot: "headline" }, `${img.created_at || "Unknown"} · #${img.id ?? "—"}`)))); + const frame = h("img", { class: "frame", src: selected.attachment_url || "", alt: "FarmBot capture" }); + select.addEventListener("change", () => { + selected = images[Number(select.value)]; + frame.src = selected.attachment_url || ""; + }); + + let mode = MODES[0]; + const modeSelect = h("md-outlined-select", { label: "Analysis mode", class: "grow" }, + MODES.map((label, i) => h("md-select-option", { value: label, selected: i === 0 }, + h("div", { slot: "headline" }, label)))); + const promptField = h("md-outlined-text-field", { label: "Target prompt", class: "grow" }); + const classesField = h("md-outlined-text-field", { label: "Classes (comma-separated)", value: "plant, weed, soil, path", class: "grow" }); + const negativeField = h("md-outlined-text-field", { label: "Background prompt", class: "grow" }); + const clusterSlider = h("md-slider", { min: 2, max: 20, value: 6, labeled: true }); + const spinner = h("md-circular-progress", { indeterminate: true, style: "display:none" }); + + function syncFields() { + mode = modeSelect.value || MODES[0]; + const isSim = mode === "Open Language Similarity"; + const isSeg = mode === "Zero-Shot Segmentation"; + const isPca = mode === "PCA Feature Visualization"; + const isTrav = mode === "Traversability Estimation"; + promptField.style.display = isSim || isTrav ? "" : "none"; + promptField.label = isTrav ? "Traversable prompt" : "Target prompt"; + classesField.style.display = isSeg ? "" : "none"; + negativeField.style.display = isSeg || isTrav ? "" : "none"; + clusterSlider.style.display = isPca ? "" : "none"; + } + modeSelect.addEventListener("change", syncFields); + + async function analyze() { + spinner.style.display = ""; + try { + const url = selected.attachment_url; + if (mode === "Open Language Similarity") { + const prompt = promptField.value.trim(); + if (!prompt) throw new Error("Enter a target prompt."); + const result = await visionChat(url, prompt); + aiResult = { images: [result.result_image_base64], captions: [`Similarity · ${prompt}`] }; + } else if (mode === "Zero-Shot Segmentation") { + const classes = classesField.value.trim(); + if (!classes) throw new Error("Enter classes."); + const result = await visionChat(url, `/segment: ${classes}`); + const labels = [String(result.detected ?? ""), String(result.undetected ?? "")]; + aiResult = { + images: (result.result_images_base64 || []).slice(0, 2), + captions: ["Overlay", "Segmentation map"], + labels, classScores: parseSegmentationLabels(labels), + }; + } else if (mode === "PCA Feature Visualization") { + const n = Number(clusterSlider.value); + const result = await visionChat(url, `/pca: ${n}`); + aiResult = { + images: (result.result_images_base64 || []).slice(0, 3), + captions: ["PCA 1", "PCA 2", "PCA 3"], nClusters: n, + }; + } else { + const prompt = promptField.value.trim(); + if (!prompt) throw new Error("Enter a traversable prompt."); + const negatives = negativeField.value.trim(); + const result = await visionChat(url, `/traverse: ${prompt}${negatives ? ` vs ${negatives}` : ""}`); + aiResult = { images: [result.result_image_base64], captions: [`Traversability · ${prompt}`] }; + } + drawResult(); + } catch (err) { + snack(`AI processing failed: ${err.message}`, { error: true }); + } finally { + spinner.style.display = "none"; + } + } + + syncFields(); + galleryArea.replaceChildren( + select, + split( + h("div", {}, frame), + card("AI analysis", stack(modeSelect, promptField, classesField, negativeField, clusterSlider, + toolbar(h("md-filled-button", { onClick: analyze }, icon("neurology"), "Analyze"), spinner))), + ), + images.length > 1 ? section("Recent captures", h("div", { class: "gallery" }, + images.slice(1, 7).map((img) => { + const meta = img.meta || {}; + return h("figure", {}, h("img", { src: img.attachment_url || "", alt: "" }), + h("figcaption", { class: "caption" }, `X ${meta.x ?? "—"} · Y ${meta.y ?? "—"}`)); + }))) : null, + ); + } + + function drawResult() { + if (!aiResult) { resultArea.replaceChildren(); return; } + const parts = [section("Analysis result", h("div", { class: "gallery" }, + aiResult.images.map((src, i) => h("figure", {}, + h("img", { src, alt: aiResult.captions[i] || "" }), + h("figcaption", { class: "caption" }, aiResult.captions[i] || "")))))]; + const scores = aiResult.classScores || {}; + if (Object.keys(scores).length) { + parts.push(metricRow(Object.entries(scores).map(([cls, score]) => [cls, `${(score * 100).toFixed(1)}%`]))); + } else if (aiResult.nClusters) { + parts.push(h("p", { class: "caption" }, `PCA with ${aiResult.nClusters} clusters`)); + } + for (const label of aiResult.labels || []) parts.push(h("p", { class: "caption" }, label)); + resultArea.replaceChildren(...parts); + } + + draw(); + const cameraS = getSettings().refreshCameraS; + const timer = cameraS > 0 ? setInterval(() => loadGallery(true), cameraS * 1000) : null; + return () => timer && clearInterval(timer); +} diff --git a/apps/ui/src/twfarmbot_ui/static/js/views/diagnostics.js b/apps/ui/src/twfarmbot_ui/static/js/views/diagnostics.js new file mode 100644 index 0000000..e795acc --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/js/views/diagnostics.js @@ -0,0 +1,61 @@ +import { errorMessage } from "../api.js"; +import { h, icon, snack, page, section, card, toolbar, metricRow, dataTable, emptyState } from "../ui.js"; +import * as state from "../state.js"; + +export async function render(container) { + const { root, body } = page("Diagnostics"); + const contentBox = h("div"); + + body.append( + toolbar(h("md-filled-tonal-button", { + onClick: async () => { + const r = await state.refreshStatus(); + if (!r.ok) snack(`Read failed: ${errorMessage(r)}`, { error: true }); + draw(); + }, + }, icon("troubleshoot"), "Load /status")), + contentBox, + ); + container.append(root); + + function draw() { + const payload = state.store.diag || {}; + const info = payload.informational_settings || {}; + const axes = payload.location_data?.axis_states || {}; + const pins = payload.pins || {}; + + if (!Object.keys(info).length && !Object.keys(axes).length && !Object.keys(pins).length) { + contentBox.replaceChildren(emptyState("Click “Load /status” to fetch diagnostic state.", { iconName: "troubleshoot" })); + return; + } + + contentBox.replaceChildren( + metricRow([ + ["Controller", info.controller_version ?? "—"], + ["Firmware", info.firmware_version ?? "—"], + ["Wi-Fi", `${info.wifi_level_percent ?? "—"}%`], + ["Uptime", `${info.uptime ?? "—"} s`], + ]), + h("div", { class: "card-grid" }, + card("Resources", + h("p", { class: "caption" }, `CPU ${info.cpu_usage ?? "—"}%`), + h("p", { class: "caption" }, `Memory ${info.memory_usage ?? "—"}% · Disk ${info.disk_usage ?? "—"}%`), + h("p", { class: "caption" }, `SoC ${info.soc_temp ?? "—"} °C`)), + card("Axis state", + h("p", { class: "caption" }, `X ${axes.x ?? "—"} · Y ${axes.y ?? "—"} · Z ${axes.z ?? "—"}`), + h("p", { class: "caption" }, `Busy: ${info.busy ?? "—"}`)), + card("Network", + h("p", { class: "caption" }, `${info.wifi_level ?? "—"} dBm`), + h("p", { class: "caption" }, `${info.private_ip ?? "—"}`), + h("p", { class: "caption" }, `Sync: ${info.sync_status ?? "—"}`)), + ), + Object.keys(pins).length + ? section("Pin snapshot", dataTable(["Pin", "Value", "Mode"], + Object.entries(pins).map(([pin, data]) => [pin, data?.value, data?.mode]))) + : null, + ); + } + + draw(); + return state.on("status", draw); +} diff --git a/apps/ui/src/twfarmbot_ui/static/js/views/garden.js b/apps/ui/src/twfarmbot_ui/static/js/views/garden.js new file mode 100644 index 0000000..4956a22 --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/js/views/garden.js @@ -0,0 +1,188 @@ +import { api, ui, errorMessage } from "../api.js"; +import { h, icon, snack, page, section, card, toolbar, metricRow, split, stack, num, dataTable } from "../ui.js"; +import * as state from "../state.js"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const GRID_STEP = 25; +const PALETTE = ["#256a4a", "#3c6472", "#8b7355", "#6b4c8a", "#a64b4b", "#3a8f7d", "#9b4b7a"]; +const KINDS = ["plant", "obstacle", "tool", "marker", "sensor", "valve", "custom"]; + +const kindColor = (kind, offset = 0) => { + let hash = offset; + for (const ch of String(kind)) hash = (hash * 31 + ch.charCodeAt(0)) % 997; + return PALETTE[hash % PALETTE.length]; +}; + +function svgEl(tag, attrs, ...children) { + const el = document.createElementNS(SVG_NS, tag); + for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, value); + el.append(...children); + return el; +} + +export async function render(container) { + const { root, body } = page("Garden map", "Spatial model · configured world state"); + const metricsBox = h("div"); + const mapBox = h("div"); + const sideBox = h("div", { class: "stack" }); + let selected = []; + + body.append( + toolbar(h("md-filled-tonal-button", { onClick: () => loadWorld(true) }, icon("refresh"), "Refresh map")), + metricsBox, + split(mapBox, sideBox), + ); + container.append(root); + + async function loadWorld(force = false) { + let world = force ? null : state.store.session?.garden_world; + if (!world) { + const r = await api("/garden"); + if (!r.ok || typeof r.body !== "object") { + mapBox.replaceChildren(card(null, h("p", { class: "caption" }, + `Garden model unavailable: ${errorMessage(r)}`))); + return; + } + world = r.body; + state.store.session.garden_world = world; + state.persistSession(); + } + selected = []; + draw(world); + } + + function draw(world) { + const bounds = world.bounds || {}; + const camera = world.camera || {}; + const robot = world.robot || {}; + const entities = world.entities || []; + const zones = world.zones || []; + const [x0, y0] = [bounds.x || 0, bounds.y || 0]; + const [width, height] = [bounds.width || 1, bounds.height || 1]; + const pad = Math.max(width, height) * 0.03; + const flipY = (y) => y0 + height - (y - y0); + + metricsBox.replaceChildren(metricRow([ + ["Garden X", `${num(width)} mm`], ["Garden Y", `${num(height)} mm`], + ["Known objects", entities.length], ["Mapped zones", zones.length], + ])); + + const svg = svgEl("svg", { + class: "garden-map", + viewBox: `${x0 - pad} ${y0 - pad} ${width + 2 * pad} ${height + 2 * pad}`, + }); + svg.append(svgEl("rect", { + x: x0, y: y0, width, height, + fill: "none", stroke: "var(--md-sys-color-outline)", "stroke-width": pad * 0.2, + })); + for (const zone of zones) { + const zb = zone.bounds || {}; + const color = kindColor(zone.kind); + svg.append(svgEl("rect", { + x: zb.x, y: flipY(zb.y + zb.height), width: zb.width, height: zb.height, + fill: color, "fill-opacity": 0.15, stroke: color, "stroke-width": pad * 0.12, rx: 6, + }, svgEl("title", {}, `${zone.name} (${zone.kind})`))); + } + const marker = (x, y, r, color, label) => svgEl("circle", { + cx: x, cy: flipY(y), r, fill: color, stroke: "white", "stroke-width": r * 0.15, + }, svgEl("title", {}, label)); + for (const entity of entities) { + const p = entity.position || {}; + svg.append(marker(p.x, p.y, Math.max(entity.radius_mm || 20, 12), kindColor(entity.kind, 3), + `${entity.name} (${entity.kind})`)); + } + svg.append(marker(robot.x || 0, robot.y || 0, 20, "#1a73e8", "FarmBot")); + const camPos = camera.position || {}; + svg.append(marker(camPos.x || 0, camPos.y || 0, 14, "#7c4dff", "Camera")); + + const selectionLayer = svgEl("g", {}); + svg.append(selectionLayer); + svg.addEventListener("click", (event) => { + const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(svg.getScreenCTM().inverse()); + const gx = Math.round(point.x / GRID_STEP) * GRID_STEP; + const gy = Math.round((y0 + height - (point.y - y0)) / GRID_STEP) * GRID_STEP; + if (gx < x0 || gx > x0 + width || gy < y0 || gy > y0 + height) return; + const idx = selected.findIndex(([sx, sy]) => sx === gx && sy === gy); + if (idx >= 0) selected.splice(idx, 1); + else selected.push([gx, gy]); + drawSelection(); + }); + + function drawSelection() { + selectionLayer.replaceChildren(...selected.map(([sx, sy]) => svgEl("circle", { + cx: sx, cy: flipY(sy), r: 12, fill: "none", stroke: "#e53935", "stroke-width": 3, + }))); + drawSide(); + } + + mapBox.replaceChildren(svg, + h("div", { class: "garden-legend" }, + ...zones.map((z) => h("span", {}, + h("span", { class: "swatch", style: `background:${kindColor(z.kind)}` }), z.name)), + h("span", {}, h("span", { class: "swatch", style: "background:#1a73e8" }), "FarmBot"), + h("span", {}, h("span", { class: "swatch", style: "background:#7c4dff" }), "Camera"), + h("span", { class: "caption" }, `Click to select ${GRID_STEP} mm grid points`))); + + function drawSide() { + const parts = []; + if (selected.length) { + let kind = "plant"; + const customField = h("md-outlined-text-field", { label: "Custom kind", class: "grow", style: "display:none" }); + const chips = h("md-chip-set", { class: "chip-row" }, KINDS.map((k) => + h("md-filter-chip", { + label: k, selected: k === kind, + onClick: (e) => { + e.preventDefault(); + kind = k; + for (const chip of chips.querySelectorAll("md-filter-chip")) chip.selected = chip.label === k; + customField.style.display = k === "custom" ? "" : "none"; + }, + }))); + const nameField = h("md-outlined-text-field", { label: "Name prefix", placeholder: "e.g. Tomato", class: "grow" }); + parts.push(card(`${selected.length} point(s) selected`, stack( + chips, customField, nameField, + toolbar( + h("md-filled-button", { + onClick: async () => { + const name = nameField.value.trim(); + if (!name) { snack("Please enter a name prefix.", { error: true }); return; } + const finalKind = kind === "custom" && customField.value.trim() ? customField.value.trim() : kind; + for (const [i, [px, py]] of selected.entries()) { + const res = await ui("/garden/entities", { + method: "POST", json: { x: px, y: py, kind: finalKind, name: `${name}-${i + 1}` }, + }); + if (!res.ok) { snack(errorMessage(res), { error: true }); return; } + } + snack(`Added ${selected.length} ${finalKind}(s)`); + loadWorld(true); + }, + }, `Assign ${selected.length}`), + h("md-outlined-button", { onClick: () => { selected = []; drawSelection(); } }, "Clear"))))); + } + parts.push( + section("Live pose", metricRow([["X", num(robot.x)], ["Y", num(robot.y)], ["Z", num(robot.z)]])), + section("Camera pose", + h("p", { class: "caption" }, `X ${num(camPos.x)} · Y ${num(camPos.y)} · Z ${num(camPos.z)} mm`), + h("p", { class: "caption" }, + `Yaw ${num(camera.yaw_deg)}° · Pitch ${num(camera.pitch_deg)}° · Roll ${num(camera.roll_deg)}°`)), + section("Mapped objects", entities.length + ? dataTable(["Name", "Kind"], entities.map((e) => [e.name, e.kind])) + : h("p", { class: "caption" }, "No objects mapped yet."))); + sideBox.replaceChildren(...parts); + } + drawSelection(); + } + + await loadWorld(); + const off = state.on("position", (pos) => { + const world = state.store.session?.garden_world; + if (!world || !pos) return; + world.robot = { ...pos }; + const offset = world.camera_offset || {}; + world.camera = { ...(world.camera || {}), position: { + x: (pos.x || 0) + (offset.x || 0), y: (pos.y || 0) + (offset.y || 0), z: (pos.z || 0) + (offset.z || 0), + }}; + if (!selected.length) draw(world); + }); + return off; +} diff --git a/apps/ui/src/twfarmbot_ui/static/js/views/history.js b/apps/ui/src/twfarmbot_ui/static/js/views/history.js new file mode 100644 index 0000000..f186ea1 --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/js/views/history.js @@ -0,0 +1,58 @@ +import { ui } from "../api.js"; +import { h, snack, page, section, card, toolbar, expander, emptyState, actionSummary } from "../ui.js"; +import * as state from "../state.js"; + +export async function render(container) { + const { root, body } = page("History"); + const r = await ui("/sessions"); + const sessions = (r.ok && r.body?.sessions) || []; + + if (!sessions.length) { + body.append(emptyState("No saved sessions yet. Chat is saved automatically.", { iconName: "history" })); + container.append(root); + return; + } + + const sessionList = h("div", { class: "stack" }); + body.append( + section("Chat sessions", sessionList), + ); + + sessionList.replaceChildren(...sessions.map((sess) => { + const updated = (sess.updated_at || "").slice(0, 19).replace("T", " "); + return card(null, h("div", { class: "toolbar", style: "justify-content:space-between" }, + h("div", {}, + h("b", {}, sess.label || sess.session_id), + h("p", { class: "caption" }, `Updated ${updated}` + (sess.preview ? ` · ${sess.preview}` : ""))), + h("md-filled-tonal-button", { + onClick: async () => { + const res = await ui(`/sessions/${encodeURIComponent(sess.session_id)}`); + if (!res.ok) { snack("Session not found", { error: true }); return; } + state.loadSessionSnapshot(res.body); + const params = new URLSearchParams(location.search); + params.set("tab", "assistant"); + location.search = params.toString(); + }, + }, "Load"))); + })); + + const executed = state.store.session?.executed_plans || []; + if (executed.length) { + body.append(section("Executed plans", h("div", { class: "stack" }, + executed.slice().reverse().map((plan, index) => { + const status = plan.status || "unknown"; + const icon = { ok: "✅", partial: "⚠️", failed: "❌" }[status] || "❓"; + const ts = (plan.queued_at || "").slice(0, 19).replace("T", " "); + return card(`${icon} Plan ${index + 1}`, + h("p", { class: "caption" }, plan.request || ""), + h("p", { class: "caption" }, `${ts} · ${(plan.actions || []).length} action(s) · ${status}`), + expander("Actions", ...(plan.actions || []).map((a) => h("p", { class: "caption" }, actionSummary(a)))), + (plan.results || []).length ? expander("Results", ...plan.results.map((res) => + h("p", { class: "caption" }, + `${res.ok ? "✅" : "❌"} ${actionSummary({ kind: res.kind })}` + + (res.detail ? ` — ${res.detail}` : "")))) : null); + })))); + } + + container.append(root); +} diff --git a/apps/ui/src/twfarmbot_ui/static/js/views/io.js b/apps/ui/src/twfarmbot_ui/static/js/views/io.js new file mode 100644 index 0000000..bca2032 --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/js/views/io.js @@ -0,0 +1,107 @@ +import { api, postAction, errorMessage } from "../api.js"; +import { h, icon, snack, page, section, card, toolbar, stack, split, emptyState } from "../ui.js"; + +async function writePin(pin, value, mode, seconds) { + const params = { pin, value, mode }; + if (seconds !== undefined) params.seconds = seconds; + const r = await postAction("write_pin", params); + if (r.ok) snack(`pin ${pin} = ${value}`); + else snack(errorMessage(r), { error: true }); +} + +export async function render(container) { + const { root, body } = page("I/O workspace"); + const r = await api("/pins"); + const pins = (r.ok && r.body?.pins) || []; + const sensors = pins.filter((p) => p.kind === "sensor"); + const outputs = pins.filter((p) => p.kind !== "sensor"); + + const sensorGrid = h("div", { class: "metric-grid" }); + body.append( + section("Sensors", sensors.length ? sensorGrid : emptyState("No sensor pins configured.", { iconName: "sensors" })), + section("Actuators", split( + card("Irrigation", (() => { + const secondsField = h("md-outlined-text-field", { + label: "Duration (seconds)", type: "number", value: "2", min: "0.1", max: "300", step: "0.5", class: "field-sm", + }); + return stack( + toolbar(secondsField, h("md-filled-button", { + onClick: async () => { + const seconds = parseFloat(secondsField.value); + if (!Number.isFinite(seconds) || seconds <= 0) { snack("Enter a positive duration.", { error: true }); return; } + const res = await postAction("water", { seconds }); + if (res.ok) snack("Watering queued"); + else snack(errorMessage(res), { error: true }); + }, + }, icon("water_drop"), "Water")), + h("p", { class: "caption" }, "Runs the pump for the selected duration.")); + })()), + card("Peripheral control", (() => { + const peripheralBody = h("div", { class: "stack" }); + if (!outputs.length) return emptyState("No output pins configured.", { iconName: "electrical_services" }); + const select = h("md-outlined-select", { label: "Output", class: "grow" }, + outputs.map((p, i) => h("md-select-option", { value: String(i), selected: i === 0 }, + h("div", { slot: "headline" }, `${p.label} · pin ${p.pin}`)))); + select.addEventListener("change", () => drawControls(outputs[Number(select.value)])); + function drawControls(sel) { + const mode = sel.mode || "digital"; + const parts = [h("span", { class: "pill" }, mode)]; + if (mode === "analog") { + const presets = sel.presets || {}; + if (Object.keys(presets).length) { + parts.push(toolbar(...Object.entries(presets) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([value, label]) => h("md-filled-tonal-button", { + onClick: () => writePin(sel.pin, Number(value), mode), + }, `${label} (${value})`)))); + } + const slider = h("md-slider", { min: 0, max: 255, value: 0, labeled: true }); + parts.push(toolbar(slider, h("md-outlined-button", { + onClick: () => writePin(sel.pin, Number(slider.value), mode), + }, "Apply"))); + } else { + const pulseSwitch = h("md-switch", { selected: true }); + const pulseField = h("md-outlined-text-field", { + label: "Pulse (seconds)", type: "number", value: "2", min: "0.1", max: "300", step: "0.5", class: "field-sm", + }); + pulseSwitch.addEventListener("change", () => { pulseField.style.display = pulseSwitch.selected ? "" : "none"; }); + parts.push( + h("label", { class: "toolbar" }, pulseSwitch, h("span", {}, "Timed pulse")), + pulseField, + toolbar( + h("md-outlined-button", { onClick: () => writePin(sel.pin, 0, mode) }, icon("power_settings_new"), "OFF"), + h("md-filled-button", { + onClick: () => { + if (pulseSwitch.selected) { + const seconds = parseFloat(pulseField.value); + if (!Number.isFinite(seconds) || seconds <= 0) { snack("Enter a positive pulse duration.", { error: true }); return; } + writePin(sel.pin, 1, mode, seconds); + } else writePin(sel.pin, 1, mode); + }, + }, icon("power"), "ON"))); + } + peripheralBody.replaceChildren(...parts); + } + drawControls(outputs[0]); + return stack(select, peripheralBody); + })()), + )), + ); + container.append(root); + + sensorGrid.replaceChildren(...sensors.map((sensor) => { + const valueBox = h("div", { class: "metric-value", style: "font-size:20px" }, "—"); + return card(sensor.label, stack( + h("div", { class: "toolbar" }, + h("span", { class: "pill" }, sensor.mode || "analog"), + h("span", { class: "caption" }, `pin ${sensor.pin}`)), + valueBox, + h("md-outlined-button", { + onClick: async () => { + const res = await api(`/pin/${sensor.pin}`, { params: { mode: sensor.mode || "analog" } }); + valueBox.textContent = res.ok ? String(res.body?.value ?? "—") : "—"; + if (!res.ok) snack(errorMessage(res), { error: true }); + }, + }, icon("sensors"), "Read"))); + })); +} diff --git a/apps/ui/src/twfarmbot_ui/static/js/views/motion.js b/apps/ui/src/twfarmbot_ui/static/js/views/motion.js new file mode 100644 index 0000000..87eb929 --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/js/views/motion.js @@ -0,0 +1,98 @@ +import { api, postAction, errorMessage } from "../api.js"; +import { h, icon, snack, page, section, toolbar, metricRow, stack, num, flt, parseNumber } from "../ui.js"; +import * as state from "../state.js"; + +async function doMove(x, y, z, label = "") { + const r = await postAction("move", { x, y, z }); + if (r.ok) { snack(label ? `→ ${label}` : `→ (${x.toFixed(0)}, ${y.toFixed(0)}, ${z.toFixed(0)})`); state.refreshPosition(); } + else snack(errorMessage(r), { error: true }); +} + +export async function render(container) { + const { root, body } = page("Motion workspace"); + const posBox = h("div"); + const presetsBox = h("div", { class: "toolbar" }); + let step = 10; + + const cur = () => ({ + x: flt(state.store.position?.x), y: flt(state.store.position?.y), z: flt(state.store.position?.z), + }); + + const stepChips = h("md-chip-set", { class: "chip-row" }, [1, 10, 50, 100].map((value) => + h("md-filter-chip", { + label: `${value} mm`, selected: value === step, + onClick: (e) => { + e.preventDefault(); + step = value; + for (const chip of stepChips.querySelectorAll("md-filter-chip")) chip.selected = chip.label === `${value} mm`; + }, + }))); + + const jog = (dx, dy, dz, label) => () => { + const { x, y, z } = cur(); + doMove(x + dx * step, y + dy * step, z + dz * step, `${label}${step}`); + }; + const dpadBtn = (label, iconName, onClick, filled = false) => + h(filled ? "md-filled-button" : "md-filled-tonal-button", { onClick }, icon(iconName)); + + const dpad = h("div", { class: "dpad" }, + h("span"), dpadBtn("", "arrow_upward", jog(0, 1, 0, "Y+")), h("span"), + dpadBtn("", "arrow_back", jog(-1, 0, 0, "X-")), + dpadBtn("", "home", () => doMove(0, 0, 0, "Home"), true), + dpadBtn("", "arrow_forward", jog(1, 0, 0, "X+")), + h("span"), dpadBtn("", "arrow_downward", jog(0, -1, 0, "Y-")), h("span"), + dpadBtn("", "keyboard_double_arrow_up", jog(0, 0, 1, "Z+")), + h("span"), + dpadBtn("", "keyboard_double_arrow_down", jog(0, 0, -1, "Z-"))); + + const fx = h("md-outlined-text-field", { label: "X", class: "field-sm" }); + const fy = h("md-outlined-text-field", { label: "Y", class: "field-sm" }); + const fz = h("md-outlined-text-field", { label: "Z", class: "field-sm" }); + const syncFields = () => { + const { x, y, z } = cur(); + fx.value = x.toFixed(2); fy.value = y.toFixed(2); fz.value = z.toFixed(2); + }; + + body.append( + section(null, posBox), + section("Jog controls", stepChips, dpad), + section("Absolute move", toolbar(fx, fy, fz, + h("md-filled-button", { + onClick: () => { + const [x, y, z] = [parseNumber(fx.value), parseNumber(fy.value), parseNumber(fz.value)]; + if (x === null || y === null || z === null) { + snack("Invalid coordinates. Use a number like 123 or 123.4.", { error: true }); + return; + } + doMove(x, y, z); + }, + }, icon("my_location"), "Go to"), + h("md-outlined-button", { + onClick: async () => { + const r = await postAction("find_home"); + if (r.ok) snack("Homing queued"); + else snack(errorMessage(r), { error: true }); + }, + }, icon("home_work"), "Find home"))), + section("Preset locations", presetsBox), + ); + container.append(root); + syncFields(); + + function updatePos() { + const pos = state.store.position || {}; + posBox.replaceChildren(metricRow([["X · mm", num(pos.x)], ["Y · mm", num(pos.y)], ["Z · mm", num(pos.z)]])); + syncFields(); + } + updatePos(); + + const r = await api("/positions"); + const presets = (r.ok && r.body?.positions) || []; + presetsBox.replaceChildren(...(presets.length + ? presets.map((p) => h("md-filled-tonal-button", { + onClick: () => doMove(flt(p.x), flt(p.y), flt(p.z), p.label), + }, icon("place"), p.label || "?")) + : [h("p", { class: "caption" }, "No preset locations configured.")])); + + return state.on("position", updatePos); +} diff --git a/apps/ui/src/twfarmbot_ui/static/js/views/overview.js b/apps/ui/src/twfarmbot_ui/static/js/views/overview.js new file mode 100644 index 0000000..0834dc3 --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/js/views/overview.js @@ -0,0 +1,140 @@ +import Chart from "chart.js/auto"; +import { h, icon, page, section, card, toolbar, metricRow, num, timeAgo } from "../ui.js"; +import * as state from "../state.js"; + +const EXPERIMENT_KEY = "twfb_experiment"; + +function chartConfig(datasets, { yMax } = {}) { + return { + type: "line", + data: { labels: state.store.telemetry.map((s) => s.time), datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + scales: { + x: { grid: { display: false } }, + y: { beginAtZero: true, max: yMax, grid: { color: "rgba(128,128,128,0.1)" } }, + }, + plugins: { legend: { labels: { boxWidth: 12, padding: 16 } } }, + }, + }; +} + +function series(key, label, color) { + return { + label, + data: state.store.telemetry.map((s) => s[key]), + borderColor: color, + backgroundColor: color, + tension: 0.3, + pointRadius: 2, + borderWidth: 2, + }; +} + +export async function render(container) { + const { root, body } = page("Research overview"); + const posBox = h("div"); + const statusBox = h("div"); + const resourceBox = h("div"); + const chartsArea = h("div"); + const networkBox = h("div", { class: "stack" }); + const eventsBox = h("div"); + const charts = []; + + const refreshBtn = h("md-filled-tonal-button", { + onClick: async () => { + await Promise.all([state.refreshHealth(), state.refreshPosition(), state.refreshStatus()]); + update(); + }, + }, icon("refresh"), "Refresh status"); + const clearBtn = h("md-outlined-button", { + onClick: () => { state.store.telemetry = []; update(); }, + }, "Clear history"); + + const experiment = JSON.parse(localStorage.getItem(EXPERIMENT_KEY) || "{}"); + const saveExp = (key, value) => { + experiment[key] = value; + localStorage.setItem(EXPERIMENT_KEY, JSON.stringify(experiment)); + }; + + body.append( + section(null, posBox), + section("System status", toolbar(refreshBtn, clearBtn), statusBox), + section("Resources over time", resourceBox, chartsArea), + h("div", { class: "split" }, + card("Network & hardware", networkBox), + card("Recent events", eventsBox)), + section("Experiment", h("div", { class: "form-grid" }, + h("md-outlined-text-field", { label: "Run", value: experiment.run || "", class: "grow", + onInput: (e) => saveExp("run", e.target.value) }), + h("md-outlined-text-field", { label: "Operator", value: experiment.operator || "", class: "grow", + onInput: (e) => saveExp("operator", e.target.value) }), + h("md-outlined-text-field", { label: "Notes", type: "textarea", rows: 2, class: "grow", + value: experiment.notes || "", onInput: (e) => saveExp("notes", e.target.value) }))), + ); + container.append(root); + + function update() { + const pos = state.store.position || {}; + const info = state.store.diag.informational_settings || {}; + const loc = state.store.diag.location_data || {}; + const axes = loc.axis_states || {}; + + posBox.replaceChildren( + metricRow([["X · mm", num(pos.x)], ["Y · mm", num(pos.y)], ["Z · mm", num(pos.z)]]), + h("p", { class: "caption" }, `Position updated ${timeAgo(state.store.lastPositionRefresh)}`)); + + statusBox.replaceChildren(metricRow([ + ["FarmBot", state.store.health?.farmbot ?? "—"], + ["Uptime", `${num(info.uptime)} s`], + ["Wi-Fi", `${num(info.wifi_level_percent)}%`], + ["Sync", info.sync_status ?? "—"], + ["Busy", info.busy ? "Yes" : "No"], + ])); + + resourceBox.replaceChildren(metricRow([ + ["CPU", info.cpu_usage != null ? `${info.cpu_usage}%` : "—"], + ["Memory", info.memory_usage != null ? `${info.memory_usage}%` : "—"], + ["Disk", info.disk_usage != null ? `${info.disk_usage}%` : "—"], + ["SoC temp", info.soc_temp != null ? `${info.soc_temp}°C` : "—"], + ])); + + networkBox.replaceChildren( + h("p", { class: "caption" }, "Private IP: ", h("span", { class: "mono" }, info.private_ip ?? "—")), + h("p", { class: "caption" }, `Wi-Fi signal: ${num(info.wifi_level)} dBm`), + h("p", { class: "caption" }, `Controller: ${info.controller_version ?? "—"}`), + h("p", { class: "caption" }, `Firmware: ${info.firmware_version ?? "—"}`), + h("p", { class: "caption" }, + `Axis states · X ${axes.x ?? "—"} · Y ${axes.y ?? "—"} · Z ${axes.z ?? "—"}`)); + + const messages = state.store.messages; + eventsBox.replaceChildren(messages.length + ? h("pre", { class: "codeblock" }, messages.slice(-10).join("\n")) + : h("p", { class: "caption" }, "No events recorded.")); + + charts.forEach((c) => c.destroy()); + charts.length = 0; + if (state.store.telemetry.length) { + const usageBox = h("div", { class: "chart-box tall" }, h("canvas")); + const wifiBox = h("div", { class: "chart-box" }, h("canvas")); + const socBox = h("div", { class: "chart-box" }, h("canvas")); + chartsArea.replaceChildren(usageBox, h("div", { class: "split", style: "--split-ratio:1fr 1fr" }, wifiBox, socBox)); + charts.push( + new Chart(usageBox.firstChild, chartConfig( + [series("cpu", "CPU", "#256a4a"), series("memory", "Memory", "#3c6472"), series("disk", "Disk", "#8b7355")], + { yMax: 100 })), + new Chart(wifiBox.firstChild, chartConfig([series("wifi", "Wi-Fi %", "#3c6472")], { yMax: 100 })), + new Chart(socBox.firstChild, chartConfig([series("soc", "SoC °C", "#ba1a1a")]))); + } else { + chartsArea.replaceChildren(h("p", { class: "caption" }, + "Click “Refresh status” to start collecting telemetry for the charts.")); + } + } + + update(); + const offs = [state.on("position", update), state.on("status", update), + state.on("health", update), state.on("messages", update)]; + return () => { offs.forEach((off) => off()); charts.forEach((c) => c.destroy()); }; +} diff --git a/apps/ui/src/twfarmbot_ui/static/js/views/settings.js b/apps/ui/src/twfarmbot_ui/static/js/views/settings.js new file mode 100644 index 0000000..bae6f59 --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/static/js/views/settings.js @@ -0,0 +1,70 @@ +import { api, ui, postAction, errorMessage, getSettings, saveSettings } from "../api.js"; +import { h, icon, snack, page, section, card, toolbar, expander, jsonBlock } from "../ui.js"; +import * as state from "../state.js"; + +export async function render(container) { + const { root, body } = page("Settings"); + const config = await ui("/config"); + const apiField = h("md-outlined-text-field", { + label: "API URL", value: (config.ok && config.body?.api_url) || "", class: "grow", + }); + const statusBox = h("div"); + + const drawStatus = () => statusBox.replaceChildren(jsonBlock({ + farmbot: state.store.health?.farmbot ?? "?", + api: apiField.value, + actions: state.store.health?.actions ?? [], + })); + + body.append( + section("Connection", toolbar(apiField, + h("md-outlined-button", { + onClick: async () => { + const r = await ui("/config", { method: "PUT", json: { api_url: apiField.value } }); + if (!r.ok) { snack(errorMessage(r), { error: true }); return; } + await state.refreshHealth(); + snack("API URL updated"); + drawStatus(); + }, + }, "Apply"), + h("md-filled-tonal-button", { + onClick: async () => { await state.refreshHealth(); drawStatus(); snack("Health checked"); }, + }, icon("ecg_heart"), "Health check")), + statusBox), + section("Auto-refresh intervals", (() => { + const settings = getSettings(); + const field = (label, key, min, max) => h("md-outlined-text-field", { + label, type: "number", value: String(settings[key]), min: String(min), max: String(max), class: "field-sm", + onChange: (e) => { + const value = parseInt(e.target.value, 10); + if (Number.isFinite(value)) { saveSettings({ [key]: value }); snack("Saved"); } + }, + }); + return toolbar( + field("Position (s)", "refreshPositionS", 1, 300), + field("Stats (s)", "refreshStatsS", 10, 3600), + field("Camera (s, 0=off)", "refreshCameraS", 0, 3600)); + })()), + section("Raw action", expander("Fire a raw action", (() => { + const kindField = h("md-outlined-text-field", { label: "Kind", value: "move", class: "field-sm" }); + const paramsField = h("md-outlined-text-field", { + label: "Params (JSON)", type: "textarea", rows: 3, + value: '{"x": 0, "y": 0, "z": 0}', class: "grow", + }); + const resultBox = h("div"); + return card(null, toolbar(kindField, paramsField, + h("md-filled-button", { + onClick: async () => { + let params; + try { params = JSON.parse(paramsField.value); } + catch (err) { snack(`Bad JSON: ${err.message}`, { error: true }); return; } + const r = await postAction(kindField.value.trim(), params); + resultBox.replaceChildren(jsonBlock(r.body)); + }, + }, icon("bolt"), "Fire")), + resultBox); + })())), + ); + container.append(root); + drawStatus(); +} diff --git a/docs/architecture.md b/docs/architecture.md index 8111924..b3004ac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -267,9 +267,12 @@ or env-only via `os.getenv(...)`. ## 9. UI -`apps/ui` is a single-page Streamlit app (`streamlit run apps/ui/src/twfarmbot_ui/app.py`). -It contains **zero business logic** — every widget is a thin HTTP proxy -to the api_server: +`apps/ui` is a custom Material 3 single-page web app (static HTML/JS using +[Material Web Components](https://github.com/material-components/material-web)) +served by a small FastAPI server (`twfarmbot_ui/server.py`). The server +reverse-proxies `/api/*` to the api_server and `/resireg/*` to the vision +server, so the browser stays same-origin. The UI contains **zero business +logic** — every widget is a thin HTTP proxy to the api_server: **Reads (buttons/forms in the UI):** - "Check status" sidebar button → `GET /health` diff --git a/pyproject.toml b/pyproject.toml index 24d02e1..0515853 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,10 +75,6 @@ ignore_missing_imports = true no_warn_return_any = true warn_unused_ignores = true -[[tool.mypy.overrides]] -module = "twfarmbot_ui.app" -disable_error_code = ["union-attr", "index", "arg-type", "call-overload", "var-annotated"] - [tool.hatch.build.targets.wheel] packages = ["."] diff --git a/scripts/install_services.sh b/scripts/install_services.sh index 80b68f6..c0a0ef7 100755 --- a/scripts/install_services.sh +++ b/scripts/install_services.sh @@ -7,15 +7,6 @@ USER_SYSTEMD_DIR="${HOME}/.config/systemd/user" mkdir -p "${USER_SYSTEMD_DIR}" cp "${SCRIPT_DIR}/systemd/"*.service "${USER_SYSTEMD_DIR}/" -# Pre-accept Streamlit's email prompt so the UI service starts non-interactively. -mkdir -p "${HOME}/.streamlit" -if [[ ! -f "${HOME}/.streamlit/credentials.toml" ]]; then - cat > "${HOME}/.streamlit/credentials.toml" <<'EOF' -[general] -email = "" -EOF -fi - systemctl --user daemon-reload systemctl --user enable twfarmbot-resireg twfarmbot-api twfarmbot-ui diff --git a/scripts/systemd/twfarmbot-ui.service b/scripts/systemd/twfarmbot-ui.service index 6deeff4..95d93b9 100644 --- a/scripts/systemd/twfarmbot-ui.service +++ b/scripts/systemd/twfarmbot-ui.service @@ -1,5 +1,5 @@ [Unit] -Description=TWFarmBot Streamlit UI +Description=TWFarmBot Web UI After=network-online.target twfarmbot-api.service Wants=network-online.target twfarmbot-api.service @@ -11,8 +11,6 @@ WorkingDirectory=/home/farmbot/TWFarmBot Environment="HOME=/home/farmbot" Environment="PATH=/home/farmbot/.local/bin:/usr/local/bin:/usr/bin:/bin" Environment="PYTHONUNBUFFERED=1" -Environment="STREAMLIT_BROWSER_GATHERUSAGESTATS=false" -Environment="STREAMLIT_SERVER_HEADLESS=true" ExecStart=/home/farmbot/.local/bin/uv run --env-file=.env twfarmbot-ui Restart=on-failure RestartSec=5 diff --git a/tests/test_assistant_tab.py b/tests/test_assistant_tab.py index e0ad629..1bab5a3 100644 --- a/tests/test_assistant_tab.py +++ b/tests/test_assistant_tab.py @@ -1,6 +1,6 @@ -"""Tests for the Assistant tab in the Streamlit UI. +"""Tests for the Assistant tab's API contract. -Streamlit's renderers are hard to unit-test directly. We exercise the +The frontend renderers are browser-side. We exercise the end-to-end flow by talking to the real ``POST /plan`` endpoint with ``planning_service.plan`` stubbed, then verifying the endpoint returns the shapes the UI expects. diff --git a/tests/test_ui.py b/tests/test_ui.py index 0dae0dd..f1d0271 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1,7 +1,7 @@ -"""Tests for the UI's API client. +"""Tests for the UI package's Python API client. -The Streamlit app itself is hard to unit-test (renders DOM), but the -``ApiClient`` it uses to talk to the API is testable in isolation. +The web frontend itself is browser-side; the ``ApiClient`` kept for +scripts/tests is testable in isolation. """ from __future__ import annotations diff --git a/tests/test_ui_server.py b/tests/test_ui_server.py new file mode 100644 index 0000000..f7fede5 --- /dev/null +++ b/tests/test_ui_server.py @@ -0,0 +1,155 @@ +"""Tests for the Material 3 UI server (static frontend + API proxy). + +The proxy is exercised against an in-process stub upstream via httpx's +ASGITransport, so no sockets are opened. +""" + +from __future__ import annotations + +import json +from typing import Any, Iterator + +import httpx +import pytest +from fastapi import FastAPI +from fastapi.responses import StreamingResponse +from fastapi.testclient import TestClient + + +def _upstream() -> FastAPI: + app = FastAPI() + + @app.get("/health") + def health() -> dict[str, Any]: + return {"status": "ok", "actions": ["move"], "farmbot": "connected"} + + @app.get("/pin/{pin}") + def pin(pin: int, mode: str = "digital") -> dict[str, Any]: + return {"pin": pin, "mode": mode, "value": 1} + + @app.post("/actions") + def actions(payload: dict[str, Any], wait: bool = True) -> dict[str, Any]: + return {"status": "ok" if wait else "queued", "action": payload} + + @app.post("/chat/stream") + def chat_stream() -> StreamingResponse: + def events() -> Iterator[str]: + yield f"data: {json.dumps({'type': 'delta', 'content': 'hi'})}\n\n" + yield f"data: {json.dumps({'type': 'meta', 'metrics': {}})}\n\n" + + return StreamingResponse(events(), media_type="text/event-stream") + + return app + + +@pytest.fixture +def client(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: + monkeypatch.setenv("TWFB_UI_DATA_DIR", str(tmp_path / "sessions")) + from twfarmbot_ui.server import create_app + + proxy_http = httpx.AsyncClient(transport=httpx.ASGITransport(app=_upstream())) + with TestClient(create_app(http_client=proxy_http)) as test_client: + yield test_client + + +def test_serves_material_frontend(client: TestClient) -> None: + r = client.get("/") + assert r.status_code == 200 + assert "@material/web" in r.text + + r = client.get("/js/main.js") + assert r.status_code == 200 + + +def test_proxies_api_get(client: TestClient) -> None: + r = client.get("/api/health") + assert r.status_code == 200 + assert r.json()["farmbot"] == "connected" + + +def test_proxies_query_params(client: TestClient) -> None: + r = client.get("/api/pin/13", params={"mode": "analog"}) + assert r.json() == {"pin": 13, "mode": "analog", "value": 1} + + +def test_proxies_post_body_and_wait_param(client: TestClient) -> None: + r = client.post( + "/api/actions", + params={"wait": "false"}, + json={"kind": "move", "params": {"x": 1}}, + ) + assert r.json()["status"] == "queued" + assert r.json()["action"]["kind"] == "move" + + +def test_proxies_sse_stream(client: TestClient) -> None: + with client.stream("POST", "/api/chat/stream", json={"messages": []}) as r: + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/event-stream") + body = b"".join(r.iter_bytes()).decode() + assert '"type": "delta"' in body + assert '"type": "meta"' in body + + +def test_proxy_returns_502_on_connection_failure( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("TWFB_UI_DATA_DIR", str(tmp_path / "sessions")) + from twfarmbot_ui.server import create_app + + class FailingTransport(httpx.AsyncBaseTransport): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("refused") + + proxy_http = httpx.AsyncClient(transport=FailingTransport()) + with TestClient(create_app(http_client=proxy_http)) as test_client: + r = test_client.get("/api/health") + assert r.status_code == 502 + assert "ConnectError" in r.json()["detail"] + + +def test_session_crud_roundtrip(client: TestClient) -> None: + snapshot = client.post("/ui/sessions").json() + session_id = snapshot["session_id"] + + snapshot["label"] = "watering experiment" + snapshot["assistant_messages"] = [{"role": "user", "content": "water bed 1"}] + assert client.put(f"/ui/sessions/{session_id}", json=snapshot).status_code == 200 + + listed = client.get("/ui/sessions").json()["sessions"] + assert [s["session_id"] for s in listed] == [session_id] + assert listed[0]["preview"] == "water bed 1" + + loaded = client.get(f"/ui/sessions/{session_id}").json() + assert loaded["label"] == "watering experiment" + + assert client.delete(f"/ui/sessions/{session_id}").json() == {"deleted": True} + assert client.get(f"/ui/sessions/{session_id}").status_code == 404 + + +def test_garden_entity_appended_to_yaml( + client: TestClient, tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + config = tmp_path / "dev.yaml" + config.write_text( + "# keep this comment\nspatial:\n bounds: {x: 0, y: 0, width: 100, height: 100}\n", + encoding="utf-8", + ) + monkeypatch.setenv("TWFB_CONFIG", str(config)) + + r = client.post( + "/ui/garden/entities", + json={"x": 25.0, "y": 50.0, "kind": "plant", "name": "Tomato 1"}, + ) + assert r.status_code == 200 + assert r.json()["entity"]["id"] == "tomato_1" + + text = config.read_text(encoding="utf-8") + assert "# keep this comment" in text # round-trip preserves comments + assert "tomato_1" in text + + +def test_config_endpoint_updates_upstream(client: TestClient) -> None: + r = client.put("/ui/config", json={"api_url": "http://other:9000/"}) + assert r.json()["api_url"] == "http://other:9000" + assert client.get("/ui/config").json()["api_url"] == "http://other:9000" diff --git a/uv.lock b/uv.lock index cdbe37a..263d733 100644 --- a/uv.lock +++ b/uv.lock @@ -46,22 +46,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/95/f456ae7928a2f3a913f467d4fd9e662e295dd7349fc58b35f77f6c757a23/abnf-2.2.0-py3-none-any.whl", hash = "sha256:5dc2ae31a84ff454f7de46e08a2a21a442a0e21a092468420587a1590b490d1f", size = 39938, upload-time = "2023-03-17T18:26:22.608Z" }, ] -[[package]] -name = "altair" -version = "6.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2" }, - { name = "jsonschema" }, - { name = "narwhals" }, - { name = "packaging" }, - { name = "typing-extensions", marker = "python_full_version < '3.15'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/86/97/9a0dc61efd4f2dee29cb6d8edbbacdb789ce48cbffd98efa2b3ab145b297/altair-6.2.1.tar.gz", hash = "sha256:ca0298fa20b1a4fae22eff8847b95f74912bd90544013ad36af192119883ea64", size = 766468, upload-time = "2026-06-05T16:23:36.57Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/78/b556548d92b9e29ae68a86e7b416888820900809e189e39caf308c7d44a3/altair-6.2.1-py3-none-any.whl", hash = "sha256:bf2fee3733c3a31a588e45b857a2495a88d506970deb87f74e1613f0247446b1", size = 797498, upload-time = "2026-06-05T16:23:34.799Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -164,15 +148,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] -[[package]] -name = "blinker" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, -] - [[package]] name = "cachetools" version = "7.1.4" @@ -930,30 +905,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, ] -[[package]] -name = "gitdb" -version = "4.0.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "smmap" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, -] - -[[package]] -name = "gitpython" -version = "3.1.50" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "gitdb" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, -] - [[package]] name = "googleapis-common-protos" version = "1.75.0" @@ -1058,56 +1009,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] -[[package]] -name = "httptools" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" }, - { url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" }, - { url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" }, - { url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" }, - { url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, - { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, - { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, - { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, - { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, - { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, - { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, - { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, - { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, - { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, - { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, - { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, - { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, - { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, - { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, - { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, - { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, - { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, -] - [[package]] name = "httpx" version = "0.28.1" @@ -1183,15 +1084,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, ] -[[package]] -name = "itsdangerous" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -2706,144 +2598,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, ] -[[package]] -name = "pandas" -version = "2.3.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, - { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, - { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, - { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, - { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, - { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, - { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, - { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, - { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, - { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, - { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, - { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, - { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, -] - -[[package]] -name = "pandas" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and sys_platform == 'win32'", - "python_full_version >= '3.15' and sys_platform == 'emscripten'", - "python_full_version == '3.14.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, - { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, - { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, - { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, - { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, - { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, - { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, - { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, - { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, - { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, - { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, - { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, - { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, - { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, - { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, - { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, - { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, - { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, - { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, - { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, - { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, - { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, - { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, - { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, - { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, - { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, - { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, - { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, - { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, - { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, - { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, - { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, -] - [[package]] name = "pathspec" version = "1.1.1" @@ -3179,63 +2933,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] -[[package]] -name = "pyarrow" -version = "24.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/bf/a34fee1d624152124fa8355c42f34195ad5fe5233ce5bb87946432047d52/pyarrow-24.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb", size = 35076681, upload-time = "2026-04-21T08:51:46.845Z" }, - { url = "https://files.pythonhosted.org/packages/1d/41/64180033d7027afce12dc96d0fe1f504c6fa112190582b458acea2399530/pyarrow-24.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147", size = 36684260, upload-time = "2026-04-21T08:51:53.642Z" }, - { url = "https://files.pythonhosted.org/packages/57/02/9b9320e673dd8a99411fac78690f3df92f6dd6f59754c750110bca66d64e/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c", size = 45698566, upload-time = "2026-04-21T10:46:02.133Z" }, - { url = "https://files.pythonhosted.org/packages/67/33/f75e91b9a64c3f33c787e263c93b871ad91b8a4a68c1d5cebddd9840e835/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041", size = 48835562, upload-time = "2026-04-21T10:46:10.278Z" }, - { url = "https://files.pythonhosted.org/packages/a5/63/097510448e47e4091faa41c43ba92f97cecaab8f4535b56a3d149578f634/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491", size = 49394997, upload-time = "2026-04-21T10:46:18.08Z" }, - { url = "https://files.pythonhosted.org/packages/60/6b/c047d6222ab279024a062742d1807e2fbaf27bba88a98637299ff47b9236/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1", size = 51911424, upload-time = "2026-04-21T10:46:25.347Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ba/464cc70761c2a525d97ebd84e21c31ebd47f3ef4bdcee117009f51c46f24/pyarrow-24.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591", size = 27251730, upload-time = "2026-04-21T10:46:30.913Z" }, - { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, - { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, - { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, - { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, - { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, - { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, - { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, - { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, - { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, - { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, - { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, - { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, - { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, - { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, - { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, - { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, - { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, - { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, - { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, - { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, - { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, - { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, - { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, - { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, - { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -3376,20 +3073,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] -[[package]] -name = "pydeck" -version = "0.9.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/df/4e9e7f20f8034a37c6571c93809f6d22388c39978c98d174d656c1a18fd2/pydeck-0.9.2.tar.gz", hash = "sha256:c10d9035e81ead6385264cac8d19402471f6866a15ca1f7df1400f52142bcf87", size = 5849672, upload-time = "2026-04-16T18:30:30.089Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/24/b30ee7d723100fd822de1bb4c0adea62f3419884a75a536f35f355d1e7c0/pydeck-0.9.2-py2.py3-none-any.whl", hash = "sha256:8213dfeacc5f6bfe6825f61c8ee34e3850e8a31fc43924379ec98edb34a75b25", size = 11305615, upload-time = "2026-04-16T18:30:28.133Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -3475,24 +3158,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] -[[package]] -name = "python-multipart" -version = "0.0.32" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, -] - -[[package]] -name = "pytz" -version = "2026.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -4411,15 +4076,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "smmap" -version = "5.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -4451,43 +4107,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] -[[package]] -name = "streamlit" -version = "1.58.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "altair" }, - { name = "anyio" }, - { name = "blinker" }, - { name = "cachetools" }, - { name = "click" }, - { name = "gitpython" }, - { name = "httptools" }, - { name = "itsdangerous" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "packaging" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pillow" }, - { name = "protobuf" }, - { name = "pyarrow" }, - { name = "pydeck" }, - { name = "python-multipart" }, - { name = "requests" }, - { name = "starlette" }, - { name = "tenacity" }, - { name = "toml" }, - { name = "typing-extensions" }, - { name = "uvicorn" }, - { name = "watchdog", marker = "sys_platform != 'darwin'" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/74/20dac6d6200d6ec0e1c230fb8eeb6a1a423645eacb76e8d802adfc246456/streamlit-1.58.0.tar.gz", hash = "sha256:78a22e7085b053af7ce544442bf4b670771e68c509ba1bdaa056ba0708f49c3d", size = 8721149, upload-time = "2026-05-28T18:02:44.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/84/14c36a92fb24f8e1cea452f53b0744b5da69d52cdd2fe22e71e6fbf765d5/streamlit-1.58.0-py3-none-any.whl", hash = "sha256:4ca8a7afc5bd16a5f176ccf4be1e34e8121cad0240becd127fb58a103ea3178d", size = 9219185, upload-time = "2026-05-28T18:02:41.993Z" }, -] - [[package]] name = "sympy" version = "1.14.0" @@ -4609,15 +4228,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/b1c87148aa15e099243ec9f0cf9d0e970cc2234c3257d558c25a2c5304e6/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5", size = 3373542, upload-time = "2026-01-05T10:40:52.803Z" }, ] -[[package]] -name = "toml" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, -] - [[package]] name = "tomli" version = "2.4.1" @@ -5038,20 +4648,20 @@ name = "twfarmbot-ui" version = "0.1.0" source = { editable = "apps/ui" } dependencies = [ - { name = "altair" }, + { name = "fastapi" }, { name = "httpx" }, - { name = "streamlit" }, + { name = "ruamel-yaml" }, { name = "twfarmbot-core" }, - { name = "twfarmbot-ml-utils" }, + { name = "uvicorn" }, ] [package.metadata] requires-dist = [ - { name = "altair", specifier = ">=5" }, + { name = "fastapi", specifier = ">=0.110" }, { name = "httpx", specifier = ">=0.27" }, - { name = "streamlit", specifier = ">=1.30" }, + { name = "ruamel-yaml", specifier = ">=0.18" }, { name = "twfarmbot-core", editable = "core" }, - { name = "twfarmbot-ml-utils", editable = "libs/ml_utils" }, + { name = "uvicorn", specifier = ">=0.29" }, ] [[package]] @@ -5250,24 +4860,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, ] -[[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, -] - [[package]] name = "weave" version = "0.52.43" From fde03ab8595ae2461b2d93600f075384f132a150 Mon Sep 17 00:00:00 2001 From: David Seyser <96821053+DavidSeyserGit@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:58:10 +0200 Subject: [PATCH 2/8] Potential fix for pull request finding 'An assert statement has a side-effect' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tests/test_ui_server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_ui_server.py b/tests/test_ui_server.py index f7fede5..6a2b2f0 100644 --- a/tests/test_ui_server.py +++ b/tests/test_ui_server.py @@ -123,7 +123,8 @@ def test_session_crud_roundtrip(client: TestClient) -> None: loaded = client.get(f"/ui/sessions/{session_id}").json() assert loaded["label"] == "watering experiment" - assert client.delete(f"/ui/sessions/{session_id}").json() == {"deleted": True} + delete_resp = client.delete(f"/ui/sessions/{session_id}") + assert delete_resp.json() == {"deleted": True} assert client.get(f"/ui/sessions/{session_id}").status_code == 404 From 27f0f98e43eff1989b7c659e8bb474c0098fab7b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 19:35:09 +0000 Subject: [PATCH 3/8] Tighten UI spacing and fix empty-state layout - Reduce sidebar nav padding; use rounded rects instead of full-width pills - Shrink page header/title gaps; move Camera actions into title row - Add compact top-aligned empty-panel variant (replaces centered void) - Normalize MWC button height and card body padding - Cache-bust static assets so CSS/JS updates load reliably Co-authored-by: David Seyser --- apps/ui/src/twfarmbot_ui/static/app.css | 119 +++++++++++------- apps/ui/src/twfarmbot_ui/static/index.html | 4 +- apps/ui/src/twfarmbot_ui/static/js/ui.js | 22 ++-- .../twfarmbot_ui/static/js/views/camera.js | 34 ++--- .../static/js/views/diagnostics.js | 2 +- .../twfarmbot_ui/static/js/views/history.js | 2 +- .../ui/src/twfarmbot_ui/static/js/views/io.js | 4 +- 7 files changed, 112 insertions(+), 75 deletions(-) diff --git a/apps/ui/src/twfarmbot_ui/static/app.css b/apps/ui/src/twfarmbot_ui/static/app.css index 2dfa52e..a93c03f 100644 --- a/apps/ui/src/twfarmbot_ui/static/app.css +++ b/apps/ui/src/twfarmbot_ui/static/app.css @@ -12,7 +12,7 @@ --radius-md: 12px; --radius-lg: 16px; --radius-full: 999px; - --sidebar-w: 280px; + --sidebar-w: 256px; --content-max: 1080px; --chat-bar-h: 92px; @@ -116,7 +116,7 @@ body { width: var(--sidebar-w); display: flex; flex-direction: column; - padding: var(--space-6) var(--space-4); + padding: var(--space-4) var(--space-3); background: var(--md-sys-color-surface-container-low); border-right: 1px solid var(--md-sys-color-outline-variant); overflow-y: auto; @@ -131,7 +131,7 @@ body.has-chat-bar #content { padding-bottom: var(--chat-bar-h); } /* ── Sidebar ──────────────────────────────────────────────────────────── */ -.brand { margin-bottom: var(--space-6); } +.brand { margin-bottom: var(--space-4); } .brand .kicker { font-size: 11px; font-weight: 700; @@ -148,33 +148,37 @@ body.has-chat-bar #content { padding-bottom: var(--chat-bar-h); } } .nav-label { - font-size: 11px; + font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--md-sys-color-on-surface-variant); - margin: 0 0 var(--space-2) var(--space-3); + margin: 0 0 var(--space-1) var(--space-2); + padding: 0 var(--space-1); } #nav { display: flex; flex-direction: column; - gap: var(--space-1); + gap: 2px; flex: 1; + min-height: 0; } .nav-item { display: flex; align-items: center; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-full); + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + margin: 0 var(--space-1); + border-radius: var(--radius-md); cursor: pointer; color: var(--md-sys-color-on-surface-variant); - font-size: 14px; + font-size: 13px; font-weight: 500; + line-height: 1.3; user-select: none; - transition: background 0.15s; + transition: background 0.12s; } .nav-item:hover { background: var(--md-sys-color-surface-container-high); } .nav-item.active { @@ -182,28 +186,30 @@ body.has-chat-bar #content { padding-bottom: var(--chat-bar-h); } color: var(--md-sys-color-on-secondary-container); font-weight: 600; } -.nav-item md-icon { --md-icon-size: 20px; flex-shrink: 0; } +.nav-item md-icon { --md-icon-size: 18px; flex-shrink: 0; } .sidebar-footer { display: flex; flex-direction: column; - gap: var(--space-3); - padding-top: var(--space-4); - margin-top: var(--space-4); + gap: var(--space-2); + padding-top: var(--space-3); + margin-top: var(--space-3); border-top: 1px solid var(--md-sys-color-outline-variant); } #status-block { - padding: var(--space-3) var(--space-4); + padding: var(--space-3); background: var(--md-sys-color-surface-container); border-radius: var(--radius-md); + border: 1px solid var(--md-sys-color-outline-variant); } .live-pos { - font-size: 13px; + font-size: 12px; margin-top: var(--space-2); font-variant-numeric: tabular-nums; color: var(--md-sys-color-on-surface); + line-height: 1.35; } .pos-age { font-size: 11px; @@ -229,38 +235,39 @@ body.has-chat-bar #content { padding-bottom: var(--chat-bar-h); } .page { max-width: var(--content-max); margin: 0 auto; - padding: var(--space-6) var(--space-8) var(--space-8); + padding: var(--space-5) var(--space-6) var(--space-6); } -.page-header { margin-bottom: var(--space-6); } +.page-header { margin-bottom: var(--space-4); } .eyebrow { - font-size: 11px; + font-size: 10px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--md-sys-color-primary); - margin: 0 0 var(--space-2); + margin: 0 0 var(--space-1); } .page-title-row { display: flex; align-items: center; justify-content: space-between; - gap: var(--space-4); + gap: var(--space-3); flex-wrap: wrap; } .page-title { - font-size: 28px; + font-size: 22px; font-weight: 600; letter-spacing: -0.02em; margin: 0; - line-height: 1.2; + line-height: 1.25; } .page-actions { display: flex; + align-items: center; gap: var(--space-2); flex-wrap: wrap; } @@ -268,19 +275,20 @@ body.has-chat-bar #content { padding-bottom: var(--chat-bar-h); } .page-body { display: flex; flex-direction: column; - gap: var(--space-6); + gap: var(--space-4); + align-items: stretch; } -.section { display: flex; flex-direction: column; gap: var(--space-4); } +.section { display: flex; flex-direction: column; gap: var(--space-3); } .section-title { - font-size: 16px; + font-size: 14px; font-weight: 600; margin: 0; color: var(--md-sys-color-on-surface); } -.section + .section { margin-top: var(--space-2); } +.section + .section { margin-top: 0; } /* ── Cards & surfaces ───────────────────────────────────────────────── */ @@ -292,22 +300,22 @@ body.has-chat-bar #content { padding-bottom: var(--chat-bar-h); } } .card-header { - padding: var(--space-4) var(--space-5); + padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--md-sys-color-outline-variant); background: var(--md-sys-color-surface-container-low); } .card-title { - font-size: 14px; + font-size: 13px; font-weight: 600; margin: 0; } .card-body { - padding: var(--space-5); + padding: var(--space-4); display: flex; flex-direction: column; - gap: var(--space-4); + gap: var(--space-3); } /* ── Metrics ────────────────────────────────────────────────────────── */ @@ -322,7 +330,7 @@ body.has-chat-bar #content { padding-bottom: var(--chat-bar-h); } background: var(--md-sys-color-surface-container-low); border: 1px solid var(--md-sys-color-outline-variant); border-radius: var(--radius-md); - padding: var(--space-4); + padding: var(--space-3); min-width: 0; } @@ -335,9 +343,9 @@ body.has-chat-bar #content { padding-bottom: var(--chat-bar-h); } } .metric-value { - font-size: 22px; + font-size: 20px; font-weight: 600; - margin-top: var(--space-1); + margin-top: 2px; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; @@ -365,7 +373,7 @@ body.has-chat-bar #content { padding-bottom: var(--chat-bar-h); } .toolbar { display: flex; align-items: center; - gap: var(--space-3); + gap: var(--space-2); flex-wrap: wrap; } @@ -373,12 +381,13 @@ body.has-chat-bar #content { padding-bottom: var(--chat-bar-h); } display: flex; flex-direction: column; gap: var(--space-3); + align-items: stretch; } .split { display: grid; grid-template-columns: var(--split-ratio, 2fr 1fr); - gap: var(--space-5); + gap: var(--space-4); align-items: start; } @@ -401,13 +410,29 @@ body.has-chat-bar #content { padding-bottom: var(--chat-bar-h); } display: flex; flex-direction: column; align-items: center; - gap: var(--space-3); - padding: var(--space-8) var(--space-4); + gap: var(--space-2); + padding: var(--space-6) var(--space-4); text-align: center; color: var(--md-sys-color-on-surface-variant); } -.empty-state md-icon { --md-icon-size: 32px; opacity: 0.5; } -.empty-state p { margin: 0; max-width: 360px; } +.empty-state md-icon { --md-icon-size: 28px; opacity: 0.45; } +.empty-state p { margin: 0; max-width: 360px; font-size: 13px; } + +/* Top-aligned empty placeholder — no vertical centering in a huge void */ +.empty-panel { + display: flex; + align-items: center; + gap: var(--space-3); + width: 100%; + max-width: 520px; + padding: var(--space-3) var(--space-4); + background: var(--md-sys-color-surface-container-low); + border: 1px dashed var(--md-sys-color-outline-variant); + border-radius: var(--radius-md); + color: var(--md-sys-color-on-surface-variant); +} +.empty-panel md-icon { --md-icon-size: 22px; opacity: 0.5; flex-shrink: 0; } +.empty-panel p { margin: 0; font-size: 13px; line-height: 1.4; } /* ── Data display ───────────────────────────────────────────────────── */ @@ -468,11 +493,21 @@ details.expander[open] summary { .chart-box { position: relative; height: 220px; } .chart-box.tall { height: 260px; } -/* ── Form controls ──────────────────────────────────────────────────── */ +/* ── Form controls (dense) ──────────────────────────────────────────── */ + +md-filled-button, +md-outlined-button, +md-filled-tonal-button, +md-text-button { + --md-filled-button-container-height: 36px; + --md-outlined-button-container-height: 36px; + --md-filled-tonal-button-container-height: 36px; +} md-outlined-text-field, md-outlined-select { --md-outlined-field-container-shape: var(--radius-sm); + --md-outlined-text-field-container-shape: var(--radius-sm); min-width: 0; } diff --git a/apps/ui/src/twfarmbot_ui/static/index.html b/apps/ui/src/twfarmbot_ui/static/index.html index c7736cc..1c70662 100644 --- a/apps/ui/src/twfarmbot_ui/static/index.html +++ b/apps/ui/src/twfarmbot_ui/static/index.html @@ -9,7 +9,7 @@ - + - +