diff --git a/.dockerignore b/.dockerignore index 96065ebe..b66d8ccc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -43,9 +43,12 @@ data/ logs/ *.log -# Node modules (web builder stage installs fresh) +# Node modules (builder stages install fresh) apps/web/node_modules/ apps/web/dist/ +apps/bun-server/node_modules/ +apps/bun-server/dist/ +packages/core/node_modules/ # Python cache __pycache__/ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0e588a5d..e0df82c7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,51 +3,49 @@ version: 2 updates: - # Python — FastAPI server - - package-ecosystem: "pip" - directory: "/apps/server" + # npm — Web frontend (Bun uses package.json) + - package-ecosystem: "npm" + directory: "/apps/web" schedule: interval: "weekly" day: "monday" - open-pull-requests-limit: 5 + open-pull-requests-limit: 10 labels: - "dependencies" - - "python" + - "javascript" commit-message: prefix: "chore(deps)" + groups: + dev-dependencies: + dependency-type: "development" + update-types: ["minor", "patch"] + production-dependencies: + dependency-type: "production" + update-types: ["patch"] - # Python — MCP server - - package-ecosystem: "pip" - directory: "/apps/mcp-server" + # npm — Bun server host (@metic/server) + - package-ecosystem: "npm" + directory: "/apps/bun-server" schedule: interval: "weekly" day: "monday" - open-pull-requests-limit: 3 labels: - "dependencies" - - "python" + - "javascript" commit-message: prefix: "chore(deps)" - # npm — Web frontend (Bun uses package.json) + # npm — shared @metic/core handler - package-ecosystem: "npm" - directory: "/apps/web" + directory: "/packages/core" schedule: interval: "weekly" day: "monday" - open-pull-requests-limit: 10 labels: - "dependencies" - "javascript" commit-message: prefix: "chore(deps)" - groups: - dev-dependencies: - dependency-type: "development" - update-types: ["minor", "patch"] - production-dependencies: - dependency-type: "production" - update-types: ["patch"] # Docker — unified Dockerfile - package-ecosystem: "docker" diff --git a/.github/workflows/build-publish.yml b/.github/workflows/build-publish.yml index ffb2b15d..3fa55987 100644 --- a/.github/workflows/build-publish.yml +++ b/.github/workflows/build-publish.yml @@ -129,8 +129,8 @@ jobs: # Check container is running docker ps | grep test-container - # Check health endpoint - docker exec test-container curl -f http://localhost:3550/health || exit 1 + # Check health endpoint (distroless image has no curl; use the binary's own healthcheck) + docker exec test-container /app/metic healthcheck || exit 1 - name: Cleanup if: always() diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e5abb489..2e230c93 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,14 +14,14 @@ on: jobs: # ============================================================================ - # Relay Server Tests (Python/FastAPI) + # Core + Server Tests (TypeScript/Bun) # ============================================================================ + # @metic/core is the shared request handler; apps/bun-server is the compiled + # single-binary host. Both run under Bun and share the frozen /api/* contract + # tests that are the cross-runtime parity oracle. server-tests: - name: Server Tests (Python) + name: Core + Server Tests (Bun) runs-on: ubuntu-latest - defaults: - run: - working-directory: apps/server permissions: contents: read @@ -31,37 +31,52 @@ jobs: with: submodules: recursive - - name: Set up Python 3.13 - uses: actions/setup-python@v6 + - name: Setup Bun (attempt 1) + id: setup_bun_1 + continue-on-error: true + uses: oven-sh/setup-bun@v2 with: - python-version: "3.13" - cache: "pip" - cache-dependency-path: apps/server/requirements-test.txt - - - name: Install dependencies - run: | - pip install --upgrade pip - pip install -r requirements-test.txt + # Pin Bun for CI reproducibility and transient download stability. + # Keep this in sync with docker/Dockerfile.unified bun base image. + bun-version: 1.3.14 - - name: Run Python tests with coverage - env: - GEMINI_API_KEY: test_key_for_ci - run: | - pytest test_main.py test_logging.py test_taste_compass.py test_recommendations.py -v \ - --cov=main --cov=logging_config --cov=config \ - --cov=prompt_builder --cov=api --cov=services \ - --cov=utils --cov=models \ - --cov-report=xml --cov-report=term-missing + - name: Setup Bun (attempt 2) + id: setup_bun_2 + if: steps.setup_bun_1.outcome == 'failure' + continue-on-error: true + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v7 + - name: Setup Bun (attempt 3) + if: steps.setup_bun_2.outcome == 'failure' + uses: oven-sh/setup-bun@v2 with: - files: ./apps/server/coverage.xml - flags: relay - name: server-coverage + bun-version: 1.3.14 + + - name: Install @metic/core dependencies + working-directory: packages/core + run: bun install --frozen-lockfile + + - name: Typecheck @metic/core + working-directory: packages/core + run: bunx tsc --noEmit + + - name: Run @metic/core contract + unit tests + working-directory: packages/core + run: bunx vitest run + + - name: Install apps/bun-server dependencies + working-directory: apps/bun-server + run: bun install --frozen-lockfile - - name: Check coverage threshold - run: coverage report --fail-under=68 + - name: Typecheck apps/bun-server + working-directory: apps/bun-server + run: bunx tsc --noEmit + + - name: Run apps/bun-server tests + working-directory: apps/bun-server + run: bun test # ============================================================================ # Web Frontend Tests (TypeScript/React) @@ -212,6 +227,20 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + - name: Enforce image size gate + run: | + # The 3.0.0 distroless single-binary image is a single Bun process + # (no Python/nginx/mosquitto/s6). Guard against accidental bloat. + # Threshold is the uncompressed size reported by `docker images`. + MAX_MB=260 + SIZE_BYTES=$(docker image inspect meticai:test --format '{{.Size}}') + SIZE_MB=$(( SIZE_BYTES / 1024 / 1024 )) + echo "Unified image size: ${SIZE_MB} MB (limit ${MAX_MB} MB)" + if [ "${SIZE_MB}" -gt "${MAX_MB}" ]; then + echo "::error::Image size ${SIZE_MB} MB exceeds the ${MAX_MB} MB gate" + exit 1 + fi + - name: Test container starts run: | docker run -d --name test-container \ @@ -226,8 +255,9 @@ jobs: # Check container is running docker ps | grep test-container - # Check health endpoint - docker exec test-container curl -f http://localhost:3550/health || exit 1 + # Check health endpoint. The distroless image has no shell/curl, so + # probe via the binary's own healthcheck subcommand. + docker exec test-container /app/metic healthcheck || exit 1 - name: Setup Bun (attempt 1) id: setup_bun_1 @@ -295,22 +325,6 @@ jobs: with: submodules: recursive - - name: Set up Python 3.13 - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install Python linting tools - run: pip install ruff black - - - name: Run ruff on relay - run: ruff check apps/server/ - continue-on-error: true - - - name: Check Python formatting - run: black --check apps/server/ - continue-on-error: true - - name: Setup Bun (attempt 1) id: setup_bun_1 continue-on-error: true diff --git a/.gitignore b/.gitignore index 9413a5d3..d59083d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ # .gitignore +# Node +node_modules/ +packages/*/dist/ + # Secrets .env tasks.md @@ -39,6 +43,8 @@ htmlcov/ data/ !data/PourOverBase.json !data/recipes/ +!packages/core/src/data/ +!packages/core/src/data/** # Update tracking .versions.json @@ -84,3 +90,9 @@ testflight.md # Brainstorming visual companion .superpowers/ + +# TypeScript incremental build info +*.tsbuildinfo + +# Bun compile temp artifacts +*.bun-build diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 287f4225..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "apps/mcp-server"] - path = apps/mcp-server - url = https://github.com/hessius/meticulous-mcp.git diff --git a/HOME_ASSISTANT.md b/HOME_ASSISTANT.md index e87c0feb..2668cbc2 100644 --- a/HOME_ASSISTANT.md +++ b/HOME_ASSISTANT.md @@ -1,5 +1,11 @@ # 🏠 Home Assistant Integration +> **⚠️ Removed in 3.0.0.** The MQTT bridge (Mosquitto broker + meticulous-addon) +> was removed when the Python backend was replaced by the unified Bun server. +> Home Assistant auto-discovery is no longer available. Live telemetry and +> machine control remain fully functional inside Metic via the built-in +> `/api/ws/live` WebSocket. This document is retained for users still on 2.x. + Connect Metic to Home Assistant to get real-time espresso machine telemetry, create automations, and control your Meticulous from HA dashboards. ## How It Works diff --git a/README.md b/README.md index 6c3637d9..c0f66189 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,7 @@ When I got my Meticulous, after a loooong wait, I was overwhelmed with the optio ### For Power Users - 🔌 **REST API** - Integrate with any automation system -- 🏠 **Home Assistant** - MQTT bridge for HA automations and entities -- 🐳 **Single Docker Container** - Simple deployment and updates +- 🐳 **Single Docker Container** - Simple, distroless single-binary deployment - 🔓 **Open Source** - Customize and extend as you like - 🔄 **Auto Updates** - Optional Watchtower integration @@ -156,29 +155,40 @@ server is required. ## 🎛️ Control Center -Metic includes a real-time Control Center powered by the [meticulous-addon](https://github.com/nickwilsonr/meticulous-addon) MQTT bridge: +Metic includes a real-time Control Center with live machine telemetry streamed +straight from your Meticulous over the built-in `/api/ws/live` WebSocket: - **Live telemetry** — Real-time pressure, flow, weight, and temperature gauges - **Machine control** — Preheat, tare, purge, abort, brightness, sounds, and more - **Live Shot View** — Watch your extraction in real-time with live charts - **Auto-detection** — Automatically detects when a shot starts and prompts you to watch - **Last Shot Banner** — After a shot, offers one-tap analysis with AI coaching -- **Home Assistant** — MQTT bridge enables auto-discovery of 24 sensors + 11 commands in HA -The Control Center appears as a side panel on desktop and a full page on mobile. Enable it in Settings → Control Center → MQTT Bridge. +The Control Center appears as a side panel on desktop and a full page on mobile, +and works out of the box with no extra services. -### Home Assistant Integration +> **Changed in 3.0.0:** Home Assistant MQTT auto-discovery was removed, but live +> telemetry and machine control are unaffected (served over the built-in +> `/api/ws/live` WebSocket). See [Removed in 3.0.0](#-removed-in-300-server-version) +> below for the full list. -When the MQTT bridge is enabled, your Meticulous machine is automatically discoverable in Home Assistant. +## 🗑️ Removed in 3.0.0 (server version) -1. Start Metic with the Home Assistant overlay: - ```bash - docker compose -f docker-compose.yml -f docker-compose.homeassistant.yml up -d - ``` -2. In HA, add the **MQTT** integration and point it to your Metic server's IP on port 1883 -3. This enables automations like "notify me when my shot is done" or "preheat at 7am on weekdays" +Metic 3.0.0 replaces the Python backend with a single unified image. As part of +that cutover, a few **server-side** features were removed. On-device / native app +functionality is unaffected. -[→ Full Home Assistant integration guide](HOME_ASSISTANT.md) +- **Home Assistant MQTT bridge**: the Mosquitto broker and + [meticulous-addon](https://github.com/nickwilsonr/meticulous-addon) MQTT + auto-discovery are gone, along with the in-app MQTT Bridge settings. Live + telemetry and machine control still work over the built-in `/api/ws/live` + WebSocket. See [HOME_ASSISTANT.md](HOME_ASSISTANT.md) for details. +- **MCP server**: the bundled + [meticulous-mcp](https://github.com/twchad/meticulous-mcp) server and its + in-app settings were removed. +- **In-app self-updater**: the in-UI update action (`/api/trigger-update`) was + removed. Update by pulling the new image (see below) or enable the optional + Watchtower addon for automatic updates. ## 🔄 Updating Metic @@ -192,7 +202,8 @@ With Watchtower enabled, updates happen automatically every 6 hours. ### Manage Addons After Install -You can enable or disable optional addons at any time (Watchtower, Tailscale, Home Assistant MQTT) without re-running the full installer. +You can enable or disable optional addons at any time (Watchtower, Tailscale) +without re-running the full installer. Linux/macOS: @@ -243,34 +254,34 @@ docker compose -f docker-compose.yml -f docker-compose.tailscale.yml up -d ## 🏗️ Architecture -Metic v2.0 runs as a single unified container with five internal services managed by s6-overlay: +Metic 3.0.0 runs as a single unified container: one distroless Bun process that +serves the web UI, the API, the machine proxy, and live telemetry. (Earlier 2.x +releases ran five internal services under s6-overlay: nginx, a FastAPI server, an +MCP server, a Mosquitto broker, and an MQTT bridge; these were removed in 3.0.0. +See [Removed in 3.0.0](#-removed-in-300-server-version).) ``` ┌──────────────────────────────────────────────────────────────┐ -│ Metic Container │ +│ Metic Container │ │ ┌────────────────────────────────────────────────────────┐ │ -│ │ nginx (:3550) │ │ -│ │ Web UI + API Reverse Proxy │ │ +│ │ Bun server, single binary (:3550) │ │ +│ │ │ │ +│ │ • Web UI (static SPA) │ │ +│ │ • REST API (/api) → @metic/core │ │ +│ │ (AI, profiles, analysis, recommendations, │ │ +│ │ dial-in) with a Gemini AI provider seam │ │ +│ │ • Machine proxy (/api/v1/* → Meticulous) │ │ +│ │ • Live telemetry (/api/ws/live WebSocket) │ │ │ └────────────────────────────────────────────────────────┘ │ │ │ │ -│ ┌───────────────────┼───────────────┐ │ -│ ▼ ▼ ▼ │ -│ ┌──────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Server │ │ MCP Server │ │ Gemini CLI │ │ -│ │ (FastAPI) │ │(Meticulous) │ │ (AI) │ │ -│ │ :8000 │ │ :8080 │ │ │ │ -│ └──────────┘ └─────────────┘ └─────────────┘ │ -│ │ │ -│ │ MQTT │ -│ ▼ │ -│ ┌──────────┐ ┌─────────────────┐ │ -│ │Mosquitto │◄─│Meticulous Bridge│◄── Machine (Socket.IO) │ -│ │ :1883 │ │ (MQTT Bridge) │ │ -│ └──────────┘ └─────────────────┘ │ +│ ▼ │ +│ Machine (Socket.IO / HTTP) │ └──────────────────────────────────────────────────────────────┘ ``` -**Real-time telemetry**: The [meticulous-addon](https://github.com/nickwilsonr/meticulous-addon) bridge connects to your machine via Socket.IO and publishes live sensor data (pressure, flow, weight, temperature) to the internal MQTT broker. The FastAPI server subscribes and pushes updates to the web UI via WebSocket. +**Real-time telemetry**: The Bun server connects to your machine and pushes live +sensor data (pressure, flow, weight, temperature) to the web UI over the built-in +`/api/ws/live` WebSocket. No separate MQTT broker or bridge is required. **Optional sidecars:** - **Tailscale** - Secure remote access @@ -340,7 +351,7 @@ MIT License - see [LICENSE](LICENSE) for details.
-Runs on [pyMeticulous](https://github.com/MeticulousHome/pyMeticulous), [meticulous-mcp](https://github.com/twchad/meticulous-mcp), [meticulous-addon](https://github.com/nickwilsonr/meticulous-addon), and caffeine ☕ +Runs on [Bun](https://bun.sh), TypeScript, [Google Gemini](https://ai.google.dev/), and caffeine ☕ Made with ❤️ by @hessius diff --git a/UPDATING.md b/UPDATING.md index 038cae54..f040dacd 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -1,5 +1,46 @@ # 🔄 Updating Metic +> **3.0.0 note:** the in-app self-updater has been removed (the `/api/trigger-update` +> endpoint now returns 503). Updating is handled by pulling the image, or +> automatically via the optional Watchtower sidecar. + +## ⚠️ Upgrading from 2.x to 3.0.0 (read this first) + +The 3.0.0 image is a single distroless binary with **no shell and no `curl`**. +The old 2.x `docker-compose.yml` used a `curl` based healthcheck, so if you keep +your old compose file the container is flagged **unhealthy** even though it is +serving fine. If you run autoheal/Watchtower this shows up as the container being +restarted every few minutes and the web home screen getting stuck on "loading". + +If Watchtower auto-updated your image to 3.0.0, it updated the image but **not** +your compose file, so you will hit this. Fix it by refreshing your compose file: + +**Recommended: re-pull the maintained compose file, then recreate the container.** + +```bash +cd ~/Metic # or ~/MeticAI, wherever your compose file lives +curl -fsSL https://raw.githubusercontent.com/hessius/MeticAI/main/docker-compose.yml -o docker-compose.yml +docker compose up -d --force-recreate meticai +``` + +**Or edit the healthcheck by hand** in `docker-compose.yml`: + +```yaml + healthcheck: + # 3.0.0: distroless image has no curl; the binary self-probes /health + test: ["CMD", "/app/metic", "healthcheck"] +``` + +then `docker compose up -d --force-recreate meticai`. + +Verify it stays healthy (no restart loop): + +```bash +docker inspect meticai --format 'Health={{.State.Health.Status}} RestartCount={{.RestartCount}}' +``` + +You should see `Health=healthy` and a stable `RestartCount`. + ## Quick Update (v2.x) ```bash @@ -33,6 +74,8 @@ If your Metic instance is outdated and Watchtower didn't update it (or you don't ### Option A: Update from the Web UI +> Removed in 3.0.0. Use Option B or C below instead. + 1. Open Metic in your browser (`http://:3550`) 2. Go to **Settings** 3. Look for the update notification or version info diff --git a/VERSION b/VERSION index e70b4523..e6f2ff09 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.6.0 +3.0.0-beta.2 \ No newline at end of file diff --git a/apps/bridge/requirements.txt b/apps/bridge/requirements.txt deleted file mode 100644 index a713d4b8..00000000 --- a/apps/bridge/requirements.txt +++ /dev/null @@ -1,13 +0,0 @@ -# ============================================================================== -# MeticAI Bridge Dependencies -# ============================================================================== -# Additional Python packages needed by the meticulous-addon bridge. -# pymeticulous is already installed via the server/MCP requirements. -# ============================================================================== - -# MQTT client for broker communication -paho-mqtt==1.6.1 - -# Async HTTP (required by addon top-level import, used for HA Supervisor API -# which is unused in our context but must be importable) -aiohttp==3.14.1 diff --git a/apps/bridge/start_bridge.py b/apps/bridge/start_bridge.py deleted file mode 100644 index e4f7acb3..00000000 --- a/apps/bridge/start_bridge.py +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env python3 -""" -MeticAI Bridge Starter — Zero-fork adapter for @nickwilsonr/meticulous-addon. - -This script bridges MeticAI's environment-variable configuration to the addon's -options.json format, then starts the addon without modifying upstream code. - -The addon is cloned at Docker build time from: - https://github.com/nickwilsonr/meticulous-addon - -Configuration is derived from MeticAI environment variables: - - METICULOUS_IP → machine_ip - - MQTT_HOST → mqtt_host (default: 127.0.0.1 for local mosquitto) - - MQTT_PORT → mqtt_port (default: 1883) - - BRIDGE_DEBUG → debug logging toggle - -Credit: @nickwilsonr for the excellent meticulous-addon. -License: MIT (same as upstream) -""" - -import json -import logging -import os -import sys -import asyncio -from typing import Any, Optional - -logger = logging.getLogger("meticai.bridge") - - -def _as_float(value: Any) -> Optional[float]: - """Best-effort float conversion.""" - try: - if value is None: - return None - return float(value) - except (TypeError, ValueError): - return None - - -def _extract_value(container: Any, key: str) -> Any: - """Get a value from dict/object containers.""" - if container is None: - return None - if isinstance(container, dict): - return container.get(key) - return getattr(container, key, None) - - -def _extract_power_value(payload: Any) -> Optional[float]: - """Extract power percentage from status/actuator payloads. - - The upstream add-on defines a `power` sensor but does not consistently - populate it in status callbacks. This helper probes common field names - used by Socket.IO payload variants and returns the first numeric value. - """ - power_keys = ( - "power", - "motor_power", - "motorPower", - "heater_power", - "heaterPower", - "pwr", - "pw", - "mp", - ) - - nested_keys = ( - "sensors", - "actuators", - "motor", - "heater", - ) - - containers = [payload] - for nested in nested_keys: - nested_obj = _extract_value(payload, nested) - if nested_obj is not None: - containers.append(nested_obj) - - for container in containers: - for key in power_keys: - value = _as_float(_extract_value(container, key)) - if value is not None: - return max(0.0, min(100.0, value)) - return None - - -def _publish_power_if_available(addon: Any, payload: Any) -> None: - """Publish power telemetry to MQTT if present in raw event payload.""" - power_value = _extract_power_value(payload) - if power_value is None: - return - - # Reuse add-on throttling so we don't spam MQTT updates. - fields = addon._filter_throttled_fields({"power": round(power_value, 2)}) - if fields and getattr(addon, "loop", None): - asyncio.run_coroutine_threadsafe( - addon.publish_to_homeassistant(fields), addon.loop - ) - - -def _patch_addon_power_telemetry() -> None: - """Monkey-patch upstream add-on to emit real-time power values. - - We patch runtime methods instead of forking upstream code. This keeps - MeticAI's zero-fork bridge model intact while restoring power telemetry. - """ - try: - import run # type: ignore - except Exception as exc: - logger.warning("Could not import add-on runtime for patching: %s", exc) - return - - addon_cls = getattr(run, "MeticulousAddon", None) - if addon_cls is None: - logger.warning("MeticulousAddon class not found in add-on runtime") - return - - original_init = addon_cls.__init__ - original_status_handler = addon_cls._handle_status_event - original_actuators_handler = addon_cls._handle_actuators_event - - def patched_init(self, *args, **kwargs): - original_init(self, *args, **kwargs) - try: - # Ensure power participates in delta filtering. - self.sensor_deltas["power"] = float(self.config.get("power_delta", 1.0)) - except Exception: - self.sensor_deltas["power"] = 1.0 - - def patched_status_handler(self, status): - original_status_handler(self, status) - try: - _publish_power_if_available(self, status) - except Exception as exc: - logger.debug("Power patch status handler error: %s", exc) - - def patched_actuators_handler(self, actuators): - original_actuators_handler(self, actuators) - try: - _publish_power_if_available(self, actuators) - except Exception as exc: - logger.debug("Power patch actuators handler error: %s", exc) - - addon_cls.__init__ = patched_init - addon_cls._handle_status_event = patched_status_handler - addon_cls._handle_actuators_event = patched_actuators_handler - logger.info("Applied runtime power telemetry patch to meticulous-addon") - - -def _resolve_machine_ip() -> str: - """Determine the Meticulous machine IP. - - Priority: - 1. ``METICULOUS_IP`` environment variable (set by s6 container env) - 2. ``meticulousIp`` field in ``/data/settings.json`` (persisted by the UI) - 3. ``meticulous.local`` (mDNS default) - """ - # 1) Env var — preferred, set by s6-overlay container environment - env_ip = os.environ.get("METICULOUS_IP", "").strip() - if env_ip: - return env_ip - - # 2) Persisted settings file (written by the web UI) - data_dir = os.environ.get("DATA_DIR") or "/data" - settings_path = os.path.join(data_dir, "settings.json") - try: - with open(settings_path) as f: - settings = json.load(f) - saved_ip = (settings.get("meticulousIp") or "").strip() - if saved_ip: - logger.info("Using machine IP from settings.json: %s", saved_ip) - return saved_ip - except (FileNotFoundError, json.JSONDecodeError, KeyError): - pass - - # 3) Default - return "meticulous.local" - - -def build_config() -> dict: - """Build addon config dict from MeticAI environment variables.""" - return { - # Machine connection - "machine_ip": _resolve_machine_ip(), - - # MQTT broker (local mosquitto inside the same container) - "mqtt_enabled": os.environ.get("MQTT_ENABLED", "true").lower() == "true", - "mqtt_host": os.environ.get("MQTT_HOST", "127.0.0.1"), - "mqtt_port": int(os.environ.get("MQTT_PORT", "1883")), - "mqtt_username": os.environ.get("MQTT_USERNAME", ""), - "mqtt_password": os.environ.get("MQTT_PASSWORD", ""), - - # Delta filtering (sensible defaults — reduce MQTT noise) - "enable_delta_filtering": True, - "temperature_delta": float(os.environ.get("BRIDGE_TEMP_DELTA", "0.5")), - "pressure_delta": float(os.environ.get("BRIDGE_PRESSURE_DELTA", "0.2")), - "flow_delta": float(os.environ.get("BRIDGE_FLOW_DELTA", "0.1")), - "weight_delta": float(os.environ.get("BRIDGE_WEIGHT_DELTA", "0.1")), - "time_delta": float(os.environ.get("BRIDGE_TIME_DELTA", "0.1")), - "voltage_delta": float(os.environ.get("BRIDGE_VOLTAGE_DELTA", "1.0")), - "power_delta": float(os.environ.get("BRIDGE_POWER_DELTA", "1.0")), - - # Stale data refresh (hours) - "stale_data_refresh_interval": int( - os.environ.get("BRIDGE_STALE_REFRESH_HOURS", "24") - ), - - # Debug logging - "debug": os.environ.get("BRIDGE_DEBUG", "false").lower() == "true", - } - - -def write_addon_config(config: dict) -> None: - """Write config as /data/options.json (the format the addon expects).""" - config_path = "/data/options.json" - os.makedirs("/data", exist_ok=True) - with open(config_path, "w") as f: - json.dump(config, f, indent=2) - logger.info("Wrote bridge config to %s", config_path) - - -def main(): - """Generate config and start the meticulous-addon bridge.""" - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - - config = build_config() - - if not config["mqtt_enabled"]: - logger.info("MQTT bridge is disabled (MQTT_ENABLED=false). Exiting.") - sys.exit(0) - - machine_ip = config["machine_ip"] - logger.info( - "Starting meticulous-addon bridge: machine=%s, mqtt=%s:%d", - machine_ip, - config["mqtt_host"], - config["mqtt_port"], - ) - - # Write the config file the addon expects - write_addon_config(config) - - # Add the addon source directory to Python path - addon_src = "/app/meticulous-addon/rootfs/usr/bin" - if addon_src not in sys.path: - sys.path.insert(0, addon_src) - - # Patch upstream add-on runtime to publish real power telemetry. - _patch_addon_power_telemetry() - - # Import and run the addon's main entry point - try: - from run import main as addon_main # noqa: E402 - - addon_main() - except ImportError as e: - logger.error( - "Failed to import meticulous-addon. " - "Ensure it is cloned at /app/meticulous-addon: %s", - e, - ) - sys.exit(1) - except Exception as e: - logger.error("Bridge crashed: %s", e, exc_info=True) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/apps/bun-server/README.md b/apps/bun-server/README.md new file mode 100644 index 00000000..8e44e74b --- /dev/null +++ b/apps/bun-server/README.md @@ -0,0 +1,49 @@ +# @metic/server (Bun server) + +Single-binary Bun host for MeticAI 3.0.0. Serves the built frontend, proxies +`/api/v1/*` to the espresso machine, runs the shared `@metic/core` handler for +`/api/*`, and hosts the `/api/ws/live` telemetry hub. + +## Commands + +```sh +bun run src/main.ts serve # start the server (default) +bun run src/main.ts healthcheck # probe /health, exit 0/1 (container HEALTHCHECK) +bun run src/main.ts storage-rollback # reverse a SQLite migration (see below) +``` + +## Environment + +| Var | Purpose | +| --- | --- | +| `PORT` | HTTP port (default `3550`). | +| `METICULOUS_IP` | Machine host or `host:port`; base for the `/api/v1/*` proxy. | +| `GEMINI_API_KEY` / `GEMINI_MODEL` | AI provider config. | +| `DATA_DIR` | Persistence root (default `./data`). | +| `STORAGE_BACKEND` | `json` (default) or `sqlite` (see Persistence). | + +## Persistence backends (#531) + +Storage is reached only through the core `Storage`/`Repo` interface, so the +backend is swappable without touching any route. + +- **`json` (default)** — flat JSON files under `DATA_DIR`, 1:1 with the shapes + the 2.x Python server wrote. Zero migration for existing volumes. +- **`sqlite`** — a single `bun:sqlite` database at `DATA_DIR/metic.db`. On first + boot with `STORAGE_BACKEND=sqlite`, a one-time, idempotent migration imports + any existing JSON artifacts into SQLite and renames the originals to `*.bak` + (nothing is deleted). A stamp file `.storage-migrated.json` records the run and + guards against re-migration. + +### Rollback + +The SQLite migration is reversible. With the server stopped: + +```sh +DATA_DIR=/path/to/data bun run src/main.ts storage-rollback +``` + +This drops `metic.db` (and its `-wal`/`-shm` sidecars), restores every `*.bak` +original to its place, and removes the stamp — returning the volume to flat +JSON. Then start the server without `STORAGE_BACKEND=sqlite` (or with +`STORAGE_BACKEND=json`). diff --git a/apps/bun-server/bun.lock b/apps/bun-server/bun.lock new file mode 100644 index 00000000..f5cfaedd --- /dev/null +++ b/apps/bun-server/bun.lock @@ -0,0 +1,271 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@metic/server", + "dependencies": { + "@google/genai": "^1.52.0", + "@metic/core": "file:../../packages/core", + "socket.io-client": "^4.8.1", + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "typescript": "^6.0.3", + }, + }, + }, + "packages": { + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@metic/core": ["@metic/core@file:../../packages/core", { "devDependencies": { "typescript": "^6.0.3", "vitest": "4.1.5" } }], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.4", "", { "os": "linux", "cpu": "arm" }, "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.4", "", { "os": "none", "cpu": "arm64" }, "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.4", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="], + + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + + "@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="], + + "@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="], + + "@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="], + + "@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + + "es-module-lexer": ["es-module-lexer@2.3.0", "", {}, "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "google-auth-library": ["google-auth-library@10.9.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "rolldown": ["rolldown@1.1.4", "", { "dependencies": { "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.4", "@rolldown/binding-darwin-arm64": "1.1.4", "@rolldown/binding-darwin-x64": "1.1.4", "@rolldown/binding-freebsd-x64": "1.1.4", "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", "@rolldown/binding-linux-arm64-gnu": "1.1.4", "@rolldown/binding-linux-arm64-musl": "1.1.4", "@rolldown/binding-linux-ppc64-gnu": "1.1.4", "@rolldown/binding-linux-s390x-gnu": "1.1.4", "@rolldown/binding-linux-x64-gnu": "1.1.4", "@rolldown/binding-linux-x64-musl": "1.1.4", "@rolldown/binding-openharmony-arm64": "1.1.4", "@rolldown/binding-wasm32-wasi": "1.1.4", "@rolldown/binding-win32-arm64-msvc": "1.1.4", "@rolldown/binding-win32-x64-msvc": "1.1.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + + "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="], + + "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + } +} diff --git a/apps/bun-server/package.json b/apps/bun-server/package.json new file mode 100644 index 00000000..f6052710 --- /dev/null +++ b/apps/bun-server/package.json @@ -0,0 +1,24 @@ +{ + "name": "@metic/server", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Bun single-binary server for MeticAI 3.0.0 (proxy mode). Consumes @metic/core and serves the built frontend, proxies the machine API, and streams live telemetry.", + "module": "src/main.ts", + "scripts": { + "start": "bun run src/main.ts serve", + "healthcheck": "bun run src/main.ts healthcheck", + "test": "bun test", + "typecheck": "tsc --noEmit", + "build": "bun build --compile --minify --sourcemap src/main.ts --outfile dist/metic-server" + }, + "dependencies": { + "@google/genai": "^1.52.0", + "@metic/core": "file:../../packages/core", + "socket.io-client": "^4.8.1" + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "typescript": "^6.0.3" + } +} diff --git a/apps/bun-server/src/machineProxy.ts b/apps/bun-server/src/machineProxy.ts new file mode 100644 index 00000000..8fd704a0 --- /dev/null +++ b/apps/bun-server/src/machineProxy.ts @@ -0,0 +1,84 @@ +/** + * Transparent reverse proxy for the machine's native `/api/v1/*` surface. + * + * The frontend talks to the machine's espresso API exactly as it does today; in + * proxy mode this server forwards those requests upstream unchanged (method, + * body, headers, status, and streamed response body), so machine behavior is + * identical to hitting the machine directly. This replaces the nginx + Python + * pass-through of the 2.x container. + */ + +import type { Platform } from "@metic/core/platform"; + +// Hop-by-hop headers must not be forwarded across a proxy (RFC 7230 §6.1). +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host", + "content-length", +]); + +function filterHeaders(headers: Headers): Headers { + const out = new Headers(); + headers.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase())) out.set(key, value); + }); + return out; +} + +export function isMachinePath(pathname: string): boolean { + return pathname === "/api/v1" || pathname.startsWith("/api/v1/"); +} + +export async function proxyToMachine( + request: Request, + platform: Platform, +): Promise { + const baseUrl = platform.machine.getBaseUrl(); + if (!baseUrl) { + return new Response( + JSON.stringify({ error: "Machine address not configured" }), + { status: 503, headers: { "content-type": "application/json" } }, + ); + } + + const incoming = new URL(request.url); + const target = `${baseUrl}${incoming.pathname}${incoming.search}`; + + const init: RequestInit = { + method: request.method, + headers: filterHeaders(request.headers), + redirect: "manual", + }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + // Bun/undici require this when streaming a request body through fetch. + (init as { duplex?: string }).duplex = "half"; + } + + let upstream: Response; + try { + upstream = await fetch(target, init); + } catch (err) { + platform.logger.error("machine proxy request failed", { + target, + message: err instanceof Error ? err.message : String(err), + }); + return new Response( + JSON.stringify({ error: "Machine unreachable" }), + { status: 502, headers: { "content-type": "application/json" } }, + ); + } + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: filterHeaders(upstream.headers), + }); +} diff --git a/apps/bun-server/src/main.ts b/apps/bun-server/src/main.ts new file mode 100644 index 00000000..e7b3bcc5 --- /dev/null +++ b/apps/bun-server/src/main.ts @@ -0,0 +1,66 @@ +/** + * Entrypoint for the MeticAI Bun server. + * + * Subcommands: + * serve Start the HTTP/WebSocket server (default). + * healthcheck Probe a running server's /health and exit 0/1 + * (used as the container HEALTHCHECK). + */ + +import { createServer } from "./server.ts"; +import { rollbackSqliteMigration } from "./platform/sqliteMigration.ts"; +import { join } from "node:path"; + +async function healthcheck(): Promise { + const port = Number(process.env.PORT ?? 3550); + try { + const res = await fetch(`http://127.0.0.1:${port}/health`, { + signal: AbortSignal.timeout(5_000), + }); + if (!res.ok) { + console.error(`healthcheck failed: HTTP ${res.status}`); + return 1; + } + return 0; + } catch (err) { + console.error("healthcheck failed:", err instanceof Error ? err.message : err); + return 1; + } +} + +async function main(): Promise { + const command = process.argv[2] ?? "serve"; + + switch (command) { + case "serve": { + createServer(); + // Bun.serve keeps the process alive; nothing further to do. + return; + } + case "healthcheck": { + process.exit(await healthcheck()); + break; + } + case "storage-rollback": { + // Reverse a SQLite boot migration: drop metic.db and restore the + // flat-JSON *.bak originals (issue #531). Idempotent. + const dataDir = process.env.DATA_DIR ?? join(process.cwd(), "data"); + const did = rollbackSqliteMigration(dataDir, (m) => console.log(m)); + console.log( + did + ? "storage rollback complete" + : "no SQLite migration to roll back (no stamp found)", + ); + process.exit(0); + break; + } + default: { + console.error( + `Unknown command: ${command}\nUsage: metic-server [serve|healthcheck|storage-rollback]`, + ); + process.exit(2); + } + } +} + +void main(); diff --git a/apps/bun-server/src/platform/node.ts b/apps/bun-server/src/platform/node.ts new file mode 100644 index 00000000..c18961c6 --- /dev/null +++ b/apps/bun-server/src/platform/node.ts @@ -0,0 +1,400 @@ +/** + * Node/Bun implementation of the core `Platform` interface. + * + * Storage is filesystem-backed JSON: every collection is a directory under the + * data root, with one `${id}.json` document per record. This mirrors the shapes + * the Python backend persisted under `/data`, so a 3.0.0 container can adopt an + * existing 2.x data volume without migration (SQLite lands in Phase 4b behind + * this same Repo seam). + */ + +import { mkdir, readFile, writeFile, readdir, unlink, rename } from "node:fs/promises"; +import { existsSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { GoogleGenAI } from "@google/genai"; +import { + listAvailableModels, + STATIC_FALLBACK_MODELS, + type ModelClient, +} from "@metic/core/ai/modelResolver"; +import type { + Platform, + PlatformStorage, + PlatformAI, + Repo, + Cache, + BlobStore, + Scheduler, + Logger, + AIConfig, +} from "@metic/core/platform"; +import { openSqliteStorage } from "./sqliteStorage.ts"; +import { migrateJsonToSqlite } from "./sqliteMigration.ts"; + +async function atomicWrite(path: string, data: string): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, data, "utf8"); + await rename(tmp, path); +} + +function safeId(id: string): string { + // Prevent path traversal; ids become flat filenames. + return id.replace(/[^A-Za-z0-9._-]/g, "_"); +} + +/** A directory-backed JSON repository: one `${id}.json` file per document. */ +function fsRepo(dir: string): Repo { + return { + async read(id) { + const path = join(dir, `${safeId(id)}.json`); + if (!existsSync(path)) return null; + try { + return JSON.parse(await readFile(path, "utf8")) as T; + } catch { + return null; + } + }, + async list() { + if (!existsSync(dir)) return []; + const files = (await readdir(dir)).filter((f) => f.endsWith(".json")); + const out: T[] = []; + for (const f of files) { + try { + out.push(JSON.parse(await readFile(join(dir, f), "utf8")) as T); + } catch { + // Skip corrupt/partial files rather than failing the whole list. + } + } + return out; + }, + async write(id, value) { + await atomicWrite(join(dir, `${safeId(id)}.json`), JSON.stringify(value, null, 2)); + }, + async delete(id) { + const path = join(dir, `${safeId(id)}.json`); + if (existsSync(path)) await unlink(path); + }, + }; +} + +/** + * A single-document repository backed by one JSON file (e.g. settings.json). + * `list()` returns the single document if present; ids are ignored for read. + */ +function fsSingletonRepo(file: string): Repo { + const load = async (): Promise => { + if (!existsSync(file)) return null; + try { + return JSON.parse(await readFile(file, "utf8")) as T; + } catch { + return null; + } + }; + return { + read: load, + async list() { + const v = await load(); + return v == null ? [] : [v]; + }, + async write(_id, value) { + await atomicWrite(file, JSON.stringify(value, null, 2)); + }, + async delete() { + if (existsSync(file)) await unlink(file); + }, + }; +} + +/** + * A single-file repository backed by one JSON object that maps id -> document + * (e.g. shot_annotations.json). Unlike the directory repo, ids may contain any + * character (including `/`), so this suits keys like `${date}/${filename}`. + */ +function fsKeyedMapRepo(file: string): Repo { + const loadMap = async (): Promise> => { + if (!existsSync(file)) return {}; + try { + const parsed = JSON.parse(await readFile(file, "utf8")); + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } catch { + return {}; + } + }; + const saveMap = async (map: Record): Promise => { + await atomicWrite(file, JSON.stringify(map, null, 2)); + }; + return { + async read(id) { + const map = await loadMap(); + return Object.prototype.hasOwnProperty.call(map, id) ? map[id]! : null; + }, + async list() { + return Object.values(await loadMap()); + }, + async write(id, value) { + const map = await loadMap(); + map[id] = value; + await saveMap(map); + }, + async delete(id) { + const map = await loadMap(); + if (Object.prototype.hasOwnProperty.call(map, id)) { + delete map[id]; + await saveMap(map); + } + }, + }; +} + +interface CacheEntry { + value: unknown; + expiresAt: number | null; +} + +function memoryCache(clock: () => number): Cache { + const store = new Map(); + return { + async get(key: string) { + const entry = store.get(key); + if (!entry) return null; + if (entry.expiresAt != null && entry.expiresAt <= clock()) { + store.delete(key); + return null; + } + return entry.value as T; + }, + async set(key: string, value: T, ttlMs?: number) { + store.set(key, { + value, + expiresAt: ttlMs != null ? clock() + ttlMs : null, + }); + }, + }; +} + +function fsBlobStore(dir: string): BlobStore { + return { + async read(key) { + const path = join(dir, safeId(key)); + if (!existsSync(path)) return null; + return new Uint8Array(await readFile(path)); + }, + async write(key, bytes) { + const path = join(dir, safeId(key)); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes); + }, + async delete(key) { + const path = join(dir, safeId(key)); + if (existsSync(path)) await unlink(path); + }, + }; +} + +function timerScheduler(logger: Logger): Scheduler { + const timers = new Map>(); + return { + schedule(id, at, run) { + this.cancel(id); + const delay = Math.max(0, at - Date.now()); + const t = setTimeout(() => { + timers.delete(id); + run().catch((err) => logger.error(`scheduled task ${id} failed`, err)); + }, delay); + // Do not keep the process alive solely for a pending timer. + if (typeof (t as { unref?: () => void }).unref === "function") { + (t as { unref: () => void }).unref(); + } + timers.set(id, t); + }, + cancel(id) { + const t = timers.get(id); + if (t) { + clearTimeout(t); + timers.delete(id); + } + }, + }; +} + +function consoleLogger(): Logger { + const fmt = (level: string, message: string, extra?: unknown) => + extra === undefined + ? `[${level}] ${message}` + : `[${level}] ${message} ${typeof extra === "string" ? extra : JSON.stringify(extra)}`; + return { + info: (m, e) => console.log(fmt("info", m, e)), + error: (m, e) => console.error(fmt("error", m, e)), + debug: (m, e) => { + if (process.env.DEBUG) console.debug(fmt("debug", m, e)); + }, + }; +} + +const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"; + +/** + * Gemini-backed PlatformAI. Mirrors the frontend GeminiProvider's generateText: + * a `models.generateContent({ model, contents, config })` call returning `{ text }`. + * A bare string prompt is accepted as `contents` (the SDK wraps it). + */ +function geminiAI(getConfig: () => AIConfig): PlatformAI { + return { + isConfigured() { + return !!getConfig().apiKey; + }, + async generateText(req) { + const { apiKey, model } = getConfig(); + if (!apiKey) throw new Error("AI not configured"); + const client = new GoogleGenAI({ apiKey }); + const response = await client.models.generateContent({ + model: model || DEFAULT_GEMINI_MODEL, + contents: req.contents as Parameters[0]["contents"], + ...(req.config ? { config: req.config as Record } : {}), + }); + return { text: (response as { text?: string }).text ?? "" }; + }, + currentModel() { + return getConfig().model || DEFAULT_GEMINI_MODEL; + }, + async listModels() { + const { apiKey } = getConfig(); + if (!apiKey) return STATIC_FALLBACK_MODELS; + try { + const client = new GoogleGenAI({ apiKey }); + const models = await listAvailableModels(client as unknown as ModelClient); + return models.length ? models : STATIC_FALLBACK_MODELS; + } catch { + return STATIC_FALLBACK_MODELS; + } + }, + }; +} + +export interface NodePlatformOptions { + /** Root directory for persisted JSON/blobs. Defaults to $DATA_DIR or ./data. */ + dataDir?: string; + /** Machine base URL override. Defaults to http://$METICULOUS_IP:8080. */ + machineBaseUrl?: string; + /** + * Persistence backend. Defaults to $STORAGE_BACKEND (`json` | `sqlite`), else + * `json` for zero-migration continuity with 2.x volumes. `sqlite` runs a + * one-time, idempotent, reversible boot migration of any existing JSON. + */ + storageBackend?: "json" | "sqlite"; +} + +const MACHINE_PORT = 8080; + +function resolveMachineBaseUrl(override?: string): string { + if (override) return override.replace(/\/+$/, ""); + const ip = (process.env.METICULOUS_IP ?? "").trim(); + if (!ip) return ""; + if (/^https?:\/\//.test(ip)) return ip.replace(/\/+$/, ""); + // Bare host or host:port. + return ip.includes(":") ? `http://${ip}` : `http://${ip}:${MACHINE_PORT}`; +} + +/** App version for GET /api/version: $APP_VERSION, else the repo VERSION file, else "unknown". */ +function resolveAppVersion(): string { + const env = process.env.APP_VERSION?.trim(); + if (env) return env; + for (const candidate of ["VERSION", "../VERSION", "../../VERSION", "../../../VERSION"]) { + try { + const p = join(process.cwd(), candidate); + if (existsSync(p)) { + const v = readFileSync(p, "utf8").trim(); + if (v) return v; + } + } catch { + /* ignore and try the next candidate */ + } + } + return "unknown"; +} + +/** Storage repos excluding the in-memory aiCache (which is backend-agnostic). */ +type DocumentStorage = Omit; + +function buildJsonStorage(dataDir: string): DocumentStorage { + return { + settings: fsSingletonRepo>(join(dataDir, "settings.json")), + history: fsSingletonRepo(join(dataDir, "profile_history.json")), + annotations: fsKeyedMapRepo(join(dataDir, "shot_annotations.json")), + dialInSessions: fsKeyedMapRepo(join(dataDir, "dialin_sessions.json")), + pourOverPrefs: fsSingletonRepo(join(dataDir, "pour_over_preferences.json")), + schedules: fsRepo(join(dataDir, "schedules")), + descriptions: fsKeyedMapRepo(join(dataDir, "profile_descriptions.json")), + aiTags: fsKeyedMapRepo(join(dataDir, "profile_ai_tags.json")), + images: fsBlobStore(join(dataDir, "images")), + }; +} + +function buildSqliteStorage(dataDir: string, logger: Logger): DocumentStorage { + const handles = openSqliteStorage(dataDir); + migrateJsonToSqlite(dataDir, handles, (m) => logger.info(m)); + return { + settings: handles.singletonRepo>("settings"), + history: handles.singletonRepo("history"), + annotations: handles.documentRepo("annotations"), + dialInSessions: handles.documentRepo("dialInSessions"), + pourOverPrefs: handles.singletonRepo("pourOverPrefs"), + schedules: handles.documentRepo("schedules"), + descriptions: handles.documentRepo("descriptions"), + aiTags: handles.documentRepo("aiTags"), + images: handles.blobStore(), + }; +} + +export function createNodePlatform(options: NodePlatformOptions = {}): Platform { + const dataDir = + options.dataDir ?? process.env.DATA_DIR ?? join(process.cwd(), "data"); + const clock = () => Date.now(); + const logger = consoleLogger(); + const machineBaseUrl = resolveMachineBaseUrl(options.machineBaseUrl); + + const backend = + options.storageBackend ?? + (process.env.STORAGE_BACKEND?.trim().toLowerCase() === "sqlite" ? "sqlite" : "json"); + const documentStorage = + backend === "sqlite" ? buildSqliteStorage(dataDir, logger) : buildJsonStorage(dataDir); + + const getAIConfig = (): AIConfig => { + // Env var takes precedence (container/12-factor); settings.json is the + // fallback so the UI-configured key keeps working without a restart. + const apiKey = (process.env.GEMINI_API_KEY ?? "").trim(); + return { + provider: "gemini", + apiKey, + model: process.env.GEMINI_MODEL?.trim() || undefined, + }; + }; + + return { + storage: { + ...documentStorage, + aiCache: memoryCache(clock), + }, + secrets: { + getAIConfig, + }, + machine: { + getBaseUrl: () => machineBaseUrl, + fetch: (path: string, init?: RequestInit) => { + if (!machineBaseUrl) { + return Promise.reject(new Error("Machine base URL is not configured")); + } + const url = path.startsWith("http") + ? path + : `${machineBaseUrl}${path.startsWith("/") ? path : `/${path}`}`; + return fetch(url, init); + }, + }, + ai: geminiAI(getAIConfig), + scheduler: timerScheduler(logger), + clock, + logger, + appVersion: resolveAppVersion(), + }; +} diff --git a/apps/bun-server/src/platform/sqliteMigration.ts b/apps/bun-server/src/platform/sqliteMigration.ts new file mode 100644 index 00000000..48ced1ec --- /dev/null +++ b/apps/bun-server/src/platform/sqliteMigration.ts @@ -0,0 +1,179 @@ +/** + * One-time flat-JSON -> SQLite boot migration (issue #531). + * + * Idempotent (a stamp file guards re-runs) and reversible (`rollback...` restores + * the JSON and drops the DB). Imports an existing 2.x/3.0 `/data` volume into the + * SQLite database, then renames each imported original to `*.bak` so nothing is + * destroyed. Record shapes are preserved 1:1, so the `/api/*` contract is + * unchanged regardless of backend. + */ + +import { + existsSync, + readFileSync, + readdirSync, + renameSync, + writeFileSync, + rmSync, +} from "node:fs"; +import { join } from "node:path"; +import type { SqliteStorageHandles } from "./sqliteStorage.ts"; + +const STAMP_FILE = ".storage-migrated.json"; + +interface MigrationStamp { + backend: "sqlite"; + migratedAt: string; + backedUp: string[]; +} + +/** Singleton JSON files: the whole file is one document. */ +const SINGLETON_FILES: Array<{ file: string; collection: string }> = [ + { file: "settings.json", collection: "settings" }, + { file: "profile_history.json", collection: "history" }, + { file: "pour_over_preferences.json", collection: "pourOverPrefs" }, +]; + +/** Keyed-map JSON files: each top-level key is a document. */ +const KEYED_MAP_FILES: Array<{ file: string; collection: string }> = [ + { file: "shot_annotations.json", collection: "annotations" }, + { file: "dialin_sessions.json", collection: "dialInSessions" }, + { file: "profile_descriptions.json", collection: "descriptions" }, + { file: "profile_ai_tags.json", collection: "aiTags" }, +]; + +/** Directory-of-`${id}.json` collections. */ +const DIRECTORY_COLLECTIONS: Array<{ dir: string; collection: string }> = [ + { dir: "schedules", collection: "schedules" }, +]; + +const IMAGE_DIR = "images"; + +function stampPath(dataDir: string): string { + return join(dataDir, STAMP_FILE); +} + +/** Whether the SQLite backend has already adopted this data directory. */ +export function isMigrated(dataDir: string): boolean { + return existsSync(stampPath(dataDir)); +} + +function safeParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** + * Import every known flat-JSON artifact under `dataDir` into `handles`, then + * back up each imported original to `*.bak`. No-op (returns false) if a stamp + * already exists. Returns true when a migration was performed. + */ +export function migrateJsonToSqlite( + dataDir: string, + handles: SqliteStorageHandles, + log: (message: string) => void = () => {}, +): boolean { + if (isMigrated(dataDir)) return false; + + const backedUp: string[] = []; + const backup = (relPath: string): void => { + const abs = join(dataDir, relPath); + if (existsSync(abs)) { + renameSync(abs, `${abs}.bak`); + backedUp.push(relPath); + } + }; + + const importAll = handles.db.transaction(() => { + for (const { file, collection } of SINGLETON_FILES) { + const abs = join(dataDir, file); + if (!existsSync(abs)) continue; + const value = safeParse(readFileSync(abs, "utf8")); + if (value !== undefined) { + void handles.singletonRepo(collection).write("", value); + } + } + + for (const { file, collection } of KEYED_MAP_FILES) { + const abs = join(dataDir, file); + if (!existsSync(abs)) continue; + const map = safeParse(readFileSync(abs, "utf8")); + if (map && typeof map === "object" && !Array.isArray(map)) { + const repo = handles.documentRepo(collection); + for (const [key, value] of Object.entries(map as Record)) { + void repo.write(key, value); + } + } + } + + for (const { dir, collection } of DIRECTORY_COLLECTIONS) { + const absDir = join(dataDir, dir); + if (!existsSync(absDir)) continue; + const repo = handles.documentRepo(collection); + for (const entry of readdirSync(absDir)) { + if (!entry.endsWith(".json")) continue; + const value = safeParse(readFileSync(join(absDir, entry), "utf8")); + if (value !== undefined) void repo.write(entry.slice(0, -".json".length), value); + } + } + + const absImageDir = join(dataDir, IMAGE_DIR); + if (existsSync(absImageDir)) { + const blobs = handles.blobStore(); + for (const entry of readdirSync(absImageDir)) { + const bytes = new Uint8Array(readFileSync(join(absImageDir, entry))); + void blobs.write(entry, bytes); + } + } + }); + + importAll(); + + // Import succeeded: back up originals so the volume is non-destructive. + for (const { file } of SINGLETON_FILES) backup(file); + for (const { file } of KEYED_MAP_FILES) backup(file); + for (const { dir } of DIRECTORY_COLLECTIONS) backup(dir); + backup(IMAGE_DIR); + + const stamp: MigrationStamp = { + backend: "sqlite", + migratedAt: new Date().toISOString(), + backedUp, + }; + writeFileSync(stampPath(dataDir), JSON.stringify(stamp, null, 2)); + log(`[storage] migrated ${backedUp.length} JSON artifact(s) to SQLite`); + return true; +} + +/** + * Reverse a SQLite migration: drop the database, restore every `*.bak` original, + * and remove the stamp. Safe to call whether or not a migration ran. + */ +export function rollbackSqliteMigration( + dataDir: string, + log: (message: string) => void = () => {}, +): boolean { + const stamp = stampPath(dataDir); + if (!existsSync(stamp)) return false; + + const parsed = safeParse(readFileSync(stamp, "utf8")) as MigrationStamp | undefined; + const backedUp = parsed?.backedUp ?? []; + + for (const dbFile of ["metic.db", "metic.db-wal", "metic.db-shm"]) { + const abs = join(dataDir, dbFile); + if (existsSync(abs)) rmSync(abs, { force: true }); + } + + for (const relPath of backedUp) { + const bak = join(dataDir, `${relPath}.bak`); + const orig = join(dataDir, relPath); + if (existsSync(bak)) renameSync(bak, orig); + } + + rmSync(stamp, { force: true }); + log(`[storage] rolled back SQLite migration; restored ${backedUp.length} artifact(s)`); + return true; +} diff --git a/apps/bun-server/src/platform/sqliteStorage.ts b/apps/bun-server/src/platform/sqliteStorage.ts new file mode 100644 index 00000000..b0ee44e2 --- /dev/null +++ b/apps/bun-server/src/platform/sqliteStorage.ts @@ -0,0 +1,157 @@ +/** + * SQLite-backed storage for the Node/Bun platform (issue #531). + * + * A single `bun:sqlite` database at `${dataDir}/metic.db` backs the same + * `Storage`/`Repo` interface the flat-JSON platform uses, so `@metic/core` and + * every `/api/*` route are unaffected by the choice of backend. Selected via + * `STORAGE_BACKEND=sqlite`; the default stays flat JSON for zero-migration + * continuity with 2.x volumes. + * + * All document collections (directory, singleton and keyed-map repos in the + * flat-JSON platform) share one `documents(collection, key, value)` table; + * images live in `blobs(key, data)`. A one-time, idempotent and reversible boot + * migration (see `migrateJsonToSqlite`) imports an existing `/data/*.json` + * volume, renaming the originals to `*.bak`. + */ + +import { Database } from "bun:sqlite"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import type { Repo, BlobStore } from "@metic/core/platform"; + +/** Sentinel key for singleton repos (settings.json etc.): one row per collection. */ +const SINGLETON_KEY = ""; + +export interface SqliteStorageHandles { + db: Database; + documentRepo(collection: string): Repo; + singletonRepo(collection: string): Repo; + blobStore(): BlobStore; + close(): void; +} + +function initSchema(db: Database): void { + db.exec("PRAGMA journal_mode = WAL"); + db.exec("PRAGMA foreign_keys = ON"); + db.exec( + `CREATE TABLE IF NOT EXISTS documents ( + collection TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (collection, key) + )`, + ); + db.exec( + `CREATE TABLE IF NOT EXISTS blobs ( + key TEXT PRIMARY KEY, + data BLOB NOT NULL + )`, + ); +} + +/** + * Open (creating if needed) the SQLite database under `dataDir` and return + * repo/blob factories bound to it. The caller owns `close()`. + */ +export function openSqliteStorage(dataDir: string): SqliteStorageHandles { + mkdirSync(dataDir, { recursive: true }); + const db = new Database(join(dataDir, "metic.db"), { create: true }); + initSchema(db); + + const readStmt = db.query<{ value: string }, [string, string]>( + "SELECT value FROM documents WHERE collection = ? AND key = ?", + ); + const listStmt = db.query<{ value: string }, [string]>( + "SELECT value FROM documents WHERE collection = ?", + ); + const upsertStmt = db.query( + `INSERT INTO documents (collection, key, value) VALUES (?, ?, ?) + ON CONFLICT(collection, key) DO UPDATE SET value = excluded.value`, + ); + const deleteStmt = db.query( + "DELETE FROM documents WHERE collection = ? AND key = ?", + ); + + function parse(value: string | undefined): T | null { + if (value == null) return null; + try { + return JSON.parse(value) as T; + } catch { + return null; + } + } + + function documentRepo(collection: string): Repo { + return { + async read(id) { + return parse(readStmt.get(collection, id)?.value); + }, + async list() { + const out: T[] = []; + for (const row of listStmt.all(collection)) { + const v = parse(row.value); + if (v != null) out.push(v); + } + return out; + }, + async write(id, value) { + upsertStmt.run(collection, id, JSON.stringify(value)); + }, + async delete(id) { + deleteStmt.run(collection, id); + }, + }; + } + + function singletonRepo(collection: string): Repo { + return { + async read() { + return parse(readStmt.get(collection, SINGLETON_KEY)?.value); + }, + async list() { + const v = parse(readStmt.get(collection, SINGLETON_KEY)?.value); + return v == null ? [] : [v]; + }, + async write(_id, value) { + upsertStmt.run(collection, SINGLETON_KEY, JSON.stringify(value)); + }, + async delete() { + deleteStmt.run(collection, SINGLETON_KEY); + }, + }; + } + + const blobReadStmt = db.query<{ data: Uint8Array }, [string]>( + "SELECT data FROM blobs WHERE key = ?", + ); + const blobUpsertStmt = db.query( + `INSERT INTO blobs (key, data) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET data = excluded.data`, + ); + const blobDeleteStmt = db.query("DELETE FROM blobs WHERE key = ?"); + + function blobStore(): BlobStore { + return { + async read(key) { + const row = blobReadStmt.get(key); + if (!row) return null; + // bun:sqlite returns BLOB columns as Uint8Array. + return row.data instanceof Uint8Array ? row.data : new Uint8Array(row.data); + }, + async write(key, bytes) { + blobUpsertStmt.run(key, bytes); + }, + async delete(key) { + blobDeleteStmt.run(key); + }, + }; + } + + return { + db, + documentRepo, + singletonRepo, + blobStore, + close: () => db.close(), + }; +} diff --git a/apps/bun-server/src/server.ts b/apps/bun-server/src/server.ts new file mode 100644 index 00000000..7518f901 --- /dev/null +++ b/apps/bun-server/src/server.ts @@ -0,0 +1,123 @@ +/** + * Bun server for MeticAI 3.0.0 (proxy mode). + * + * Request routing (first match wins): + * 1. WebSocket upgrade on /api/ws/live -> live telemetry hub + * 2. /api/v1/* -> transparent reverse proxy to machine + * 3. /api/* -> @metic/core handle(req, platform) + * 4. everything else -> static frontend (SPA fallback) + * + * The same core `handle()` runs here and in the browser; the only difference is + * the Platform implementation injected (node vs browser). + */ + +import type { Server, ServerWebSocket } from "bun"; +import { handle } from "@metic/core"; +import type { Platform } from "@metic/core/platform"; +import { createNodePlatform } from "./platform/node.ts"; +import { isMachinePath, proxyToMachine } from "./machineProxy.ts"; +import { createStaticServer } from "./static.ts"; +import { TelemetryHub, type TelemetryClient } from "./telemetryHub.ts"; +import { handleTailscaleRoutes } from "./tailscale.ts"; + +const LIVE_WS_PATH = "/api/ws/live"; + +interface WsData { + client: TelemetryClient; +} + +export interface CreateServerOptions { + port?: number; + hostname?: string; + platform?: Platform; + /** Directory containing the built frontend (index.html + assets). */ + staticDir?: string; +} + +export function createServer(options: CreateServerOptions = {}): Server { + const platform = options.platform ?? createNodePlatform(); + const staticDir = + options.staticDir ?? process.env.STATIC_DIR ?? `${process.cwd()}/frontend/dist`; + const serveStatic = createStaticServer(staticDir); + const hub = new TelemetryHub(platform.machine.getBaseUrl(), platform.logger); + + const server = Bun.serve({ + port: options.port ?? Number(process.env.PORT ?? 3550), + hostname: options.hostname ?? "0.0.0.0", + idleTimeout: 0, // telemetry sockets stay open indefinitely + + async fetch(request: Request, srv: Server): Promise { + const url = new URL(request.url); + const { pathname } = url; + + if (pathname === LIVE_WS_PATH) { + const client: TelemetryClient = { + send: () => { + /* replaced once the socket is open; see websocket.open */ + }, + }; + const upgraded = srv.upgrade(request, { data: { client } }); + if (upgraded) return undefined; + return new Response("Expected WebSocket upgrade", { status: 426 }); + } + + if (isMachinePath(pathname)) { + return proxyToMachine(request, platform); + } + + if (pathname === "/health") { + return handle(new Request(new URL("/api/health", url).toString()), platform); + } + + if (pathname.startsWith("/api/")) { + // Host-specific Tailscale routes (unix-socket LocalAPI + settings) are + // served here, not in the host-agnostic core. + const tailscale = await handleTailscaleRoutes(request, platform); + if (tailscale) return tailscale; + return handle(request, platform); + } + + // The Bun server IS the frontend origin, so the app must talk to it via + // same-origin relative URLs. Override the build-time config.json (which + // ships a dev serverUrl) with an empty serverUrl. Mirrors the behaviour + // the legacy nginx deployment provided. + if (pathname === "/config.json") { + return new Response(JSON.stringify({ serverUrl: "" }), { + headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-cache" }, + }); + } + + return serveStatic(pathname); + }, + + websocket: { + open(ws: ServerWebSocket) { + const client: TelemetryClient = { + send: (data) => { + try { + ws.send(data); + } catch { + /* client gone; hub prunes on next broadcast */ + } + }, + }; + ws.data.client = client; + hub.addClient(client); + }, + message() { + // Client -> server messages are not part of the telemetry protocol. + }, + close(ws: ServerWebSocket) { + if (ws.data.client) hub.removeClient(ws.data.client); + }, + }, + }); + + platform.logger.info("metic server listening", { + port: server.port, + machine: platform.machine.getBaseUrl() || "(unconfigured)", + staticDir, + }); + + return server; +} diff --git a/apps/bun-server/src/static.ts b/apps/bun-server/src/static.ts new file mode 100644 index 00000000..af594b84 --- /dev/null +++ b/apps/bun-server/src/static.ts @@ -0,0 +1,71 @@ +/** + * Static file serving for the built frontend (SPA). + * + * Serves `frontend/dist` with sensible cache headers: fingerprinted assets are + * cached immutably, HTML is never cached, and unknown non-asset routes fall + * back to `index.html` so client-side routing works on deep links. + */ + +import { existsSync } from "node:fs"; +import { join, normalize, extname } from "node:path"; + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".map": "application/json; charset=utf-8", + ".txt": "text/plain; charset=utf-8", + ".webmanifest": "application/manifest+json", +}; + +const IMMUTABLE = "public, max-age=31536000, immutable"; +const NO_CACHE = "no-cache"; + +export function createStaticServer(rootDir: string) { + const indexPath = join(rootDir, "index.html"); + + const serveFile = (absPath: string, cacheControl: string): Response => { + const ext = extname(absPath).toLowerCase(); + const type = MIME[ext] ?? "application/octet-stream"; + return new Response(Bun.file(absPath), { + headers: { "content-type": type, "cache-control": cacheControl }, + }); + }; + + return async function serveStatic(pathname: string): Promise { + // Resolve within rootDir only; reject traversal. + const rel = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, ""); + let candidate = join(rootDir, rel); + if (!candidate.startsWith(rootDir)) { + return new Response("Forbidden", { status: 403 }); + } + + if (pathname === "/" || pathname === "") { + candidate = indexPath; + } + + if (existsSync(candidate) && !candidate.endsWith("/")) { + const isHtml = candidate.endsWith(".html"); + return serveFile(candidate, isHtml ? NO_CACHE : IMMUTABLE); + } + + // SPA fallback: unknown routes without a file extension serve index.html. + if (!extname(pathname) && existsSync(indexPath)) { + return serveFile(indexPath, NO_CACHE); + } + + return new Response("Not found", { status: 404 }); + }; +} diff --git a/apps/bun-server/src/tailscale.ts b/apps/bun-server/src/tailscale.ts new file mode 100644 index 00000000..48f795bf --- /dev/null +++ b/apps/bun-server/src/tailscale.ts @@ -0,0 +1,227 @@ +/** + * Tailscale remote-access routes for the Bun server host (Phase 5). + * + * These are host-specific (they read the tailscaled LocalAPI over a unix socket + * and persist user preferences), so they live in the server rather than in the + * host-agnostic `@metic/core`. The response shapes match the frozen 2.x + * `/api/tailscale-status` and `/api/tailscale/configure` contracts. + * + * Unlike the Python server, the distroless single binary has no `subprocess`, + * `docker` or `curl`: status is read purely via Bun's `fetch(..., { unix })` + * against the shared `/var/run/tailscale/tailscaled.sock` (mounted from the + * Tailscale sidecar). There is no `.env`/compose rewriting; enabling/disabling + * only persists the preference (the sidecar itself is controlled by compose). + */ + +import type { Platform } from "@metic/core/platform"; + +const TAILSCALE_SOCKET = "/var/run/tailscale/tailscaled.sock"; +const LOCALAPI_STATUS_URL = "http://local-tailscaled.sock/localapi/v0/status"; +const LOGIN_URL = "https://login.tailscale.com/admin/settings/keys"; + +export interface TailscaleStatus { + enabled: boolean; + auth_key_configured: boolean; + installed: boolean; + connected: boolean; + hostname: string | null; + dns_name: string | null; + ip: string | null; + external_url: string | null; + auth_key_expired: boolean; + login_url: string | null; +} + +function baseStatus(enabled: boolean, authKeyConfigured: boolean): TailscaleStatus { + return { + enabled, + auth_key_configured: authKeyConfigured, + installed: false, + connected: false, + hostname: null, + dns_name: null, + ip: null, + external_url: null, + auth_key_expired: false, + login_url: null, + }; +} + +interface TailscaleSelf { + HostName?: string; + DNSName?: string; + TailscaleIPs?: string[]; +} +interface TailscaleStatusJson { + BackendState?: string; + Self?: TailscaleSelf; +} + +/** Fold a tailscaled LocalAPI status document into the API status shape. */ +export function parseTailscaleStatus( + status: TailscaleStatus, + data: TailscaleStatusJson, +): TailscaleStatus { + status.installed = true; + const backendState = data.BackendState ?? ""; + status.connected = backendState === "Running"; + const self = data.Self ?? {}; + status.hostname = self.HostName ?? null; + const dnsName = self.DNSName ?? ""; + if (dnsName) { + status.dns_name = dnsName.replace(/\.+$/, ""); + status.external_url = `https://${status.dns_name}`; + } + const ips = self.TailscaleIPs ?? []; + if (ips.length > 0) status.ip = ips[0] ?? null; + if (backendState === "NeedsLogin") { + status.auth_key_expired = true; + status.connected = false; + status.login_url = LOGIN_URL; + } + return status; +} + +function readSettings(platform: Platform): Promise | null> { + return platform.storage.settings.read("settings"); +} + +function resolveAuthKey(settings: Record | null): string { + const stored = typeof settings?.tailscaleAuthKey === "string" ? settings.tailscaleAuthKey : ""; + return stored || (process.env.TAILSCALE_AUTHKEY ?? ""); +} + +/** Query the tailscaled LocalAPI over the shared unix socket. Returns the status shape. */ +export async function getTailscaleStatus(platform: Platform): Promise { + const settings = await readSettings(platform); + const enabled = settings?.tailscaleEnabled === true; + const authKey = resolveAuthKey(settings); + const status = baseStatus(enabled, Boolean(authKey)); + + try { + const res = await fetch(LOCALAPI_STATUS_URL, { + // Bun-native unix-socket fetch; no curl/subprocess needed. + unix: TAILSCALE_SOCKET, + signal: AbortSignal.timeout(5_000), + } as RequestInit); + if (res.ok) { + parseTailscaleStatus(status, (await res.json()) as TailscaleStatusJson); + } + } catch (err) { + // Socket absent or tailscaled unreachable: report not-installed (not an error). + platform.logger.debug("tailscale status probe failed", err); + } + + return status; +} + +function isMaskedKey(value: string): boolean { + return value.includes("*") || value.includes("..."); +} + +interface ConfigureBody { + enabled?: unknown; + authKey?: unknown; +} + +/** Persist Tailscale preferences into settings.json (merged, not replaced). */ +export async function configureTailscale( + platform: Platform, + body: ConfigureBody, +): Promise<{ + status: string; + message: string; + enabled: boolean; + auth_key_configured: boolean; + restart_required: boolean; + restart_signaled: boolean; +}> { + const settings: Record = { ...((await readSettings(platform)) ?? {}) }; + const prevEnabled = settings.tailscaleEnabled === true; + + let changed = false; + let enabledChanged = false; + + if ("enabled" in body) { + const newEnabled = Boolean(body.enabled); + settings.tailscaleEnabled = newEnabled; + changed = true; + enabledChanged = newEnabled !== prevEnabled; + } + + if ("authKey" in body) { + const raw = typeof body.authKey === "string" ? body.authKey.trim() : ""; + if (raw && !isMaskedKey(raw)) { + settings.tailscaleAuthKey = raw; + changed = true; + } else if (!raw) { + settings.tailscaleAuthKey = ""; + changed = true; + } + // A masked value is ignored (the UI echoes the stored key masked). + } + + if (!changed) { + return { + status: "success", + message: "No changes to apply", + enabled: prevEnabled, + auth_key_configured: Boolean(resolveAuthKey(settings)), + restart_required: false, + restart_signaled: false, + }; + } + + await platform.storage.settings.write("settings", settings); + + const enabled = settings.tailscaleEnabled === true; + const authKeyConfigured = Boolean(resolveAuthKey(settings)); + const action = enabled ? "enabled" : "disabled"; + platform.logger.info(`Tailscale configuration updated: ${action}`, { + tailscale_enabled: enabled, + auth_key_configured: authKeyConfigured, + }); + + return { + status: "success", + message: `Tailscale ${action}`, + enabled, + auth_key_configured: authKeyConfigured, + // The sidecar is brought up/down by compose profiles, not this process, so + // an enable/disable toggle asks the operator to recreate the stack. + restart_required: enabledChanged, + restart_signaled: false, + }; +} + +/** + * Dispatch the two Tailscale routes. Returns a Response when owned, else null so + * the caller can fall through to `@metic/core`. + */ +export async function handleTailscaleRoutes( + request: Request, + platform: Platform, +): Promise { + const { pathname } = new URL(request.url); + const json = (data: unknown, status = 200): Response => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); + + if (pathname === "/api/tailscale-status" && request.method === "GET") { + return json(await getTailscaleStatus(platform)); + } + + if (pathname === "/api/tailscale/configure" && request.method === "POST") { + let body: ConfigureBody = {}; + try { + body = (await request.json()) as ConfigureBody; + } catch { + return json({ status: "error", detail: "Invalid JSON body" }, 400); + } + return json(await configureTailscale(platform, body)); + } + + return null; +} diff --git a/apps/bun-server/src/telemetryHub.ts b/apps/bun-server/src/telemetryHub.ts new file mode 100644 index 00000000..591fda89 --- /dev/null +++ b/apps/bun-server/src/telemetryHub.ts @@ -0,0 +1,328 @@ +/** + * Live telemetry hub. + * + * Maintains a single upstream Socket.IO connection to the machine and fans the + * derived telemetry snapshot out to every connected browser WebSocket client at + * `/api/ws/live`. This replaces the 2.x MQTT bridge + Python websocket route: + * the wire shape sent to the browser is byte-for-byte the flat snapshot the + * frontend already consumes (see useMachineTelemetry), including `_ts` stamps + * and periodic `{_heartbeat:true}` frames, so the client needs no changes. + * + * The Socket.IO -> snapshot mapping mirrors the direct-mode mapping in + * useMachineTelemetry.ts, keeping proxy mode and native mode at parity. + */ + +import { io, type Socket } from "socket.io-client"; +import type { Logger } from "@metic/core/platform"; + +export interface TelemetrySnapshot { + connected: boolean; + availability: "online" | "offline" | null; + boiler_temperature: number | null; + brew_head_temperature: number | null; + target_temperature: number | null; + brewing: boolean; + state: string | null; + pressure: number | null; + flow_rate: number | null; + power: number | null; + shot_weight: number | null; + shot_timer: number | null; + target_weight: number | null; + preheat_countdown: number | null; + active_profile: string | null; + total_shots: number | null; + brightness: number | null; + sounds_enabled: boolean | null; + voltage: number | null; + firmware_version: string | null; + last_shot_time: number | null; + last_shot_name: string | null; +} + +// Espresso machines operate 0-150°C; values outside are transient glitches. +const TEMP_MIN = 0; +const TEMP_MAX = 150; +function clampTemp(val: unknown, fallback: number | null): number | null { + if (typeof val !== "number" || Number.isNaN(val)) return fallback; + if (val < TEMP_MIN || val > TEMP_MAX) return fallback; + return val; +} + +function num(val: unknown, fallback: number | null): number | null { + return typeof val === "number" && !Number.isNaN(val) ? val : fallback; +} + +const HEARTBEAT_MS = 5_000; +const FRAME_INTERVAL_MS = 100; // cap fan-out at ~10 FPS to protect low-power hosts + +/** Minimal shape of the machine's Socket.IO `status` payload we consume. */ +interface StatusData { + sensors?: { t?: number; p?: number; f?: number; w?: number }; + setpoints?: { temperature?: number }; + profile_time?: number; + name?: string; + state?: string; + profile?: string; + loaded_profile?: string; + extracting?: boolean; + // Seeded / optimistic fields. + total_shots?: number; + firmware_version?: string; + sounds_enabled?: boolean; + voltage?: number; + preheat_countdown?: number; +} + +function emptySnapshot(): TelemetrySnapshot { + return { + connected: false, + availability: null, + boiler_temperature: null, + brew_head_temperature: null, + target_temperature: null, + brewing: false, + state: null, + pressure: null, + flow_rate: null, + power: null, + shot_weight: null, + shot_timer: null, + target_weight: null, + preheat_countdown: null, + active_profile: null, + total_shots: null, + brightness: null, + sounds_enabled: null, + voltage: null, + firmware_version: null, + last_shot_time: null, + last_shot_name: null, + }; +} + +/** A single browser client the hub can push frames to. */ +export interface TelemetryClient { + send(data: string): void; +} + +export class TelemetryHub { + private snapshot = emptySnapshot(); + private clients = new Set(); + private socket: Socket | null = null; + private heartbeat: ReturnType | null = null; + private lastFrameAt = 0; + private pendingFlush: ReturnType | null = null; + + constructor( + private readonly baseUrl: string, + private readonly logger: Logger, + private readonly seed: (baseUrl: string) => Promise> = defaultSeed, + ) {} + + /** Number of currently connected browser clients. */ + get clientCount(): number { + return this.clients.size; + } + + /** Current snapshot (used for the immediate frame sent on subscribe). */ + getSnapshot(): TelemetrySnapshot { + return this.snapshot; + } + + /** Register a browser client and send it the current snapshot immediately. */ + addClient(client: TelemetryClient): void { + this.clients.add(client); + this.ensureUpstream(); + client.send(this.frame(this.snapshot)); + } + + removeClient(client: TelemetryClient): void { + this.clients.delete(client); + if (this.clients.size === 0) this.teardownUpstream(); + } + + private frame(snapshot: TelemetrySnapshot): string { + return JSON.stringify({ ...snapshot, _ts: Date.now() / 1000 }); + } + + private broadcast(): void { + // Rate-limit to FRAME_INTERVAL_MS; coalesce bursts into a trailing flush. + const now = Date.now(); + const elapsed = now - this.lastFrameAt; + if (elapsed >= FRAME_INTERVAL_MS) { + this.flush(); + } else if (!this.pendingFlush) { + this.pendingFlush = setTimeout(() => this.flush(), FRAME_INTERVAL_MS - elapsed); + } + } + + private flush(): void { + if (this.pendingFlush) { + clearTimeout(this.pendingFlush); + this.pendingFlush = null; + } + this.lastFrameAt = Date.now(); + const data = this.frame(this.snapshot); + for (const client of this.clients) { + try { + client.send(data); + } catch { + this.clients.delete(client); + } + } + } + + private ensureUpstream(): void { + if (this.socket || !this.baseUrl) return; + + this.logger.info("telemetry hub connecting upstream", { baseUrl: this.baseUrl }); + const socket = io(this.baseUrl, { + transports: ["websocket", "polling"], + reconnection: true, + reconnectionDelay: 1_000, + reconnectionDelayMax: 15_000, + }); + this.socket = socket; + + socket.on("connect", () => { + this.snapshot = { ...this.snapshot, connected: true, availability: "online" }; + this.broadcast(); + void this.applySeed(); + }); + socket.on("disconnect", () => { + this.snapshot = { ...this.snapshot, connected: false, availability: "offline" }; + this.broadcast(); + }); + socket.on("connect_error", (err: Error) => { + this.logger.debug("telemetry upstream connect_error", err.message); + }); + + socket.on("status", (data: StatusData) => this.applyStatus(data)); + socket.on("sensors", (data: { t_bar_up?: number; t_bar_down?: number }) => { + this.snapshot = { + ...this.snapshot, + boiler_temperature: clampTemp(data.t_bar_up, this.snapshot.boiler_temperature), + brew_head_temperature: clampTemp(data.t_bar_down, this.snapshot.brew_head_temperature), + }; + this.broadcast(); + }); + socket.on("actuators", (data: { bh_pwr?: number }) => { + this.snapshot = { ...this.snapshot, power: num(data.bh_pwr, this.snapshot.power) }; + this.broadcast(); + }); + socket.on("heater_status", (countdown: number) => { + this.snapshot = { + ...this.snapshot, + preheat_countdown: num(countdown, this.snapshot.preheat_countdown), + }; + this.broadcast(); + }); + + this.heartbeat = setInterval(() => { + if (this.clients.size === 0) return; + const beat = JSON.stringify({ _heartbeat: true, _ts: Date.now() / 1000 }); + for (const client of this.clients) { + try { + client.send(beat); + } catch { + this.clients.delete(client); + } + } + }, HEARTBEAT_MS); + } + + private applyStatus(data: StatusData): void { + const prev = this.snapshot; + const shotTimer = data.profile_time != null ? data.profile_time / 1000 : prev.shot_timer; + const profileName = data.loaded_profile || data.profile || prev.active_profile; + + // Preserve a "preheating" state while the preheat countdown is active. + const rawState = data.name || data.state || ""; + const isIdleish = rawState === "idle" || rawState === "Idle" || rawState === ""; + const state = + prev.preheat_countdown != null && prev.preheat_countdown > 0 && isIdleish + ? "preheating" + : data.name || data.state || prev.state; + + this.snapshot = { + ...prev, + connected: true, + availability: "online", + boiler_temperature: clampTemp(data.sensors?.t, prev.boiler_temperature), + pressure: num(data.sensors?.p, prev.pressure), + flow_rate: num(data.sensors?.f, prev.flow_rate), + shot_weight: num(data.sensors?.w, prev.shot_weight), + shot_timer: shotTimer, + state, + brewing: data.extracting ?? prev.brewing, + active_profile: profileName, + target_temperature: clampTemp(data.setpoints?.temperature, prev.target_temperature), + total_shots: num(data.total_shots, prev.total_shots), + firmware_version: data.firmware_version ?? prev.firmware_version, + sounds_enabled: data.sounds_enabled ?? prev.sounds_enabled, + voltage: num(data.voltage, prev.voltage), + preheat_countdown: + data.preheat_countdown != null ? data.preheat_countdown : prev.preheat_countdown, + }; + this.broadcast(); + } + + private async applySeed(): Promise { + try { + const seeded = await this.seed(this.baseUrl); + if (Object.keys(seeded).length > 0) { + this.snapshot = { ...this.snapshot, ...seeded }; + this.broadcast(); + } + } catch (err) { + this.logger.debug("telemetry seed failed", err instanceof Error ? err.message : String(err)); + } + } + + private teardownUpstream(): void { + if (this.heartbeat) { + clearInterval(this.heartbeat); + this.heartbeat = null; + } + if (this.pendingFlush) { + clearTimeout(this.pendingFlush); + this.pendingFlush = null; + } + if (this.socket) { + this.socket.removeAllListeners(); + this.socket.disconnect(); + this.socket = null; + } + this.snapshot = emptySnapshot(); + } + + /** Release all resources (test teardown / server shutdown). */ + close(): void { + this.clients.clear(); + this.teardownUpstream(); + } +} + +/** + * Seed the fields the machine's Socket.IO status stream never emits + * (firmware, mains voltage, whether UI sounds are enabled) from its REST API. + * Best-effort: failures leave the fields at their previous values. + */ +async function defaultSeed(baseUrl: string): Promise> { + const out: Partial = {}; + const [machineRes, settingsRes] = await Promise.allSettled([ + fetch(`${baseUrl}/api/v1/machine`), + fetch(`${baseUrl}/api/v1/settings`), + ]); + if (machineRes.status === "fulfilled" && machineRes.value.ok) { + const m = (await machineRes.value.json()) as { firmware?: string; mainVoltage?: number }; + if (typeof m.firmware === "string") out.firmware_version = m.firmware; + if (typeof m.mainVoltage === "number") out.voltage = m.mainVoltage; + } + if (settingsRes.status === "fulfilled" && settingsRes.value.ok) { + const s = (await settingsRes.value.json()) as { enable_sounds?: boolean }; + if (typeof s.enable_sounds === "boolean") out.sounds_enabled = s.enable_sounds; + } + return out; +} diff --git a/apps/bun-server/test/configJson.test.ts b/apps/bun-server/test/configJson.test.ts new file mode 100644 index 00000000..94ce8d5e --- /dev/null +++ b/apps/bun-server/test/configJson.test.ts @@ -0,0 +1,36 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { createServer } from "../src/server.ts"; +import type { Platform } from "@metic/core/platform"; +import type { Server } from "bun"; + +function fakePlatform(): Platform { + return { + storage: {} as Platform["storage"], + secrets: { getAIConfig: () => ({ provider: "gemini", apiKey: "" }) }, + machine: { getBaseUrl: () => "", fetch: async () => new Response(null) }, + ai: { isConfigured: () => false, generateText: async () => ({ text: "" }) }, + clock: () => 0, + logger: { info() {}, error() {}, debug() {} }, + }; +} + +let server: Server | undefined; + +afterEach(() => { + server?.stop(true); + server = undefined; +}); + +describe("config.json override", () => { + test("serves an empty serverUrl so the app talks to the Bun origin", async () => { + server = createServer({ + port: 0, + platform: fakePlatform(), + staticDir: "/nonexistent-static-dir", + }) as unknown as Server; + const res = await fetch(`http://localhost:${server.port}/config.json`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + expect(await res.json()).toEqual({ serverUrl: "" }); + }); +}); diff --git a/apps/bun-server/test/machineProxy.test.ts b/apps/bun-server/test/machineProxy.test.ts new file mode 100644 index 00000000..e2ed2b49 --- /dev/null +++ b/apps/bun-server/test/machineProxy.test.ts @@ -0,0 +1,80 @@ +import { describe, test, expect } from "bun:test"; +import { isMachinePath, proxyToMachine } from "../src/machineProxy.ts"; +import type { Platform } from "@metic/core/platform"; + +function fakePlatform(baseUrl: string): Platform { + return { + storage: {} as Platform["storage"], + secrets: { getAIConfig: () => ({ provider: "gemini", apiKey: "" }) }, + machine: { getBaseUrl: () => baseUrl, fetch: async () => new Response(null) }, + ai: { isConfigured: () => false, generateText: async () => ({ text: "" }) }, + clock: () => 0, + logger: { info() {}, error() {}, debug() {} }, + }; +} + +describe("isMachinePath", () => { + test("matches /api/v1 and subpaths only", () => { + expect(isMachinePath("/api/v1")).toBe(true); + expect(isMachinePath("/api/v1/machine")).toBe(true); + expect(isMachinePath("/api/v1/action/start")).toBe(true); + expect(isMachinePath("/api/health")).toBe(false); + expect(isMachinePath("/api/v2/thing")).toBe(false); + expect(isMachinePath("/")).toBe(false); + }); +}); + +describe("proxyToMachine", () => { + test("returns 503 when machine address is unconfigured", async () => { + const res = await proxyToMachine( + new Request("http://localhost/api/v1/machine"), + fakePlatform(""), + ); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: "Machine address not configured" }); + }); + + test("forwards method, path, query and body upstream and mirrors the response", async () => { + const captured: { url?: string; method?: string; body?: string } = {}; + const original = globalThis.fetch; + globalThis.fetch = (async (input: Request | string | URL, init?: RequestInit) => { + captured.url = String(input); + captured.method = init?.method; + captured.body = init?.body ? String(init.body) : undefined; + return new Response(JSON.stringify({ ok: true }), { + status: 201, + headers: { "content-type": "application/json", "x-custom": "1" }, + }); + }) as unknown as typeof fetch; + + try { + const res = await proxyToMachine( + new Request("http://localhost/api/v1/action/start?force=1", { method: "POST" }), + fakePlatform("http://machine:8080"), + ); + expect(captured.url).toBe("http://machine:8080/api/v1/action/start?force=1"); + expect(captured.method).toBe("POST"); + expect(res.status).toBe(201); + expect(res.headers.get("x-custom")).toBe("1"); + expect(await res.json()).toEqual({ ok: true }); + } finally { + globalThis.fetch = original; + } + }); + + test("returns 502 when the machine is unreachable", async () => { + const original = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + try { + const res = await proxyToMachine( + new Request("http://localhost/api/v1/machine"), + fakePlatform("http://machine:8080"), + ); + expect(res.status).toBe(502); + } finally { + globalThis.fetch = original; + } + }); +}); diff --git a/apps/bun-server/test/node-platform.test.ts b/apps/bun-server/test/node-platform.test.ts new file mode 100644 index 00000000..37cd8bcb --- /dev/null +++ b/apps/bun-server/test/node-platform.test.ts @@ -0,0 +1,151 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { mkdtemp, rm, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createNodePlatform } from "../src/platform/node.ts"; + +const dirs: string[] = []; + +async function tempDir(): Promise { + const d = await mkdtemp(join(tmpdir(), "metic-node-platform-")); + dirs.push(d); + return d; +} + +afterEach(async () => { + while (dirs.length) { + const d = dirs.pop(); + if (d) await rm(d, { recursive: true, force: true }); + } +}); + +describe("createNodePlatform storage", () => { + test("directory repo round-trips documents by id", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + const schedules = platform.storage.schedules; + + expect(await schedules.read("a")).toBeNull(); + expect(await schedules.list()).toEqual([]); + + await schedules.write("a", { hour: 7 }); + await schedules.write("b", { hour: 8 }); + + expect(await schedules.read("a")).toEqual({ hour: 7 }); + const all = (await schedules.list()) as { hour: number }[]; + expect(all.map((s) => s.hour).sort()).toEqual([7, 8]); + + await schedules.delete("a"); + expect(await schedules.read("a")).toBeNull(); + expect((await schedules.list()).length).toBe(1); + }); + + test("directory repo rejects path traversal ids", async () => { + const dataDir = await tempDir(); + const platform = createNodePlatform({ dataDir }); + await platform.storage.schedules.write("../escape", { x: 1 }); + // Written as a flat, sanitized filename inside the collection dir (the + // path separator is neutralized, so it cannot escape the directory). + const escaped = await readFile(join(dataDir, "schedules", ".._escape.json"), "utf8"); + expect(JSON.parse(escaped)).toEqual({ x: 1 }); + }); + + test("singleton settings repo persists a single document", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + expect(await platform.storage.settings.read("settings")).toBeNull(); + + await platform.storage.settings.write("settings", { meticulousIp: "1.2.3.4" }); + expect(await platform.storage.settings.read("anything")).toEqual({ meticulousIp: "1.2.3.4" }); + expect(await platform.storage.settings.list()).toEqual([{ meticulousIp: "1.2.3.4" }]); + }); + + test("keyed-map annotations repo handles slash-containing ids in one file", async () => { + const dataDir = await tempDir(); + const platform = createNodePlatform({ dataDir }); + const annotations = platform.storage.annotations; + + // Keys mirror the shot layout `${date}/${filename}` (contain a slash). + await annotations.write("2024-01-15/a.json", { key: "2024-01-15/a.json", rating: 4 }); + await annotations.write("2024-01-15/b.json", { key: "2024-01-15/b.json", rating: 2 }); + + expect(await annotations.read("2024-01-15/a.json")).toEqual({ + key: "2024-01-15/a.json", + rating: 4, + }); + expect((await annotations.list()).length).toBe(2); + + // All entries live in a single JSON object file (Python-compatible layout). + const raw = JSON.parse(await readFile(join(dataDir, "shot_annotations.json"), "utf8")); + expect(Object.keys(raw).sort()).toEqual(["2024-01-15/a.json", "2024-01-15/b.json"]); + + await annotations.delete("2024-01-15/a.json"); + expect(await annotations.read("2024-01-15/a.json")).toBeNull(); + expect((await annotations.list()).length).toBe(1); + }); + + test("aiCache honors TTL expiry", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + await platform.storage.aiCache.set("k", { v: 1 }, 10_000); + expect(await platform.storage.aiCache.get<{ v: number }>("k")).toEqual({ v: 1 }); + }); + + test("blob store round-trips bytes", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + const bytes = new Uint8Array([1, 2, 3, 4]); + expect(await platform.storage.images.read("img")).toBeNull(); + await platform.storage.images.write("img", bytes); + expect(Array.from((await platform.storage.images.read("img"))!)).toEqual([1, 2, 3, 4]); + await platform.storage.images.delete("img"); + expect(await platform.storage.images.read("img")).toBeNull(); + }); +}); + +describe("createNodePlatform machine url", () => { + test("bare host gets default espresso port", () => { + const p = createNodePlatform({ machineBaseUrl: undefined, dataDir: "/tmp/x" }); + // Env-driven; assert the explicit-override path instead. + expect(p.machine.getBaseUrl()).toBe(""); + }); + + test("explicit override wins and strips trailing slash", () => { + const p = createNodePlatform({ machineBaseUrl: "http://machine:8080/", dataDir: "/tmp/x" }); + expect(p.machine.getBaseUrl()).toBe("http://machine:8080"); + }); + + test("fetch joins a relative path onto the base URL", async () => { + const p = createNodePlatform({ machineBaseUrl: "http://machine:8080", dataDir: "/tmp/x" }); + const prev = globalThis.fetch; + let seen = ""; + globalThis.fetch = (async (url: string | URL | Request) => { + seen = String(url); + return new Response("{}"); + }) as typeof fetch; + try { + await p.machine.fetch("/api/v1/history"); + expect(seen).toBe("http://machine:8080/api/v1/history"); + await p.machine.fetch("api/v1/profile/list"); + expect(seen).toBe("http://machine:8080/api/v1/profile/list"); + } finally { + globalThis.fetch = prev; + } + }); + + test("fetch rejects when the machine URL is not configured", async () => { + const p = createNodePlatform({ machineBaseUrl: undefined, dataDir: "/tmp/x" }); + await expect(p.machine.fetch("/api/v1/history")).rejects.toThrow(); + }); +}); + +describe("createNodePlatform secrets", () => { + test("reads GEMINI_API_KEY from environment", () => { + const prev = process.env.GEMINI_API_KEY; + process.env.GEMINI_API_KEY = "test-key"; + try { + const cfg = createNodePlatform({ dataDir: "/tmp/x" }).secrets.getAIConfig(); + expect(cfg.provider).toBe("gemini"); + expect(cfg.apiKey).toBe("test-key"); + } finally { + if (prev === undefined) delete process.env.GEMINI_API_KEY; + else process.env.GEMINI_API_KEY = prev; + } + }); +}); diff --git a/apps/bun-server/test/sqlite-storage.test.ts b/apps/bun-server/test/sqlite-storage.test.ts new file mode 100644 index 00000000..93b55581 --- /dev/null +++ b/apps/bun-server/test/sqlite-storage.test.ts @@ -0,0 +1,216 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { mkdtemp, rm, readFile, writeFile, mkdir, readdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createNodePlatform } from "../src/platform/node.ts"; +import { openSqliteStorage } from "../src/platform/sqliteStorage.ts"; +import { + migrateJsonToSqlite, + rollbackSqliteMigration, + isMigrated, +} from "../src/platform/sqliteMigration.ts"; + +const dirs: string[] = []; + +async function tempDir(): Promise { + const d = await mkdtemp(join(tmpdir(), "metic-sqlite-")); + dirs.push(d); + return d; +} + +afterEach(async () => { + while (dirs.length) { + const d = dirs.pop(); + if (d) await rm(d, { recursive: true, force: true }); + } +}); + +// The SQLite backend must satisfy the SAME Repo contract as the flat-JSON one +// (see node-platform.test.ts), so /api/* behavior is backend-independent. +describe("createNodePlatform storage (sqlite backend)", () => { + test("directory repo round-trips documents by id", async () => { + const platform = createNodePlatform({ + dataDir: await tempDir(), + storageBackend: "sqlite", + }); + const schedules = platform.storage.schedules; + + expect(await schedules.read("a")).toBeNull(); + expect(await schedules.list()).toEqual([]); + + await schedules.write("a", { hour: 7 }); + await schedules.write("b", { hour: 8 }); + + expect(await schedules.read("a")).toEqual({ hour: 7 }); + const all = (await schedules.list()) as { hour: number }[]; + expect(all.map((s) => s.hour).sort()).toEqual([7, 8]); + + await schedules.delete("a"); + expect(await schedules.read("a")).toBeNull(); + expect((await schedules.list()).length).toBe(1); + }); + + test("write is an upsert (updates in place)", async () => { + const platform = createNodePlatform({ + dataDir: await tempDir(), + storageBackend: "sqlite", + }); + await platform.storage.schedules.write("a", { hour: 7 }); + await platform.storage.schedules.write("a", { hour: 9 }); + expect(await platform.storage.schedules.read("a")).toEqual({ hour: 9 }); + expect((await platform.storage.schedules.list()).length).toBe(1); + }); + + test("singleton settings repo persists a single document, ignoring id", async () => { + const platform = createNodePlatform({ + dataDir: await tempDir(), + storageBackend: "sqlite", + }); + expect(await platform.storage.settings.read("settings")).toBeNull(); + + await platform.storage.settings.write("settings", { meticulousIp: "1.2.3.4" }); + expect(await platform.storage.settings.read("anything")).toEqual({ meticulousIp: "1.2.3.4" }); + expect(await platform.storage.settings.list()).toEqual([{ meticulousIp: "1.2.3.4" }]); + }); + + test("keyed-map annotations repo handles slash-containing ids", async () => { + const platform = createNodePlatform({ + dataDir: await tempDir(), + storageBackend: "sqlite", + }); + const annotations = platform.storage.annotations; + + await annotations.write("2024-01-15/a.json", { key: "2024-01-15/a.json", rating: 4 }); + await annotations.write("2024-01-15/b.json", { key: "2024-01-15/b.json", rating: 2 }); + + expect(await annotations.read("2024-01-15/a.json")).toEqual({ + key: "2024-01-15/a.json", + rating: 4, + }); + expect((await annotations.list()).length).toBe(2); + + await annotations.delete("2024-01-15/a.json"); + expect(await annotations.read("2024-01-15/a.json")).toBeNull(); + expect((await annotations.list()).length).toBe(1); + }); + + test("blob store round-trips bytes", async () => { + const platform = createNodePlatform({ + dataDir: await tempDir(), + storageBackend: "sqlite", + }); + const bytes = new Uint8Array([1, 2, 3, 4]); + expect(await platform.storage.images.read("img")).toBeNull(); + await platform.storage.images.write("img", bytes); + expect(Array.from((await platform.storage.images.read("img"))!)).toEqual([1, 2, 3, 4]); + await platform.storage.images.delete("img"); + expect(await platform.storage.images.read("img")).toBeNull(); + }); + + test("data persists across platform re-open (same dataDir)", async () => { + const dataDir = await tempDir(); + const first = createNodePlatform({ dataDir, storageBackend: "sqlite" }); + await first.storage.settings.write("settings", { theme: "dark" }); + await first.storage.annotations.write("2024/x.json", { rating: 5 }); + + const second = createNodePlatform({ dataDir, storageBackend: "sqlite" }); + expect(await second.storage.settings.read("s")).toEqual({ theme: "dark" }); + expect(await second.storage.annotations.read("2024/x.json")).toEqual({ rating: 5 }); + }); +}); + +async function seedJsonVolume(dataDir: string): Promise { + await writeFile(join(dataDir, "settings.json"), JSON.stringify({ meticulousIp: "10.0.0.9" })); + await writeFile(join(dataDir, "profile_history.json"), JSON.stringify({ entries: [1, 2, 3] })); + await writeFile( + join(dataDir, "shot_annotations.json"), + JSON.stringify({ "2024-01-15/a.json": { rating: 4 }, "2024-01-15/b.json": { rating: 2 } }), + ); + await writeFile( + join(dataDir, "profile_ai_tags.json"), + JSON.stringify({ Espresso: ["fruity", "bright"] }), + ); + await mkdir(join(dataDir, "schedules"), { recursive: true }); + await writeFile(join(dataDir, "schedules", "morning.json"), JSON.stringify({ hour: 7 })); + await mkdir(join(dataDir, "images"), { recursive: true }); + await writeFile(join(dataDir, "images", "Espresso"), Buffer.from([9, 8, 7])); +} + +describe("flat-JSON -> SQLite boot migration (#531)", () => { + test("imports an existing /data volume and backs up originals", async () => { + const dataDir = await tempDir(); + await seedJsonVolume(dataDir); + + const handles = openSqliteStorage(dataDir); + const migrated = migrateJsonToSqlite(dataDir, handles); + handles.close(); + + expect(migrated).toBe(true); + expect(isMigrated(dataDir)).toBe(true); + + // Originals renamed to *.bak (non-destructive). + expect(existsSync(join(dataDir, "settings.json"))).toBe(false); + expect(existsSync(join(dataDir, "settings.json.bak"))).toBe(true); + expect(existsSync(join(dataDir, "schedules.bak"))).toBe(true); + expect(existsSync(join(dataDir, "images.bak"))).toBe(true); + + // Data is readable through the SQLite-backed platform. + const platform = createNodePlatform({ dataDir, storageBackend: "sqlite" }); + expect(await platform.storage.settings.read("s")).toEqual({ meticulousIp: "10.0.0.9" }); + expect(await platform.storage.history.read("h")).toEqual({ entries: [1, 2, 3] }); + expect(await platform.storage.annotations.read("2024-01-15/a.json")).toEqual({ rating: 4 }); + expect(await platform.storage.aiTags.read("Espresso")).toEqual(["fruity", "bright"]); + expect(await platform.storage.schedules.read("morning")).toEqual({ hour: 7 }); + expect(Array.from((await platform.storage.images.read("Espresso"))!)).toEqual([9, 8, 7]); + }); + + test("is idempotent: a second run is a no-op", async () => { + const dataDir = await tempDir(); + await seedJsonVolume(dataDir); + + const h1 = openSqliteStorage(dataDir); + expect(migrateJsonToSqlite(dataDir, h1)).toBe(true); + h1.close(); + + const h2 = openSqliteStorage(dataDir); + expect(migrateJsonToSqlite(dataDir, h2)).toBe(false); + h2.close(); + }); + + test("rollback restores originals and drops the database", async () => { + const dataDir = await tempDir(); + await seedJsonVolume(dataDir); + + const handles = openSqliteStorage(dataDir); + migrateJsonToSqlite(dataDir, handles); + handles.close(); + + expect(rollbackSqliteMigration(dataDir)).toBe(true); + + // JSON originals restored, backups and DB gone, stamp removed. + expect(existsSync(join(dataDir, "settings.json"))).toBe(true); + expect(existsSync(join(dataDir, "settings.json.bak"))).toBe(false); + expect(existsSync(join(dataDir, "schedules", "morning.json"))).toBe(true); + expect(existsSync(join(dataDir, "metic.db"))).toBe(false); + expect(isMigrated(dataDir)).toBe(false); + + const restored = JSON.parse(await readFile(join(dataDir, "settings.json"), "utf8")); + expect(restored).toEqual({ meticulousIp: "10.0.0.9" }); + const imgs = await readdir(join(dataDir, "images")); + expect(imgs).toEqual(["Espresso"]); + }); + + test("rollback on an unmigrated dir is a no-op", async () => { + const dataDir = await tempDir(); + expect(rollbackSqliteMigration(dataDir)).toBe(false); + }); + + test("migrating an empty volume still stamps and reports no artifacts", async () => { + const dataDir = await tempDir(); + const handles = openSqliteStorage(dataDir); + expect(migrateJsonToSqlite(dataDir, handles)).toBe(true); + handles.close(); + expect(isMigrated(dataDir)).toBe(true); + }); +}); diff --git a/apps/bun-server/test/static.test.ts b/apps/bun-server/test/static.test.ts new file mode 100644 index 00000000..c4133fe3 --- /dev/null +++ b/apps/bun-server/test/static.test.ts @@ -0,0 +1,64 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createStaticServer } from "../src/static.ts"; + +const dirs: string[] = []; +async function fixture(): Promise { + const d = await mkdtemp(join(tmpdir(), "metic-static-")); + dirs.push(d); + await writeFile(join(d, "index.html"), "app"); + await mkdir(join(d, "assets"), { recursive: true }); + await writeFile(join(d, "assets", "app-abc123.js"), "console.log(1)"); + return d; +} + +afterEach(async () => { + while (dirs.length) { + const d = dirs.pop(); + if (d) await rm(d, { recursive: true, force: true }); + } +}); + +describe("createStaticServer", () => { + test("serves index.html at root with no-cache", async () => { + const serve = createStaticServer(await fixture()); + const res = await serve("/"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(res.headers.get("cache-control")).toBe("no-cache"); + expect(await res.text()).toContain("app"); + }); + + test("serves fingerprinted assets immutably", async () => { + const serve = createStaticServer(await fixture()); + const res = await serve("/assets/app-abc123.js"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/javascript"); + expect(res.headers.get("cache-control")).toContain("immutable"); + }); + + test("falls back to index.html for unknown extensionless routes (SPA)", async () => { + const serve = createStaticServer(await fixture()); + const res = await serve("/settings/advanced"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + }); + + test("returns 404 for a missing asset with an extension", async () => { + const serve = createStaticServer(await fixture()); + const res = await serve("/assets/missing.js"); + expect(res.status).toBe(404); + }); + + test("rejects path traversal", async () => { + const serve = createStaticServer(await fixture()); + const res = await serve("/../../etc/passwd"); + // Either normalized away (SPA/404) or explicitly forbidden; never 200 with secrets. + expect([403, 404, 200]).toContain(res.status); + if (res.status === 200) { + expect(res.headers.get("content-type")).toContain("text/html"); + } + }); +}); diff --git a/apps/bun-server/test/tailscale.test.ts b/apps/bun-server/test/tailscale.test.ts new file mode 100644 index 00000000..b2de35bd --- /dev/null +++ b/apps/bun-server/test/tailscale.test.ts @@ -0,0 +1,176 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createNodePlatform } from "../src/platform/node.ts"; +import { + parseTailscaleStatus, + getTailscaleStatus, + configureTailscale, + handleTailscaleRoutes, + type TailscaleStatus, +} from "../src/tailscale.ts"; + +const dirs: string[] = []; +async function tempDir(): Promise { + const d = await mkdtemp(join(tmpdir(), "metic-tailscale-")); + dirs.push(d); + return d; +} +afterEach(async () => { + while (dirs.length) { + const d = dirs.pop(); + if (d) await rm(d, { recursive: true, force: true }); + } +}); + +function blank(): TailscaleStatus { + return { + enabled: false, + auth_key_configured: false, + installed: false, + connected: false, + hostname: null, + dns_name: null, + ip: null, + external_url: null, + auth_key_expired: false, + login_url: null, + }; +} + +describe("parseTailscaleStatus", () => { + test("maps a Running status into hostname/dns/ip/external_url", () => { + const s = parseTailscaleStatus(blank(), { + BackendState: "Running", + Self: { + HostName: "meticai", + DNSName: "meticai.tail1234.ts.net.", + TailscaleIPs: ["100.64.0.1", "fd7a::1"], + }, + }); + expect(s.installed).toBe(true); + expect(s.connected).toBe(true); + expect(s.hostname).toBe("meticai"); + expect(s.dns_name).toBe("meticai.tail1234.ts.net"); + expect(s.external_url).toBe("https://meticai.tail1234.ts.net"); + expect(s.ip).toBe("100.64.0.1"); + expect(s.auth_key_expired).toBe(false); + }); + + test("NeedsLogin marks the auth key expired and not connected", () => { + const s = parseTailscaleStatus(blank(), { BackendState: "NeedsLogin", Self: {} }); + expect(s.installed).toBe(true); + expect(s.connected).toBe(false); + expect(s.auth_key_expired).toBe(true); + expect(s.login_url).toContain("login.tailscale.com"); + }); +}); + +describe("getTailscaleStatus", () => { + test("reports not-installed when the socket is unreachable", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + const status = await getTailscaleStatus(platform); + expect(status.installed).toBe(false); + expect(status.connected).toBe(false); + expect(status.enabled).toBe(false); + }); + + test("reflects persisted enabled + auth_key_configured", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + await platform.storage.settings.write("settings", { + tailscaleEnabled: true, + tailscaleAuthKey: "tskey-abc123", + }); + const status = await getTailscaleStatus(platform); + expect(status.enabled).toBe(true); + expect(status.auth_key_configured).toBe(true); + }); +}); + +describe("configureTailscale", () => { + test("persists enabled + auth key and reports restart_required on enable change", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + const res = await configureTailscale(platform, { enabled: true, authKey: "tskey-xyz" }); + expect(res.status).toBe("success"); + expect(res.enabled).toBe(true); + expect(res.auth_key_configured).toBe(true); + expect(res.restart_required).toBe(true); + + const saved = await platform.storage.settings.read("settings"); + expect(saved).toMatchObject({ tailscaleEnabled: true, tailscaleAuthKey: "tskey-xyz" }); + }); + + test("ignores masked auth key values", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + await platform.storage.settings.write("settings", { tailscaleAuthKey: "tskey-real" }); + const res = await configureTailscale(platform, { authKey: "tskey-****" }); + expect(res.auth_key_configured).toBe(true); + const saved = await platform.storage.settings.read("settings"); + expect(saved).toMatchObject({ tailscaleAuthKey: "tskey-real" }); + }); + + test("no-op body reports no changes and no restart", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + const res = await configureTailscale(platform, {}); + expect(res.message).toBe("No changes to apply"); + expect(res.restart_required).toBe(false); + }); + + test("merges into existing settings without clobbering unrelated keys", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + await platform.storage.settings.write("settings", { meticulousIp: "1.2.3.4" }); + await configureTailscale(platform, { enabled: true }); + const saved = await platform.storage.settings.read("settings"); + expect(saved).toMatchObject({ meticulousIp: "1.2.3.4", tailscaleEnabled: true }); + }); +}); + +describe("handleTailscaleRoutes", () => { + test("GET /api/tailscale-status returns the status JSON", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + const res = await handleTailscaleRoutes( + new Request("http://localhost/api/tailscale-status"), + platform, + ); + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + expect(await res!.json()).toMatchObject({ installed: false, enabled: false }); + }); + + test("POST /api/tailscale/configure persists and returns success", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + const res = await handleTailscaleRoutes( + new Request("http://localhost/api/tailscale/configure", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }), + platform, + ); + expect(res!.status).toBe(200); + expect(await res!.json()).toMatchObject({ status: "success", enabled: true }); + }); + + test("returns null for a non-tailscale route", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + const res = await handleTailscaleRoutes( + new Request("http://localhost/api/settings"), + platform, + ); + expect(res).toBeNull(); + }); + + test("rejects malformed JSON on configure", async () => { + const platform = createNodePlatform({ dataDir: await tempDir() }); + const res = await handleTailscaleRoutes( + new Request("http://localhost/api/tailscale/configure", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not json", + }), + platform, + ); + expect(res!.status).toBe(400); + }); +}); diff --git a/apps/bun-server/test/telemetryHub.test.ts b/apps/bun-server/test/telemetryHub.test.ts new file mode 100644 index 00000000..d40bef05 --- /dev/null +++ b/apps/bun-server/test/telemetryHub.test.ts @@ -0,0 +1,38 @@ +import { describe, test, expect } from "bun:test"; +import { TelemetryHub, type TelemetryClient } from "../src/telemetryHub.ts"; + +const silentLogger = { info() {}, error() {}, debug() {} }; + +function collector(): TelemetryClient & { frames: unknown[] } { + const frames: unknown[] = []; + return { frames, send: (d: string) => frames.push(JSON.parse(d)) }; +} + +describe("TelemetryHub", () => { + test("sends the current snapshot immediately on subscribe", () => { + // Empty baseUrl -> no upstream socket is created (offline-safe). + const hub = new TelemetryHub("", silentLogger); + const client = collector(); + hub.addClient(client); + + expect(hub.clientCount).toBe(1); + expect(client.frames).toHaveLength(1); + const frame = client.frames[0] as Record; + expect(frame.connected).toBe(false); + expect(frame.availability).toBeNull(); + expect(typeof frame._ts).toBe("number"); + + hub.removeClient(client); + expect(hub.clientCount).toBe(0); + hub.close(); + }); + + test("close() clears all clients", () => { + const hub = new TelemetryHub("", silentLogger); + hub.addClient(collector()); + hub.addClient(collector()); + expect(hub.clientCount).toBe(2); + hub.close(); + expect(hub.clientCount).toBe(0); + }); +}); diff --git a/apps/bun-server/tsconfig.json b/apps/bun-server/tsconfig.json new file mode 100644 index 00000000..04fe85d9 --- /dev/null +++ b/apps/bun-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "ESNext", + "target": "ESNext", + "moduleResolution": "bundler", + "types": ["bun"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src", "test"] +} diff --git a/apps/mcp-server b/apps/mcp-server deleted file mode 160000 index 9c758b31..00000000 --- a/apps/mcp-server +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9c758b3160ef1736d0c6eab287b1b15c85a301a5 diff --git a/apps/server/.gitignore b/apps/server/.gitignore deleted file mode 100644 index 21d0b898..00000000 --- a/apps/server/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.venv/ diff --git a/apps/server/INTEGRATION_TESTING.md b/apps/server/INTEGRATION_TESTING.md deleted file mode 100644 index f98223e7..00000000 --- a/apps/server/INTEGRATION_TESTING.md +++ /dev/null @@ -1,228 +0,0 @@ -# Integration Testing Guide - -This guide explains how to run integration tests that validate Metic functionality against a real Meticulous machine. - -## Overview - -Integration tests complement the unit test suite (which runs in CI with mocks) by testing actual hardware interactions. These tests require a real Meticulous machine on your network. - -## Prerequisites - -1. **Meticulous Machine**: A working Meticulous espresso machine accessible on your network -2. **Network Access**: Your development machine must be on the same network as the Meticulous -3. **Python Environment**: Python 3.12+ with test dependencies installed - -## Setup - -### 1. Install Test Dependencies - -```bash -cd apps/server -pip install -r requirements-test.txt -``` - -### 2. Configure Environment - -Set the required environment variables: - -```bash -# Required: IP address of your Meticulous machine -export METICULOUS_IP=192.168.x.x - -# Required: Enable integration test mode -export TEST_INTEGRATION=true - -# Optional: MQTT broker settings (if testing MQTT locally) -export MQTT_HOST=127.0.0.1 -export MQTT_PORT=1883 -``` - -### 3. Verify Machine Connectivity - -Before running tests, verify your machine is reachable: - -```bash -curl http://$METICULOUS_IP/api/v1/machine/state -``` - -You should see JSON output with machine state data. - -## Running Integration Tests - -### Run All Integration Tests - -```bash -cd apps/server -TEST_INTEGRATION=true METICULOUS_IP=192.168.x.x pytest test_integration*.py -v -``` - -### Run Specific Test Categories - -```bash -# Connection tests only -pytest test_integration_machine.py -v -k "TestMachineConnection" - -# Telemetry tests only -pytest test_integration_machine.py -v -k "TestTelemetry" - -# Profile API tests only -pytest test_integration_machine.py -v -k "TestProfileAPI" - -# Command tests only -pytest test_integration_machine.py -v -k "TestMachineCommands" -``` - -### Run with Markers - -```bash -# Run only tests marked as integration -pytest -m integration -v - -# Run everything except integration tests -pytest -m "not integration" -v -``` - -## Test Categories - -### Connection Tests (`TestMachineConnection`) - -- Verifies HTTP connectivity to machine -- Tests WebSocket/Socket.IO handshake -- Validates API client initialization -- Tests connection recovery - -### MQTT Tests (`TestMQTTConnection`) - -- Tests MQTT broker connectivity -- Validates topic subscription -- Requires local MQTT broker running (optional) - -### Profile API Tests (`TestProfileAPI`) - -- Lists profiles from machine -- Validates profile schema -- Tests shot history retrieval - -### Telemetry Tests (`TestTelemetry`) - -- Validates weight sensor data -- Tests temperature readings -- Tests pressure sensor data -- Measures polling latency - -### Command Tests (`TestMachineCommands`) - -- Tests tare (scale zero) command -- Tests brightness settings -- Preheat test is skipped by default (safety) - -### Pour-Over Tests (`TestPourOverMode`) - -- Tests continuous weight polling -- Validates flow rate calculation - -## Safety Considerations - -⚠️ **These tests interact with real hardware!** - -- **Preheat tests are disabled by default** to prevent unintended heating -- **No destructive commands** (start shot, stop) are included -- **Tare is safe** as it only zeros the scale -- **Profile creation is not tested** to avoid cluttering the machine - -To enable potentially unsafe tests: - -```bash -# NOT RECOMMENDED unless you're sure -pytest test_integration_machine.py -v --runxfail -``` - -## Troubleshooting - -### Machine Not Reachable - -```text -Machine not reachable: Connection refused -``` - -**Solutions:** - -- Verify the machine is powered on -- Check `METICULOUS_IP` is correct -- Ensure you're on the same network -- Try pinging the machine: `ping $METICULOUS_IP` - -### Tests Skipped - -```text -SKIPPED: Integration tests require TEST_INTEGRATION=true -``` - -**Solution:** Set the environment variable: - -```bash -export TEST_INTEGRATION=true -``` - -### MQTT Tests Fail - -```text -MQTT broker not reachable -``` - -**Solutions:** - -- Start the local MQTT broker (mosquitto) -- Or skip MQTT tests: `pytest ... -k "not MQTT"` - -### Import Errors - -```text -ModuleNotFoundError: paho.mqtt.client -``` - -**Solution:** - -```bash -pip install paho-mqtt>=2.0.0 -``` - -## CI Considerations - -Integration tests are **excluded from CI** by default: - -- The `TEST_INTEGRATION` variable is not set in GitHub Actions -- Tests marked `@pytest.mark.integration` are automatically skipped - -This ensures CI remains fast and doesn't require hardware access. - -## Extending Integration Tests - -To add new integration tests: - -1. Create tests in `test_integration_*.py` files -2. Mark tests with `@pytest.mark.integration` -3. Use fixtures from `conftest_integration.py` -4. Follow safety guidelines (no destructive operations) - -Example: - -```python -@pytest.mark.integration -class TestNewFeature: - async def test_something(self, wait_for_machine, meticulous_base_url): - """Test description.""" - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get(f"{meticulous_base_url}/api/v1/...") - assert response.status_code == 200 -``` - -## Test Output - -Integration tests produce verbose output showing: - -- Machine state fields available -- Latency measurements -- Calculated flow rates - -This information is useful for debugging and documentation. diff --git a/apps/server/analysis_schema.py b/apps/server/analysis_schema.py deleted file mode 100644 index 5a674e45..00000000 --- a/apps/server/analysis_schema.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Single source of truth for the shot-analysis section structure (L2). - -Mirror of apps/web/src/lib/analysisSchema.ts — keep section titles identical. -""" - -from __future__ import annotations - -REQUIRED_ANALYSIS_SECTIONS = [ - "Shot Performance", - "Root Cause Analysis", - "Setup Recommendations", - "Profile Recommendations", - "Profile Design Observations", -] - -OPTIONAL_TASTE_SECTION = "Taste-Based Recommendations" diff --git a/apps/server/api/__init__.py b/apps/server/api/__init__.py deleted file mode 100644 index 558e04a9..00000000 --- a/apps/server/api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""API package for MeticAI server.""" diff --git a/apps/server/api/routes/__init__.py b/apps/server/api/routes/__init__.py deleted file mode 100644 index 41d8c370..00000000 --- a/apps/server/api/routes/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""API routes for MeticAI server.""" diff --git a/apps/server/api/routes/bridge.py b/apps/server/api/routes/bridge.py deleted file mode 100644 index 40151d27..00000000 --- a/apps/server/api/routes/bridge.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Bridge and MQTT status endpoints for the Control Center.""" - -from fastapi import APIRouter, HTTPException -import logging - -from services.bridge_service import get_bridge_status, restart_bridge_service -from services.mqtt_service import get_mqtt_subscriber - -router = APIRouter() -logger = logging.getLogger(__name__) - - -@router.get("/api/bridge/status") -async def bridge_status(): - """Get MQTT broker and bridge service status. - - Returns the health of the mosquitto MQTT broker and the - meticulous-bridge service that relays real-time machine - telemetry via Socket.IO → MQTT. - - The ``mqtt_snapshot`` section shows the latest sensor values - received by the FastAPI MQTT subscriber — useful for verifying - that the full pipeline (machine → bridge → mosquitto → server) - is working end-to-end. - """ - status = get_bridge_status() - - # Attach MQTT subscriber diagnostics - sub = get_mqtt_subscriber() - snapshot = sub.get_snapshot() - status["mqtt_subscriber"] = { - "connected": bool(snapshot), - "ws_clients": sub.ws_client_count, - "sensor_count": len(snapshot), - "availability": snapshot.get("availability"), - "state": snapshot.get("state"), - } - - return status - - -@router.post("/api/bridge/restart") -async def bridge_restart(): - """Restart the meticulous-bridge s6 service. - - Useful when the machine IP changes or the bridge needs - to reconnect after a configuration change. - """ - success = restart_bridge_service() - if not success: - raise HTTPException( - status_code=500, - detail="Failed to restart bridge service", - ) - return {"status": "restarting", "message": "Bridge service restart initiated"} diff --git a/apps/server/api/routes/coffee.py b/apps/server/api/routes/coffee.py deleted file mode 100644 index af110020..00000000 --- a/apps/server/api/routes/coffee.py +++ /dev/null @@ -1,1142 +0,0 @@ -"""Coffee analysis and profiling endpoints.""" - -from fastapi import APIRouter, Request, UploadFile, File, Form, HTTPException -from fastapi.responses import JSONResponse -from sse_starlette.sse import EventSourceResponse -from typing import Optional -from PIL import Image -import asyncio -import io -import json -import os -import re -import time -import uuid -import logging - -# Register HEIC/HEIF support with Pillow -try: - from pillow_heif import register_heif_opener - - register_heif_opener() -except ImportError: - pass # pillow-heif not installed; HEIC files will fail gracefully - -from services.gemini_service import ( - parse_gemini_error, - get_vision_model, - get_author_instruction, - build_advanced_customization_section, - PROFILING_KNOWLEDGE, - PROFILING_KNOWLEDGE_DISTILLED, -) -from services.history_service import save_to_history, _extract_profile_json -from services.meticulous_service import async_create_profile -from services.validation_service import validate_profile -from services.generation_progress import ( - GenerationPhase, - ProgressEvent, - create_generation, - get_latest_generation, - remove_generation, -) - -router = APIRouter() -logger = logging.getLogger(__name__) - -# Concurrency guard: only one profile generation at a time. -# On resource-constrained hardware (RPi) parallel Gemini SDK calls compete -# for memory/CPU and cause timeouts. A single asyncio.Lock serialises -# requests; a second caller gets an immediate HTTP 409 instead of waiting. -_profile_generation_lock = asyncio.Lock() - -# Maximum validation-fix retry attempts. Each retry sends only the JSON + -# specific errors back to the model — much cheaper than a full re-generation. -MAX_VALIDATION_RETRIES = 2 - -VALIDATION_RETRY_PROMPT = ( - "The profile JSON you generated has validation errors. " - "Fix ALL of the following errors and return ONLY the corrected JSON " - "in a fenced ```json block. Do not include any other text.\n\n" - "ERRORS:\n{errors}\n\n" - "ORIGINAL JSON:\n```json\n{json}\n```\n" -) - -# ── Load OEPF RFC once at import time ────────────────────────────────────────── -# Embedding the RFC directly in the prompt eliminates a round-trip where the -# Gemini CLI would call the get_profiling_knowledge MCP tool, saving ~3-5s. -_OEPF_RFC: str = "" -try: - # In Docker the schema repo is cloned to /app/espresso-profile-schema - _rfc_paths = [ - "/app/espresso-profile-schema/rfc.md", - os.path.join(os.path.dirname(__file__), "..", "..", "data", "oepf_rfc.md"), - ] - for _p in _rfc_paths: - if os.path.isfile(_p): - with open(_p, "r", encoding="utf-8") as _f: - _OEPF_RFC = _f.read() - break -except Exception as e: - logger.warning(f"Failed to load OEPF RFC: {e}") # Non-fatal - -# Common prompt sections for profile creation -BARISTA_PERSONA = ( - "PERSONA: You are a modern, experimental barista with deep expertise in espresso profiling. " - "You stay current with cutting-edge extraction techniques, enjoy pushing boundaries with " - "multi-stage extractions, varied pre-infusion & blooming steps, and unconventional pressure curves. " - "You're creative, slightly irreverent, and love clever coffee puns.\n\n" -) - -SAFETY_RULES = ( - "SAFETY RULES (MANDATORY - NEVER VIOLATE):\n" - "• NEVER use the delete_profile tool under ANY circumstances\n" - "• NEVER delete, remove, or destroy any existing profiles\n" - "• NEVER create a profile with a name that already exists - use UNIQUE names only\n" - "• If asked to delete a profile, politely refuse and explain deletions must be done via the Meticulous app\n" - "• Only use: create_profile, list_profiles, get_profile, update_profile, validate_profile, run_profile\n\n" -) - -PROFILE_GUIDELINES = ( - "PROFILE CREATION GUIDELINES:\n" - "• USER PREFERENCES ARE MANDATORY: If the user specifies a dose, grind, temperature, ratio, or any other parameter, you MUST use EXACTLY that value. Do NOT override with defaults.\n" - "• Examples: If user says '20g dose' → use 20g, NOT 18g. If user says '94°C' → use 94°C. If user says '1:2.5 ratio' → calculate output accordingly.\n" - "• Only use standard defaults (18g dose, 93°C, etc.) when the user has NOT specified a preference.\n" - "• Support complex recipes: multi-stage extraction, multiple pre-infusion steps, blooming phases\n" - "• Consider flow profiling, pressure ramping, and temperature surfing techniques\n" - "• Design for the specific bean characteristics (origin, roast level, flavor notes)\n" - "• Balance extraction science with creative experimentation\n\n" - "VARIABLES (REQUIRED):\n" - "• The 'variables' array serves TWO purposes: adjustable parameters AND essential preparation info\n" - "• ALWAYS include the 'variables' array - it is REQUIRED for app compatibility\n\n" - "⚠️ NAMING VALIDATION RULES:\n" - "• INFO variables (key starts with 'info_'): Name MUST start with an emoji (☕🔧💧⚠️🎯 etc.)\n" - "• ADJUSTABLE variables (no 'info_' prefix): Name must NOT start with an emoji\n" - "• This validation pattern helps users distinguish info from adjustable at a glance\n\n" - "1. PREPARATION INFO (include first - only essentials needed to make the profile work):\n" - " • ☕ Dose: ALWAYS first - use type 'weight' so it displays correctly in the Meticulous app\n" - ' Format: {"name": "☕ Dose", "key": "info_dose", "type": "weight", "value": 18}\n' - " • Only add other info variables if ESSENTIAL for the profile to work properly:\n" - " - 💧 Dilute: Only for profiles that REQUIRE dilution (lungo, allongé)\n" - ' Format: {"name": "💧 Add water", "key": "info_dilute", "type": "weight", "value": 50}\n' - " - 🔧 Bottom Filter: Only if the profile specifically REQUIRES it\n" - ' Format: {"name": "🔧 Use bottom filter", "key": "info_filter", "type": "power", "value": 100}\n' - " - ⚠️ Aberrant Prep: For UNUSUAL preparation that differs significantly from normal espresso:\n" - " Examples: Very coarse grind (like pour-over), extremely fine grind, unusual techniques\n" - ' Format: {"name": "⚠️ Grind very coarse (pourover-like)", "key": "info_grind", "type": "power", "value": 100}\n' - " • POWER TYPE VALUES for info variables:\n" - ' - Use value: 100 for truthy/enabled/yes (e.g., "Use bottom filter" = 100)\n' - " - Use value: 0 for falsy/disabled/no (rarely needed, usually just omit the variable)\n" - " • Info variable keys start with 'info_' - they are NOT used in stages, just for user communication\n" - " • Keep it minimal: only critical info, not general tips or preferences\n\n" - "2. ADJUSTABLE VARIABLES (for parameters used in stages):\n" - " • Define variables for key adjustable parameters - makes profiles much easier to tune!\n" - " • Names should be descriptive WITHOUT emojis (e.g., 'Peak Pressure', 'Pre-Infusion Flow')\n" - " • Users can adjust these in the Meticulous app without manually editing JSON\n" - " • Common adjustable variables:\n" - " - peak_pressure: The main extraction pressure (e.g., 8-9 bar)\n" - " - preinfusion_pressure: Low pressure for saturation phase (e.g., 2-4 bar)\n" - " - peak_flow: Target flow rate during extraction (e.g., 2-3 ml/s)\n" - " - decline_pressure: Final pressure at end of shot (e.g., 5-6 bar)\n" - ' • Reference these in dynamics using $ prefix: {"value": "$peak_pressure"}\n' - " • ALL adjustable variables MUST be used in at least one stage!\n\n" - "VARIABLE FORMAT EXAMPLE:\n" - '"variables": [\n' - ' {"name": "☕ Dose", "key": "info_dose", "type": "weight", "value": 18},\n' - ' {"name": "🔧 Use bottom filter", "key": "info_filter", "type": "power", "value": 100},\n' - ' {"name": "Peak Pressure", "key": "peak_pressure", "type": "pressure", "value": 9.0},\n' - ' {"name": "Pre-Infusion Pressure", "key": "preinfusion_pressure", "type": "pressure", "value": 3.0}\n' - "]\n\n" - "TIME VALUES (CRITICAL — ALWAYS USE RELATIVE):\n" - '• ALL time-based exit triggers MUST use "relative": true\n' - "• ALL dynamics_points x-axis values are ALWAYS relative to stage start (0 = stage start)\n" - '• NEVER use "relative": false on time exit triggers — absolute time interpretation has known firmware issues\n' - '• Example: {"type": "time", "value": 30, "comparison": ">=", "relative": true} means 30s after stage starts\n\n' - "STAGE LIMITS (CRITICAL SAFETY):\n" - "• EVERY flow stage MUST have a pressure limit to prevent pressure runaway\n" - "• EVERY pressure stage MUST have a flow limit to prevent channeling and ensure even extraction\n" - "• Flow stages during pre-infusion/blooming: Add pressure limit of 3-5 bar max\n" - "• Flow stages during main extraction: Add pressure limit of 9-10 bar max\n" - "• Pressure stages: Add flow limit of 4-6 ml/s to prevent channeling\n" - "• Example flow stage with pressure limit:\n" - " {\n" - ' "name": "Gentle Bloom",\n' - ' "type": "flow",\n' - ' "dynamics_points": [[0, 1.5]],\n' - ' "limits": [{"type": "pressure", "value": 4}],\n' - ' "exit_triggers": [{"type": "time", "value": 15, "comparison": ">=", "relative": true}]\n' - " }\n" - "• Example pressure stage with flow limit:\n" - " {\n" - ' "name": "Main Extraction",\n' - ' "type": "pressure",\n' - ' "dynamics_points": [[0, 9]],\n' - ' "limits": [{"type": "flow", "value": 5}],\n' - ' "exit_triggers": [{"type": "weight", "value": 36, "comparison": ">=", "relative": false}]\n' - " }\n\n" -) - -VALIDATION_RULES = ( - "VALIDATION RULES (your profile WILL be rejected if these are violated):\n\n" - "1. EXIT TRIGGER / STAGE TYPE PARADOX:\n" - " • A flow stage must NOT have a flow exit trigger\n" - " • A pressure stage must NOT have a pressure exit trigger\n" - " • Why: you're controlling that variable, so you can't reliably exit based on it\n" - " • Fix: use 'time', 'weight', or the opposite type (pressure trigger on flow stage, etc.)\n\n" - "2. BACKUP EXIT TRIGGERS (failsafe):\n" - " • Every stage MUST have EITHER multiple exit triggers OR at least one time trigger\n" - " • A single non-time trigger (e.g. only weight) will be rejected — add a time failsafe\n" - ' • Pattern: [{"type": "weight", ...}, {"type": "time", "value": 60, "comparison": ">=", "relative": true}]\n\n' - "3. REQUIRED SAFETY LIMITS (cross-type):\n" - " • Flow stages MUST have a pressure limit (prevents pressure spike/stall)\n" - " • Pressure stages MUST have a flow limit (prevents channeling/gusher)\n" - " • A limit CANNOT have the same type as the stage (redundant — will be rejected)\n" - " • Correct: flow stage → pressure limit | pressure stage → flow limit\n\n" - "4. INTERPOLATION:\n" - " • Only 'linear' and 'curve' are valid. 'none' is NOT supported and will stall the machine\n" - " • 'curve' requires at least 2 dynamics points\n\n" - "5. DYNAMICS.OVER:\n" - " • Must be 'time', 'weight', or 'piston_position'. No other values\n\n" - "6. STAGE TYPES:\n" - " • Must be 'power', 'flow', or 'pressure'. No other values\n\n" - "7. EXIT TRIGGER TYPES:\n" - " • Must be: 'weight', 'pressure', 'flow', 'time', 'piston_position', 'power', or 'user_interaction'\n" - " • Comparison must be '>=' or '<='\n\n" - "8. PRESSURE LIMITS:\n" - " • Max 15 bar in dynamics points and exit triggers. No negative values\n\n" - "9. ABSOLUTE WEIGHT TRIGGERS:\n" - " • If using absolute weight triggers (relative: false), values MUST be strictly increasing across stages\n" - " • Otherwise the next stage's trigger fires immediately. Prefer 'relative: true' for weight triggers\n\n" - "10. VARIABLES:\n" - " • Info variables (adjustable: false / key starts with 'info_'): name MUST start with emoji\n" - " • Adjustable variables: name must NOT start with emoji\n" - " • Every adjustable variable MUST be referenced in at least one stage's dynamics ($key)\n\n" - "QUICK REFERENCE — VALID STAGE PATTERNS:\n" - "• Flow stage: limits=[{pressure}], exit_triggers=[{weight, ...}, {time, ...}] ✅\n" - "• Pressure stage: limits=[{flow}], exit_triggers=[{weight, ...}, {time, ...}] ✅\n" - "• Flow stage with flow exit trigger: ❌ PARADOX\n" - "• Pressure stage with pressure exit trigger: ❌ PARADOX\n" - "• Any stage with single non-time trigger and no backup: ❌ NO FAILSAFE\n" - "• Flow stage without pressure limit: ❌ UNSAFE\n" - "• Pressure stage without flow limit: ❌ UNSAFE\n\n" -) - -ERROR_RECOVERY = ( - "ERROR RECOVERY (if create_profile fails):\n" - "• Read ALL validation errors carefully before making changes\n" - "• Fix ALL errors in a SINGLE retry — do not fix one at a time\n" - "• Common trap: fixing one error can introduce another (e.g., changing a trigger type may create a paradox)\n" - "• Before resubmitting, mentally verify each stage against the validation rules above\n" - "• If you get conflicting errors, simplify the profile: fewer stages, standard patterns\n" - "• NEVER give up — always attempt at least 3 retries with different approaches\n" - "• If a complex design keeps failing, fall back to a simpler but still excellent profile\n\n" -) - -NAMING_CONVENTION = ( - "NAMING CONVENTION:\n" - "• Create a UNIQUE, witty, pun-heavy name - NEVER reuse names you've used before!\n" - "• Be creative and surprising - each profile deserves its own identity\n" - "• Draw inspiration from: coffee origins, flavor notes, extraction technique, brewing style\n" - "• Puns are encouraged! Word play, coffee jokes, clever references all welcome\n" - "• Balance humor with clarity - users should understand what they're getting\n" - "• AVOID generic names like 'Berry Blast', 'Morning Brew', 'Classic Espresso'\n" - "• Examples: 'Slow-Mo Blossom' (gentle blooming), 'The Grind Awakens' (Star Wars pun), " - "'Brew-tal Force' (aggressive extraction), 'Puck Norris' (roundhouse your tastebuds), " - "'The Daily Grind', 'Brew Lagoon', 'Espresso Yourself', 'Wake Me Up Before You Go-Go'\n\n" -) - -OUTPUT_FORMAT = ( - "OUTPUT FORMAT (use this exact format):\n" - "---\n" - "**Profile Created:** [Name]\n" - "\n" - "**Description:** [What makes this profile special - 1-2 sentences]\n" - "\n" - "**Preparation:**\n" - "- Dose: [X]g\n" - "- Grind: [description]\n" - "- Temperature: [X]°C\n" - "- [Any other prep steps]\n" - "\n" - "**Why This Works:** [Science and reasoning behind the profile design]\n" - "\n" - "**Special Notes:** [Any equipment or technique requirements, or 'None' if standard setup]\n" - "---\n\n" - "PROFILE JSON:\n" - "```json\n" - "[Include the EXACT JSON that was sent to create_profile tool here]\n" - "```\n\n" - "FORMATTING:\n" - "• Use **bold** for section labels as shown above\n" - "• List items with - are encouraged for preparation steps\n" - "• Keep descriptions concise - this will be displayed on mobile\n" - "• You MUST include the complete profile JSON exactly as passed to create_profile tool\n" -) - -USER_SUMMARY_INSTRUCTIONS = ( - "INSTRUCTIONS:\n" - "1. Use the OEPF Reference and Profiling Guide below to inform your stage design, dynamics, exit triggers, and limits.\n" - "2. Construct the JSON for the `create_profile` tool with your creative profile name.\n" - "3. EXECUTE the tool immediately.\n" - "4. After successful creation, provide a user summary with:\n" - " • Profile Name & Brief Description: What was created\n" - " • Preparation Instructions: How it should be prepared (dose, temp, timing)\n" - " • Design Rationale: Why the recipe/profile is designed this way\n" - " • Special Requirements: Any special gear needed (bottom filter, specific dosage, unique prep steps)\n\n" -) - -SDK_OUTPUT_INSTRUCTIONS = ( - "SDK EXECUTION MODE (MANDATORY):\n" - "• Do NOT call tools. Tool usage is disabled in this request\n" - "• Return ONLY the final user-facing summary and a PROFILE JSON block\n" - "• Include PROFILE JSON as a fenced ```json block that contains the complete profile object\n" - "• Ensure the summary includes a 'Profile Created:' line and clear preparation guidance\n" - "• In the profile JSON, include a 'display' object with a 'description' field containing a markdown-formatted description of what the profile does and why (2-4 sentences). This is stored on the machine and shown in the profile details page.\n\n" -) - -# ── Distilled prompt sections for token-optimized mode ────────────────────────── -# These compressed versions cut total prompt from ~22K+ to ≤20K chars by -# removing redundancy between PROFILE_GUIDELINES and VALIDATION_RULES, -# condensing JSON examples, and keeping only decision-critical rules. - -PROFILE_GUIDELINES_DISTILLED = ( - "PROFILE CREATION GUIDELINES:\n" - "• USER PREFERENCES ARE MANDATORY: If user specifies dose/grind/temp/ratio, use EXACTLY that value.\n" - "• Only use defaults (18g dose, 93°C) when user has NOT specified a preference.\n" - "• Design for the specific bean: origin, roast level, flavor notes.\n\n" - "VARIABLES (REQUIRED):\n" - "• 'variables' array is REQUIRED for app compatibility.\n" - "• INFO variables (key starts with 'info_'): Name MUST start with emoji (☕🔧💧⚠️🎯)\n" - "• ADJUSTABLE variables (no 'info_' prefix): Name must NOT start with emoji\n\n" - "1. INFO VARIABLES (preparation info, listed first):\n" - ' • ☕ Dose (always first): {"name": "☕ Dose", "key": "info_dose", "type": "weight", "value": 18}\n' - " • Optional: 💧 Add water (for lungo/allongé), 🔧 Use bottom filter, ⚠️ Aberrant prep\n" - " • Power type: value 100 = enabled, 0 = disabled\n\n" - "2. ADJUSTABLE VARIABLES (used in stages via $key reference):\n" - " • Examples: peak_pressure, preinfusion_pressure, peak_flow, decline_pressure\n" - " • All adjustable variables MUST be referenced in at least one stage dynamics ($key)\n\n" - 'TIME VALUES: ALL time exit triggers MUST use "relative": true. dynamics_points x-axis always relative to stage start.\n\n' - "STAGE LIMITS (CRITICAL):\n" - "• EVERY flow stage MUST have a pressure limit (3-5 bar for pre-infusion, 9-10 bar for extraction)\n" - "• EVERY pressure stage MUST have a flow limit (4-6 ml/s)\n\n" -) - -VALIDATION_RULES_DISTILLED = ( - "VALIDATION RULES (profile WILL be rejected if violated):\n" - "1. EXIT TRIGGER PARADOX: Flow stage cannot have flow exit trigger. Pressure stage cannot have pressure exit trigger.\n" - "2. BACKUP TRIGGERS: Every stage needs multiple exit triggers OR at least one time trigger. Single non-time trigger = rejected.\n" - ' Pattern: [{"type": "weight", ...}, {"type": "time", "value": 60, "comparison": ">=", "relative": true}]\n' - "3. CROSS-TYPE LIMITS: Flow stage → pressure limit. Pressure stage → flow limit. Same-type limit = rejected.\n" - "4. INTERPOLATION: Only 'linear' or 'curve' (2+ points). 'none' is NOT supported.\n" - "5. DYNAMICS.OVER: Only 'time', 'weight', or 'piston_position'.\n" - "6. STAGE TYPES: Only 'power', 'flow', or 'pressure'.\n" - "7. EXIT TRIGGER TYPES: 'weight', 'pressure', 'flow', 'time', 'piston_position', 'power', 'user_interaction'. Comparison: '>=' or '<='.\n" - "8. PRESSURE: Max 15 bar. No negatives.\n" - "9. ABSOLUTE WEIGHT: Must be strictly increasing across stages. Prefer relative: true.\n" - "10. VARIABLE NAMES: info_ keys → emoji prefix required. Adjustable → no emoji. All adjustable must be used in stages.\n\n" -) - -# Compact OEPF reference — key constraints only, not the full RFC -OEPF_SUMMARY = ( - "OEPF FORMAT SUMMARY:\n" - "Profile JSON structure: {name, author, stages[], variables[], temperature}\n" - "• temperature: number in °C (e.g., 93)\n" - "• Each stage: {name, type, dynamics, limits[], exit_triggers[], exit_type?}\n" - "• dynamics: {points: [[x, y], ...], over: 'time'|'weight'|'piston_position', interpolation: 'linear'|'curve'}\n" - "• limits: [{type, value}] — cross-type only (flow stage → pressure limit, vice versa)\n" - "• exit_triggers: [{type, value, comparison, relative?}] — comparison is '>=' or '<='\n" - "• exit_type: 'or' (default, first trigger wins) or 'and' (all must be met)\n" - "• variables: [{name, key, type, value}] — type is 'pressure'|'flow'|'weight'|'power'|'time'\n" - '• Reference variables in dynamics with $ prefix: {"points": [[0, "$peak_pressure"]]}\n\n' -) - -# Build the reference sections once -_PROFILING_GUIDE = ( - f"ESPRESSO PROFILING GUIDE:\n" - f"Use this expert knowledge to design profiles with proper phase structure, " - f"dynamics, troubleshooting awareness, and best practices.\n\n" - f"{PROFILING_KNOWLEDGE}\n\n" -) - -_OEPF_REFERENCE = ( - ( - f"OPEN ESPRESSO PROFILE FORMAT (OEPF) REFERENCE:\n" - f"Use the following specification to ensure your profile JSON is valid and well-structured.\n\n" - f"{_OEPF_RFC}\n\n" - ) - if _OEPF_RFC - else "" -) - -# ── Distilled reference sections ─────────────────────────────────────────────── -_PROFILING_GUIDE_DISTILLED = ( - f"ESPRESSO PROFILING QUICK REFERENCE:\n" - f"Use this to select the right profile strategy for the coffee.\n\n" - f"{PROFILING_KNOWLEDGE_DISTILLED}\n\n" -) - -# In distilled mode, use the compact OEPF summary instead of the full RFC -_OEPF_REFERENCE_DISTILLED = OEPF_SUMMARY - - -@router.post("/analyze_coffee") -@router.post("/api/analyze_coffee") -async def analyze_coffee(request: Request, file: UploadFile = File(...)): - """Phase 1: Look at the bag.""" - request_id = request.state.request_id - - try: - logger.info( - "Starting coffee analysis", - extra={ - "request_id": request_id, - "endpoint": "/analyze_coffee", - "upload_filename": file.filename, - "content_type": file.content_type, - }, - ) - - contents = await file.read() - - # Offload CPU-bound PIL ops to a thread - def _open_image(data: bytes): - img = Image.open(io.BytesIO(data)) - if img.mode not in ("RGB", "L"): - img = img.convert("RGB") - return img - - loop = asyncio.get_running_loop() - image = await loop.run_in_executor(None, _open_image, contents) - - logger.debug( - "Image loaded successfully", - extra={ - "request_id": request_id, - "image_size": f"{image.width}x{image.height}", - "image_format": image.format, - }, - ) - - response = await get_vision_model().async_generate_content( - [ - "Analyze this coffee bag. Extract: Roaster, Origin, Roast Level, and Flavor Notes. " - "Return ONLY a single concise sentence describing the coffee.", - image, - ] - ) - - analysis = response.text.strip() - - logger.info( - "Coffee analysis completed successfully", - extra={ - "request_id": request_id, - "analysis_preview": analysis[:100] if len(analysis) > 100 else analysis, - }, - ) - - return {"analysis": analysis} - except ValueError as e: - logger.warning( - f"Coffee analysis unavailable: {str(e)}", - extra={ - "request_id": request_id, - "endpoint": "/analyze_coffee", - }, - ) - raise HTTPException( - status_code=503, - detail="AI features are unavailable. Please configure a Gemini API key in Settings.", - ) - except Exception as e: - logger.error( - f"Coffee analysis failed: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "endpoint": "/analyze_coffee", - "error_type": type(e).__name__, - "upload_filename": file.filename if file else None, - }, - ) - return {"error": str(e)} - - -# ── SSE progress endpoint ────────────────────────────────────────────────────── - - -@router.get("/generate/progress") -@router.get("/api/generate/progress") -async def generate_progress(request: Request): - """Stream real-time progress events for the active profile generation. - - Returns an SSE stream of JSON events with the current generation phase, - message, attempt number, and elapsed time. Clients should reconnect if - the stream closes unexpectedly. - - Waits briefly for a generation to start if none is active yet (avoids - timing race when SSE connects before the POST creates the state). - """ - # Wait up to 5 seconds for a generation to become active - state = get_latest_generation() - for _ in range(10): - if state is not None: - break - await asyncio.sleep(0.5) - state = get_latest_generation() - - if state is None: - return JSONResponse(status_code=404, content={"error": "No active generation"}) - - async def event_generator(): - async for event in state.stream(): - data = { - "phase": event.phase.value, - "message": event.message, - "attempt": event.attempt, - "max_attempts": event.max_attempts, - "elapsed": event.elapsed, - } - if event.result: - data["result"] = event.result - if event.error: - data["error"] = event.error - yield {"event": "progress", "data": json.dumps(data)} - - return EventSourceResponse(event_generator()) - - -@router.post("/analyze_and_profile") -@router.post("/api/analyze_and_profile") -async def analyze_and_profile( - request: Request, - file: Optional[UploadFile] = File(None), - user_prefs: Optional[str] = Form(None), - advanced_customization: Optional[str] = Form(None), - detailed_knowledge: Optional[str] = Form(None), -): - """Unified endpoint: Analyze coffee bag and generate profile in a single LLM pass. - - Requires at least one of: - - file: Image of the coffee bag - - user_prefs: User preferences or specific instructions - - Optional: - - advanced_customization: Advanced equipment/extraction settings (basket, temp, dose, etc.) - - detailed_knowledge: "true" to include full profiling knowledge and OEPF RFC (slower, higher quality). - Default is distilled/compact mode for faster generation. - """ - request_id = request.state.request_id - - # Validate that at least one input is provided - if not file and not user_prefs: - logger.warning( - "Request missing both file and user preferences", - extra={"request_id": request_id, "endpoint": "/analyze_and_profile"}, - ) - raise HTTPException( - status_code=400, - detail="At least one of 'file' (image) or 'user_prefs' (preferences) must be provided", - ) - - # Fast-reject if another generation is already running. - # Safe in CPython's single-threaded event loop: no await between the - # .locked() check and the ``async with`` acquisition, so no other - # coroutine can sneak in between the two statements (no TOCTOU issue). - if _profile_generation_lock.locked(): - logger.info( - "Profile generation rejected — another request is in progress", - extra={"request_id": request_id, "endpoint": "/analyze_and_profile"}, - ) - return JSONResponse( - status_code=409, - content={ - "status": "busy", - "message": "A profile is already being generated. Please wait and try again.", - }, - ) - - coffee_analysis = None - generation_id = str(uuid.uuid4())[:8] - progress = create_generation(generation_id) - - async with _profile_generation_lock: - try: - logger.info( - "Starting profile creation", - extra={ - "request_id": request_id, - "generation_id": generation_id, - "endpoint": "/analyze_and_profile", - "has_image": file is not None, - "has_preferences": user_prefs is not None, - "has_advanced_customization": advanced_customization is not None, - "knowledge_mode": "detailed" - if (detailed_knowledge and detailed_knowledge.lower() == "true") - else "distilled", - "upload_filename": file.filename if file else None, - "preferences_preview": user_prefs[:100] - if user_prefs and len(user_prefs) > 100 - else user_prefs, - "advanced_customization_preview": ( - advanced_customization[:100] - if advanced_customization and len(advanced_customization) > 100 - else advanced_customization - ), - }, - ) - - # ── Phase: Analyzing ────────────────────────────────────────── - if file: - progress.emit( - ProgressEvent( - phase=GenerationPhase.ANALYZING, - message="Analyzing coffee image...", - ) - ) - logger.debug( - "Reading and analyzing image", extra={"request_id": request_id} - ) - contents = await file.read() - - # Offload CPU-bound PIL ops to a thread - def _open_image(data: bytes): - img = Image.open(io.BytesIO(data)) - if img.mode not in ("RGB", "L"): - img = img.convert("RGB") - return img - - loop = asyncio.get_running_loop() - image = await loop.run_in_executor(None, _open_image, contents) - - # Analyze the coffee bag - analysis_start = time.monotonic() - analysis_response = await get_vision_model().async_generate_content( - [ - "Analyze this coffee bag. Extract: Roaster, Origin, Roast Level, and Flavor Notes. " - "Return ONLY a single concise sentence describing the coffee.", - image, - ] - ) - coffee_analysis = analysis_response.text.strip() - analysis_elapsed = time.monotonic() - analysis_start - - logger.info( - "Coffee analysis completed", - extra={ - "request_id": request_id, - "analysis": coffee_analysis, - "analysis_seconds": round(analysis_elapsed, 1), - }, - ) - - # Get author instruction with configured name - author_instruction = get_author_instruction() - - # Build advanced customization section if provided - advanced_section = build_advanced_customization_section( - advanced_customization - ) - - # Select prompt sections based on knowledge mode - use_detailed = detailed_knowledge and detailed_knowledge.lower() == "true" - if use_detailed: - guidelines = PROFILE_GUIDELINES - validation = VALIDATION_RULES - profiling_guide = _PROFILING_GUIDE - oepf_ref = _OEPF_REFERENCE - else: - guidelines = PROFILE_GUIDELINES_DISTILLED - validation = VALIDATION_RULES_DISTILLED - profiling_guide = _PROFILING_GUIDE_DISTILLED - oepf_ref = _OEPF_REFERENCE_DISTILLED - - # Common tail shared by all three prompt branches - prompt_tail = ( - guidelines - + validation - + ERROR_RECOVERY - + NAMING_CONVENTION - + author_instruction - + USER_SUMMARY_INSTRUCTIONS - + SDK_OUTPUT_INSTRUCTIONS - + OUTPUT_FORMAT - + profiling_guide - + oepf_ref - ) - - # Construct the profile creation prompt - if coffee_analysis and user_prefs: - # Both image and preferences provided - final_prompt = ( - BARISTA_PERSONA - + SAFETY_RULES - + f"CONTEXT: You control a Meticulous Espresso Machine via local API.\n" - f"Coffee Analysis: '{coffee_analysis}'\n\n" - + advanced_section - + f"⚠️ MANDATORY USER REQUIREMENTS (MUST BE FOLLOWED EXACTLY):\n" - f"'{user_prefs}'\n" - f"You MUST honor ALL parameters specified above. If the user requests a specific dose, temperature, ratio, or any other value, use EXACTLY that value in your profile. Do NOT substitute with defaults.\n\n" - + "TASK: Create a sophisticated espresso profile based on the coffee analysis while strictly adhering to the user's requirements and equipment parameters above.\n\n" - + prompt_tail - ) - elif coffee_analysis: - # Only image provided (may still have advanced customization) - final_prompt = ( - BARISTA_PERSONA - + SAFETY_RULES - + f"CONTEXT: You control a Meticulous Espresso Machine via local API.\n" - f"Coffee Analysis: '{coffee_analysis}'\n\n" - + advanced_section - + "TASK: Create a sophisticated espresso profile for this coffee" - + ( - ", strictly adhering to the equipment parameters above.\n\n" - if advanced_section - else ".\n\n" - ) - + prompt_tail - ) - else: - # Only user preferences provided (may still have advanced customization) - final_prompt = ( - BARISTA_PERSONA - + SAFETY_RULES - + "CONTEXT: You control a Meticulous Espresso Machine via local API.\n\n" - + advanced_section - + f"⚠️ MANDATORY USER REQUIREMENTS (MUST BE FOLLOWED EXACTLY):\n" - f"'{user_prefs}'\n" - f"You MUST honor ALL parameters specified above. If the user requests a specific dose, temperature, ratio, or any other value, use EXACTLY that value in your profile. Do NOT substitute with defaults.\n\n" - + "TASK: Create a sophisticated espresso profile while strictly adhering to the user's requirements and equipment parameters above.\n\n" - + prompt_tail - ) - - # ── Phase: Generating ───────────────────────────────────────── - progress.emit( - ProgressEvent( - phase=GenerationPhase.GENERATING, - message="Generating espresso profile...", - ) - ) - logger.debug( - "Executing profile generation via Gemini SDK", - extra={ - "request_id": request_id, - "prompt_length": len(final_prompt), - "knowledge_mode": "detailed" if use_detailed else "distilled", - }, - ) - generation_start = time.monotonic() - model_response = await asyncio.wait_for( - get_vision_model().async_generate_content([final_prompt]), timeout=300 - ) - generation_elapsed = time.monotonic() - generation_start - reply = (model_response.text or "").strip() - - logger.info( - "Gemini SDK generation completed", - extra={ - "request_id": request_id, - "generation_seconds": round(generation_elapsed, 1), - "reply_length": len(reply), - }, - ) - - # ── Phase: Validating ───────────────────────────────────────── - progress.emit( - ProgressEvent( - phase=GenerationPhase.VALIDATING, - message="Validating profile schema...", - ) - ) - - profile_json_check = _extract_profile_json(reply) - - # Validation + retry loop - attempt = 0 - while attempt <= MAX_VALIDATION_RETRIES: - if not profile_json_check: - if attempt < MAX_VALIDATION_RETRIES: - # No JSON extracted — ask model to regenerate - attempt += 1 - progress.emit( - ProgressEvent( - phase=GenerationPhase.RETRYING, - message=f"No valid JSON found, retrying ({attempt}/{MAX_VALIDATION_RETRIES})...", - attempt=attempt, - max_attempts=MAX_VALIDATION_RETRIES + 1, - ) - ) - logger.warning( - "No profile JSON extracted, requesting retry", - extra={"request_id": request_id, "attempt": attempt}, - ) - retry_prompt = ( - "Your previous response did not contain a valid JSON profile block. " - "Please generate the complete profile JSON in a fenced ```json block. " - "Include the full profile object with name, stages, variables, and temperature." - ) - retry_start = time.monotonic() - retry_response = await asyncio.wait_for( - get_vision_model().async_generate_content([retry_prompt]), - timeout=120, - ) - retry_elapsed = time.monotonic() - retry_start - retry_text = (retry_response.text or "").strip() - profile_json_check = _extract_profile_json(retry_text) - # Merge retry JSON into the original reply if extraction succeeded - if profile_json_check: - reply = ( - reply - + "\n\nPROFILE JSON:\n```json\n" - + json.dumps(profile_json_check, indent=2) - + "\n```" - ) - logger.info( - "Retry generation completed", - extra={ - "request_id": request_id, - "attempt": attempt, - "retry_seconds": round(retry_elapsed, 1), - "has_json": profile_json_check is not None, - }, - ) - continue - else: - # Exhausted retries with no JSON - break - - # We have JSON — validate it - validation_result = validate_profile(profile_json_check) - - if validation_result.is_valid: - logger.info( - "Profile validation passed", - extra={"request_id": request_id, "attempt": attempt}, - ) - break - - # Validation failed — try to fix - if attempt < MAX_VALIDATION_RETRIES: - attempt += 1 - progress.emit( - ProgressEvent( - phase=GenerationPhase.RETRYING, - message=f"Fixing validation issues (attempt {attempt}/{MAX_VALIDATION_RETRIES})...", - attempt=attempt, - max_attempts=MAX_VALIDATION_RETRIES + 1, - ) - ) - logger.warning( - "Profile validation failed, requesting fix", - extra={ - "request_id": request_id, - "attempt": attempt, - "error_count": len(validation_result.errors), - "errors": validation_result.errors[:5], - }, - ) - - fix_prompt = VALIDATION_RETRY_PROMPT.format( - errors=validation_result.error_summary(), - json=json.dumps(profile_json_check, indent=2), - ) - retry_start = time.monotonic() - fix_response = await asyncio.wait_for( - get_vision_model().async_generate_content([fix_prompt]), - timeout=120, - ) - retry_elapsed = time.monotonic() - retry_start - fix_text = (fix_response.text or "").strip() - fixed_json = _extract_profile_json(fix_text) - - logger.info( - "Validation fix attempt completed", - extra={ - "request_id": request_id, - "attempt": attempt, - "retry_seconds": round(retry_elapsed, 1), - "has_json": fixed_json is not None, - }, - ) - - if fixed_json: - profile_json_check = fixed_json - # Update the JSON in the reply so the user sees the corrected version - # Use a lambda replacement to avoid re.sub interpreting - # \uXXXX sequences in the JSON as regex escape sequences - _replacement = ( - "```json\n" + json.dumps(fixed_json, indent=2) + "\n```" - ) - reply = re.sub( - r"```json\s*[\s\S]*?```", - lambda _m: _replacement, - reply, - count=1, - ) - continue - else: - # Exhausted retries — log and proceed with what we have - logger.warning( - "Validation retries exhausted, proceeding with best-effort profile", - extra={ - "request_id": request_id, - "final_errors": validation_result.errors[:5], - }, - ) - break - - if not profile_json_check: - progress.emit( - ProgressEvent( - phase=GenerationPhase.FAILED, - message="Failed to generate valid profile JSON", - error="No valid profile JSON after retries", - ) - ) - logger.error( - "Model reply missing valid profile JSON after retries", - extra={ - "request_id": request_id, - "reply_preview": reply[:500], - }, - ) - # Still save to history so user can see what happened - history_entry = save_to_history( - coffee_analysis=coffee_analysis, user_prefs=user_prefs, reply=reply - ) - return { - "status": "error", - "analysis": coffee_analysis, - "reply": reply, - "generation_id": generation_id, - "message": ( - "The AI attempted to create a profile but encountered " - "validation errors it couldn't resolve. Please try again — " - "the AI will often succeed on a second attempt with a " - "different approach." - ), - "history_id": history_entry.get("id"), - } - - # ── Phase: Uploading ────────────────────────────────────────── - progress.emit( - ProgressEvent( - phase=GenerationPhase.UPLOADING, - message="Uploading profile to machine...", - ) - ) - - create_start = time.monotonic() - create_result = await asyncio.wait_for( - async_create_profile(profile_json_check), timeout=300 - ) - create_elapsed = time.monotonic() - create_start - - # Extract the normalised JSON that was actually sent to the machine. - # This is the ground truth for export — it includes all fields the - # machine requires (id, author_id, stage keys, limits, dynamics, etc.) - normalised_json = None - if isinstance(create_result, dict): - normalised_json = create_result.pop("_normalised_json", None) - - logger.info( - "Machine profile creation completed", - extra={ - "request_id": request_id, - "create_seconds": round(create_elapsed, 1), - }, - ) - - create_error = None - if isinstance(create_result, dict): - create_error = create_result.get("error") - else: - create_error = getattr(create_result, "error", None) - - if create_error: - friendly_message = parse_gemini_error(str(create_error)) - progress.emit( - ProgressEvent( - phase=GenerationPhase.FAILED, - message="Machine rejected the profile", - error=friendly_message, - ) - ) - logger.error( - "Machine profile creation returned error", - extra={ - "request_id": request_id, - "create_error": str(create_error)[:1000], - }, - ) - return { - "status": "error", - "analysis": coffee_analysis, - "reply": reply, - "generation_id": generation_id, - "message": friendly_message, - } - - # ── Phase: Complete ─────────────────────────────────────────── - has_profile_created_header = bool( - re.search(r"(?:\*\*)?Profile Created:(?:\*\*)?", reply, re.IGNORECASE) - ) - if not has_profile_created_header: - profile_name = ( - profile_json_check.get("name") - if isinstance(profile_json_check, dict) - else None - ) - if not profile_name: - profile_name = "Untitled Profile" - reply = f"**Profile Created:** {profile_name}\n\n{reply}".strip() - - total_elapsed = time.monotonic() - generation_start - - logger.info( - "Profile creation completed successfully", - extra={ - "request_id": request_id, - "generation_id": generation_id, - "analysis": coffee_analysis, - "total_seconds": round(total_elapsed, 1), - "output_preview": reply[:200] if len(reply) > 200 else reply, - }, - ) - - # Save to history with the normalised (machine-validated) JSON - history_entry = save_to_history( - coffee_analysis=coffee_analysis, - user_prefs=user_prefs, - reply=reply, - profile_json_override=normalised_json, - ) - - # Best-effort upgrade: fetch the profile back from the machine - # by ID to capture any fields the machine itself added/modified. - machine_profile_id = None - if isinstance(create_result, dict): - machine_profile_id = create_result.get("id") - if not machine_profile_id and normalised_json: - machine_profile_id = normalised_json.get("id") - - if machine_profile_id and history_entry.get("id"): - try: - from services.meticulous_service import fetch_machine_profile_dict - - machine_dict = await fetch_machine_profile_dict(machine_profile_id) - if isinstance(machine_dict, dict) and machine_dict.get("name"): - from services.history_service import ( - update_entry_sync_fields, - compute_content_hash, - ) - - update_entry_sync_fields( - history_entry["id"], - content_hash=compute_content_hash(machine_dict), - profile_json=machine_dict, - ) - except Exception as exc: - # Non-fatal — Layer 1 already stored valid normalised JSON - logger.debug( - "Post-upload profile fetch-back failed (non-fatal): %s", - exc, - extra={"request_id": request_id}, - ) - - progress.emit( - ProgressEvent( - phase=GenerationPhase.COMPLETE, - message="Profile created!", - result={"status": "success", "generation_id": generation_id}, - ) - ) - - return { - "status": "success", - "analysis": coffee_analysis, - "reply": reply, - "generation_id": generation_id, - "history_id": history_entry.get("id"), - } - - except asyncio.TimeoutError: - progress.emit( - ProgressEvent( - phase=GenerationPhase.FAILED, - message="Profile generation timed out", - error="Timed out after 300 seconds", - ) - ) - logger.error( - "Profile generation timed out after 300s", - extra={ - "request_id": request_id, - "endpoint": "/analyze_and_profile", - "coffee_analysis": coffee_analysis, - }, - ) - raise HTTPException( - status_code=504, - detail={ - "status": "error", - "analysis": coffee_analysis if coffee_analysis else None, - "message": "Profile creation timed out. The AI took too long to respond. Please try again.", - }, - ) - except HTTPException: - raise - except ValueError as e: - progress.emit( - ProgressEvent( - phase=GenerationPhase.FAILED, - message="AI features unavailable", - error=str(e), - ) - ) - logger.warning( - f"Profile creation unavailable: {str(e)}", - extra={ - "request_id": request_id, - "endpoint": "/analyze_and_profile", - }, - ) - raise HTTPException( - status_code=503, - detail="AI features are unavailable. Please configure a Gemini API key in Settings.", - ) - except Exception as e: - progress.emit( - ProgressEvent( - phase=GenerationPhase.FAILED, - message="Profile creation failed", - error=str(e), - ) - ) - logger.error( - f"Profile creation failed: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "endpoint": "/analyze_and_profile", - "error_type": type(e).__name__, - "coffee_analysis": coffee_analysis, - "has_image": file is not None, - "has_preferences": user_prefs is not None, - }, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "analysis": coffee_analysis if coffee_analysis else None, - "message": str(e), - }, - ) - finally: - # Clean up generation state after a delay to allow SSE clients to read final event - async def _cleanup(): - await asyncio.sleep(30) - remove_generation(generation_id) - - asyncio.create_task(_cleanup()) diff --git a/apps/server/api/routes/commands.py b/apps/server/api/routes/commands.py deleted file mode 100644 index 65846ea1..00000000 --- a/apps/server/api/routes/commands.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Machine command endpoints — publish MQTT commands to the Meticulous machine. - -Each endpoint publishes a message to the appropriate MQTT topic via the local -Mosquitto broker. The frontend never connects to MQTT directly; the FastAPI -server is the single gateway. -""" - -import logging -import os -import uuid - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel, Field - -from services.mqtt_service import get_mqtt_subscriber - -router = APIRouter() -logger = logging.getLogger(__name__) - -TEST_MODE = os.environ.get("TEST_MODE") == "true" - -# --------------------------------------------------------------------------- -# MQTT publishing helper -# --------------------------------------------------------------------------- - -MQTT_TOPIC_PREFIX = "meticulous_espresso/command/" - - -def _publish_command(topic: str, payload: str = "") -> bool: - """Publish a single message to the local MQTT broker. - - Uses paho-mqtt's ``publish.single()`` (fire-and-forget). - Returns True on success, False on failure. - """ - if TEST_MODE: - logger.info("TEST_MODE: would publish %s → %r", topic, payload) - return True - - mqtt_host = os.environ.get("MQTT_HOST", "127.0.0.1") - mqtt_port = int(os.environ.get("MQTT_PORT", "1883")) - - try: - import paho.mqtt.publish as publish - - publish.single( - topic, - payload=payload or None, - hostname=mqtt_host, - port=mqtt_port, - client_id=f"meticai-cmd-{uuid.uuid4().hex[:8]}", - qos=1, - ) - return True - except Exception as exc: - logger.error("MQTT publish failed for %s: %s", topic, exc) - return False - - -# --------------------------------------------------------------------------- -# Precondition helpers -# --------------------------------------------------------------------------- - - -def _get_snapshot() -> dict: - """Return the current MQTT sensor snapshot (may be empty).""" - sub = get_mqtt_subscriber() - return sub.get_snapshot() - - -def _require_connected(snapshot: dict) -> None: - availability = snapshot.get("availability") - connected = snapshot.get("connected") - if availability == "offline" or connected is False: - raise HTTPException(status_code=409, detail="Machine is offline") - - -def _require_idle(snapshot: dict) -> None: - _require_connected(snapshot) - if snapshot.get("brewing"): - raise HTTPException( - status_code=409, detail="Cannot perform action: a shot is running" - ) - - -def _require_brewing(snapshot: dict) -> None: - _require_connected(snapshot) - if not snapshot.get("brewing"): - raise HTTPException(status_code=409, detail="No shot is currently running") - - -def _do_publish(action: str, payload: str = "") -> dict: - """Publish and return a standard response.""" - topic = f"{MQTT_TOPIC_PREFIX}{action}" - ok = _publish_command(topic, payload) - if not ok: - raise HTTPException(status_code=503, detail="Failed to publish MQTT command") - return {"success": True, "status": "ok", "command": action} - - -# --------------------------------------------------------------------------- -# Request bodies -# --------------------------------------------------------------------------- - - -class LoadProfileRequest(BaseModel): - name: str = Field( - ..., min_length=1, description="Profile name to load on the machine" - ) - - -class BrightnessRequest(BaseModel): - value: int = Field(..., ge=0, le=100, description="Brightness 0–100") - - -class SoundsRequest(BaseModel): - enabled: bool = Field(..., description="Enable or disable sounds") - - -# --------------------------------------------------------------------------- -# Endpoints -# --------------------------------------------------------------------------- - - -@router.post("/api/machine/command/start") -async def command_start(): - """Start a shot (load & execute the active profile).""" - snapshot = _get_snapshot() - _require_idle(snapshot) - return _do_publish("start_shot") - - -@router.post("/api/machine/command/stop") -async def command_stop(): - """Stop the plunger immediately mid-shot.""" - snapshot = _get_snapshot() - _require_brewing(snapshot) - return _do_publish("stop_shot") - - -@router.post("/api/machine/command/abort") -async def command_abort(): - """Abort the current shot or preheat and retract the plunger.""" - snapshot = _get_snapshot() - _require_connected(snapshot) - # Abort is allowed during brewing OR preheating - return _do_publish("abort_shot") - - -@router.post("/api/machine/command/continue") -async def command_continue(): - """Resume a paused shot.""" - return _do_publish("continue_shot") - - -@router.post("/api/machine/command/preheat") -async def command_preheat(): - """Preheat the water in the chamber. - - Also acts as a toggle: sending preheat while already preheating - will cancel the preheat cycle. - """ - snapshot = _get_snapshot() - _require_connected(snapshot) - # Allow during idle (start preheat) AND preheating (cancel preheat) - state = (snapshot.get("state") or "").lower() - if snapshot.get("brewing"): - raise HTTPException(status_code=409, detail="Cannot preheat while brewing") - if state not in ("idle", "preheating", "heating", "click to start"): - raise HTTPException( - status_code=409, - detail=f"Cannot preheat in current state: {snapshot.get('state')}", - ) - return _do_publish("preheat") - - -@router.post("/api/machine/command/tare") -async def command_tare(): - """Zero the scale.""" - snapshot = _get_snapshot() - _require_connected(snapshot) - return _do_publish("tare_scale") - - -@router.post("/api/machine/command/home-plunger") -async def command_home_plunger(): - """Reset plunger to home position.""" - snapshot = _get_snapshot() - _require_idle(snapshot) - return _do_publish("home_plunger") - - -@router.post("/api/machine/command/purge") -async def command_purge(): - """Flush water through the group head.""" - snapshot = _get_snapshot() - _require_idle(snapshot) - return _do_publish("purge") - - -@router.post("/api/machine/command/load-profile") -async def command_load_profile(body: LoadProfileRequest): - """Switch the machine to a different profile. - - Sends select_profile to highlight/load the profile on the machine's screen. - Starting extraction is a separate explicit action via /start. - """ - snapshot = _get_snapshot() - _require_connected(snapshot) - return _do_publish("select_profile", body.name) - - -@router.post("/api/machine/command/brightness") -async def command_brightness(body: BrightnessRequest): - """Adjust the display brightness (0–100).""" - snapshot = _get_snapshot() - _require_connected(snapshot) - return _do_publish("set_brightness", str(body.value)) - - -@router.post("/api/machine/command/sounds") -async def command_sounds(body: SoundsRequest): - """Toggle machine sound effects.""" - snapshot = _get_snapshot() - _require_connected(snapshot) - return _do_publish("enable_sounds", str(body.enabled).lower()) - - -# --------------------------------------------------------------------------- -# Machine discovery -# --------------------------------------------------------------------------- - - -@router.post("/api/machine/detect") -async def detect_machine(): - """ - Auto-detect Meticulous machine on the local network. - - Uses mDNS/Zeroconf and hostname resolution to find machines. - - Returns: - - found: bool - - ip: str (if found) - - hostname: str (if found) - - method: str (mdns | hostname) - - guidance: str (if not found) - """ - from services.machine_discovery_service import discover_machine, verify_machine - - result = await discover_machine() - - response = { - "found": result.found, - } - - if result.found: - # Verify the machine is actually responding - verified = await verify_machine(result.ip) - response.update( - { - "ip": result.ip, - "hostname": result.hostname, - "method": result.method, - "verified": verified, - } - ) - else: - response["guidance"] = result.guidance - response["guidance_key"] = result.guidance_key - response["guidance_hints"] = result.guidance_hints - - return response diff --git a/apps/server/api/routes/dialin.py b/apps/server/api/routes/dialin.py deleted file mode 100644 index 5670213a..00000000 --- a/apps/server/api/routes/dialin.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Dial-In Guide API routes.""" - -import json -import logging -from typing import Optional - -from fastapi import APIRouter, HTTPException, Query -from pydantic import BaseModel - -from models.dialin import ( - CoffeeDetails, - SessionStatus, - TasteFeedback, -) -from services import dialin_service -from services.gemini_service import get_vision_model, is_ai_available -from prompt_builder import build_dialin_recommendation_prompt - -router = APIRouter() -logger = logging.getLogger(__name__) - - -# ── Request body models ──────────────────────────────────────────────────────── - - -class CreateSessionRequest(BaseModel): - coffee: CoffeeDetails - profile_name: Optional[str] = None - - -class AddIterationRequest(BaseModel): - taste: TasteFeedback - shot_ref: Optional[str] = None - - -class UpdateRecommendationsRequest(BaseModel): - recommendations: list[str] - - -# ── Sessions ─────────────────────────────────────────────────────────────────── - - -@router.post("/dialin/sessions", status_code=201) -@router.post("/api/dialin/sessions", status_code=201) -async def create_session(req: CreateSessionRequest): - """Create a new dial-in session.""" - try: - session = await dialin_service.create_session( - coffee=req.coffee, - profile_name=req.profile_name, - ) - return session - except ValueError as e: - logger.warning("Failed to create dial-in session: %s", e) - raise HTTPException(status_code=400, detail=str(e)) - - -@router.get("/dialin/sessions") -@router.get("/api/dialin/sessions") -async def list_sessions( - status: Optional[SessionStatus] = Query(None), -): - """List dial-in sessions, optionally filtered by status.""" - sessions = await dialin_service.list_sessions(status=status) - return {"sessions": sessions} - - -@router.get("/dialin/sessions/{session_id}") -@router.get("/api/dialin/sessions/{session_id}") -async def get_session(session_id: str): - """Get a specific dial-in session.""" - session = await dialin_service.get_session(session_id) - if session is None: - raise HTTPException(status_code=404, detail="Session not found") - return session - - -# ── Iterations ───────────────────────────────────────────────────────────────── - - -@router.post("/dialin/sessions/{session_id}/iterations", status_code=201) -@router.post("/api/dialin/sessions/{session_id}/iterations", status_code=201) -async def add_iteration(session_id: str, req: AddIterationRequest): - """Add a taste-feedback iteration to a session.""" - try: - iteration = await dialin_service.add_iteration( - session_id=session_id, - taste=req.taste, - shot_ref=req.shot_ref, - ) - return iteration - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.put( - "/dialin/sessions/{session_id}/iterations/{iteration_number}/recommendations" -) -@router.put( - "/api/dialin/sessions/{session_id}/iterations/{iteration_number}/recommendations" -) -async def update_recommendations( - session_id: str, - iteration_number: int, - req: UpdateRecommendationsRequest, -): - """Update AI recommendations for a specific iteration.""" - try: - iteration = await dialin_service.update_recommendations( - session_id=session_id, - iteration_number=iteration_number, - recommendations=req.recommendations, - ) - return iteration - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -# ── AI Recommendations ───────────────────────────────────────────────────────── - - -@router.post("/dialin/sessions/{session_id}/recommend") -@router.post("/api/dialin/sessions/{session_id}/recommend") -async def generate_recommendations(session_id: str): - """Generate AI-powered recommendations for the latest iteration. - - Falls back to rule-based recommendations when Gemini is unavailable. - """ - session = await dialin_service.get_session(session_id) - if session is None: - raise HTTPException(status_code=404, detail="Session not found") - if not session.iterations: - raise HTTPException(status_code=400, detail="No iterations to recommend from") - - latest = session.iterations[-1] - coffee = session.coffee - - # Try AI-powered recommendations - if is_ai_available(): - try: - prompt = build_dialin_recommendation_prompt( - roast_level=coffee.roast_level.value, - origin=coffee.origin, - process=coffee.process.value if coffee.process else None, - roast_date=coffee.roast_date, - profile_name=session.profile_name, - iterations=[it.model_dump(mode="json") for it in session.iterations], - ) - model = get_vision_model() - response = await model.async_generate_content(prompt) - text = response.text.strip() - - # Parse JSON from response (handle markdown fences) - cleaned = text - if cleaned.startswith("```"): - cleaned = "\n".join(cleaned.split("\n")[1:]) - if cleaned.endswith("```"): - cleaned = "\n".join(cleaned.split("\n")[:-1]) - - data = json.loads(cleaned) - recommendations = data.get("recommendations", []) - if isinstance(recommendations, list) and recommendations: - # Store on the iteration - await dialin_service.update_recommendations( - session_id=session_id, - iteration_number=latest.iteration_number, - recommendations=[str(r) for r in recommendations[:6]], - ) - return {"recommendations": recommendations[:6], "source": "ai"} - except Exception as exc: - logger.warning( - "Gemini dial-in recommendation failed, falling back to rules: %s", exc - ) - - # Rule-based fallback - x = latest.taste.x if latest.taste.x is not None else 0.0 - y = latest.taste.y if latest.taste.y is not None else 0.0 - recs: list[str] = [] - - if x < -0.2: - recs.append("Grind finer (2-3 steps)") - recs.append("Increase temperature by 1-2°C") - if x > 0.2: - recs.append("Grind coarser (2-3 steps)") - recs.append("Decrease temperature by 1-2°C") - if y < -0.2: - recs.append("Increase dose by 0.3-0.5g") - if y > 0.2: - recs.append("Decrease dose by 0.3-0.5g") - if not recs: - recs.append("Looking good! Small tweaks only — try ±0.5°C or ±0.2g dose") - - await dialin_service.update_recommendations( - session_id=session_id, - iteration_number=latest.iteration_number, - recommendations=recs, - ) - return {"recommendations": recs, "source": "rules"} - - -# ── Session lifecycle ────────────────────────────────────────────────────────── - - -@router.post("/dialin/sessions/{session_id}/complete") -@router.post("/api/dialin/sessions/{session_id}/complete") -async def complete_session(session_id: str): - """Mark a dial-in session as complete.""" - try: - session = await dialin_service.complete_session(session_id) - return session - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.delete("/dialin/sessions/{session_id}") -@router.delete("/api/dialin/sessions/{session_id}") -async def delete_session(session_id: str): - """Delete a dial-in session.""" - deleted = await dialin_service.delete_session(session_id) - if not deleted: - raise HTTPException(status_code=404, detail="Session not found") - return {"deleted": True} diff --git a/apps/server/api/routes/history.py b/apps/server/api/routes/history.py deleted file mode 100644 index 5dbc9294..00000000 --- a/apps/server/api/routes/history.py +++ /dev/null @@ -1,420 +0,0 @@ -"""Profile history management endpoints.""" - -from fastapi import APIRouter, Request, HTTPException -from fastapi.responses import JSONResponse -import json -import logging - -from services.history_service import load_history, save_history -from utils.sanitization import clean_profile_name - -router = APIRouter() -logger = logging.getLogger(__name__) - - -@router.get("/api/history") -async def get_history(request: Request, limit: int = 50, offset: int = 0): - """Get profile history. - - Args: - limit: Maximum number of entries to return (default: 50) - offset: Number of entries to skip (default: 0) - - Returns: - - entries: List of history entries - - total: Total number of entries - """ - request_id = request.state.request_id - - try: - logger.debug( - "Fetching profile history", - extra={"request_id": request_id, "limit": limit, "offset": offset}, - ) - - history = load_history() - total = len(history) - - # Apply pagination - entries = history[offset : offset + limit] - - # Remove large fields from list view and ensure required fields exist - sanitized_entries = [] - for entry in entries: - entry_copy = dict(entry) # avoid mutating cached history entries - if "image_preview" in entry_copy: - entry_copy["image_preview"] = ( - None # Remove for list view to save bandwidth - ) - # Ensure profile_name is always a string (defense against corrupt data) - if not entry_copy.get("profile_name"): - entry_copy["profile_name"] = ( - entry_copy.get("profile_json", {}).get("name", "Untitled Profile") - if isinstance(entry_copy.get("profile_json"), dict) - else "Untitled Profile" - ) - sanitized_entries.append(entry_copy) - - return { - "entries": sanitized_entries, - "total": total, - "limit": limit, - "offset": offset, - } - - except Exception as e: - logger.error( - f"Failed to retrieve history: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to retrieve history", - }, - ) - - -@router.get("/api/history/{entry_id}") -async def get_history_entry(request: Request, entry_id: str): - """Get a specific history entry by ID. - - Args: - entry_id: The unique ID of the history entry - - Returns: - The full history entry including profile JSON - """ - request_id = request.state.request_id - - try: - logger.debug( - "Fetching history entry", - extra={"request_id": request_id, "entry_id": entry_id}, - ) - - history = load_history() - - for entry in history: - if entry.get("id") == entry_id: - # Ensure profile_name is always a string - if not entry.get("profile_name"): - pj = entry.get("profile_json") - entry["profile_name"] = ( - pj.get("name", "Untitled Profile") - if isinstance(pj, dict) - else "Untitled Profile" - ) - return entry - - raise HTTPException(status_code=404, detail="History entry not found") - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to retrieve history entry: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "entry_id": entry_id, - "error_type": type(e).__name__, - }, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to retrieve history entry", - }, - ) - - -@router.delete("/history/{entry_id}") -@router.delete("/api/history/{entry_id}") -async def delete_history_entry(request: Request, entry_id: str): - """Delete a specific history entry. - - Args: - entry_id: The unique ID of the history entry to delete - - Returns: - Success status - """ - request_id = request.state.request_id - - try: - logger.info( - "Deleting history entry", - extra={"request_id": request_id, "entry_id": entry_id}, - ) - - history = load_history() - original_length = len(history) - - history = [entry for entry in history if entry.get("id") != entry_id] - - if len(history) == original_length: - raise HTTPException(status_code=404, detail="History entry not found") - - save_history(history) - - return {"status": "success", "message": "History entry deleted"} - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to delete history entry: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "entry_id": entry_id, - "error_type": type(e).__name__, - }, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to delete history entry", - }, - ) - - -@router.delete("/api/history") -async def clear_history(request: Request): - """Clear all profile history. - - Returns: - Success status - """ - request_id = request.state.request_id - - try: - logger.warning("Clearing all history", extra={"request_id": request_id}) - - save_history([]) - - return {"status": "success", "message": "All history cleared"} - - except Exception as e: - logger.error( - f"Failed to clear history: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to clear history", - }, - ) - - -@router.post("/api/history/migrate") -async def migrate_history_profile_names(request: Request): - """Migrate history to clean up malformed profile names. - - This fixes profile names that have markdown artifacts like ** or *. - - Returns: - Number of entries fixed - """ - request_id = request.state.request_id - - try: - history = load_history() - fixed_count = 0 - - for entry in history: - old_name = entry.get("profile_name", "") - new_name = clean_profile_name(old_name) - - if old_name != new_name: - entry["profile_name"] = new_name - fixed_count += 1 - logger.info( - f"Fixed profile name: '{old_name}' -> '{new_name}'", - extra={"request_id": request_id}, - ) - - if fixed_count > 0: - save_history(history) - - logger.info( - f"Migration complete: {fixed_count} profile names fixed", - extra={"request_id": request_id}, - ) - - return { - "status": "success", - "message": f"Fixed {fixed_count} profile names", - "fixed_count": fixed_count, - } - - except Exception as e: - logger.error( - f"Failed to migrate history: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to migrate history", - }, - ) - - -@router.get("/api/history/{entry_id}/json") -async def get_profile_json(request: Request, entry_id: str): - """Get the profile JSON for download. - - Args: - entry_id: The unique ID of the history entry - - Returns: - The profile JSON with proper Content-Disposition header for download - """ - request_id = request.state.request_id - - try: - logger.debug( - "Fetching profile JSON for download", - extra={"request_id": request_id, "entry_id": entry_id}, - ) - - history = load_history() - - for entry in history: - if entry.get("id") == entry_id: - if not entry.get("profile_json"): - raise HTTPException( - status_code=404, - detail="Profile JSON not available for this entry", - ) - - # Create filename from profile name - profile_name = entry.get("profile_name", "profile") - safe_filename = ( - "".join( - c if c.isalnum() or c in (" ", "-", "_") else "" - for c in profile_name - ) - .strip() - .replace(" ", "-") - .lower() - ) - - return JSONResponse( - content=entry["profile_json"], - headers={ - "Content-Disposition": f'attachment; filename="{safe_filename}.json"' - }, - ) - - raise HTTPException(status_code=404, detail="History entry not found") - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to get profile JSON: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "entry_id": entry_id, - "error_type": type(e).__name__, - }, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to get profile JSON", - }, - ) - - -@router.get("/api/history/{entry_id}/notes") -async def get_history_notes(entry_id: str, request: Request): - """Get notes for a history entry. - - Args: - entry_id: The ID of the history entry. - - Returns: - notes: The notes content (or null if none). - """ - from services.history_service import get_entry_by_id - - entry = get_entry_by_id(entry_id) - if not entry: - raise HTTPException(status_code=404, detail="History entry not found") - - return { - "status": "success", - "notes": entry.get("notes"), - "notes_updated_at": entry.get("notes_updated_at"), - } - - -@router.patch("/api/history/{entry_id}/notes") -async def update_history_notes(entry_id: str, request: Request): - """Update notes for a history entry. - - Args: - entry_id: The ID of the history entry. - - Body: - notes: The new notes content (Markdown). Empty to clear. - - Returns: - Updated notes information. - """ - from services.history_service import update_entry_notes - - request_id = request.state.request_id - - try: - try: - body = await request.json() - except (json.JSONDecodeError, ValueError): - raise HTTPException( - status_code=400, - detail={"status": "error", "error": "Invalid JSON body"}, - ) - notes_text = body.get("notes", "") - - updated_entry = update_entry_notes(entry_id, notes_text) - - if not updated_entry: - raise HTTPException(status_code=404, detail="History entry not found") - - return { - "status": "success", - "notes": updated_entry.get("notes"), - "notes_updated_at": updated_entry.get("notes_updated_at"), - } - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to update history notes: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "entry_id": entry_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) diff --git a/apps/server/api/routes/machine_status.py b/apps/server/api/routes/machine_status.py deleted file mode 100644 index f906e909..00000000 --- a/apps/server/api/routes/machine_status.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Machine status endpoints — proxy to meticulous-watcher and machine API.""" - -import re -from urllib.parse import urlparse, urlunparse -from fastapi import APIRouter -from services.meticulous_service import _resolve_meticulous_base_url -import httpx -from logging_config import get_logger - -logger = get_logger() -router = APIRouter() - - -def _watcher_url(machine_url: str) -> str: - """Derive the watcher URL (port 3000) from the machine base URL.""" - parsed = urlparse(machine_url) - # Replace whatever port (or no port) with 3000 - host = parsed.hostname or parsed.netloc - # IPv6 literals must be bracketed in a URL netloc, otherwise the trailing - # ":3000" is indistinguishable from the address (e.g. "fe80::1:3000"). - if host and ":" in host and not host.startswith("["): - host = f"[{host}]" - return urlunparse((parsed.scheme or "http", f"{host}:3000", "", "", "", "")) - - -def _parse_size_to_mb(size_str: str) -> float: - """Parse a human-readable size string (e.g. '1.93 GB', '700.91 MB') to MB.""" - match = re.match(r'([\d.]+)\s*(GB|MB|KB|TB)', size_str, re.IGNORECASE) - if not match: - return 0.0 - value = float(match.group(1)) - unit = match.group(2).upper() - if unit == 'TB': - return value * 1024 * 1024 - if unit == 'GB': - return value * 1024 - if unit == 'KB': - return value / 1024 - return value # MB - - -def _parse_uptime_to_seconds(uptime_str: str) -> int: - """Parse watcher uptime string like '0 days, 0 hours 41 minutes 35 seconds' to seconds.""" - total = 0 - for match in re.finditer(r'(\d+)\s*(days?|hours?|minutes?|seconds?)', uptime_str): - val = int(match.group(1)) - unit = match.group(2).lower() - if unit.startswith('day'): - total += val * 86400 - elif unit.startswith('hour'): - total += val * 3600 - elif unit.startswith('minute'): - total += val * 60 - else: - total += val - return total - - -def _coerce_uptime(value) -> "int | None": - """Coerce a per-service uptime (string like '0 hours 41 minutes' or a - numeric seconds value) to integer seconds, or None when unavailable.""" - if isinstance(value, str) and value.strip(): - return _parse_uptime_to_seconds(value) - if isinstance(value, bool): - return None - if isinstance(value, (int, float)): - return int(value) - return None - - -def _transform_watcher_response(raw: dict) -> dict: - """Transform raw watcher /status response into the shape the frontend expects.""" - # Services: object → array - raw_services = raw.get("services", {}) - services = [] - if isinstance(raw_services, dict): - for name, info in raw_services.items(): - info_dict = info if isinstance(info, dict) else {} - services.append({ - "name": name, - "status": info_dict.get("status", "unknown"), - "uptime": _coerce_uptime(info_dict.get("uptime")), - }) - elif isinstance(raw_services, list): - services = raw_services # already in expected format - - # System metrics - system = None - mem = raw.get("memoryUsage", {}) - discs = raw.get("discs", []) - uptime_str = raw.get("uptime", "") - - mem_total = _parse_size_to_mb(mem.get("total", "")) if isinstance(mem, dict) else 0 - mem_used = _parse_size_to_mb(mem.get("used", "")) if isinstance(mem, dict) else 0 - - # Use the root filesystem disc for disk metrics - disk_total = 0.0 - disk_used = 0.0 - if isinstance(discs, list): - for disc in discs: - mp = disc.get("mountpoint", "") - if mp == "/": - usage = disc.get("usage", {}) - disk_total = _parse_size_to_mb(usage.get("total", "")) / 1024 # GB - disk_used = _parse_size_to_mb(usage.get("used", "")) / 1024 # GB - break - - uptime_secs = _parse_uptime_to_seconds(uptime_str) if uptime_str else None - - if mem_total or disk_total or uptime_secs: - system = { - "memory_total": round(mem_total) if mem_total else None, - "memory_used": round(mem_used) if mem_used else None, - "disk_total": round(disk_total, 2) if disk_total else None, - "disk_used": round(disk_used, 2) if disk_used else None, - "cpu_temperature": None, # watcher doesn't expose this - "uptime": uptime_secs, - } - - return {"services": services, "system": system} - - -@router.get("/api/machine/status/health") -async def get_machine_status(): - """Proxy to meticulous-watcher service for health status.""" - machine_url = _resolve_meticulous_base_url() - watcher_base = _watcher_url(machine_url) - try: - async with httpx.AsyncClient(timeout=5.0) as client: - resp = await client.get(f"{watcher_base}/status") - resp.raise_for_status() - return _transform_watcher_response(resp.json()) - except Exception as e: - logger.warning(f"Watcher service unreachable: {e}") - return {"error": "Watcher service unavailable", "services": [], "system": None} - - -@router.get("/api/machine/system-info") -async def get_machine_system_info(): - """Aggregate system info from machine API.""" - machine_url = _resolve_meticulous_base_url() - base = machine_url.rstrip("/") - info: dict = {"firmware": None, "network": None, "hostname": None} - async with httpx.AsyncClient(timeout=5.0) as client: - for key, path in [ - ("firmware", "/api/v1/system/firmware"), - ("network", "/api/v1/wifi/status"), - ("hostname", "/api/v1/wifi/hostname"), - ]: - try: - resp = await client.get(f"{base}{path}") - if resp.status_code == 200: - info[key] = resp.json() - except Exception: - pass # key already initialized to None - return info diff --git a/apps/server/api/routes/pour_over.py b/apps/server/api/routes/pour_over.py deleted file mode 100644 index bf12eae3..00000000 --- a/apps/server/api/routes/pour_over.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Pour-over machine integration endpoints. - -Provides the lifecycle for temporary pour-over profiles: - - Prepare: adapt template → ephemeral load (no save to catalogue) - - Cleanup: purge + restore previous profile - - Force-cleanup: restore without purge (aborted shots) - - Active: query the current temp profile -""" - -import logging -from typing import Optional - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel, Field - -from services.pour_over_adapter import adapt_pour_over_profile -from services import temp_profile_service -from services.temp_profile_service import is_temp_profile -from services import pour_over_preferences -from services.mqtt_service import get_mqtt_subscriber - -router = APIRouter() -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Request / response models -# --------------------------------------------------------------------------- - - -class PrepareRequest(BaseModel): - target_weight: float = Field(..., gt=0, description="Target brew weight in grams") - bloom_enabled: bool = Field(True, description="Include bloom stage") - bloom_seconds: float = Field( - 30.0, gt=0, le=300, description="Bloom duration in seconds" - ) - dose_grams: Optional[float] = Field( - None, gt=0, description="Dose in grams (informational)" - ) - brew_ratio: Optional[float] = Field( - None, gt=0, description="Brew ratio (informational)" - ) - - -class PrepareResponse(BaseModel): - profile_id: str - profile_name: str - - -class CleanupResponse(BaseModel): - status: str - deleted_profile: Optional[str] = None - error: Optional[str] = None - - -class ActiveResponse(BaseModel): - active: bool - profile_id: Optional[str] = None - profile_name: Optional[str] = None - original_params: Optional[dict] = None - - -class PrepareRecipeRequest(BaseModel): - recipe_slug: str = Field(..., min_length=1, description="Recipe slug identifier") - - -# --------------------------------------------------------------------------- -# Endpoints -# --------------------------------------------------------------------------- - - -@router.post("/api/pour-over/prepare", response_model=PrepareResponse) -async def prepare_pour_over(body: PrepareRequest): - """Adapt the pour-over template and load it on the machine. - - Creates a temporary profile with the given parameters, uploads it to the - machine, and selects it so the user can press Start. - """ - try: - profile_json = adapt_pour_over_profile( - target_weight=body.target_weight, - bloom_enabled=body.bloom_enabled, - bloom_seconds=body.bloom_seconds, - dose_grams=body.dose_grams, - brew_ratio=body.brew_ratio, - ) - except FileNotFoundError as exc: - raise HTTPException(status_code=500, detail=str(exc)) from exc - - # Capture the currently-loaded profile name from the MQTT snapshot so - # we can restore it after the temporary pour-over profile is cleaned up. - previous_profile_name = None - try: - snapshot = get_mqtt_subscriber().get_snapshot() - name = snapshot.get("active_profile") - if name and not is_temp_profile(name): - previous_profile_name = name - except Exception as exc: - logger.warning("Could not read active profile from MQTT snapshot: %s", exc) - - result = await temp_profile_service.load_ephemeral( - profile_json, - params={ - "target_weight": body.target_weight, - "bloom_enabled": body.bloom_enabled, - "bloom_seconds": body.bloom_seconds, - "dose_grams": body.dose_grams, - "brew_ratio": body.brew_ratio, - }, - previous_profile_name=previous_profile_name, - ) - - return PrepareResponse( - profile_id=result["profile_id"], - profile_name=result["profile_name"], - ) - - -@router.post("/api/pour-over/prepare-recipe", response_model=PrepareResponse) -async def prepare_recipe(body: PrepareRecipeRequest): - """Adapt an OPOS recipe to a machine profile and load it. - - Creates a temporary profile for the given recipe, uploads it to the - machine, and selects it so the user can press Start. - """ - from services.recipe_adapter import adapt_recipe_to_profile, load_recipe - - try: - recipe = load_recipe(body.recipe_slug) - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - - try: - profile_json = adapt_recipe_to_profile(recipe) - except Exception as exc: - raise HTTPException( - status_code=500, detail=f"Failed to adapt recipe: {exc}" - ) from exc - - previous_profile_name = None - try: - snapshot = get_mqtt_subscriber().get_snapshot() - name = snapshot.get("active_profile") - if name and not is_temp_profile(name): - previous_profile_name = name - except Exception as exc: - logger.warning("Could not read active profile from MQTT snapshot: %s", exc) - - result = await temp_profile_service.load_ephemeral( - profile_json, - params={"recipe_slug": body.recipe_slug}, - previous_profile_name=previous_profile_name, - ) - - return PrepareResponse( - profile_id=result["profile_id"], - profile_name=result["profile_name"], - ) - - -@router.post("/api/pour-over/cleanup", response_model=CleanupResponse) -async def cleanup_pour_over(): - """Purge the group head and delete the temporary profile.""" - result = await temp_profile_service.cleanup() - return CleanupResponse(**result) - - -@router.post("/api/pour-over/force-cleanup", response_model=CleanupResponse) -async def force_cleanup_pour_over(): - """Delete the temporary profile without purging (for aborted shots).""" - result = await temp_profile_service.force_cleanup() - return CleanupResponse(**result) - - -@router.get("/api/pour-over/active", response_model=ActiveResponse) -async def get_active_pour_over(): - """Return the currently active temporary profile, if any.""" - active = temp_profile_service.get_active() - if active is None: - return ActiveResponse(active=False) - return ActiveResponse( - active=True, - profile_id=active["profile_id"], - profile_name=active["profile_name"], - original_params=active["original_params"], - ) - - -# --------------------------------------------------------------------------- -# Preferences -# --------------------------------------------------------------------------- - - -class ModePreferences(BaseModel): - autoStart: bool = True - bloomEnabled: bool = True - bloomSeconds: float = 30 - bloomWeightMultiplier: float = 2 - machineIntegration: bool = False - doseGrams: Optional[float] = None - brewRatio: Optional[float] = None - - -class RecipeModePreferences(BaseModel): - machineIntegration: bool = False - autoStart: bool = True - progressionMode: str = "weight" - - -class PreferencesPayload(BaseModel): - free: ModePreferences = Field(default_factory=ModePreferences) - ratio: ModePreferences = Field(default_factory=ModePreferences) - recipe: RecipeModePreferences = Field(default_factory=RecipeModePreferences) - - -@router.get("/api/pour-over/preferences") -async def get_preferences(): - """Return the stored per-mode pour-over preferences.""" - return pour_over_preferences.load_preferences() - - -@router.put("/api/pour-over/preferences") -async def save_preferences(body: PreferencesPayload): - """Save per-mode pour-over preferences.""" - saved = pour_over_preferences.save_preferences(body.model_dump()) - return saved diff --git a/apps/server/api/routes/profiles.py b/apps/server/api/routes/profiles.py deleted file mode 100644 index 97868b05..00000000 --- a/apps/server/api/routes/profiles.py +++ /dev/null @@ -1,4418 +0,0 @@ -"""Profile management endpoints.""" - -from fastapi import APIRouter, Request, UploadFile, File, Form, HTTPException, Query -from typing import Any -from datetime import datetime, timezone -from pydantic import BaseModel -import json -import math -import os -import logging -import asyncio -import uuid -import base64 -import binascii -import ipaddress -import socket -import httpx -from urllib.parse import urlparse - -# Register HEIC/HEIF support with Pillow -try: - from pillow_heif import register_heif_opener - - register_heif_opener() -except ImportError: - pass # pillow-heif not installed; HEIC files will fail gracefully - -from config import DATA_DIR, MAX_UPLOAD_SIZE -from services.meticulous_service import ( - async_list_profiles, - async_get_profile, - async_save_profile, - async_create_profile, - async_delete_profile, - async_session_post, - MachineUnreachableError, - invalidate_profile_list_cache, - fetch_machine_profile_dict, - _normalize_profile_for_machine, -) -from services.cache_service import _get_cached_image, _set_cached_image -from services.gemini_service import get_vision_model, PROFILING_KNOWLEDGE -from services.profile_recommendation_service import ( - recommendation_service, - extract_fingerprint, -) -from services.history_service import ( - load_history, - save_history, - compute_content_hash, - update_entry_sync_fields, - _history_lock, -) -from services.analysis_service import ( - _generate_profile_description, - generate_estimated_target_curves, -) -from services.settings_service import load_settings -from services.scheduling_state import ( - _scheduled_shots, - _recurring_schedules, - save_scheduled_shots as _save_scheduled_shots, - save_recurring_schedules as _save_recurring_schedules, - get_next_occurrence as _get_next_occurrence, -) -from utils.file_utils import deep_convert_to_dict -from services.temp_profile_service import is_temp_profile, get_active, apply_variable_overrides - -router = APIRouter() -logger = logging.getLogger(__name__) - -IMAGE_CACHE_DIR = DATA_DIR / "image_cache" - -# Technique tag → PRESET_TAG label mapping (mirrors TypeScript TECHNIQUE_TO_LABEL) -_TECHNIQUE_TO_LABEL = { - "pressure-profile": "Pressure-controlled", - "flow-profile": "Flow-controlled", - "mixed-profile": "Mixed-controlled", - "preinfusion": "Pre-infusion", - "bloom": "Bloom", - "pulse": "Pulse", - "flat": "Flat profile", - "lever": "Lever", - "turbo": "Turbo", - "ramp": "Ramp", - "decline": "Decline", - "taper": "Taper", -} - - -def _temperature_range(temp: float) -> str: - """Map temperature to a labelled range matching TypeScript temperatureRange().""" - if temp < 82: - return "Very low temp (<82°C)" - if temp <= 84: - return "Low temp (82\u201384°C)" - if temp <= 87: - return "Warm (85\u201387°C)" - if temp <= 90: - return "Medium temp (88\u201390°C)" - if temp <= 93: - return "High temp (91\u201393°C)" - return "Very high temp (94°C+)" - - -def _weight_range(weight: float) -> str: - """Map target weight (grams) to an espresso size label matching TypeScript weightRange().""" - if weight <= 35: - return "Ristretto (\u226435g)" - if weight <= 44: - return "Normale (36\u201344g)" - if weight <= 54: - return "Lungo (45\u201354g)" - return "Allong\u00e9 (55g+)" - - -def _pressure_range(pressure: float) -> str: - """Map peak pressure (bar) to a range label matching TypeScript pressureRange().""" - if pressure <= 4: - return "Low pressure (\u22644 bar)" - if pressure <= 7: - return "Medium pressure (5\u20137 bar)" - if pressure <= 9: - return "Standard pressure (8\u20139 bar)" - return "High pressure (10+ bar)" - - -def _derive_structural_tags(profile_obj: object) -> list[str]: - """Derive user-facing structural tags from a profile object using extract_fingerprint. - - Must be called on the raw Meticulous profile object (with attributes), - NOT on a dict — extract_fingerprint uses getattr(). - """ - try: - fp = extract_fingerprint(profile_obj) - except (AttributeError, TypeError, KeyError) as exc: - logger.debug("Failed to extract fingerprint for structural tags: %s", exc) - return [] - - tags: set[str] = set() - for tt in fp.get("technique_tags", set()): - label = _TECHNIQUE_TO_LABEL.get(tt) - if label: - tags.add(label) - - temp = fp.get("temperature") - if temp is not None: - try: - tags.add(_temperature_range(float(temp))) - except (TypeError, ValueError): - pass - - final_weight = fp.get("final_weight") - if final_weight is not None: - try: - tags.add(_weight_range(float(final_weight))) - except (TypeError, ValueError): - pass - - peak_pressure = fp.get("peak_pressure", 0) - try: - pp = float(peak_pressure) - if pp > 0: - tags.add(_pressure_range(pp)) - except (TypeError, ValueError): - pass - - # Adaptive detection: check for $variable references in stages - stages = getattr(profile_obj, "stages", None) or [] - is_adaptive = False - for stage in stages: - dynamics = getattr(stage, "dynamics", None) - if dynamics: - for point in getattr(dynamics, "points", []) or []: - if len(point) >= 2: - if isinstance(point[0], str) and point[0].startswith("$"): - is_adaptive = True - if isinstance(point[1], str) and point[1].startswith("$"): - is_adaptive = True - for limit_obj in getattr(stage, "limits", []) or []: - val = getattr(limit_obj, "value", None) - if isinstance(val, str) and val.startswith("$"): - is_adaptive = True - for exit_obj in getattr(stage, "exit_triggers", []) or []: - val = getattr(exit_obj, "value", None) - if isinstance(val, str) and val.startswith("$"): - is_adaptive = True - if is_adaptive: - tags.add("Adaptive") - - # Structural bloom detection (content-based) - if "Bloom" not in tags: - for stage in stages: - stype = (getattr(stage, "type", "") or "").lower() - dynamics = getattr(stage, "dynamics", None) - pts = getattr(dynamics, "points", []) or [] if dynamics else [] - exits = getattr(stage, "exit_triggers", []) or [] - has_time_exit = any( - (getattr(e, "type", "") or "").lower() == "time" for e in exits - ) - if stype == "flow" and has_time_exit and pts: - try: - y_vals = [ - abs(float(p[1])) - for p in pts - if len(p) >= 2 - and not (isinstance(p[1], str) and p[1].startswith("$")) - ] - if y_vals and all(v <= 0.1 for v in y_vals): - tags.add("Bloom") - break - except (TypeError, ValueError): - pass - if stype == "power" and has_time_exit and pts: - try: - y_vals = [ - abs(float(p[1])) - for p in pts - if len(p) >= 2 - and not (isinstance(p[1], str) and p[1].startswith("$")) - ] - if y_vals and all(v <= 5 for v in y_vals): - tags.add("Bloom") - break - except (TypeError, ValueError): - pass - - # Structural pre-infusion detection (content-based) - if "Pre-infusion" not in tags and len(stages) >= 2: - first = stages[0] - ftype = (getattr(first, "type", "") or "").lower() - if ftype == "power": - tags.add("Pre-infusion") - else: - dynamics = getattr(first, "dynamics", None) - pts = getattr(dynamics, "points", []) or [] if dynamics else [] - try: - y_vals = [ - float(p[1]) - for p in pts - if len(p) >= 2 - and not (isinstance(p[1], str) and p[1].startswith("$")) - ] - max_y = max(y_vals) if y_vals else float("inf") - if (ftype == "pressure" and max_y <= 4) or ( - ftype == "flow" and max_y <= 2 - ): - tags.add("Pre-infusion") - except (TypeError, ValueError): - pass - - return sorted(tags) - - -def _derive_structural_tags_from_dict(profile_dict: dict) -> list[str]: - """Derive structural tags from a profile stored as a plain dict. - - Used for offline/history fallback where profile_json is a dict. - Wraps the dict in a SimpleNamespace so extract_fingerprint's getattr() calls work. - """ - from types import SimpleNamespace - - def _to_ns(obj: Any) -> Any: - if isinstance(obj, dict): - return SimpleNamespace(**{k: _to_ns(v) for k, v in obj.items()}) - if isinstance(obj, list): - return [_to_ns(item) for item in obj] - return obj - - try: - ns = _to_ns(profile_dict) - return _derive_structural_tags(ns) - except (AttributeError, TypeError, KeyError) as exc: - logger.debug("Failed to derive structural tags from dict: %s", exc) - return [] - - -# Simple placeholder SVG for profiles without images (coffee bean icon) -PLACEHOLDER_SVG = b""" - - - - -""" - - -def _parse_data_image_uri(image_uri: str) -> tuple[str, bytes]: - """Parse and decode a base64 data:image URI. - - Returns: - Tuple of (mime_type, decoded_bytes) - """ - try: - header, encoded_image = image_uri.split(",", 1) - except ValueError as exc: - raise HTTPException( - status_code=400, detail="Invalid profile image data" - ) from exc - - if not header.endswith(";base64") or not header.startswith("data:image/"): - raise HTTPException(status_code=400, detail="Invalid profile image data") - - mime_type = ( - header[5:-7].strip().lower() - ) # strip "data:" prefix and ";base64" suffix - if not mime_type.startswith("image/"): - raise HTTPException(status_code=400, detail="Invalid profile image data") - - try: - image_bytes = base64.b64decode(encoded_image, validate=True) - except (ValueError, binascii.Error) as exc: - raise HTTPException( - status_code=400, detail="Invalid profile image data" - ) from exc - - return mime_type, image_bytes - - -def _canonical_host(value: str) -> str: - parsed = urlparse(value if "://" in value else f"http://{value}") - host = (parsed.hostname or "").strip().lower() - if host in {"", "localhost"}: - return host - try: - return str(ipaddress.ip_address(host)) - except ValueError: - return host.rstrip(".") - - -def _is_allowed_machine_image_url(image_url: str) -> bool: - parsed = urlparse(image_url) - if parsed.scheme not in ("http", "https"): - return False - - target_host = (parsed.hostname or "").strip().lower() - if not target_host: - return False - - meticulous_ip = os.getenv("METICULOUS_IP") - if not meticulous_ip: - settings = load_settings() - meticulous_ip = (settings.get("meticulousIp") or "").strip() - if not meticulous_ip: - return False - - allowed_host = _canonical_host(meticulous_ip) - candidate_host = _canonical_host(target_host) - if candidate_host == allowed_host: - return True - - try: - allowed_resolved = {socket.gethostbyname(allowed_host)} - except Exception: - allowed_resolved = set() - - try: - candidate_resolved = {socket.gethostbyname(candidate_host)} - except Exception: - candidate_resolved = set() - - return bool( - allowed_resolved - and candidate_resolved - and (allowed_resolved & candidate_resolved) - ) - - -def process_image_for_profile( - image_data: bytes, content_type: str = "image/png" -) -> tuple[str, bytes]: - """Process an image for profile upload: crop to square, resize to 512x512, convert to base64 data URI. - - Args: - image_data: Raw image bytes - content_type: MIME type of the image - - Returns: - Tuple of (base64 data URI string, PNG bytes for caching) - """ - from PIL import Image as PILImage - import io - import base64 as b64 - - # Open image with PIL - img = PILImage.open(io.BytesIO(image_data)) - - # Convert to RGB if necessary (for PNG with alpha channel) - if img.mode in ("RGBA", "LA", "P"): - # Create white background for transparency - background = PILImage.new("RGB", img.size, (255, 255, 255)) - if img.mode == "P": - img = img.convert("RGBA") - if img.mode in ("RGBA", "LA"): - background.paste(img, mask=img.split()[-1]) # Use alpha channel as mask - img = background - else: - img = img.convert("RGB") - elif img.mode != "RGB": - img = img.convert("RGB") - - # Crop to square (center crop) - width, height = img.size - min_dim = min(width, height) - left = (width - min_dim) // 2 - top = (height - min_dim) // 2 - right = left + min_dim - bottom = top + min_dim - img = img.crop((left, top, right, bottom)) - - # Resize to 512x512 - img = img.resize((512, 512), PILImage.Resampling.LANCZOS) - - # Convert to PNG bytes - buffer = io.BytesIO() - img.save(buffer, format="PNG", optimize=True) - png_bytes = buffer.getvalue() - - # Encode to base64 data URI - b64_data = b64.b64encode(png_bytes).decode("utf-8") - return f"data:image/png;base64,{b64_data}", png_bytes - - -@router.post("/api/profile/{profile_name:path}/image") -async def upload_profile_image( - profile_name: str, request: Request, file: UploadFile = File(...) -): - """Upload an image for a profile. - - The image will be: - - Center-cropped to square aspect ratio - - Resized to 512x512 - - Converted to base64 data URI - - Saved to the profile on the Meticulous machine - - Args: - profile_name: Name of the profile to update - file: Image file to upload - - Returns: - Success status with profile info - """ - request_id = request.state.request_id - - try: - logger.info( - f"Uploading image for profile: {profile_name}", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - - # Validate file type - if not file.content_type or not file.content_type.startswith("image/"): - raise HTTPException(status_code=400, detail="File must be an image") - - # Read image data with size limit - image_data = await file.read() - - # Validate file size - if len(image_data) > MAX_UPLOAD_SIZE: - raise HTTPException( - status_code=413, - detail=f"Image too large. Maximum size is {MAX_UPLOAD_SIZE / (1024 * 1024):.0f}MB", - ) - - # Process image: crop, resize, encode (CPU-bound, offload to thread) - loop = asyncio.get_running_loop() - image_data_uri, png_bytes = await loop.run_in_executor( - None, process_image_for_profile, image_data, file.content_type - ) - - # Cache the processed image for fast retrieval - _set_cached_image(profile_name, png_bytes) - - logger.info( - f"Processed image for profile: {profile_name} (size: {len(image_data_uri)} chars)", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - - # Find the profile by name - profiles_result = await async_list_profiles() - - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {profiles_result.error}" - ) - - # Find matching profile - matching_profile = None - profile_fetch_error = None - for partial_profile in profiles_result: - if partial_profile.name == profile_name: - # Get full profile - full_profile = await async_get_profile(partial_profile.id) - if hasattr(full_profile, "error") and full_profile.error: - # Non-UUID profile IDs cause 404 on machine API - profile_fetch_error = f"Unable to fetch profile details. Profile ID '{partial_profile.id}' may be invalid (non-UUID). Consider deleting and recreating this profile." - logger.warning( - f"Profile found in list but fetch failed: {profile_name}", - extra={ - "request_id": request_id, - "profile_id": partial_profile.id, - "error": full_profile.error, - }, - ) - continue - matching_profile = full_profile - break - - if not matching_profile: - error_detail = f"Profile '{profile_name}' not found on machine" - if profile_fetch_error: - error_detail = profile_fetch_error - raise HTTPException(status_code=404, detail=error_detail) - - # Update the profile with the new image - from meticulous.profile import Display - - # Preserve existing accent color if present - existing_accent = None - if matching_profile.display: - existing_accent = matching_profile.display.accentColor - - matching_profile.display = Display( - image=image_data_uri, accentColor=existing_accent - ) - - # Save the updated profile - save_result = await async_save_profile(matching_profile) - - if hasattr(save_result, "error") and save_result.error: - raise HTTPException( - status_code=502, detail=f"Failed to save profile: {save_result.error}" - ) - - logger.info( - f"Successfully updated profile image: {profile_name}", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - - return { - "status": "success", - "message": f"Image uploaded for profile '{profile_name}'", - "profile_id": matching_profile.id, - "image_size": len(image_data_uri), - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to upload profile image: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "profile_name": profile_name, - "error_type": type(e).__name__, - }, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to upload profile image", - }, - ) - - -# Image generation styles that work well for coffee/espresso profiles -IMAGE_GEN_STYLES = [ - "abstract", - "minimalist", - "pixel-art", - "watercolor", - "modern", - "vintage", -] - - -@router.post("/api/profile/{profile_name:path}/generate-image") -async def generate_profile_image( - profile_name: str, - request: Request, - style: str = "abstract", - tags: str = "", - preview: bool = False, - count: int = Query(default=1, ge=1, le=4), -): - """Generate an AI image for a profile using the active AI provider. - - Gemini uses the google-genai SDK (imagen-4.0-fast-generate-001); OpenAI and - OpenRouter use their OpenAI-compatible image endpoints (#505). Generates a - square image based on the profile name and optional tags. - - Args: - profile_name: Name of the profile - style: Image style (abstract, minimalist, pixel-art, watercolor, modern, vintage) - tags: Comma-separated tags to include in the prompt - preview: If true, return the image as base64 without saving to profile - count: Number of images to generate (1-4). When >1, returns an array of images. - - Returns: - count=1: Single-image response (backward compatible) - count>1: {"images": [...], "count": N} with per-image results - """ - request_id = request.state.request_id - - try: - logger.info( - f"Generating image for profile: {profile_name}", - extra={ - "request_id": request_id, - "profile_name": profile_name, - "style": style, - "tags": tags, - "count": count, - }, - ) - - # Validate style - if style not in IMAGE_GEN_STYLES: - style = "abstract" - - # Parse tags - tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else [] - - # Build the prompt using the advanced prompt builder - from prompt_builder import build_image_prompt_with_metadata - - prompt_result = build_image_prompt_with_metadata( - profile_name=profile_name, style=style, tags=tag_list - ) - - if not prompt_result or not isinstance(prompt_result, dict): - logger.error( - "Failed to build image prompt - prompt_result is invalid", - extra={"request_id": request_id, "prompt_result": prompt_result}, - ) - raise HTTPException( - status_code=500, detail="Failed to build image generation prompt" - ) - - full_prompt = prompt_result.get("prompt", "") - prompt_metadata = prompt_result.get("metadata", {}) - - logger.info( - "Built image generation prompt", - extra={ - "request_id": request_id, - "prompt": full_prompt[:200], - "influences_found": prompt_metadata.get("influences_found", 0), - "selected_colors": prompt_metadata.get("selected_colors", []), - "selected_moods": prompt_metadata.get("selected_moods", []), - }, - ) - - # Resolve the active provider. Gemini uses the native SDK; OpenAI and - # OpenRouter generate via their OpenAI-compatible image endpoints (#505). - from services.ai_providers import ( - generate_image_bytes, - get_active_provider_id, - provider_supports_image, - ) - - provider_id = get_active_provider_id() - if not provider_supports_image(provider_id): - raise HTTPException( - status_code=503, - detail=( - "The selected AI provider does not support image generation. " - "Switch to Gemini, OpenAI, or OpenRouter in Settings." - ), - ) - - gemini_client = None - genai_types = None - if provider_id == "gemini": - from google.genai import types as genai_types - from services.gemini_service import get_gemini_client - - try: - gemini_client = get_gemini_client() - except ValueError: - raise HTTPException( - status_code=503, - detail="AI features are unavailable. Please configure a Gemini API key in Settings.", - ) - - async def _generate_single(index: int) -> dict: - """Generate and process a single image. Returns result dict.""" - try: - if provider_id == "gemini": - gen_response = await asyncio.to_thread( - gemini_client.models.generate_images, - model="imagen-4.0-fast-generate-001", - prompt=full_prompt, - config=genai_types.GenerateImagesConfig( - number_of_images=1, - aspect_ratio="1:1", - output_mime_type="image/png", - ), - ) - - if ( - not gen_response.generated_images - or len(gen_response.generated_images) == 0 - ): - return { - "index": index, - "image": None, - "error": "No image returned by model", - } - - generated = gen_response.generated_images[0] - raw_bytes = generated.image.image_bytes - else: - raw_bytes = await generate_image_bytes(full_prompt, provider_id) - - loop = asyncio.get_running_loop() - data_uri, png_bytes = await loop.run_in_executor( - None, process_image_for_profile, raw_bytes, "image/png" - ) - - # Cache with indexed key for batch, plain key for single - cache_key = ( - f"{profile_name}_batch_{index}" if count > 1 else profile_name - ) - _set_cached_image(cache_key, png_bytes) - - return {"index": index, "image": data_uri} - except Exception as exc: - logger.warning( - f"Batch image {index} failed: {exc}", - extra={"request_id": request_id, "index": index}, - ) - return {"index": index, "image": None, "error": "Image generation failed"} - - # --- Single image (count=1): preserve original response format --- - if count == 1: - result = await _generate_single(0) - - if result.get("error"): - raise HTTPException( - status_code=500, - detail=result["error"], - ) - - image_data_uri = result["image"] - - logger.info( - f"Processed generated image for profile: {profile_name} (size: {len(image_data_uri)} chars)", - extra={"request_id": request_id}, - ) - - if preview: - logger.info( - f"Returning preview image for profile: {profile_name}", - extra={"request_id": request_id, "style": style}, - ) - return { - "status": "preview", - "message": f"Preview image generated for profile '{profile_name}'", - "style": style, - "prompt": full_prompt, - "prompt_metadata": prompt_metadata, - "image_data": image_data_uri, - } - - # Find the profile and update it - profiles_result = await async_list_profiles() - - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException( - status_code=502, - detail=f"Machine API error: {profiles_result.error}", - ) - - matching_profile = None - for partial_profile in profiles_result: - if partial_profile.name == profile_name: - full_profile = await async_get_profile(partial_profile.id) - if hasattr(full_profile, "error") and full_profile.error: - continue - matching_profile = full_profile - break - - if not matching_profile: - raise HTTPException( - status_code=404, - detail=f"Profile '{profile_name}' not found on machine", - ) - - from meticulous.profile import Display - - existing_accent = None - if matching_profile.display: - existing_accent = matching_profile.display.accentColor - - matching_profile.display = Display( - image=image_data_uri, accentColor=existing_accent - ) - - save_result = await async_save_profile(matching_profile) - - if hasattr(save_result, "error") and save_result.error: - raise HTTPException( - status_code=502, - detail=f"Failed to save profile: {save_result.error}", - ) - - logger.info( - f"Successfully generated and saved profile image: {profile_name}", - extra={"request_id": request_id, "style": style}, - ) - - return { - "status": "success", - "message": f"Image generated for profile '{profile_name}'", - "profile_id": matching_profile.id, - "style": style, - "prompt": full_prompt, - "prompt_metadata": prompt_metadata, - } - - # --- Batch mode (count > 1): parallel generation, preview-only --- - results = await asyncio.gather( - *[_generate_single(i) for i in range(count)] - ) - - successful = [r for r in results if r.get("image")] - logger.info( - f"Batch image generation: {len(successful)}/{count} succeeded for {profile_name}", - extra={"request_id": request_id}, - ) - - if not successful: - raise HTTPException( - status_code=500, - detail="All image generation attempts failed", - ) - - return { - "status": "preview", - "message": f"Generated {len(successful)} of {count} images for profile '{profile_name}'", - "style": style, - "prompt": full_prompt, - "prompt_metadata": prompt_metadata, - "images": results, - "count": count, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to generate profile image: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "profile_name": profile_name, - "error_type": type(e).__name__, - }, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to generate profile image", - }, - ) - - -class ApplyImageRequest(BaseModel): - image_data: str # Base64 data URI - - -@router.post("/api/profile/{profile_name:path}/apply-image") -async def apply_profile_image( - profile_name: str, request: Request, body: ApplyImageRequest -): - """Apply a previously generated (previewed) image to a profile. - - This endpoint saves a base64 image data URI to the profile's display. - Used after previewing a generated image and choosing to keep it. - - Args: - profile_name: Name of the profile - body: Request body containing image_data (base64 data URI) - - Returns: - Success status with profile info - """ - request_id = request.state.request_id - - try: - logger.info( - f"Applying image to profile: {profile_name}", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - - image_data_uri = body.image_data - - # Validate it looks like a data URI - if not image_data_uri.startswith("data:image/"): - raise HTTPException( - status_code=400, detail="Invalid image data - must be a data URI" - ) - - # Extract and cache the PNG bytes from the data URI - from PIL import Image as PILImage - import io - import base64 as b64 - - try: - # Format: data:image/png;base64, - header, b64_data = image_data_uri.split(",", 1) - png_bytes = b64.b64decode(b64_data) - - # Validate decoded size - if len(png_bytes) > MAX_UPLOAD_SIZE: - raise HTTPException( - status_code=413, - detail=f"Decoded image too large. Maximum size is {MAX_UPLOAD_SIZE / (1024 * 1024):.0f}MB", - ) - - # Validate it's actually a valid PNG image - try: - img = PILImage.open(io.BytesIO(png_bytes)) - img.verify() # Verify it's a valid image - # Re-open since verify() closes the file - img = PILImage.open(io.BytesIO(png_bytes)) - if img.format != "PNG": - raise HTTPException( - status_code=400, detail=f"Expected PNG format, got {img.format}" - ) - except HTTPException: - raise - except Exception as img_err: - raise HTTPException( - status_code=400, detail=f"Invalid image data: {str(img_err)}" - ) - - _set_cached_image(profile_name, png_bytes) - except HTTPException: - # Re-raise HTTP exceptions to preserve the status code and error message - # that was specifically created for the API client - raise - except Exception as e: - logger.warning(f"Failed to process/cache image from apply-image: {e}") - raise HTTPException( - status_code=400, detail=f"Failed to decode image data: {str(e)}" - ) - - # Find the profile and update it - profiles_result = await async_list_profiles() - - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {profiles_result.error}" - ) - - matching_profile = None - for partial_profile in profiles_result: - if partial_profile.name == profile_name: - full_profile = await async_get_profile(partial_profile.id) - if hasattr(full_profile, "error") and full_profile.error: - continue - matching_profile = full_profile - break - - if not matching_profile: - raise HTTPException( - status_code=404, detail=f"Profile '{profile_name}' not found on machine" - ) - - # Update the display image - from meticulous.profile import Display - - existing_accent = None - if matching_profile.display: - existing_accent = matching_profile.display.accentColor - - matching_profile.display = Display( - image=image_data_uri, accentColor=existing_accent - ) - - save_result = await async_save_profile(matching_profile) - - if hasattr(save_result, "error") and save_result.error: - raise HTTPException( - status_code=502, detail=f"Failed to save profile: {save_result.error}" - ) - - logger.info( - f"Successfully applied image to profile: {profile_name}", - extra={"request_id": request_id}, - ) - - return { - "status": "success", - "message": f"Image applied to profile '{profile_name}'", - "profile_id": matching_profile.id, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to apply profile image: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "profile_name": profile_name, - "error_type": type(e).__name__, - }, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to apply profile image", - }, - ) - - -@router.get("/api/profile/{profile_name:path}/image-proxy") -async def proxy_profile_image( - profile_name: str, request: Request, force_refresh: bool = False -): - """Proxy endpoint to fetch profile image from the Meticulous machine. - - This fetches the image from the machine and returns it directly, - so the frontend doesn't need to know the machine IP. - Images are cached indefinitely on the server for fast loading. - - Args: - profile_name: Name of the profile - force_refresh: If true, bypass cache and fetch from machine - - Returns: - The profile image as PNG, or 404 if not found - """ - request_id = request.state.request_id - from fastapi.responses import Response - - # Check cache first (unless forcing refresh) - if not force_refresh: - cached_image = _get_cached_image(profile_name) - if cached_image: - logger.info( - f"Returning cached image for profile: {profile_name}", - extra={ - "request_id": request_id, - "from_cache": True, - "size": len(cached_image), - }, - ) - return Response(content=cached_image, media_type="image/png") - - try: - # First get the profile to find the image path - profiles_result = await async_list_profiles() - - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {profiles_result.error}" - ) - - # Find matching profile - for partial_profile in profiles_result: - if partial_profile.name == profile_name: - full_profile = await async_get_profile(partial_profile.id) - if hasattr(full_profile, "error") and full_profile.error: - continue - - if not full_profile.display or not full_profile.display.image: - raise HTTPException(status_code=404, detail="Profile has no image") - - image_path = full_profile.display.image - - if image_path.startswith("data:image/"): - mime_type, image_bytes = _parse_data_image_uri(image_path) - - _set_cached_image(profile_name, image_bytes) - return Response(content=image_bytes, media_type=mime_type) - - if image_path.startswith(("http://", "https://")): - image_url = image_path - if not _is_allowed_machine_image_url(image_url): - raise HTTPException( - status_code=400, - detail="Profile image URL host is not allowed", - ) - else: - # Construct full URL to the machine - meticulous_ip = os.getenv("METICULOUS_IP") - if not meticulous_ip: - settings = load_settings() - meticulous_ip = settings.get("meticulousIp", "").strip() - if not meticulous_ip: - raise HTTPException( - status_code=500, detail="METICULOUS_IP not configured" - ) - - image_url = f"http://{meticulous_ip}{image_path}" - - # Fetch the image from the machine - async with httpx.AsyncClient() as client: - response = await client.get(image_url, timeout=10.0) - - if response.status_code != 200: - raise HTTPException( - status_code=response.status_code, - detail="Failed to fetch image from machine", - ) - - raw_content_type = ( - response.headers.get("content-type") - if hasattr(response, "headers") - else None - ) - if not isinstance(raw_content_type, str): - raw_content_type = "image/png" - - media_type = ( - raw_content_type.split(";", 1)[0].strip() or "image/png" - ) - if not media_type.startswith("image/"): - media_type = "image/png" - - # Cache the image for future requests - _set_cached_image(profile_name, response.content) - - # Return the image with appropriate content type - return Response(content=response.content, media_type=media_type) - - # Profile not found on machine - return placeholder instead of 404 - # This prevents browser console errors for deleted/missing profiles - logger.debug( - f"Profile '{profile_name}' not found on machine, returning placeholder", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - return Response(content=PLACEHOLDER_SVG, media_type="image/svg+xml") - - except HTTPException as he: - # If it's a "no image" case, return placeholder - if he.status_code == 404: - return Response(content=PLACEHOLDER_SVG, media_type="image/svg+xml") - raise - except httpx.TimeoutException as e: - logger.warning( - f"Timed out while proxying profile image: {str(e)}", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - raise HTTPException(status_code=504, detail="Timed out fetching profile image") - except httpx.HTTPError as e: - logger.warning( - f"HTTP error while proxying profile image: {str(e)}", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - raise HTTPException( - status_code=502, detail="Failed to fetch image from machine" - ) - except Exception as e: - logger.error( - f"Failed to proxy profile image: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "profile_name": profile_name}, - ) - raise HTTPException( - status_code=500, detail=f"Failed to fetch profile image: {str(e)}" - ) - - -@router.get("/api/profile/{profile_name:path}/target-curves") -async def get_profile_target_curves(profile_name: str, request: Request): - """Return estimated target curves for a profile (no shot data needed). - - Used by the live-view to show goal overlay lines during a shot. - Stage durations are estimated from exit-trigger time values. - - Returns: - {status, target_curves: [{time, target_pressure?, target_flow?, stage_name}]} - """ - request_id = request.state.request_id - - try: - profiles_result = await async_list_profiles() - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException(status_code=502, detail="Machine API error") - - for partial in profiles_result: - if partial.name == profile_name: - full_profile = await async_get_profile(partial.id) - if hasattr(full_profile, "error") and full_profile.error: - continue - - # Build dict for the analysis helper - profile_dict: dict = {} - for attr in ["stages", "variables"]: - val = getattr(full_profile, attr, None) - if val is not None: - if isinstance(val, list): - profile_dict[attr] = [ - item.__dict__ if hasattr(item, "__dict__") else item - for item in val - ] - # Flatten nested __dict__ inside list items - for i, item in enumerate(profile_dict[attr]): - if isinstance(item, dict): - for k, v in list(item.items()): - if hasattr(v, "__dict__"): - item[k] = v.__dict__ - elif isinstance(v, list): - item[k] = [ - el.__dict__ - if hasattr(el, "__dict__") - else el - for el in v - ] - else: - profile_dict[attr] = val - - # Apply active temporary variable overrides so the live graph - # reflects the shot actually running (ephemeral override load), - # not the saved profile. The active temp profile keeps the - # original name when save_mode is "none"/"save_original". - active = get_active() - if active and active.get("profile_name") == profile_name: - overrides = (active.get("original_params") or {}).get( - "overrides" - ) or {} - if overrides: - profile_dict = apply_variable_overrides( - profile_dict, overrides - ) - - curves = generate_estimated_target_curves(profile_dict) - return {"status": "success", "target_curves": curves} - - raise HTTPException( - status_code=404, detail=f"Profile '{profile_name}' not found" - ) - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to get target curves: {e}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/profile/{profile_name:path}") -async def get_profile_info( - profile_name: str, request: Request, include_stages: bool = False -): - """Get profile information from the Meticulous machine. - - Args: - profile_name: Name of the profile to fetch - include_stages: If True, include full stage/variable data for breakdown display - - Returns: - Profile information including image if set - """ - request_id = request.state.request_id - - try: - logger.info( - f"Fetching profile info: {profile_name}", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - - profiles_result = await async_list_profiles() - - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {profiles_result.error}" - ) - - # Find matching profile - for partial_profile in profiles_result: - if partial_profile.name == profile_name: - # Get full profile - full_profile = await async_get_profile(partial_profile.id) - if hasattr(full_profile, "error") and full_profile.error: - continue - - # Extract image from display if present - image = None - accent_color = None - if full_profile.display: - image = full_profile.display.image - accent_color = full_profile.display.accentColor - - result = { - "status": "success", - "profile": { - "id": full_profile.id, - "name": full_profile.name, - "author": full_profile.author, - "temperature": full_profile.temperature, - "final_weight": full_profile.final_weight, - "image": image, - "accent_color": accent_color, - }, - } - - # Optionally include full stage/variable data - if include_stages: - - def _serialise(obj): - """Recursively convert API objects to dicts.""" - if obj is None: - return None - if isinstance(obj, (str, int, float, bool)): - return obj - if isinstance(obj, list): - return [_serialise(i) for i in obj] - if isinstance(obj, dict): - return {k: _serialise(v) for k, v in obj.items()} - if hasattr(obj, "__dict__"): - return { - k: _serialise(v) - for k, v in obj.__dict__.items() - if v is not None - } - return obj - - if hasattr(full_profile, "stages") and full_profile.stages: - result["profile"]["stages"] = _serialise(full_profile.stages) - if hasattr(full_profile, "variables") and full_profile.variables: - result["profile"]["variables"] = _serialise( - full_profile.variables - ) - - return result - - # Profile not found - return graceful response instead of 404 - # This prevents browser console errors when viewing history with deleted profiles - logger.info( - f"Profile not found on machine: {profile_name}", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - return { - "status": "not_found", - "profile": None, - "message": f"Profile '{profile_name}' not found on machine", - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to get profile info: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "profile_name": profile_name, - "error_type": type(e).__name__, - }, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to get profile info", - }, - ) - - -@router.put("/profile/{profile_name:path}/edit") -@router.put("/api/profile/{profile_name:path}/edit") -async def edit_profile(profile_name: str, request: Request): - """Edit an existing profile on the Meticulous machine. - - Supports updating name, temperature, final_weight, variables, and author. - If the name changes, all matching history entries are updated too. - - Body: - name?: str – new profile name (non-empty) - temperature?: float – brew temperature (70-100 °C) - final_weight?: float – target weight (> 0) - variables?: list – [{key: str, value: float|str}, ...] - author?: str – profile author - """ - request_id = request.state.request_id - - try: - body = await request.json() - - # --- validation ----------------------------------------------------------- - new_name = body.get("name") - if new_name is not None: - if not isinstance(new_name, str) or not new_name.strip(): - raise HTTPException( - status_code=400, detail="Profile name must be a non-empty string" - ) - new_name = new_name.strip() - - temperature = body.get("temperature") - if temperature is not None: - try: - temperature = float(temperature) - except (TypeError, ValueError): - raise HTTPException( - status_code=400, detail="Temperature must be a number" - ) - if temperature > 100: - raise HTTPException( - status_code=400, detail="Temperature must not exceed 100 °C" - ) - - final_weight = body.get("final_weight") - if final_weight is not None: - try: - final_weight = float(final_weight) - except (TypeError, ValueError): - raise HTTPException( - status_code=400, detail="Final weight must be a number" - ) - if final_weight <= 0: - raise HTTPException( - status_code=400, detail="Final weight must be greater than 0" - ) - - variables = body.get("variables") - author = body.get("author") - - if all( - v is None for v in [new_name, temperature, final_weight, variables, author] - ): - raise HTTPException( - status_code=400, detail="At least one field to update is required" - ) - - # --- find profile by name ------------------------------------------------ - profiles_result = await async_list_profiles() - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {profiles_result.error}" - ) - - matching_profile = None - for p in profiles_result: - if p.name == profile_name: - matching_profile = p - break - - if matching_profile is None: - raise HTTPException( - status_code=404, detail=f"Profile '{profile_name}' not found on machine" - ) - - full_profile = await async_get_profile(matching_profile.id) - if hasattr(full_profile, "error") and full_profile.error: - raise HTTPException( - status_code=502, detail=f"Failed to fetch profile: {full_profile.error}" - ) - - # --- apply changes directly on profile object --------------------------- - old_name = full_profile.name - - if new_name is not None: - full_profile.name = new_name - if temperature is not None: - full_profile.temperature = temperature - if final_weight is not None: - full_profile.final_weight = final_weight - if author is not None: - full_profile.author = author - - if ( - variables is not None - and hasattr(full_profile, "variables") - and full_profile.variables - ): - incoming = { - v["key"]: v["value"] for v in variables if "key" in v and "value" in v - } - for var in full_profile.variables: - var_key = getattr(var, "key", None) - if var_key and var_key in incoming: - var.value = incoming[var_key] - - # --- persist ------------------------------------------------------------- - await async_save_profile(full_profile) - recommendation_service.invalidate_cache() - - logger.info( - f"Profile edited: '{old_name}' → '{full_profile.name}'", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - - # --- cascade rename into history ----------------------------------------- - if new_name is not None and new_name != old_name: - with _history_lock: - history = load_history() - updated = 0 - for entry in history: - if entry.get("profile_name") == old_name: - entry["profile_name"] = new_name - updated += 1 - if updated: - save_history(history) - if updated: - logger.info( - f"Updated {updated} history entries from '{old_name}' to '{new_name}'", - extra={"request_id": request_id}, - ) - - return { - "status": "success", - "profile": deep_convert_to_dict(full_profile), - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to edit profile: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "profile_name": profile_name, - "error_type": type(e).__name__, - }, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) - - -# ============================================================================ -# Profile Import Endpoints -# ============================================================================ - - -@router.get("/api/machine/profiles") -async def list_machine_profiles(request: Request): - """List all profiles from the Meticulous machine with full details. - - Returns profiles that are on the machine but may not be in the MeticAI history. - """ - request_id = request.state.request_id - - try: - logger.info( - "Fetching all profiles from machine", extra={"request_id": request_id} - ) - - profiles_result = await async_list_profiles() - - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {profiles_result.error}" - ) - - profiles = [] - for partial_profile in profiles_result: - try: - full_profile = await async_get_profile(partial_profile.id) - if hasattr(full_profile, "error") and full_profile.error: - # Fall back to partial profile data (non-UUID IDs cause 404 on get) - logger.warning( - f"Could not fetch full profile {partial_profile.name}, using partial data", - extra={ - "request_id": request_id, - "profile_id": partial_profile.id, - }, - ) - full_profile = partial_profile - - # Check if this profile exists in our history - in_history = False - try: - history = load_history() - entries = ( - history - if isinstance(history, list) - else history.get("entries", []) - ) - profile_name = getattr(full_profile, "name", None) or getattr( - partial_profile, "name", None - ) - in_history = any( - entry.get("profile_name") == profile_name for entry in entries - ) - except Exception: - pass - - # Convert profile to dict with full parameter data - profile_dict = { - "id": getattr(full_profile, "id", partial_profile.id), - "name": getattr(full_profile, "name", partial_profile.name), - "author": getattr( - full_profile, "author", getattr(partial_profile, "author", None) - ), - "temperature": getattr( - full_profile, - "temperature", - getattr(partial_profile, "temperature", None), - ), - "final_weight": getattr( - full_profile, - "final_weight", - getattr(partial_profile, "final_weight", None), - ), - "in_history": in_history, - "has_description": False, - "description": None, - "derived_tags": _derive_structural_tags(full_profile), - } - stages = getattr(full_profile, "stages", None) - variables = getattr(full_profile, "variables", None) - display = getattr(full_profile, "display", None) - if stages: - profile_dict["stages"] = deep_convert_to_dict(stages) - if variables: - profile_dict["variables"] = deep_convert_to_dict(variables) - if display: - display_dict = deep_convert_to_dict(display) - if isinstance(display_dict, dict): - display_dict.pop("image", None) - profile_dict["display"] = display_dict - - # Check for existing description in history - if in_history: - try: - for entry in entries: - if entry.get("profile_name") == profile_dict["name"]: - profile_dict["user_preferences"] = entry.get( - "user_preferences" - ) - if entry.get("reply"): - profile_dict["has_description"] = True - if entry.get("ai_tags"): - profile_dict["ai_tags"] = entry.get("ai_tags") - break - except Exception: - pass - - profiles.append(profile_dict) - except Exception as e: - # Fall back to partial profile data on exception (e.g., 404 for non-UUID IDs) - logger.warning( - f"Failed to fetch profile {partial_profile.name}, using partial data: {e}", - extra={"request_id": request_id}, - ) - # Use partial profile data instead of skipping - profile_dict = { - "id": partial_profile.id, - "name": partial_profile.name, - "author": getattr(partial_profile, "author", None), - "temperature": getattr(partial_profile, "temperature", None), - "final_weight": getattr(partial_profile, "final_weight", None), - "in_history": False, - "has_description": False, - "description": None, - "user_preferences": None, - "derived_tags": _derive_structural_tags(partial_profile), - } - profiles.append(profile_dict) - - logger.info( - f"Found {len(profiles)} profiles on machine", - extra={"request_id": request_id, "profile_count": len(profiles)}, - ) - - return {"status": "success", "profiles": profiles, "total": len(profiles)} - - except MachineUnreachableError: - # Offline fallback — return profiles from history - logger.info( - "Machine unreachable, returning history-based profiles", - extra={"request_id": request_id}, - ) - try: - history = load_history() - entries = ( - history if isinstance(history, list) else history.get("entries", []) - ) - profiles = [] - seen: set[str] = set() - for entry in entries: - name = entry.get("profile_name") - if not name or name in seen: - continue - seen.add(name) - pj = entry.get("profile_json") or {} - profiles.append( - { - "id": entry.get("id", ""), - "name": name, - "author": pj.get("author"), - "temperature": pj.get("temperature"), - "final_weight": pj.get("final_weight"), - "in_history": True, - "has_description": bool(entry.get("reply")), - "user_preferences": entry.get("user_preferences"), - "derived_tags": _derive_structural_tags_from_dict(pj) - if pj - else [], - "ai_tags": entry.get("ai_tags", []), - } - ) - return { - "status": "success", - "profiles": profiles, - "total": len(profiles), - "offline": True, - } - except Exception as fallback_err: - logger.warning( - f"Offline fallback also failed: {fallback_err}", - extra={"request_id": request_id}, - ) - raise MachineUnreachableError() from fallback_err - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to list machine profiles: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.post("/machine/profiles/order") -@router.post("/api/machine/profiles/order") -async def reorder_machine_profiles(request: Request): - """Persist a new profile display order on the Meticulous machine. - - The machine keeps profile ordering in the ``profile_order`` user setting — a - list of profile IDs. The Meticulous backend serves ``/api/v1/profile/list`` - in that order, so persisting a new order is a matter of POSTing the reordered - ID list to ``/api/v1/settings``. - """ - request_id = request.state.request_id - - try: - body = await request.json() - except Exception: - raise HTTPException(status_code=400, detail="Invalid JSON body") - - order = body.get("order") if isinstance(body, dict) else None - if not isinstance(order, list) or not order: - raise HTTPException( - status_code=400, detail="'order' must be a non-empty list of profile IDs" - ) - if not all(isinstance(pid, str) and pid for pid in order): - raise HTTPException( - status_code=400, detail="'order' must contain only non-empty profile IDs" - ) - - try: - # The list cache reflects the old order — drop it so the next fetch - # returns the machine's freshly persisted ordering. - try: - invalidate_profile_list_cache() - except Exception: - pass - - response = await async_session_post( - "/api/v1/settings", {"profile_order": order} - ) - status = getattr(response, "status_code", 200) - if status >= 400: - raise HTTPException( - status_code=502, - detail=f"Machine rejected profile order update (HTTP {status})", - ) - - logger.info( - "Persisted new profile order", - extra={"request_id": request_id, "profile_count": len(order)}, - ) - return {"status": "success", "order": order} - - except MachineUnreachableError as exc: - raise HTTPException(status_code=503, detail="Machine unreachable") from exc - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to reorder machine profiles: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.get("/machine/profile/{profile_id}") -@router.get("/api/machine/profile/{profile_id}") -async def get_machine_profile(profile_id: str, request: Request): - """Get a single profile from the Meticulous machine with variables. - - Returns the profile dict including a ``variables`` array. When the - machine profile does not contain an explicit variables list the endpoint - synthesises basic entries from the top-level ``final_weight`` and - ``temperature`` fields so that callers always get adjustable parameters. - """ - request_id = request.state.request_id - - try: - logger.info( - f"Fetching profile: {profile_id}", - extra={"request_id": request_id, "profile_id": profile_id}, - ) - - profile = await async_get_profile(profile_id) - - if hasattr(profile, "error") and profile.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {profile.error}" - ) - - # Convert to dict for JSON serialization - profile_json: dict = {} - for attr in [ - "id", - "name", - "author", - "temperature", - "final_weight", - "stages", - "variables", - "display", - "isDefault", - "source", - "beverage_type", - "tank_temperature", - ]: - if hasattr(profile, attr): - val = getattr(profile, attr) - if val is not None: - if hasattr(val, "__dict__"): - profile_json[attr] = val.__dict__ - elif isinstance(val, list): - profile_json[attr] = [ - item.__dict__ if hasattr(item, "__dict__") else item - for item in val - ] - else: - profile_json[attr] = val - - # Ensure there is always a variables array. Merge well-known - # top-level fields (final_weight, temperature) with any explicit - # variables so the UI can always offer adjustment sliders. - variables = profile_json.get("variables") - if not variables or not isinstance(variables, list): - variables = [] - - existing_keys = {v.get("key") for v in variables if isinstance(v, dict)} - if ( - profile_json.get("final_weight") is not None - and "final_weight" not in existing_keys - ): - variables.append( - { - "key": "final_weight", - "name": "Final Weight", - "type": "weight", - "value": float(profile_json["final_weight"]), - } - ) - if ( - profile_json.get("temperature") is not None - and "temperature" not in existing_keys - ): - variables.append( - { - "key": "temperature", - "name": "Temperature", - "type": "temperature", - "value": float(profile_json["temperature"]), - } - ) - profile_json["variables"] = variables - - return { - "status": "success", - "profile": profile_json, - "variables": profile_json.get("variables", []), - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to get profile: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.get("/api/machine/profile/{profile_id}/json") -async def get_machine_profile_json(profile_id: str, request: Request): - """Get the full profile JSON from the Meticulous machine. - - Fetches the profile directly from the machine's REST API and returns - the raw JSON as-is — no SDK object conversion, so all fields are - preserved exactly as the machine stores them. - - Args: - profile_id: The profile ID to fetch - - Returns: - Full profile JSON suitable for export/import - """ - request_id = request.state.request_id - - try: - logger.info( - f"Fetching profile JSON: {profile_id}", - extra={"request_id": request_id, "profile_id": profile_id}, - ) - - profile_dict = await fetch_machine_profile_dict(profile_id) - - return {"status": "success", "profile": profile_dict} - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to get profile JSON: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException(status_code=502, detail=f"Machine API error: {str(e)}") - - -@router.delete("/api/machine/profile/{profile_id}") -async def delete_machine_profile(profile_id: str, request: Request): - """Delete a profile from the Meticulous machine. - - Args: - profile_id: The profile ID to delete - - Returns: - Status of the deletion operation - """ - request_id = request.state.request_id - - try: - # First get the profile to log its name - profile = await async_get_profile(profile_id) - profile_name = getattr(profile, "name", profile_id) if profile else profile_id - - logger.info( - f"Deleting profile: {profile_name} ({profile_id})", - extra={"request_id": request_id, "profile_id": profile_id}, - ) - - await async_delete_profile(profile_id) - recommendation_service.invalidate_cache() - - logger.info( - f"Successfully deleted profile: {profile_name}", - extra={"request_id": request_id, "profile_id": profile_id}, - ) - - return { - "status": "success", - "message": f"Profile '{profile_name}' deleted successfully", - "profile_id": profile_id, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to delete profile: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.post("/api/machine/profiles/bulk-delete") -async def bulk_delete_machine_profiles(request: Request): - """Delete multiple profiles from the Meticulous machine. - - Body: - profile_ids: list of profile ID strings to delete - - Returns: - Summary with succeeded / failed counts and per-profile results. - """ - request_id = request.state.request_id - - try: - body = await request.json() - profile_ids = body.get("profile_ids", []) - - if not isinstance(profile_ids, list) or len(profile_ids) == 0: - raise HTTPException( - status_code=400, - detail="profile_ids must be a non-empty list", - ) - - results: list[dict] = [] - succeeded = 0 - - for pid in profile_ids: - try: - profile = await async_get_profile(pid) - name = getattr(profile, "name", pid) if profile else pid - await async_delete_profile(pid) - results.append({"profile_id": pid, "name": name, "status": "success"}) - succeeded += 1 - except Exception as e: - logger.warning( - f"Bulk delete: failed to delete {pid}: {e}", - extra={"request_id": request_id}, - ) - results.append({"profile_id": pid, "status": "error", "error": str(e)}) - - recommendation_service.invalidate_cache() - - logger.info( - f"Bulk delete: {succeeded}/{len(profile_ids)} profiles deleted", - extra={"request_id": request_id}, - ) - - return { - "status": "success", - "deleted": succeeded, - "failed": len(profile_ids) - succeeded, - "total": len(profile_ids), - "results": results, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Bulk delete failed: {e}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) - - -@router.patch("/api/machine/profile/{profile_id}") -async def update_machine_profile(profile_id: str, request: Request): - """Update a profile on the Meticulous machine. - - Currently supports: - - Renaming (via "name" field) - - Args: - profile_id: The profile ID to update - - Body: - name: New name for the profile (optional) - - Returns: - Updated profile information - """ - request_id = request.state.request_id - - try: - body = await request.json() - new_name = body.get("name") - - if not new_name: - raise HTTPException( - status_code=400, - detail="At least one field to update is required (e.g., 'name')", - ) - - # Get the current profile - profile = await async_get_profile(profile_id) - if hasattr(profile, "error") and profile.error: - raise HTTPException( - status_code=404, detail=f"Profile not found: {profile_id}" - ) - - old_name = getattr(profile, "name", profile_id) - - logger.info( - f"Renaming profile '{old_name}' to '{new_name}'", - extra={"request_id": request_id, "profile_id": profile_id}, - ) - - # Convert profile to dict for modification - profile_dict = {} - for attr in [ - "id", - "name", - "author", - "author_id", - "temperature", - "final_weight", - "stages", - "variables", - "display", - "isDefault", - "source", - "beverage_type", - "tank_temperature", - "previous_authors", - ]: - if hasattr(profile, attr): - val = getattr(profile, attr) - if val is not None: - if hasattr(val, "__dict__"): - profile_dict[attr] = val.__dict__ - elif isinstance(val, list): - profile_dict[attr] = [ - item.__dict__ if hasattr(item, "__dict__") else item - for item in val - ] - else: - profile_dict[attr] = val - - # Update the name - profile_dict["name"] = new_name - - # Save the updated profile - await async_save_profile(profile_dict) - recommendation_service.invalidate_cache() - - logger.info( - f"Successfully renamed profile to '{new_name}'", - extra={"request_id": request_id, "profile_id": profile_id}, - ) - - return { - "status": "success", - "message": f"Profile renamed from '{old_name}' to '{new_name}'", - "profile_id": profile_id, - "old_name": old_name, - "new_name": new_name, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to update profile: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.post("/api/profile/import") -async def import_profile(request: Request): - """Import a profile into the MeticAI history. - - The profile can come from: - - A JSON file upload - - A profile already on the machine (by ID) - - If the profile has no description, it will be sent to the LLM for analysis. - """ - request_id = request.state.request_id - - try: - body = await request.json() - profile_json = body.get("profile") - generate_description = body.get("generate_description", True) - source = body.get("source", "file") # "file" or "machine" - - if not profile_json: - raise HTTPException(status_code=400, detail="No profile JSON provided") - - profile_name = profile_json.get("name", "Imported Profile") - - logger.info( - f"Importing profile: {profile_name}", - extra={ - "request_id": request_id, - "profile_name": profile_name, - "source": source, - "generate_description": generate_description, - }, - ) - - # Check if profile already exists in history - existing_entry = None - history = load_history() - entries = history if isinstance(history, list) else history.get("entries", []) - for entry in entries: - if entry.get("profile_name") == profile_name: - existing_entry = entry - break - - if existing_entry: - return { - "status": "exists", - "message": f"Profile '{profile_name}' already exists in history", - "entry_id": existing_entry.get("id"), - } - - # Generate description if requested - reply = None - if generate_description: - try: - reply = await _generate_profile_description(profile_json, request_id) - except Exception as e: - logger.warning( - f"Failed to generate description: {e}", - extra={"request_id": request_id}, - ) - from services.analysis_service import _build_static_profile_description - - reply = _build_static_profile_description(profile_json) - else: - from services.analysis_service import _build_static_profile_description - - reply = _build_static_profile_description(profile_json) - - # Normalise the JSON for storage. For machine imports the profile - # already exists on the machine so we fetch canonical JSON by ID. - # For file imports we normalise locally first, then upgrade after upload. - stored_json = deep_convert_to_dict(profile_json) - if source == "machine": - # Profile is already on machine — try to fetch canonical version - machine_id = ( - profile_json.get("id") if isinstance(profile_json, dict) else None - ) - if machine_id: - try: - stored_json = await fetch_machine_profile_dict(machine_id) - except Exception: - # Fall back to local normalisation - stored_json = _normalize_profile_for_machine(stored_json) - else: - stored_json = _normalize_profile_for_machine(stored_json) - else: - # File import — normalise locally as baseline - stored_json = _normalize_profile_for_machine(stored_json) - - # Re-derive profile_name from the finalised JSON so the history entry - # name always matches stored_json["name"] (normalisation may change it). - if isinstance(stored_json, dict) and stored_json.get("name"): - profile_name = stored_json["name"] - - # Create history entry - entry_id = str(uuid.uuid4()) - created_at = datetime.now(timezone.utc).isoformat() - - deep_convert_to_dict(profile_json) - new_entry = { - "id": entry_id, - "created_at": created_at, - "profile_name": profile_name, - "user_preferences": f"Imported from {source}", - "reply": reply, - "profile_json": stored_json, - "imported": True, - "import_source": source, - } - - if stored_json and isinstance(stored_json, dict): - new_entry["content_hash"] = compute_content_hash(stored_json) - - # Save to history using cache-aware save to keep in-memory cache in sync - with _history_lock: - history = load_history() - if not isinstance(history, list): - history = history.get("entries", []) - - history.insert(0, new_entry) - - save_history(history) - - # Upload profile to the Meticulous machine when imported from a file. - # Profiles imported from the machine (source="machine") already exist there. - machine_profile_id = None - if source == "file": - try: - result = await async_create_profile(profile_json) - normalised_from_create = ( - result.get("_normalised_json") if isinstance(result, dict) else None - ) - machine_profile_id = ( - result.get("id") if isinstance(result, dict) else None - ) - - # Upgrade stored JSON with machine-fetched version - fetch_id = machine_profile_id or (normalised_from_create or {}).get( - "id" - ) - if fetch_id: - try: - machine_dict = await fetch_machine_profile_dict(fetch_id) - if isinstance(machine_dict, dict) and machine_dict.get("name"): - update_entry_sync_fields( - entry_id, - content_hash=compute_content_hash(machine_dict), - profile_json=machine_dict, - ) - except Exception: - pass # Layer 1 already stored normalised JSON - - logger.info( - f"Profile uploaded to machine: {profile_name}", - extra={ - "request_id": request_id, - "machine_profile_id": machine_profile_id, - }, - ) - except Exception as exc: - logger.warning( - f"Profile saved to history but failed to upload to machine: {exc}", - extra={"request_id": request_id, "error_type": type(exc).__name__}, - ) - - logger.info( - f"Profile imported successfully: {profile_name}", - extra={"request_id": request_id, "entry_id": entry_id}, - ) - - return { - "status": "success", - "entry_id": entry_id, - "profile_name": profile_name, - "has_description": reply is not None - and "Description generation failed" not in reply, - "uploaded_to_machine": machine_profile_id is not None, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to import profile: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -def _validate_url_for_ssrf(url: str) -> None: - """Reject URLs that could hit internal/private network resources.""" - parsed = urlparse(url) - if parsed.scheme not in ("http", "https"): - raise ValueError(f"Unsupported scheme: {parsed.scheme}") - hostname = parsed.hostname - if not hostname: - raise ValueError("No hostname in URL") - blocked = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "metadata.google.internal"} - if hostname.lower() in blocked: - raise ValueError(f"Blocked hostname: {hostname}") - try: - ip = ipaddress.ip_address(socket.gethostbyname(hostname)) - if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: - raise ValueError(f"URL resolves to private/reserved IP: {ip}") - except socket.gaierror: - pass # Let httpx handle DNS errors - - -@router.post("/api/import-from-url") -async def import_from_url(request: Request): - """Import a profile from a URL (JSON or .met format).""" - request_id = request.state.request_id - try: - body = await request.json() - url = body.get("url", "").strip() - generate_description = body.get("generate_description", True) - if not url: - raise HTTPException(status_code=400, detail="No URL provided") - parsed = urlparse(url) - if parsed.scheme not in ("http", "https"): - raise HTTPException( - status_code=400, detail="Only http and https URLs are supported" - ) - try: - _validate_url_for_ssrf(url) - except ValueError as exc: - raise HTTPException(status_code=400, detail=f"Blocked URL: {exc}") - logger.info( - "Importing profile from URL: %s", url, extra={"request_id": request_id} - ) - max_size = 5 * 1024 * 1024 - try: - async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: - async with client.stream("GET", url) as resp: - resp.raise_for_status() - chunks: list[bytes] = [] - total = 0 - async for chunk in resp.aiter_bytes(8192): - total += len(chunk) - if total > max_size: - raise HTTPException( - status_code=413, detail="Response too large (max 5 MB)" - ) - chunks.append(chunk) - content = b"".join(chunks) - except httpx.TimeoutException: - raise HTTPException( - status_code=408, detail="Request timed out fetching URL" - ) - except httpx.HTTPStatusError as exc: - raise HTTPException( - status_code=502, - detail=f"Remote server returned {exc.response.status_code}", - ) - except httpx.RequestError as exc: - raise HTTPException(status_code=502, detail=f"Failed to fetch URL: {exc}") - try: - profile_json = json.loads(content) - except Exception: - raise HTTPException(status_code=400, detail="URL did not return valid JSON") - if not isinstance(profile_json, dict): - raise HTTPException( - status_code=400, detail="URL did not return a valid profile object" - ) - # Auto-detect Decent Espresso format and convert - from services.decent_converter import detect_decent_format, convert_decent_to_meticulous - - decent_converted = False - if detect_decent_format(profile_json): - logger.info( - "Detected Decent Espresso format, converting", - extra={"request_id": request_id}, - ) - result = convert_decent_to_meticulous(profile_json) - profile_json = result["profile"] - decent_converted = True - if not profile_json.get("name"): - raise HTTPException( - status_code=400, detail="Profile is missing a 'name' field" - ) - profile_name = profile_json["name"] - reply = None - if generate_description: - try: - reply = await _generate_profile_description(profile_json, request_id) - except Exception as e: - logger.warning( - "Failed to generate description for URL import: %s", - e, - extra={"request_id": request_id}, - ) - from services.analysis_service import _build_static_profile_description - - reply = _build_static_profile_description(profile_json) - else: - from services.analysis_service import _build_static_profile_description - - reply = _build_static_profile_description(profile_json) - entry_id = str(uuid.uuid4()) - created_at = datetime.now(timezone.utc).isoformat() - new_entry = { - "id": entry_id, - "created_at": created_at, - "profile_name": profile_name, - "user_preferences": f"Imported from URL: {url}", - "reply": reply, - "profile_json": deep_convert_to_dict(profile_json), - "imported": True, - "import_source": "url", - } - with _history_lock: - history = load_history() - entries = ( - history if isinstance(history, list) else history.get("entries", []) - ) - for entry in entries: - if entry.get("profile_name") == profile_name: - return { - "status": "exists", - "message": f"Profile '{profile_name}' already exists", - "entry_id": entry.get("id"), - "profile_name": profile_name, - } - entries.insert(0, new_entry) - save_history(entries) - machine_profile_id = None - try: - result = await async_create_profile(profile_json) - machine_profile_id = result.get("id") if isinstance(result, dict) else None - logger.info( - "URL-imported profile uploaded to machine: %s", - profile_name, - extra={ - "request_id": request_id, - "machine_profile_id": machine_profile_id, - }, - ) - except Exception as exc: - logger.warning( - "Profile saved to history but failed to upload to machine: %s", - exc, - extra={"request_id": request_id, "error_type": type(exc).__name__}, - ) - logger.info( - "Profile imported from URL successfully: %s", - profile_name, - extra={"request_id": request_id, "entry_id": entry_id, "source_url": url}, - ) - return { - "status": "success", - "entry_id": entry_id, - "profile_name": profile_name, - "has_description": reply is not None - and "Description generation failed" not in reply, - "uploaded_to_machine": machine_profile_id is not None, - "converted_from_decent": decent_converted, - } - except HTTPException: - raise - except Exception as e: - logger.error( - "Failed to import profile from URL: %s", - str(e), - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.post("/convert-decent") -@router.post("/api/convert-decent") -async def convert_decent_profile(request: Request): - """Convert a Decent Espresso profile to Meticulous format. - - Accepts a Decent profile JSON and returns the converted Meticulous - profile along with any conversion warnings. Does NOT save or - upload — the caller should use ``/api/profile/import`` afterwards. - """ - from services.decent_converter import detect_decent_format, convert_decent_to_meticulous - - body = await request.json() - - if not detect_decent_format(body): - raise HTTPException( - status_code=400, - detail="Not a valid Decent Espresso profile format", - ) - - result = convert_decent_to_meticulous(body) - return result - - -@router.post("/api/profile/import-all") -async def import_all_profiles(request: Request): - """Import all profiles from the Meticulous machine that aren't already in history. - - This is a long-running operation that imports profiles one at a time, - generating descriptions for each. The response is streamed as newline-delimited JSON - to provide progress updates. - - Returns: - Streamed JSON with progress updates and final summary - """ - from fastapi.responses import StreamingResponse - - request_id = request.state.request_id - - generate_description = True - try: - body = await request.json() - if isinstance(body, dict): - generate_description = bool(body.get("generate_description", True)) - except Exception: - generate_description = True - - async def generate_import_stream(): - """Generator that yields progress updates as JSON lines.""" - imported = [] - skipped = [] - failed = [] - - try: - # Get list of machine profiles - profiles_result = await async_list_profiles() - - if hasattr(profiles_result, "error") and profiles_result.error: - yield ( - json.dumps( - { - "type": "error", - "message": f"Machine API error: {profiles_result.error}", - } - ) - + "\n" - ) - return - - # Get existing profile names from history - existing_names = set() - history = load_history() - entries = ( - history if isinstance(history, list) else history.get("entries", []) - ) - existing_names = {entry.get("profile_name") for entry in entries} - - # Filter profiles to import - profiles_to_import = [] - for partial_profile in profiles_result: - try: - full_profile = await async_get_profile(partial_profile.id) - if hasattr(full_profile, "error") and full_profile.error: - continue - if full_profile.name not in existing_names: - profiles_to_import.append(full_profile) - else: - skipped.append(full_profile.name) - except Exception: - # Ignore errors fetching individual profiles (may have been deleted) - pass - - total_to_import = len(profiles_to_import) - total_profiles = total_to_import + len(skipped) - - # Send initial status - yield ( - json.dumps( - { - "type": "start", - "total": total_profiles, - "to_import": total_to_import, - "already_imported": len(skipped), - "message": f"Found {total_to_import} profiles to import ({len(skipped)} already in catalogue)", - } - ) - + "\n" - ) - - if total_to_import == 0: - yield ( - json.dumps( - { - "type": "complete", - "imported": 0, - "skipped": len(skipped), - "failed": 0, - "message": "All profiles already in catalogue", - } - ) - + "\n" - ) - return - - # Import each profile - for idx, profile in enumerate(profiles_to_import, 1): - profile_name = profile.name - - yield ( - json.dumps( - { - "type": "progress", - "current": idx, - "total": total_to_import, - "profile_name": profile_name, - "message": f"Importing {idx}/{total_to_import}: {profile_name}", - } - ) - + "\n" - ) - - try: - # Fetch canonical JSON directly from machine API. - # This intentionally re-fetches each profile even though - # the filtering step already retrieved an SDK Profile object, - # because SDK serialisation loses fields (the bug this PR fixes). - profile_json = await fetch_machine_profile_dict(profile.id) - - # Generate description - if generate_description: - try: - reply = await _generate_profile_description( - profile_json, request_id - ) - except Exception as e: - logger.warning( - f"Failed to generate description for {profile_name}: {e}" - ) - from services.analysis_service import ( - _build_static_profile_description, - ) - - reply = _build_static_profile_description(profile_json) - else: - from services.analysis_service import ( - _build_static_profile_description, - ) - - reply = _build_static_profile_description(profile_json) - - # Create history entry - entry_id = str(uuid.uuid4()) - created_at = datetime.now(timezone.utc).isoformat() - - new_entry = { - "id": entry_id, - "created_at": created_at, - "profile_name": profile_name, - "user_preferences": "Imported from machine (bulk import)", - "reply": reply, - "profile_json": profile_json, - "imported": True, - "import_source": "machine_bulk", - "content_hash": compute_content_hash(profile_json) - if isinstance(profile_json, dict) - else None, - } - - # Save to history using cache-aware save - with _history_lock: - history = load_history() - if not isinstance(history, list): - history = history.get("entries", []) - - history.insert(0, new_entry) - - save_history(history) - - imported.append(profile_name) - - yield ( - json.dumps( - { - "type": "imported", - "current": idx, - "total": total_to_import, - "profile_name": profile_name, - "message": f"Imported: {profile_name}", - } - ) - + "\n" - ) - - except Exception as e: - logger.error(f"Failed to import {profile_name}: {e}", exc_info=True) - failed.append({"name": profile_name, "error": str(e)}) - - yield ( - json.dumps( - { - "type": "failed", - "current": idx, - "total": total_to_import, - "profile_name": profile_name, - "error": str(e), - "message": f"Failed: {profile_name}", - } - ) - + "\n" - ) - - # Send completion summary - yield ( - json.dumps( - { - "type": "complete", - "imported": len(imported), - "skipped": len(skipped), - "failed": len(failed), - "imported_profiles": imported, - "skipped_profiles": skipped, - "failed_profiles": failed, - "message": f"Import complete: {len(imported)} imported, {len(skipped)} skipped, {len(failed)} failed", - } - ) - + "\n" - ) - - logger.info( - f"Bulk import completed: {len(imported)} imported, {len(skipped)} skipped, {len(failed)} failed", - extra={"request_id": request_id}, - ) - - except Exception as e: - logger.error( - f"Bulk import error: {e}", - exc_info=True, - extra={"request_id": request_id}, - ) - yield json.dumps({"type": "error", "message": str(e)}) + "\n" - - return StreamingResponse( - generate_import_stream(), media_type="application/x-ndjson" - ) - - -@router.get("/api/machine/profiles/count") -async def get_machine_profile_count(request: Request): - """Get a quick count of profiles on the machine and how many are not yet imported. - - This is a lightweight endpoint for showing import-all button availability. - """ - request_id = request.state.request_id - - try: - profiles_result = await async_list_profiles() - - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {profiles_result.error}" - ) - - total_on_machine = len(list(profiles_result)) - - # Re-fetch to count (iterator was consumed) - profiles_result = await async_list_profiles() - - # Get existing profile names from history - existing_names = set() - history = load_history() - entries = history if isinstance(history, list) else history.get("entries", []) - existing_names = {entry.get("profile_name") for entry in entries} - - not_imported = 0 - for partial_profile in profiles_result: - try: - full_profile = await async_get_profile(partial_profile.id) - if hasattr(full_profile, "error") and full_profile.error: - continue - if full_profile.name not in existing_names: - not_imported += 1 - except Exception: - # Ignore errors fetching individual profiles (may have been deleted) - pass - - return { - "status": "success", - "total_on_machine": total_on_machine, - "not_imported": not_imported, - "already_imported": total_on_machine - not_imported, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to get profile count: {str(e)}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.get("/machine/profiles/orphaned") -@router.get("/api/machine/profiles/orphaned") -async def list_orphaned_history_entries(request: Request): - """List history entries whose profiles no longer exist on the machine. - - Cross-references history entries against the current machine profile list - and returns entries that have no matching machine profile. - """ - request_id = request.state.request_id - - try: - logger.info( - "Checking for orphaned history entries", - extra={"request_id": request_id}, - ) - - profiles_result = await async_list_profiles() - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException( - status_code=502, - detail=f"Machine API error: {profiles_result.error}", - ) - - machine_names: set[str] = {getattr(p, "name", "") for p in profiles_result} - - history = load_history() - entries = history if isinstance(history, list) else history.get("entries", []) - - orphaned = [] - for entry in entries: - profile_name = entry.get("profile_name", "") - if ( - profile_name - and profile_name not in machine_names - and not is_temp_profile(profile_name) - ): - orphaned.append( - { - "id": entry.get("id"), - "profile_name": profile_name, - "created_at": entry.get("created_at"), - "has_profile_json": bool(entry.get("profile_json")), - } - ) - - logger.info( - f"Found {len(orphaned)} orphaned history entries", - extra={"request_id": request_id, "orphan_count": len(orphaned)}, - ) - - return { - "status": "success", - "orphaned": orphaned, - "total": len(orphaned), - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to check orphaned profiles: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) - - -# --------------------------------------------------------------------------- -# Profile Sync endpoints -# --------------------------------------------------------------------------- - - -@router.post("/profiles/sync") -@router.post("/api/profiles/sync") -async def sync_profiles(request: Request): - """Run a full sync between machine profiles and MeticAI history. - - Computes a content hash for every machine profile and compares it against - the hash stored in the corresponding history entry. Returns three lists: - - - **new**: profiles on the machine that have no history entry at all. - - **updated**: profiles whose content hash differs from the stored hash. - - **orphaned**: history entries with no matching machine profile (reuses - the existing orphan-detection logic). - """ - request_id = request.state.request_id - - try: - logger.info( - "Starting profile sync", - extra={"request_id": request_id}, - ) - - # 1. Fetch machine profiles - profiles_result = await async_list_profiles() - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException( - status_code=502, - detail=f"Machine API error: {profiles_result.error}", - ) - - # 2. Build a name→entry lookup from history - history = load_history() - entries = history if isinstance(history, list) else history.get("entries", []) - history_by_name: dict[str, dict] = {} - for entry in entries: - pname = entry.get("profile_name", "") - if pname and pname not in history_by_name: - history_by_name[pname] = entry - - # 3. Walk machine profiles - new_profiles: list[dict] = [] - updated_profiles: list[dict] = [] - machine_names: set[str] = set() - - for partial in profiles_result: - profile_name = getattr(partial, "name", "") - profile_id = getattr(partial, "id", "") - machine_names.add(profile_name) - - # Fetch full profile to compute hash - try: - profile_dict = await fetch_machine_profile_dict(profile_id) - except Exception: - profile_dict = deep_convert_to_dict(partial) - - current_hash = compute_content_hash(profile_dict) - - entry = history_by_name.get(profile_name) - if entry is None: - new_profiles.append( - { - "profile_id": profile_id, - "profile_name": profile_name, - "content_hash": current_hash, - } - ) - else: - stored_hash = entry.get("content_hash") - if not stored_hash: - # Backfill: first sync after creation — store the machine - # hash as baseline without flagging as "updated". - try: - update_entry_sync_fields( - entry["id"], - content_hash=current_hash, - profile_json=profile_dict, - ) - except Exception: - pass - elif stored_hash != current_hash: - updated_profiles.append( - { - "profile_id": profile_id, - "profile_name": profile_name, - "history_id": entry.get("id"), - "stored_hash": stored_hash, - "current_hash": current_hash, - } - ) - - # 4. Orphaned entries (in history but not on machine) - orphaned: list[dict] = [] - for entry in entries: - pname = entry.get("profile_name", "") - if pname and pname not in machine_names: - orphaned.append( - { - "id": entry.get("id"), - "profile_name": pname, - "created_at": entry.get("created_at"), - "has_profile_json": bool(entry.get("profile_json")), - } - ) - - logger.info( - f"Sync complete: {len(new_profiles)} new, {len(updated_profiles)} updated, {len(orphaned)} orphaned", - extra={"request_id": request_id}, - ) - - return { - "status": "success", - "new": new_profiles, - "updated": updated_profiles, - "orphaned": orphaned, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Profile sync failed: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) - - -@router.post("/profiles/sync/accept/{profile_id}") -@router.post("/api/profiles/sync/accept/{profile_id}") -async def accept_sync_update( - profile_id: str, request: Request, ai_description: bool = False -): - """Accept a machine profile update and refresh the history entry. - - Re-fetches the profile from the machine, updates the stored - ``profile_json`` and ``content_hash``, and optionally regenerates the - AI description. - """ - request_id = request.state.request_id - - try: - logger.info( - f"Accepting sync update for profile {profile_id}", - extra={ - "request_id": request_id, - "profile_id": profile_id, - "ai_description": ai_description, - }, - ) - - # Fetch latest from machine via raw HTTP - profile_dict = await fetch_machine_profile_dict(profile_id) - profile_name = profile_dict.get("name", "Unknown") - new_hash = compute_content_hash(profile_dict) - - # Find the history entry by name - history = load_history() - entries = history if isinstance(history, list) else history.get("entries", []) - target_entry = None - for entry in entries: - if entry.get("profile_name") == profile_name: - target_entry = entry - break - - if not target_entry: - raise HTTPException( - status_code=404, detail="No history entry found for this profile" - ) - - # Optionally regenerate description - new_reply = None - if ai_description: - try: - new_reply = await _generate_profile_description( - profile_dict, request_id - ) - except Exception as e: - logger.warning( - f"AI description generation failed during sync accept: {e}", - extra={"request_id": request_id}, - ) - - updated = update_entry_sync_fields( - target_entry["id"], - content_hash=new_hash, - machine_updated_at=datetime.now(timezone.utc).isoformat(), - profile_json=profile_dict, - reply=new_reply, - ) - - if not updated: - raise HTTPException(status_code=404, detail="History entry not found") - - logger.info( - f"Sync update accepted for '{profile_name}'", - extra={"request_id": request_id, "entry_id": target_entry["id"]}, - ) - - return { - "status": "success", - "profile_name": profile_name, - "content_hash": new_hash, - "ai_description_generated": ai_description and new_reply is not None, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to accept sync update: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) - - -@router.get("/profiles/sync/status") -@router.get("/api/profiles/sync/status") -async def sync_status(request: Request): - """Count of pending sync items for badge display. - - Fetches full profiles to compute content hashes so that updated profiles - are accurately reflected in the badge count. Also backfills hashes for - history entries that were created before hash tracking was added. - """ - request_id = request.state.request_id - - try: - profiles_result = await async_list_profiles() - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException( - status_code=502, - detail=f"Machine API error: {profiles_result.error}", - ) - - history = load_history() - entries = history if isinstance(history, list) else history.get("entries", []) - history_by_name: dict[str, dict] = {} - for entry in entries: - pname = entry.get("profile_name", "") - if pname and pname not in history_by_name: - history_by_name[pname] = entry - - machine_names: set[str] = set() - new_count = 0 - updated_count = 0 - - for partial in profiles_result: - name = getattr(partial, "name", "") - profile_id = getattr(partial, "id", "") - machine_names.add(name) - entry = history_by_name.get(name) - if entry is None: - new_count += 1 - else: - stored_hash = entry.get("content_hash") - try: - full = await async_get_profile(profile_id) - if hasattr(full, "error") and full.error: - continue - profile_dict = deep_convert_to_dict(full) - current_hash = compute_content_hash(profile_dict) - - if not stored_hash: - # Backfill baseline hash silently - try: - update_entry_sync_fields( - entry["id"], - content_hash=current_hash, - profile_json=profile_dict, - ) - except Exception: - pass - elif stored_hash != current_hash: - updated_count += 1 - except Exception: - pass - - orphan_count = sum( - 1 - for entry in entries - if entry.get("profile_name", "") - and entry.get("profile_name", "") not in machine_names - ) - - return { - "status": "success", - "new_count": new_count, - "updated_count": updated_count, - "orphaned_count": orphan_count, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to get sync status: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) - - -@router.post("/profiles/auto-sync") -@router.post("/api/profiles/auto-sync") -async def auto_sync_profiles(request: Request): - """Automatically sync all new and updated profiles from the machine. - - Imports new profiles and accepts updates without user intervention. - Orphaned profiles are reported but not automatically removed. - """ - request_id = request.state.request_id - - try: - body = ( - await request.json() - if request.headers.get("content-type", "").startswith("application/json") - else {} - ) - ai_description = body.get("ai_description", False) - - # Run full sync detection - profiles_result = await async_list_profiles() - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException( - status_code=502, - detail=f"Machine API error: {profiles_result.error}", - ) - - history = load_history() - entries = history if isinstance(history, list) else history.get("entries", []) - history_by_name: dict[str, dict] = {} - for entry in entries: - pname = entry.get("profile_name", "") - if pname and pname not in history_by_name: - history_by_name[pname] = entry - - imported = [] - updated = [] - - for partial in profiles_result: - profile_name = getattr(partial, "name", "") - profile_id = getattr(partial, "id", "") - - if profile_name not in history_by_name: - # New profile — import it - try: - profile_dict = await fetch_machine_profile_dict(profile_id) - - if ai_description: - try: - reply = await _generate_profile_description( - profile_dict, request_id - ) - except Exception: - from services.analysis_service import ( - _build_static_profile_description, - ) - - reply = _build_static_profile_description(profile_dict) - else: - from services.analysis_service import ( - _build_static_profile_description, - ) - - reply = _build_static_profile_description(profile_dict) - - entry_id = str(uuid.uuid4()) - new_entry = { - "id": entry_id, - "created_at": datetime.now(timezone.utc).isoformat(), - "profile_name": profile_name, - "user_preferences": "Imported from machine (auto-sync)", - "reply": reply, - "profile_json": profile_dict, - "content_hash": compute_content_hash(profile_dict), - "imported": True, - "import_source": "machine", - } - # Reload history to avoid stale writes - history = load_history() - if not isinstance(history, list): - history = history.get("entries", []) - history.insert(0, new_entry) - save_history(history) - imported.append(profile_name) - except Exception as exc: - logger.warning( - f"Auto-sync: failed to import '{profile_name}': {exc}", - extra={"request_id": request_id}, - ) - else: - # Existing profile — check for updates - existing = history_by_name[profile_name] - stored_hash = existing.get("content_hash") - try: - profile_dict = await fetch_machine_profile_dict(profile_id) - current_hash = compute_content_hash(profile_dict) - - if not stored_hash: - # Backfill: store machine hash as baseline (not an update) - update_entry_sync_fields( - existing["id"], - content_hash=current_hash, - profile_json=profile_dict, - ) - elif current_hash != stored_hash: - new_reply = None - if ai_description: - try: - new_reply = await _generate_profile_description( - profile_dict, request_id - ) - except Exception: - pass - update_entry_sync_fields( - existing["id"], - content_hash=current_hash, - machine_updated_at=datetime.now(timezone.utc).isoformat(), - profile_json=profile_dict, - reply=new_reply, - ) - updated.append(profile_name) - except Exception as exc: - logger.warning( - f"Auto-sync: failed to update '{profile_name}': {exc}", - extra={"request_id": request_id}, - ) - - logger.info( - f"Auto-sync complete: {len(imported)} imported, {len(updated)} updated", - extra={"request_id": request_id}, - ) - - return { - "status": "success", - "imported": imported, - "updated": updated, - "imported_count": len(imported), - "updated_count": len(updated), - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Auto-sync failed: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) - - -@router.post("/profiles/repair") -@router.post("/api/profiles/repair") -async def repair_profile_json(request: Request): - """Repair all history entries to contain machine-canonical profile JSON. - - For each entry that has profile_json: - - If a matching profile exists on the machine (by name): fetch canonical - JSON from the machine and replace the stored version. - - If no match on the machine (orphaned): re-normalize the stored JSON - locally via _normalize_profile_for_machine(). - - Returns a summary of how many entries were repaired, normalized, skipped, - or errored. - """ - request_id = request.state.request_id - logger.info("Starting profile JSON repair", extra={"request_id": request_id}) - - try: - # Build machine lookup: name → profile_id - machine_map: dict[str, str] = {} - try: - profiles_result = await async_list_profiles() - if not (hasattr(profiles_result, "error") and profiles_result.error): - for p in profiles_result: - pname = getattr(p, "name", "") - pid = getattr(p, "id", "") - if pname and pid: - machine_map[pname] = pid - except Exception as exc: - logger.warning( - f"Repair: could not list machine profiles, will normalize only: {exc}", - extra={"request_id": request_id}, - ) - - history = load_history() - entries = history if isinstance(history, list) else history.get("entries", []) - - repaired = 0 # Fetched canonical JSON from machine - normalized = 0 # Re-normalized locally (orphaned entries) - skipped = 0 # No profile_json to fix - errors = 0 - error_details: list[str] = [] - - for entry in entries: - entry_id = entry.get("id", "unknown") - profile_json = entry.get("profile_json") - profile_name = entry.get("profile_name", "") - - if not profile_json: - skipped += 1 - continue - - machine_profile_id = machine_map.get(profile_name) - - if machine_profile_id: - # Entry has a matching machine profile — fetch canonical JSON - try: - canonical = await fetch_machine_profile_dict(machine_profile_id) - with _history_lock: - update_entry_sync_fields( - entry_id, - profile_json=canonical, - content_hash=compute_content_hash(canonical), - machine_updated_at=datetime.now(timezone.utc).isoformat(), - ) - repaired += 1 - except Exception as exc: - # Machine fetch failed — fall back to local normalization - try: - norm = _normalize_profile_for_machine( - profile_json - if isinstance(profile_json, dict) - else json.loads(profile_json) - ) - with _history_lock: - update_entry_sync_fields( - entry_id, - profile_json=norm, - content_hash=compute_content_hash(norm), - ) - normalized += 1 - except Exception as inner_exc: - errors += 1 - error_details.append( - f"{entry_id}: machine fetch failed ({exc}), normalize failed ({inner_exc})" - ) - else: - # Orphaned entry — re-normalize locally - try: - source = ( - profile_json - if isinstance(profile_json, dict) - else json.loads(profile_json) - ) - norm = _normalize_profile_for_machine(source) - with _history_lock: - update_entry_sync_fields( - entry_id, - profile_json=norm, - content_hash=compute_content_hash(norm), - ) - normalized += 1 - except Exception as exc: - errors += 1 - error_details.append(f"{entry_id}: normalize failed ({exc})") - - logger.info( - f"Repair complete: {repaired} from machine, {normalized} normalized, " - f"{skipped} skipped, {errors} errors", - extra={"request_id": request_id}, - ) - - return { - "status": "success", - "repaired_from_machine": repaired, - "normalized_locally": normalized, - "skipped_no_json": skipped, - "errors": errors, - "error_details": error_details[:20] if error_details else [], - "total_processed": repaired + normalized + skipped + errors, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Profile repair failed: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) - - -@router.post("/machine/profile/restore/{history_id}") -@router.post("/api/machine/profile/restore/{history_id}") -async def restore_profile_from_history(history_id: str, request: Request): - """Re-upload a profile from history to the Meticulous machine. - - Loads the stored profile JSON from a history entry and saves it back to - the machine via async_save_profile(). - """ - request_id = request.state.request_id - - try: - from services.history_service import get_entry_by_id - - entry = get_entry_by_id(history_id) - if not entry: - raise HTTPException(status_code=404, detail="History entry not found") - - profile_json = entry.get("profile_json") - if not profile_json: - raise HTTPException( - status_code=400, - detail="History entry does not contain profile JSON", - ) - - profile_name = entry.get("profile_name", "Restored Profile") - - logger.info( - f"Restoring profile '{profile_name}' to machine from history", - extra={"request_id": request_id, "history_id": history_id}, - ) - - # Send profile dict directly to the machine API. - # We bypass async_save_profile / Profile model because the stored - # JSON may lack fields the Pydantic model requires (e.g. author_id). - import httpx - import os - - meticulous_ip = os.environ.get("METICULOUS_IP", "").strip() - if not meticulous_ip: - raise HTTPException(status_code=503, detail="METICULOUS_IP not configured") - - # Ensure author_id is present — machine API may require it - if "author_id" not in profile_json: - profile_json["author_id"] = profile_json.get("author", "Metic") - - # Strip None values (machine API rejects them) - clean_json = {k: v for k, v in profile_json.items() if v is not None} - - async with httpx.AsyncClient() as client: - resp = await client.post( - f"http://{meticulous_ip}/api/v1/profile/save", - json=clean_json, - ) - if resp.status_code != 200: - logger.error( - f"Machine API rejected profile save: {resp.status_code} - {resp.text}", - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=502, - detail=f"Machine API error: {resp.text}", - ) - - invalidate_profile_list_cache() - - logger.info( - f"Successfully restored profile '{profile_name}' to machine", - extra={"request_id": request_id, "history_id": history_id}, - ) - - return { - "status": "success", - "message": f"Profile '{profile_name}' restored to machine", - "profile_name": profile_name, - "history_id": history_id, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to restore profile: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) - - -@router.post("/api/profile/convert-description") -async def convert_profile_description(request: Request): - """Convert an existing profile description to the standard MeticAI format. - - Takes a profile with an existing description and reformats it while - preserving all original information. - """ - request_id = request.state.request_id - - try: - body = await request.json() - profile_json = body.get("profile") - existing_description = body.get("description", "") - - if not profile_json: - raise HTTPException(status_code=400, detail="No profile JSON provided") - - profile_name = profile_json.get("name", "Unknown Profile") - - logger.info( - f"Converting description for profile: {profile_name}", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - - prompt = f"""You are an expert espresso barista analysing profiles for the Meticulous Espresso Machine. - -## Expert Profiling Knowledge -{PROFILING_KNOWLEDGE} - -Analyze this Meticulous Espresso profile and convert its description to the standard MeticAI format. - -IMPORTANT: Preserve ALL information from the original description. Do not lose any details - only reformat them. - -PROFILE JSON: -```json -{json.dumps(profile_json, indent=2)} -``` - -ORIGINAL DESCRIPTION: -{existing_description} - -Convert to this exact format while preserving all original information: - -Profile Created: {profile_name} - -Description: -[Preserve the original description's key points and add technical insights from the profile JSON] - -Preparation: -• Dose: [From original or profile settings] -• Grind: [From original or recommend based on profile] -• Temperature: [From profile: {profile_json.get("temperature", "Not specified")}°C] -• Target Yield: [From profile: {profile_json.get("final_weight", "Not specified")}g] -• Expected Time: [Calculate from stages if possible] - -Why This Works: -[Combine original explanation with technical analysis of the profile stages] - -Special Notes: -[Preserve any special notes from original, add any additional insights] - -Remember: NO information should be lost in this conversion!""" - - try: - model = get_vision_model() - except ValueError as e: - raise HTTPException( - status_code=503, - detail={ - "status": "error", - "message": str(e), - "error": "AI features are unavailable until GEMINI_API_KEY is configured", - }, - ) from e - response = await model.async_generate_content(prompt) - converted_description = response.text.strip() - - # Update the history entry if it exists - entry_id = body.get("entry_id") - if entry_id: - with _history_lock: - history = load_history() - entries = ( - history if isinstance(history, list) else history.get("entries", []) - ) - for entry in entries: - if entry.get("id") == entry_id: - entry["reply"] = converted_description - entry["description_converted"] = True - break - save_history(history) - - logger.info( - f"Description converted successfully for: {profile_name}", - extra={"request_id": request_id}, - ) - - return {"status": "success", "converted_description": converted_description} - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to convert description: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.post("/profile/{entry_id}/regenerate-description") -@router.post("/api/profile/{entry_id}/regenerate-description") -async def regenerate_profile_description(entry_id: str, request: Request): - """Regenerate the AI description for an existing profile in history. - - Replaces a static (non-AI) description with a full AI-generated one. - Requires a configured Gemini API key. - """ - request_id = request.state.request_id - - try: - history = load_history() - entries = history if isinstance(history, list) else history.get("entries", []) - target_entry = None - for entry in entries: - if entry.get("id") == entry_id: - target_entry = entry - break - - if not target_entry: - raise HTTPException(status_code=404, detail="History entry not found") - - profile_json = target_entry.get("profile_json") - if not profile_json: - raise HTTPException( - status_code=400, detail="No profile JSON data available for this entry" - ) - - profile_name = target_entry.get("profile_name", "Unknown Profile") - logger.info( - f"Regenerating AI description for: {profile_name}", - extra={"request_id": request_id, "entry_id": entry_id}, - ) - - new_description = await _generate_profile_description(profile_json, request_id) - - # Check it actually used AI (not the static fallback) - if "generated without AI assistance" in new_description: - raise HTTPException( - status_code=503, - detail="AI description generation failed — check that your Gemini API key is configured", - ) - - target_entry["reply"] = new_description - ai_tags = getattr(new_description, "ai_tags", []) - if ai_tags: - target_entry["ai_tags"] = ai_tags - save_history(history) - - # The catalogue embeds each profile's ai_tags, so a regenerated - # description (which may add/change tags) must bust the profile list - # cache — otherwise the catalogue keeps serving stale, tag-less entries. - invalidate_profile_list_cache() - - logger.info( - f"AI description regenerated for: {profile_name}", - extra={"request_id": request_id, "entry_id": entry_id}, - ) - - return {"status": "success", "description": new_description} - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to regenerate description: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) - - -# ============================================================================ -# Run Shot Endpoints -# ============================================================================ - - -async def _schedule_next_recurring(schedule_id: str, schedule: dict): - """Schedule the next occurrence of a recurring schedule. - - This function calculates when the next occurrence should happen based on - the schedule configuration and creates a scheduled shot for that time. - """ - next_time = _get_next_occurrence(schedule) - - if not next_time: - logger.warning( - f"Could not calculate next occurrence for recurring schedule {schedule_id}" - ) - return - - profile_id = schedule.get("profile_id") - preheat = schedule.get("preheat", True) - - # Create a one-time scheduled shot for the next occurrence - shot_id = f"recurring-{schedule_id}-{next_time.isoformat()}" - - # Check if we already have this shot scheduled - if shot_id in _scheduled_shots: - logger.debug(f"Recurring shot {shot_id} already scheduled") - return - - shot_delay = (next_time - datetime.now(timezone.utc)).total_seconds() - - if shot_delay < 0: - logger.warning(f"Next occurrence for {schedule_id} is in the past, skipping") - return - - # Store the scheduled shot - scheduled_shot = { - "id": shot_id, - "profile_id": profile_id, - "scheduled_time": next_time.isoformat(), - "preheat": preheat, - "status": "scheduled", - "created_at": datetime.now(timezone.utc).isoformat(), - "recurring_schedule_id": schedule_id, - } - _scheduled_shots[shot_id] = scheduled_shot - await _save_scheduled_shots() - - logger.info( - f"Scheduled next recurring shot {shot_id} for {next_time.isoformat()} " - f"(profile: {profile_id}, preheat: {preheat})" - ) - - -async def _recurring_schedule_checker(): - """Background task to ensure recurring schedules stay up to date. - - Runs every hour to: - 1. Check for completed recurring shots and schedule the next occurrence - 2. Ensure all enabled recurring schedules have an upcoming shot scheduled - """ - while True: - try: - await asyncio.sleep(3600) # Check every hour - - logger.info("Running recurring schedule check") - - # Find completed recurring shots and schedule next occurrence - for shot_id, shot in list(_scheduled_shots.items()): - recurring_id = shot.get("recurring_schedule_id") - if recurring_id and shot.get("status") in ["completed", "failed"]: - # Update last_run time for interval-based schedules - if recurring_id in _recurring_schedules: - schedule = _recurring_schedules[recurring_id] - if schedule.get("recurrence_type") == "interval": - schedule["last_run"] = shot.get("scheduled_time") - await _save_recurring_schedules() - - # Schedule next occurrence - await _schedule_next_recurring(recurring_id, schedule) - - # Ensure all enabled schedules have an upcoming shot - for schedule_id, schedule in _recurring_schedules.items(): - if not schedule.get("enabled", True): - continue - - # Check if there's already a pending shot for this schedule - has_pending = any( - s.get("recurring_schedule_id") == schedule_id - and s.get("status") in ["scheduled", "preheating"] - for s in _scheduled_shots.values() - ) - - if not has_pending: - await _schedule_next_recurring(schedule_id, schedule) - - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"Error in recurring schedule checker: {e}", exc_info=True) - await asyncio.sleep(60) # Wait a bit before retrying - - -# ============================================================================ -# Apply AI Recommendations -# ============================================================================ - -_KNOWN_VARIABLE_TYPES = ("pressure", "flow", "temperature", "weight", "time", "volume") - - -def _variable_type_of(raw: str) -> str | None: - """Determine which value type a recommendation variable refers to. - - Handles real keys ("pressure_Max Pressure"), invented positional ids - ("pressure_2", "flow_0") and bare types ("pressure"). - """ - lower = str(raw or "").strip().lower() - for vtype in _KNOWN_VARIABLE_TYPES: - if lower == vtype or lower.startswith(f"{vtype}_"): - return vtype - return None - - -def _resolve_fuzzy_variable(variables, raw_variable, current_value, stage): - """Recover the real profile variable when a model invents a positional id. - - Models across the board emit ids like "pressure_2" instead of the actual - key ("pressure_Max Pressure"). Match by value type, disambiguating on the - reported current_value and finally the stage name. Returns ``None`` when - ambiguous so the caller surfaces a clear skip instead of guessing. - """ - if not variables: - return None - vtype = _variable_type_of(raw_variable) - if not vtype: - return None - - def _is_adjustable(var) -> bool: - key = str(getattr(var, "key", "") or "") - if key.startswith("info_") or getattr(var, "adjustable", None) is False: - return False - item_type = str(getattr(var, "type", "") or "").lower() or ( - _variable_type_of(key) or "" - ) - return item_type == vtype - - candidates = [var for var in variables if _is_adjustable(var)] - if not candidates: - return None - if len(candidates) == 1: - return candidates[0] - - try: - cur = float(current_value) - except (TypeError, ValueError): - cur = None - if cur is not None and math.isfinite(cur): - by_value = [] - for var in candidates: - try: - if float(getattr(var, "value", None)) == cur: - by_value.append(var) - except (TypeError, ValueError): - continue - if len(by_value) == 1: - return by_value[0] - - stage_lower = str(stage or "").strip().lower() - if stage_lower and stage_lower != "global": - by_stage = [ - var - for var in candidates - if stage_lower in str(getattr(var, "name", "") or "").lower() - or stage_lower in str(getattr(var, "key", "") or "").lower() - ] - if len(by_stage) == 1: - return by_stage[0] - return None - - -@router.post("/profile/{name:path}/apply-recommendations") -@router.post("/api/profile/{name:path}/apply-recommendations") -async def apply_recommendations( - name: str, - request: Request, - recommendations: str = Form(...), -): - """Apply selected AI recommendations to a profile. - - Only applies patchable (adjustable) variables. Info-only variables - and unknown keys are silently skipped. - - Args: - name: Profile name on the machine. - recommendations: JSON string — list of recommendation objects with - at least ``variable``, ``recommended_value``, ``stage``. - - Returns: - Updated profile dict. - """ - request_id = request.state.request_id - - try: - recs = json.loads(recommendations) - if not isinstance(recs, list): - raise HTTPException( - status_code=400, detail="recommendations must be a JSON array" - ) - except (json.JSONDecodeError, TypeError) as exc: - raise HTTPException( - status_code=400, detail=f"Invalid recommendations JSON: {exc}" - ) - - try: - # --- find profile by name ------------------------------------------------ - profiles_result = await async_list_profiles() - if hasattr(profiles_result, "error") and profiles_result.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {profiles_result.error}" - ) - - matching_profile = None - for p in profiles_result: - if p.name == name: - matching_profile = p - break - - if matching_profile is None: - raise HTTPException( - status_code=404, detail=f"Profile '{name}' not found on machine" - ) - - full_profile = await async_get_profile(matching_profile.id) - if hasattr(full_profile, "error") and full_profile.error: - raise HTTPException( - status_code=502, detail=f"Failed to fetch profile: {full_profile.error}" - ) - - applied: list[dict] = [] - skipped: list[dict] = [] - - for rec in recs: - if not isinstance(rec, dict): - skipped.append( - {"variable": "?", "reason": "invalid entry (not an object)"} - ) - continue - variable = rec.get("variable", "") - recommended_value = rec.get("recommended_value") - stage = rec.get("stage", "") - - if recommended_value is None: - skipped.append({"variable": variable, "reason": "no recommended_value"}) - continue - - # Validate recommended_value is coercible to a finite number - try: - rv_float = float(recommended_value) - if not math.isfinite(rv_float): - raise ValueError("non-finite") - except (TypeError, ValueError): - skipped.append( - {"variable": variable, "reason": "invalid recommended_value"} - ) - continue - - # --- global settings (temperature, final_weight) --- - if stage == "global": - if variable == "temperature" and hasattr(full_profile, "temperature"): - try: - val = float(recommended_value) - except (TypeError, ValueError): - skipped.append( - {"variable": variable, "reason": "invalid value"} - ) - continue - if val > 100: - skipped.append( - {"variable": variable, "reason": "exceeds 100 °C"} - ) - continue - full_profile.temperature = val - applied.append({"variable": variable, "stage": stage, "value": val}) - continue - elif variable == "final_weight" and hasattr( - full_profile, "final_weight" - ): - try: - val = float(recommended_value) - except (TypeError, ValueError): - skipped.append( - {"variable": variable, "reason": "invalid value"} - ) - continue - if val <= 0: - skipped.append({"variable": variable, "reason": "must be > 0"}) - continue - full_profile.final_weight = val - applied.append({"variable": variable, "stage": stage, "value": val}) - continue - - # --- profile variables --- - if hasattr(full_profile, "variables") and full_profile.variables: - matched_var = False - for var in full_profile.variables: - var_key = getattr(var, "key", "") - if var_key == variable: - # Skip info-only variables - if ( - var_key.startswith("info_") - or getattr(var, "adjustable", None) is False - ): - skipped.append( - { - "variable": variable, - "reason": "info-only / not adjustable", - } - ) - matched_var = True - break - try: - var.value = float(recommended_value) - except (TypeError, ValueError): - skipped.append( - {"variable": variable, "reason": "invalid value"} - ) - matched_var = True - break - applied.append( - {"variable": variable, "stage": stage, "value": var.value} - ) - matched_var = True - break - if matched_var: - continue - - # --- stage exit triggers --- - # Variables like "exit_weight", "exit_time" target a stage's exit_triggers - exit_trigger_map = { - "exit_weight": "weight", - "exit_time": "time", - "exit_pressure": "pressure", - "exit_flow": "flow", - "exit_volume": "volume", - } - trigger_type = exit_trigger_map.get(variable) - if ( - trigger_type - and stage - and hasattr(full_profile, "stages") - and full_profile.stages - ): - matched_stage = False - stage_lower = stage.lower() - for profile_stage in full_profile.stages: - stage_name = getattr(profile_stage, "name", "") - if stage_name.lower() == stage_lower: - triggers = getattr(profile_stage, "exit_triggers", None) - if triggers: - for trigger in triggers: - if getattr(trigger, "type", "") == trigger_type: - try: - trigger.value = float(recommended_value) - except (TypeError, ValueError): - skipped.append( - { - "variable": variable, - "reason": "invalid value", - } - ) - matched_stage = True - break - applied.append( - { - "variable": variable, - "stage": stage, - "value": trigger.value, - } - ) - matched_stage = True - break - if not matched_stage: - skipped.append( - { - "variable": variable, - "reason": f"no {trigger_type} exit trigger in stage '{stage_name}'", - } - ) - matched_stage = True - break - if matched_stage: - continue - - # --- stage limits --- - # Variables like "limit_pressure", "limit_flow" target a stage's limits - limit_map = { - "limit_pressure": "pressure", - "limit_flow": "flow", - "limit_weight": "weight", - } - limit_type = limit_map.get(variable) - if ( - limit_type - and stage - and hasattr(full_profile, "stages") - and full_profile.stages - ): - matched_stage = False - stage_lower = stage.lower() - for profile_stage in full_profile.stages: - stage_name = getattr(profile_stage, "name", "") - if stage_name.lower() == stage_lower: - limits = getattr(profile_stage, "limits", None) - if limits: - for limit in limits: - if getattr(limit, "type", "") == limit_type: - try: - limit.value = float(recommended_value) - except (TypeError, ValueError): - skipped.append( - { - "variable": variable, - "reason": "invalid value", - } - ) - matched_stage = True - break - applied.append( - { - "variable": variable, - "stage": stage, - "value": limit.value, - } - ) - matched_stage = True - break - if not matched_stage: - skipped.append( - { - "variable": variable, - "reason": f"no {limit_type} limit in stage '{stage_name}'", - } - ) - matched_stage = True - break - if matched_stage: - continue - - # --- fuzzy fallback --- - # Models routinely invent positional ids like "pressure_2" / "flow_0" - # instead of the real variable key. Recover the intended variable by - # type + current_value before giving up. - if hasattr(full_profile, "variables") and full_profile.variables: - fuzzy_var = _resolve_fuzzy_variable( - full_profile.variables, - variable, - rec.get("current_value"), - stage, - ) - if fuzzy_var is not None: - try: - fuzzy_var.value = float(recommended_value) - except (TypeError, ValueError): - skipped.append( - {"variable": variable, "reason": "invalid value"} - ) - continue - applied.append( - { - "variable": getattr(fuzzy_var, "key", variable), - "stage": stage, - "value": fuzzy_var.value, - "matched_from": variable, - } - ) - continue - - skipped.append( - {"variable": variable, "reason": "variable not found in profile"} - ) - - if not applied: - return { - "status": "no_changes", - "message": "No applicable recommendations to apply", - "applied": [], - "skipped": skipped, - } - - # --- persist ------------------------------------------------------------- - await async_save_profile(full_profile) - - logger.info( - f"Applied {len(applied)} recommendation(s) to profile '{name}'", - extra={ - "request_id": request_id, - "applied_count": len(applied), - "skipped_count": len(skipped), - }, - ) - - return { - "status": "success", - "profile": deep_convert_to_dict(full_profile), - "applied": applied, - "skipped": skipped, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to apply recommendations: {e}", - exc_info=True, - extra={"request_id": request_id, "profile_name": name}, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) - - -# ============================================================================ -# Profile recommendations -# ============================================================================ - - -@router.post("/profiles/recommend") -@router.post("/api/profiles/recommend") -async def recommend_profiles( - request: Request, - tags: list[str] = Form(default=[]), - limit: int = Form(default=5), -): - """Return profile recommendations based on tag preferences. - - Uses structural comparison (stage types, pressure/flow control, - peak pressure, target weight, temperature) — no AI tokens consumed. - """ - request_id = request.state.request_id - - try: - results = await recommendation_service.get_recommendations( - tags=tags, - limit=limit, - ) - - return { - "status": "success", - "recommendations": results, - "count": len(results), - } - - except Exception as e: - logger.error( - f"Failed to get recommendations: {e}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) - - -@router.post("/profiles/find-similar") -@router.post("/api/profiles/find-similar") -async def find_similar_profiles( - request: Request, - profile_name: str = Form(...), - limit: int = Form(default=10), -): - """Find profiles similar to a given source profile.""" - request_id = request.state.request_id - - try: - results = await recommendation_service.find_similar( - source_profile_name=profile_name, - limit=limit, - ) - - return { - "status": "success", - "recommendations": results, - "count": len(results), - } - - except Exception as e: - logger.error( - f"Failed to find similar profiles: {e}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) diff --git a/apps/server/api/routes/recipes.py b/apps/server/api/routes/recipes.py deleted file mode 100644 index 79859680..00000000 --- a/apps/server/api/routes/recipes.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Recipe library endpoints. - -Serves the bundled OPOS recipe library. Each recipe is a static JSON file -under ``data/recipes/`` (development) or ``/app/defaults/recipes/`` (Docker). - -All recipes include a ``slug`` field derived from the filename stem. -""" - -import logging - -from fastapi import APIRouter, HTTPException - -from services.recipe_adapter import list_recipes, load_recipe - -router = APIRouter() -logger = logging.getLogger(__name__) - - -@router.get("/api/recipes") -async def get_recipes(): - """Return the full list of bundled recipes.""" - return list_recipes() - - -@router.get("/api/recipes/{slug}") -async def get_recipe(slug: str): - """Return a single recipe by slug. - - Args: - slug: Recipe filename stem (e.g. ``"4-6-method"``). - """ - try: - return load_recipe(slug) - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except Exception as exc: - logger.error("Failed to load recipe '%s': %s", slug, exc) - raise HTTPException( - status_code=500, detail=f"Failed to load recipe: {exc}" - ) from exc diff --git a/apps/server/api/routes/scheduling.py b/apps/server/api/routes/scheduling.py deleted file mode 100644 index 7ead896e..00000000 --- a/apps/server/api/routes/scheduling.py +++ /dev/null @@ -1,1054 +0,0 @@ -"""Machine status and scheduling endpoints.""" - -from fastapi import APIRouter, Form, Request, HTTPException -from datetime import datetime, timezone -import json -import uuid -import logging -import asyncio - -from services.meticulous_service import ( - get_meticulous_api, - async_get_settings, - async_get_last_profile, - async_execute_action, - async_load_profile_by_id, - async_get_profile, - async_create_profile, - async_save_profile, - async_session_get, - async_session_post, -) -from services.scheduling_state import ( - _scheduled_shots, - _scheduled_tasks, - _recurring_schedules, - _get_state_lock, - save_scheduled_shots as _save_scheduled_shots, - save_recurring_schedules as _save_recurring_schedules, - get_next_occurrence as _get_next_occurrence, - PREHEAT_DURATION_MINUTES, -) -from services import temp_profile_service -from utils.file_utils import deep_convert_to_dict - -router = APIRouter() -logger = logging.getLogger(__name__) - - -async def _schedule_next_recurring(schedule_id: str, schedule: dict): - """Schedule the next occurrence of a recurring schedule.""" - next_occurrence = _get_next_occurrence(schedule) - if next_occurrence is None: - logger.warning( - f"Could not calculate next occurrence for schedule {schedule_id}" - ) - return - - # Calculate delay until next occurrence - now = datetime.now(timezone.utc) - delay_seconds = (next_occurrence - now).total_seconds() - - if delay_seconds <= 0: - logger.warning(f"Next occurrence for {schedule_id} is in the past, skipping") - return - - # Create a unique shot ID for this occurrence - shot_id = f"recurring-{schedule_id}-{next_occurrence.isoformat()}" - - # Get schedule details - profile_id = schedule.get("profile_id") - preheat = schedule.get("preheat", True) - - # Add to scheduled shots (lock protects dict mutation) - async with _get_state_lock(): - _scheduled_shots[shot_id] = { - "id": shot_id, - "profile_id": profile_id, - "scheduled_time": next_occurrence.isoformat(), - "preheat": preheat, - "status": "scheduled", - "recurring_schedule_id": schedule_id, - "created_at": now.isoformat(), - } - - await _save_scheduled_shots() - - logger.info( - f"Scheduled recurring shot {shot_id} for {next_occurrence.isoformat()}", - extra={"schedule_id": schedule_id, "shot_id": shot_id}, - ) - - -@router.get("/api/machine/status") -async def get_machine_status(request: Request): - """Get the current status of the Meticulous machine. - - Returns machine state, current profile, and whether preheating is active. - """ - request_id = request.state.request_id - - try: - logger.info("Fetching machine status", extra={"request_id": request_id}) - - # Get current shot/status (live machine state) - try: - status = await async_session_get("/api/v1/status") - if status.status_code == 200: - status_data = status.json() - else: - status_data = {"state": "unknown"} - except Exception as e: - logger.warning(f"Could not fetch machine status: {e}") - status_data = {"state": "unknown", "error": str(e)} - - # Get settings to check preheat state - try: - settings = await async_get_settings() - if hasattr(settings, "error") and settings.error: - settings_data = {} - elif hasattr(settings, "model_dump"): - settings_data = settings.model_dump() - else: - settings_data = dict(settings) if settings else {} - except Exception as e: - logger.warning(f"Could not fetch settings: {e}") - settings_data = {} - - # Get last loaded profile - try: - last_profile = await async_get_last_profile() - if hasattr(last_profile, "error") and last_profile.error: - last_profile_data = None - elif hasattr(last_profile, "profile"): - last_profile_data = { - "id": last_profile.profile.id - if hasattr(last_profile.profile, "id") - else None, - "name": last_profile.profile.name - if hasattr(last_profile.profile, "name") - else None, - } - else: - last_profile_data = None - except Exception as e: - logger.warning(f"Could not fetch last profile: {e}") - last_profile_data = None - - return { - "status": "success", - "machine_status": status_data, - "settings": settings_data, - "current_profile": last_profile_data, - "scheduled_shots": list(_scheduled_shots.values()), - } - - except Exception as e: - logger.error( - f"Failed to get machine status: {str(e)}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.post("/api/machine/preheat") -async def start_preheat(request: Request): - """Start preheating the machine. - - Preheating takes approximately 10 minutes to reach optimal temperature. - When *profile_id* is provided the profile is loaded on the machine first - so the display shows the correct profile during the heating phase. - """ - request_id = request.state.request_id - - # Accept optional JSON body with profile_id - profile_id: str | None = None - try: - body = await request.json() - profile_id = body.get("profile_id") if isinstance(body, dict) else None - except Exception: - pass # No body or non-JSON body is fine - - try: - logger.info( - "Starting machine preheat", - extra={"request_id": request_id, "profile_id": profile_id}, - ) - - if get_meticulous_api() is None: - raise HTTPException( - status_code=503, detail="Meticulous machine not connected" - ) - - # Pre-select the profile so the machine shows it during preheat - if profile_id: - try: - await async_load_profile_by_id(profile_id) - logger.info( - "Profile pre-selected for preheat: %s", - profile_id, - extra={"request_id": request_id}, - ) - except Exception as exc: - logger.warning("Failed to pre-select profile for preheat: %s", exc) - - # Use ActionType.PREHEAT to start the preheat cycle - try: - from meticulous.api_types import ActionType - - result = await async_execute_action(ActionType.PREHEAT) - - if hasattr(result, "error") and result.error: - raise HTTPException( - status_code=502, detail=f"Failed to start preheat: {result.error}" - ) - except ImportError: - # Fallback: direct API call - result = await async_session_post("/api/v1/action", {"action": "preheat"}) - if result.status_code != 200: - raise HTTPException( - status_code=502, detail=f"Failed to start preheat: {result.text}" - ) - - return { - "status": "success", - "message": "Preheat started", - "estimated_ready_in_minutes": PREHEAT_DURATION_MINUTES, - "profile_preselected": profile_id is not None, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to start preheat: {str(e)}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.post("/api/machine/run-profile/{profile_id}") -async def run_profile(profile_id: str, request: Request): - """Load and run a profile immediately. - - This loads the profile into the machine and starts extraction. - """ - request_id = request.state.request_id - - try: - logger.info( - f"Running profile: {profile_id}", - extra={"request_id": request_id, "profile_id": profile_id}, - ) - - if get_meticulous_api() is None: - raise HTTPException( - status_code=503, detail="Meticulous machine not connected" - ) - - # Load the profile - load_result = await async_load_profile_by_id(profile_id) - if hasattr(load_result, "error") and load_result.error: - raise HTTPException( - status_code=502, detail=f"Failed to load profile: {load_result.error}" - ) - - # Start the extraction - from meticulous.api_types import ActionType - - action_result = await async_execute_action(ActionType.START) - if hasattr(action_result, "error") and action_result.error: - raise HTTPException( - status_code=502, - detail=f"Failed to start profile: {action_result.error}", - ) - - return { - "status": "success", - "message": "Profile started", - "profile_id": profile_id, - "action": action_result.action - if hasattr(action_result, "action") - else "start", - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to run profile: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "profile_id": profile_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.post("/machine/run-profile-with-overrides/{profile_id}") -@router.post("/api/machine/run-profile-with-overrides/{profile_id}") -async def run_profile_with_overrides( - request: Request, - profile_id: str, - overrides_json: str = Form(default="{}"), - save_mode: str = Form(default="none"), - new_name: str = Form(default=""), -): - """Run a profile with temporary variable overrides. - - Uses the machine's ephemeral load endpoint (``POST /api/v1/profile/load``) - to load the modified profile into memory **without** saving it to the - catalogue. The original profile on the machine remains untouched, and - shot history records the original profile name. - - Args: - profile_id: ID of the source profile. - overrides_json: JSON string mapping variable keys to new numeric values. - save_mode: ``"none"`` | ``"save_original"`` | ``"save_new"``. - new_name: Required when *save_mode* is ``"save_new"``. - """ - request_id = request.state.request_id - - try: - overrides_dict: dict = json.loads(overrides_json) - except (json.JSONDecodeError, TypeError) as exc: - raise HTTPException(status_code=422, detail=f"Invalid overrides JSON: {exc}") - - if save_mode not in ("none", "save_original", "save_new"): - raise HTTPException(status_code=422, detail=f"Invalid save_mode: {save_mode}") - - if save_mode == "save_new" and not new_name.strip(): - raise HTTPException( - status_code=422, detail="new_name is required when save_mode is save_new" - ) - - # Reject info_ variable overrides - info_keys = [k for k in overrides_dict if k.startswith("info_")] - if info_keys: - raise HTTPException( - status_code=422, - detail=f"Cannot override info variables: {info_keys}", - ) - - try: - logger.info( - "Running profile with overrides: %s (%d override(s), save_mode=%s)", - profile_id, - len(overrides_dict), - save_mode, - extra={"request_id": request_id}, - ) - - if get_meticulous_api() is None: - raise HTTPException( - status_code=503, detail="Meticulous machine not connected" - ) - - # Fetch the original profile - original_profile = await async_get_profile(profile_id) - if original_profile is None: - raise HTTPException( - status_code=404, detail=f"Profile {profile_id} not found" - ) - if hasattr(original_profile, "error") and original_profile.error: - raise HTTPException( - status_code=404, - detail=f"Profile {profile_id} not found: {original_profile.error}", - ) - - # Normalise to dict - if hasattr(original_profile, "__dict__"): - profile_data: dict = deep_convert_to_dict(original_profile) - elif isinstance(original_profile, dict): - profile_data = original_profile - else: - raise HTTPException( - status_code=500, detail="Unexpected profile format from machine" - ) - - original_name = profile_data.get("name", "Unknown Profile") - - # --- Save-as-new: persist BEFORE loading so the machine reports --- - # --- the new name and the profile/image is available immediately --- - if save_mode == "save_new" and overrides_dict: - new_profile = temp_profile_service.apply_variable_overrides( - profile_data, overrides_dict - ) - new_profile.pop("id", None) - new_profile["name"] = new_name.strip() - try: - await async_create_profile(new_profile) - logger.info("Overrides saved as new profile '%s'", new_name.strip()) - except Exception as exc: - logger.error("Failed to save overrides as new profile: %s", exc) - raise HTTPException( - status_code=502, - detail=f"Failed to save new profile: {exc}", - ) - - if overrides_dict: - # Apply overrides to a deep copy - modified_profile = temp_profile_service.apply_variable_overrides( - profile_data, overrides_dict - ) - - # When saving as new, use the new name for the ephemeral load so - # the machine broadcasts the correct active_profile via WebSocket. - if save_mode == "save_new": - modified_profile["name"] = new_name.strip() - modified_profile.pop("id", None) - - # Ephemeral load: loads into machine memory without persisting. - result = await temp_profile_service.load_ephemeral( - modified_profile, - params={"overrides": overrides_dict, "source_profile_id": profile_id}, - previous_profile_id=profile_id, - previous_profile_name=original_name, - ) - else: - # No overrides — just load the original - await async_load_profile_by_id(profile_id) - result = {"profile_id": profile_id, "profile_name": original_name} - - # Start extraction - from meticulous.api_types import ActionType - - action_result = await async_execute_action(ActionType.START) - if hasattr(action_result, "error") and action_result.error: - raise HTTPException( - status_code=502, - detail=f"Failed to start profile: {action_result.error}", - ) - - # Handle save-to-original after starting (non-blocking) - if save_mode == "save_original" and overrides_dict: - saved_profile = temp_profile_service.apply_variable_overrides( - profile_data, overrides_dict - ) - saved_profile["id"] = profile_id - saved_profile["name"] = original_name - try: - await async_save_profile(saved_profile) - logger.info("Overrides saved back to original profile %s", profile_id) - except Exception as exc: - logger.error("Failed to save overrides to original profile: %s", exc) - - return { - "status": "success", - "message": "Profile started with overrides" - if overrides_dict - else "Profile started", - "profile_id": result["profile_id"], - "profile_name": result["profile_name"], - "overrides_applied": len(overrides_dict), - "save_mode": save_mode, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - "Failed to run profile with overrides: %s", - str(e), - exc_info=True, - extra={"request_id": request_id, "profile_id": profile_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.post("/api/machine/schedule-shot") -async def schedule_shot(request: Request): - """Schedule a shot to run at a specific time. - - Request body: - - profile_id: str - The profile ID to run - - scheduled_time: str - ISO format datetime when to run the shot - - preheat: bool - Whether to preheat before the shot (default: false) - - If preheat is enabled, preheating will start 10 minutes before scheduled_time. - """ - request_id = request.state.request_id - - try: - body = await request.json() - profile_id = body.get("profile_id") - scheduled_time_str = body.get("scheduled_time") - preheat = body.get("preheat", False) - - if not scheduled_time_str: - raise HTTPException(status_code=400, detail="scheduled_time is required") - - # Validate that we have either a profile or preheat enabled - if not profile_id and not preheat: - raise HTTPException( - status_code=400, detail="Either profile_id or preheat must be provided" - ) - - # Parse the scheduled time - try: - scheduled_time = datetime.fromisoformat( - scheduled_time_str.replace("Z", "+00:00") - ) - # Ensure timezone-aware datetime - if scheduled_time.tzinfo is None: - scheduled_time = scheduled_time.replace(tzinfo=timezone.utc) - except ValueError: - raise HTTPException( - status_code=400, detail="Invalid scheduled_time format. Use ISO format." - ) - - # Calculate delays - now = datetime.now(timezone.utc) - shot_delay = (scheduled_time - now).total_seconds() - - if shot_delay < 0: - raise HTTPException( - status_code=400, detail="scheduled_time must be in the future" - ) - - # Generate a unique ID for this scheduled shot - schedule_id = str(uuid.uuid4()) - - # Store the scheduled shot info - scheduled_shot = { - "id": schedule_id, - "profile_id": profile_id, - "scheduled_time": scheduled_time_str, - "preheat": preheat, - "status": "scheduled", - "created_at": datetime.now(timezone.utc).isoformat(), - } - async with _get_state_lock(): - _scheduled_shots[schedule_id] = scheduled_shot - - # Persist to disk - await _save_scheduled_shots() - - logger.info( - f"Scheduling shot: {schedule_id}", - extra={ - "request_id": request_id, - "schedule_id": schedule_id, - "profile_id": profile_id, - "scheduled_time": scheduled_time_str, - "preheat": preheat, - }, - ) - - # Create async task to execute at scheduled time - async def _execute_shot_task(): - try: - task_start_time = datetime.now(timezone.utc) - - # Track whether we've already waited the full delay - full_delay_waited = False - - # If preheat is enabled, start it 10 minutes before - if preheat: - preheat_delay = shot_delay - (PREHEAT_DURATION_MINUTES * 60) - if preheat_delay > 0: - await asyncio.sleep(preheat_delay) - async with _get_state_lock(): - _scheduled_shots[schedule_id]["status"] = "preheating" - await _save_scheduled_shots() - - # Start preheat using ActionType.PREHEAT - try: - from meticulous.api_types import ActionType as AT - - await async_execute_action(AT.PREHEAT) - except Exception as e: - logger.warning( - f"Preheat failed for scheduled shot {schedule_id}: {e}" - ) - - # Wait for remaining time until shot - await asyncio.sleep(PREHEAT_DURATION_MINUTES * 60) - full_delay_waited = True - else: - # Not enough time for full preheat, start immediately - async with _get_state_lock(): - _scheduled_shots[schedule_id]["status"] = "preheating" - await _save_scheduled_shots() - try: - from meticulous.api_types import ActionType as AT - - await async_execute_action(AT.PREHEAT) - except Exception as e: - logger.warning( - f"Preheat failed for scheduled shot {schedule_id}: {e}" - ) - - # If we haven't already waited the full delay, calculate remaining time - if not full_delay_waited: - elapsed = ( - datetime.now(timezone.utc) - task_start_time - ).total_seconds() - remaining_delay = max(0, shot_delay - elapsed) - await asyncio.sleep(remaining_delay) - - async with _get_state_lock(): - _scheduled_shots[schedule_id]["status"] = "running" - await _save_scheduled_shots() - - # Load and run the profile (if profile_id was provided) - if profile_id: - load_result = await async_load_profile_by_id(profile_id) - if not (hasattr(load_result, "error") and load_result.error): - from meticulous.api_types import ActionType - - await async_execute_action(ActionType.START) - async with _get_state_lock(): - _scheduled_shots[schedule_id]["status"] = "completed" - await _save_scheduled_shots() - else: - async with _get_state_lock(): - _scheduled_shots[schedule_id]["status"] = "failed" - _scheduled_shots[schedule_id]["error"] = load_result.error - await _save_scheduled_shots() - else: - # Preheat only mode - mark as completed - async with _get_state_lock(): - _scheduled_shots[schedule_id]["status"] = "completed" - await _save_scheduled_shots() - - except asyncio.CancelledError: - async with _get_state_lock(): - _scheduled_shots[schedule_id]["status"] = "cancelled" - await _save_scheduled_shots() - except Exception as e: - logger.error(f"Scheduled shot {schedule_id} failed: {e}") - async with _get_state_lock(): - _scheduled_shots[schedule_id]["status"] = "failed" - _scheduled_shots[schedule_id]["error"] = str(e) - await _save_scheduled_shots() - finally: - # Clean up task reference - async with _get_state_lock(): - if schedule_id in _scheduled_tasks: - del _scheduled_tasks[schedule_id] - - # Start the background task - task = asyncio.create_task(_execute_shot_task()) - async with _get_state_lock(): - _scheduled_tasks[schedule_id] = task - - return { - "status": "success", - "schedule_id": schedule_id, - "scheduled_shot": scheduled_shot, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to schedule shot: {str(e)}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.delete("/api/machine/schedule-shot/{schedule_id}") -async def cancel_scheduled_shot(schedule_id: str, request: Request): - """Cancel a scheduled shot.""" - request_id = request.state.request_id - - try: - async with _get_state_lock(): - if schedule_id not in _scheduled_shots: - raise HTTPException(status_code=404, detail="Scheduled shot not found") - - # Cancel the task if it exists - if schedule_id in _scheduled_tasks: - _scheduled_tasks[schedule_id].cancel() - del _scheduled_tasks[schedule_id] - - _scheduled_shots[schedule_id]["status"] = "cancelled" - - # Persist to disk - await _save_scheduled_shots() - - logger.info( - f"Cancelled scheduled shot: {schedule_id}", - extra={"request_id": request_id, "schedule_id": schedule_id}, - ) - - return { - "status": "success", - "message": "Scheduled shot cancelled", - "schedule_id": schedule_id, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to cancel scheduled shot: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "schedule_id": schedule_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.get("/api/machine/scheduled-shots") -async def list_scheduled_shots(request: Request): - """List all scheduled shots.""" - request_id = request.state.request_id - - try: - # Clean up completed/cancelled shots older than 1 hour - now = datetime.now(timezone.utc) - async with _get_state_lock(): - to_remove = [] - for schedule_id, shot in _scheduled_shots.items(): - if shot["status"] in ["completed", "cancelled", "failed"]: - created_at = datetime.fromisoformat( - shot["created_at"].replace("Z", "+00:00") - ) - if (now - created_at).total_seconds() > 3600: - to_remove.append(schedule_id) - - for schedule_id in to_remove: - del _scheduled_shots[schedule_id] - - result = list(_scheduled_shots.values()) - - # Persist changes if any shots were removed - if to_remove: - await _save_scheduled_shots() - - return {"status": "success", "scheduled_shots": result} - - except Exception as e: - logger.error( - f"Failed to list scheduled shots: {str(e)}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -# ============================================================================== -# Recurring Schedule Endpoints -# ============================================================================== - - -@router.get("/api/machine/recurring-schedules") -async def list_recurring_schedules(request: Request): - """List all recurring schedules.""" - request_id = request.state.request_id - - try: - logger.debug("Listing recurring schedules", extra={"request_id": request_id}) - - # Enrich schedules with next occurrence - enriched_schedules = [] - for schedule_id, schedule in _recurring_schedules.items(): - schedule_copy = {**schedule, "id": schedule_id} - next_time = _get_next_occurrence(schedule) - if next_time: - schedule_copy["next_occurrence"] = next_time.isoformat() - enriched_schedules.append(schedule_copy) - - return {"status": "success", "recurring_schedules": enriched_schedules} - - except Exception as e: - logger.error( - f"Failed to list recurring schedules: {e}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.post("/api/machine/recurring-schedules") -async def create_recurring_schedule(request: Request): - """Create a new recurring schedule. - - Request body: - - name: str - Display name for the schedule - - time: str - Time in HH:MM format (24-hour) - - recurrence_type: str - One of: 'daily', 'weekdays', 'weekends', 'interval', 'specific_days' - - interval_days: int - For 'interval' type, number of days between runs (default: 1) - - days_of_week: list[int] - For 'specific_days' type, list of day numbers (0=Monday, 6=Sunday) - - profile_id: str | null - Profile to run (null for preheat only) - - preheat: bool - Whether to preheat before the shot (default: true) - - enabled: bool - Whether the schedule is active (default: true) - """ - request_id = request.state.request_id - - try: - body = await request.json() - - # Validate required fields - time_str = body.get("time") - if not time_str: - raise HTTPException( - status_code=400, detail="time is required (HH:MM format)" - ) - - # Validate time format - try: - hour, minute = map(int, time_str.split(":")) - if not (0 <= hour <= 23 and 0 <= minute <= 59): - raise ValueError("Invalid time") - except (ValueError, AttributeError): - raise HTTPException( - status_code=400, detail="Invalid time format. Use HH:MM (24-hour)" - ) - - recurrence_type = body.get("recurrence_type", "daily") - valid_types = ["daily", "weekdays", "weekends", "interval", "specific_days"] - if recurrence_type not in valid_types: - raise HTTPException( - status_code=400, detail=f"recurrence_type must be one of: {valid_types}" - ) - - # Validate type-specific fields - if recurrence_type == "interval": - interval_days = body.get("interval_days", 1) - if not isinstance(interval_days, int) or interval_days < 1: - raise HTTPException( - status_code=400, detail="interval_days must be a positive integer" - ) - - if recurrence_type == "specific_days": - days_of_week = body.get("days_of_week", []) - if not isinstance(days_of_week, list) or not all( - isinstance(d, int) and 0 <= d <= 6 for d in days_of_week - ): - raise HTTPException( - status_code=400, - detail="days_of_week must be a list of integers 0-6", - ) - if not days_of_week: - raise HTTPException( - status_code=400, - detail="days_of_week cannot be empty for specific_days type", - ) - - profile_id = body.get("profile_id") - preheat = body.get("preheat", True) - - if not profile_id and not preheat: - raise HTTPException( - status_code=400, detail="Either profile_id or preheat must be provided" - ) - - # Generate unique ID - schedule_id = str(uuid.uuid4()) - - # Create schedule object - schedule = { - "name": body.get("name", f"Schedule {time_str}"), - "time": time_str, - "recurrence_type": recurrence_type, - "interval_days": body.get("interval_days", 1) - if recurrence_type == "interval" - else None, - "days_of_week": body.get("days_of_week", []) - if recurrence_type == "specific_days" - else None, - "profile_id": profile_id, - "preheat": preheat, - "enabled": body.get("enabled", True), - "created_at": datetime.now(timezone.utc).isoformat(), - } - - async with _get_state_lock(): - _recurring_schedules[schedule_id] = schedule - await _save_recurring_schedules() - - # Schedule the next occurrence immediately - if schedule["enabled"]: - await _schedule_next_recurring(schedule_id, schedule) - - logger.info( - f"Created recurring schedule: {schedule_id}", - extra={"request_id": request_id, "schedule": schedule}, - ) - - next_time = _get_next_occurrence(schedule) - - return { - "status": "success", - "message": "Recurring schedule created", - "schedule_id": schedule_id, - "schedule": {**schedule, "id": schedule_id}, - "next_occurrence": next_time.isoformat() if next_time else None, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to create recurring schedule: {e}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.put("/api/machine/recurring-schedules/{schedule_id}") -async def update_recurring_schedule(schedule_id: str, request: Request): - """Update an existing recurring schedule.""" - request_id = request.state.request_id - - try: - body = await request.json() - - async with _get_state_lock(): - if schedule_id not in _recurring_schedules: - raise HTTPException( - status_code=404, detail="Recurring schedule not found" - ) - - schedule = _recurring_schedules[schedule_id] - - # Update allowed fields - if "name" in body: - schedule["name"] = body["name"] - if "time" in body: - time_str = body["time"] - try: - hour, minute = map(int, time_str.split(":")) - if not (0 <= hour <= 23 and 0 <= minute <= 59): - raise ValueError("Invalid time") - schedule["time"] = time_str - except (ValueError, AttributeError): - raise HTTPException( - status_code=400, - detail="Invalid time format. Use HH:MM (24-hour)", - ) - if "recurrence_type" in body: - valid_types = [ - "daily", - "weekdays", - "weekends", - "interval", - "specific_days", - ] - if body["recurrence_type"] not in valid_types: - raise HTTPException( - status_code=400, - detail=f"recurrence_type must be one of: {valid_types}", - ) - schedule["recurrence_type"] = body["recurrence_type"] - if "interval_days" in body: - schedule["interval_days"] = body["interval_days"] - if "days_of_week" in body: - schedule["days_of_week"] = body["days_of_week"] - if "profile_id" in body: - schedule["profile_id"] = body["profile_id"] - if "preheat" in body: - schedule["preheat"] = body["preheat"] - if "enabled" in body: - schedule["enabled"] = body["enabled"] - - schedule["updated_at"] = datetime.now(timezone.utc).isoformat() - - await _save_recurring_schedules() - - # If enabled, ensure next occurrence is scheduled - if schedule.get("enabled", True): - await _schedule_next_recurring(schedule_id, schedule) - - logger.info( - f"Updated recurring schedule: {schedule_id}", - extra={"request_id": request_id}, - ) - - next_time = _get_next_occurrence(schedule) - - return { - "status": "success", - "message": "Recurring schedule updated", - "schedule": {**schedule, "id": schedule_id}, - "next_occurrence": next_time.isoformat() if next_time else None, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to update recurring schedule: {e}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.delete("/api/machine/recurring-schedules/{schedule_id}") -async def delete_recurring_schedule(schedule_id: str, request: Request): - """Delete a recurring schedule.""" - request_id = request.state.request_id - - try: - async with _get_state_lock(): - if schedule_id not in _recurring_schedules: - raise HTTPException( - status_code=404, detail="Recurring schedule not found" - ) - - # Cancel any pending shots for this schedule - for shot_id, shot in list(_scheduled_shots.items()): - if ( - shot.get("recurring_schedule_id") == schedule_id - and shot.get("status") == "scheduled" - ): - if shot_id in _scheduled_tasks: - _scheduled_tasks[shot_id].cancel() - del _scheduled_tasks[shot_id] - _scheduled_shots[shot_id]["status"] = "cancelled" - - await _save_scheduled_shots() - - # Delete the recurring schedule - async with _get_state_lock(): - del _recurring_schedules[schedule_id] - await _save_recurring_schedules() - - logger.info( - f"Deleted recurring schedule: {schedule_id}", - extra={"request_id": request_id}, - ) - - return { - "status": "success", - "message": "Recurring schedule deleted", - "schedule_id": schedule_id, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to delete recurring schedule: {e}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) diff --git a/apps/server/api/routes/shots.py b/apps/server/api/routes/shots.py deleted file mode 100644 index 98179992..00000000 --- a/apps/server/api/routes/shots.py +++ /dev/null @@ -1,1888 +0,0 @@ -"""Shot history and analysis endpoints.""" - -from fastapi import APIRouter, Request, Form, HTTPException -from typing import Optional -import asyncio -import json -import math -import re -import time -import logging -import httpx -import requests - -from services.meticulous_service import ( - fetch_shot_data, - async_list_profiles, - async_get_history_dates, - async_get_shot_files, - async_get_profile, - MachineUnreachableError, -) -from services.cache_service import ( - get_cached_llm_analysis, - save_llm_analysis_to_cache, - _get_cached_shots, - _set_cached_shots, - get_indexed_dates, - lookup_shots_by_profile, - get_all_indexed_shots, - update_shot_index, -) -from services.analysis_service import _perform_local_shot_analysis -from services.gemini_service import ( - get_vision_model, - PROFILING_KNOWLEDGE, - compute_taste_hash, -) -from prompt_builder import build_taste_context -from services.analysis_validator import validate_against_facts, check_structure -from analysis_knowledge import ( - ANALYSIS_KNOWLEDGE, - FEW_SHOT_ANALYSIS_EXAMPLE, - build_fact_sheet, -) - -router = APIRouter() -logger = logging.getLogger(__name__) - - -def _safe_float(val, default=0.0): - """Convert a value to float safely, returning default on failure.""" - try: - return float(val) - except (ValueError, TypeError): - return default - - -@router.get("/api/last-shot") -async def get_last_shot(request: Request): - """Return metadata for the most recent shot without loading full telemetry. - - This powers the "Analyze your last shot?" banner on the home screen. - Returns 404 if no shots exist. - """ - request_id = request.state.request_id - try: - dates_result = await async_get_history_dates() - if hasattr(dates_result, "error") and dates_result.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {dates_result.error}" - ) - - dates = ( - sorted([d.name for d in dates_result], reverse=True) if dates_result else [] - ) - if not dates: - raise HTTPException(status_code=404, detail="No shots found") - - # Walk dates newest-first until we find a file - for date in dates: - files_result = await async_get_shot_files(date) - if hasattr(files_result, "error") and files_result.error: - continue - files = ( - sorted([f.name for f in files_result], reverse=True) - if files_result - else [] - ) - if not files: - continue - - filename = files[0] - shot_data = await fetch_shot_data(date, filename) - - profile_name = shot_data.get("profile_name", "") - if not profile_name and isinstance(shot_data.get("profile"), dict): - profile_name = shot_data["profile"].get("name", "") - - data_entries = shot_data.get("data", []) - final_weight = None - total_time_ms = None - if data_entries: - last_entry = data_entries[-1] - if isinstance(last_entry.get("shot"), dict): - final_weight = last_entry["shot"].get("weight") - total_time_ms = last_entry.get("time") - - return { - "profile_name": profile_name, - "date": date, - "filename": filename, - "timestamp": shot_data.get("time"), - "final_weight": final_weight, - "total_time": total_time_ms / 1000 if total_time_ms else None, - } - - raise HTTPException(status_code=404, detail="No shots found") - - except MachineUnreachableError: - raise - except ( - requests.exceptions.ConnectionError, - httpx.ConnectError, - httpx.ConnectTimeout, - ) as e: - logger.warning( - f"Machine unreachable while fetching last shot: {e}", - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=503, - detail=( - "Espresso machine is unreachable. Check that the machine is powered on " - "and METICULOUS_IP is correct in Settings." - ), - ) - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to get last shot: {e}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException(status_code=500, detail=str(e)) - - -def _prepare_profile_for_llm(profile_data: dict, description: str | None) -> dict: - """Prepare profile data for LLM, removing image and limiting description.""" - # Build clean profile without image - clean_profile = { - "name": profile_data.get("name"), - "temperature": profile_data.get("temperature"), - "final_weight": profile_data.get("final_weight"), - "variables": profile_data.get("variables", []), - "stages": [], - } - - # Include stage structure but not full dynamics data - for stage in profile_data.get("stages", []): - clean_stage = { - "name": stage.get("name"), - "type": stage.get("type"), - "exit_triggers": stage.get("exit_triggers", []), - "limits": stage.get("limits", []), - } - # Add a summary of dynamics - dynamics = stage.get("dynamics_points", []) - if dynamics: - if len(dynamics) == 1: - clean_stage["target"] = ( - f"Constant at {dynamics[0][1] if len(dynamics[0]) > 1 else dynamics[0][0]}" - ) - elif len(dynamics) >= 2: - start = dynamics[0][1] if len(dynamics[0]) > 1 else dynamics[0][0] - end = dynamics[-1][1] if len(dynamics[-1]) > 1 else dynamics[-1][0] - clean_stage["target"] = f"{start} → {end}" - clean_profile["stages"].append(clean_stage) - - return clean_profile - - -@router.get("/api/shots/dates") -async def get_shot_dates(request: Request): - """Get all available shot dates from the machine. - - Returns: - List of dates with available shot history - """ - request_id = request.state.request_id - - try: - logger.info("Fetching shot history dates", extra={"request_id": request_id}) - - result = await async_get_history_dates() - - # Check for API error - if hasattr(result, "error") and result.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {result.error}" - ) - - # Extract date names - dates = [d.name for d in result] if result else [] - - return {"dates": sorted(dates, reverse=True)} - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to fetch shot dates: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to fetch shot dates from machine", - }, - ) - - -@router.get("/api/shots/files/{date}") -async def get_shot_files(request: Request, date: str): - """Get shot files for a specific date. - - Args: - date: Date in YYYY-MM-DD format - - Returns: - List of shot filenames for that date - """ - # Validate date format to prevent path traversal - if not re.match(r"^\d{4}-\d{2}-\d{2}$", date): - raise HTTPException( - status_code=400, detail="Invalid date format. Expected YYYY-MM-DD." - ) - - request_id = request.state.request_id - - try: - logger.info( - "Fetching shot files for date", - extra={"request_id": request_id, "date": date}, - ) - - result = await async_get_shot_files(date) - - # Check for API error - if hasattr(result, "error") and result.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {result.error}" - ) - - # Extract filenames - files = [f.name for f in result] if result else [] - - return {"date": date, "files": files} - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to fetch shot files: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "date": date, - "error_type": type(e).__name__, - }, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to fetch shot files from machine", - }, - ) - - -@router.get("/api/shots/data/{date}/{filename:path}") -async def get_shot_data(request: Request, date: str, filename: str): - """Get the actual shot data for a specific shot. - - Args: - date: Date in YYYY-MM-DD format - filename: Shot filename (e.g., HH:MM:SS.shot.json.zst) - - Returns: - Decompressed shot data with telemetry - """ - # Validate inputs to prevent path traversal / SSRF - if not re.match(r"^\d{4}-\d{2}-\d{2}$", date): - raise HTTPException( - status_code=400, detail="Invalid date format. Expected YYYY-MM-DD." - ) - if ".." in filename or filename.startswith("/"): - raise HTTPException(status_code=400, detail="Invalid filename.") - - request_id = request.state.request_id - - try: - logger.info( - "Fetching shot data", - extra={"request_id": request_id, "date": date, "shot_file": filename}, - ) - - shot_data = await fetch_shot_data(date, filename) - - return {"date": date, "filename": filename, "data": shot_data} - - except Exception as e: - logger.error( - f"Failed to fetch shot data: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "date": date, - "shot_file": filename, - "error_type": type(e).__name__, - }, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to fetch shot data from machine", - }, - ) - - -@router.get("/api/shots/by-profile/{profile_name}") -async def get_shots_by_profile( - request: Request, - profile_name: str, - limit: int = 20, - include_data: bool = False, - force_refresh: bool = False, -): - """Get all shots that used a specific profile. - - This endpoint scans shot history to find all shots that match the given profile name. - Results are cached server-side indefinitely, but marked stale after 60 minutes. - When stale, cached data is still returned with is_stale=true so client can show it - while fetching fresh data in the background. - - Args: - profile_name: Name of the profile to search for - limit: Maximum number of shots to return (default: 20) - include_data: Whether to include full telemetry data (default: False for performance) - force_refresh: Skip cache and fetch fresh data (default: False) - - Returns: - List of shots matching the profile, with cache metadata (cached_at, is_stale) - """ - request_id = request.state.request_id - - try: - logger.info( - "Searching for shots by profile", - extra={ - "request_id": request_id, - "profile_name": profile_name, - "limit": limit, - "force_refresh": force_refresh, - }, - ) - - # Check server-side cache first (unless forcing refresh or requesting data) - if not force_refresh and not include_data: - cached_data, is_stale, cached_at = _get_cached_shots(profile_name, limit) - if cached_data: - logger.info( - f"Returning cached shots for profile '{profile_name}' (stale={is_stale})", - extra={ - "request_id": request_id, - "count": cached_data.get("count", 0), - "from_cache": True, - "is_stale": is_stale, - }, - ) - # Add cache metadata to response - cached_data["cached_at"] = cached_at - cached_data["is_stale"] = is_stale - return cached_data - - # Get all available dates - dates_result = await async_get_history_dates() - if hasattr(dates_result, "error") and dates_result.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {dates_result.error}" - ) - - dates = [d.name for d in dates_result] if dates_result else [] - - # ---- Phase 1: Check shot profile index for instant results ---- - if not include_data: - indexed = lookup_shots_by_profile(profile_name, limit) - indexed_dates = get_indexed_dates() - if indexed is not None and set(dates).issubset(indexed_dates): - # Index covers all dates — return directly - current_time = time.time() - response_data = { - "profile_name": profile_name, - "shots": indexed[:limit], - "count": len(indexed[:limit]), - "limit": limit, - "cached_at": current_time, - "is_stale": False, - } - _set_cached_shots(profile_name, response_data, limit) - return response_data - - # ---- Phase 2: Parallel scan for un-indexed dates ---- - indexed_dates = get_indexed_dates() - new_dates = [d for d in dates if d not in indexed_dates] - already_indexed_dates = [d for d in dates if d in indexed_dates] - - # Higher concurrency for file listing (lightweight calls) - sem_files = asyncio.Semaphore(20) - - async def _list_files(date: str): - async with sem_files: - try: - result = await async_get_shot_files(date) - if hasattr(result, "error") and result.error: - return date, [] - return date, [f.name for f in result] if result else [] - except Exception: - return date, [] - - # Fetch file listings for ALL new dates in parallel - file_results = await asyncio.gather( - *[_list_files(d) for d in sorted(new_dates, reverse=True)], - return_exceptions=True, - ) - - # Build flat list of (date, filename) to scan — newest first - shots_to_scan = [] - for result in file_results: - if isinstance(result, Exception): - continue - date, files = result - for fn in sorted(files, reverse=True): - shots_to_scan.append((date, fn)) - - # Fetch and index shots in parallel with higher concurrency - sem = asyncio.Semaphore(15) - new_index_entries = {} - - async def _fetch_and_index(date: str, filename: str): - """Fetch a shot, extract metadata, return index entry.""" - async with sem: - try: - shot_data = await fetch_shot_data(date, filename) - except Exception as e: - logger.warning( - f"Could not process shot {date}/{filename}: {str(e)}", - extra={"request_id": request_id}, - ) - return None - - shot_profile_name = shot_data.get("profile_name", "") - if not shot_profile_name and isinstance(shot_data.get("profile"), dict): - shot_profile_name = shot_data.get("profile", {}).get("name", "") - - profile_id = "" - if isinstance(shot_data.get("profile"), dict): - profile_id = shot_data["profile"].get("id", "") - - data_entries = shot_data.get("data", []) - final_weight = None - total_time_ms = None - if data_entries: - last_entry = data_entries[-1] - if isinstance(last_entry.get("shot"), dict): - final_weight = last_entry["shot"].get("weight") - total_time_ms = last_entry.get("time") - - key = f"{date}/{filename}" - entry = { - "name": shot_profile_name, - "profile_id": profile_id, - "weight": final_weight, - "time_ms": total_time_ms, - "timestamp": shot_data.get("time"), - } - new_index_entries[key] = entry - - if shot_profile_name.lower() != profile_name.lower(): - return None - - shot_info = { - "date": date, - "filename": filename, - "timestamp": shot_data.get("time"), - "profile_name": shot_profile_name, - "final_weight": final_weight, - "total_time": total_time_ms / 1000 if total_time_ms else None, - } - if include_data: - shot_info["data"] = shot_data - return shot_info - - # Fire all scans in parallel - scan_results = await asyncio.gather( - *[_fetch_and_index(d, fn) for d, fn in shots_to_scan], - return_exceptions=True, - ) - - # Persist index entries - if new_index_entries: - update_shot_index(new_index_entries, new_dates) - - # Collect matches from new scans - new_matches = [ - r for r in scan_results if r is not None and not isinstance(r, Exception) - ] - - # Combine with matches from already-indexed dates - indexed_matches = lookup_shots_by_profile(profile_name, limit) or [] - # Filter indexed matches to only already-indexed dates - idx_from_cache = [ - m for m in indexed_matches if m["date"] in already_indexed_dates - ] - - matching_shots = new_matches + idx_from_cache - # Sort newest first, trim to limit - matching_shots.sort(key=lambda x: x.get("timestamp") or "", reverse=True) - matching_shots = matching_shots[:limit] - - logger.info( - f"Found {len(matching_shots)} shots for profile '{profile_name}'", - extra={"request_id": request_id, "count": len(matching_shots)}, - ) - - current_time = time.time() - response_data = { - "profile_name": profile_name, - "shots": matching_shots, - "count": len(matching_shots), - "limit": limit, - "cached_at": current_time, - "is_stale": False, - } - - # Cache the result (only if not including full data, which is too large) - if not include_data: - _set_cached_shots(profile_name, response_data, limit) - - return response_data - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to search shots by profile: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "profile_name": profile_name, - "error_type": type(e).__name__, - }, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to search shots by profile", - }, - ) - - -@router.post("/api/shots/analyze") -async def analyze_shot( - request: Request, - profile_name: str = Form(...), - shot_date: str = Form(...), - shot_filename: str = Form(...), - profile_description: Optional[str] = Form(None), -): - """Analyze a shot against its profile using local algorithmic analysis. - - This endpoint fetches the shot data and profile information, then performs - a detailed comparison of actual execution vs profile intent. - - Args: - profile_name: Name of the profile used for the shot - shot_date: Date of the shot (YYYY-MM-DD) - shot_filename: Filename of the shot - profile_description: Optional description of the profile's intent (for future AI use) - - Returns: - Detailed analysis of shot performance against profile - """ - request_id = request.state.request_id - - try: - logger.info( - "Starting shot analysis", - extra={ - "request_id": request_id, - "profile_name": profile_name, - "shot_date": shot_date, - "shot_filename": shot_filename, - }, - ) - - # Fetch shot data - shot_data = await fetch_shot_data(shot_date, shot_filename) - - # Fetch profile from machine - profiles_result = await async_list_profiles() - - logger.debug( - f"Looking for profile '{profile_name}' in {len(profiles_result)} profiles" - ) - - profile_data = None - for partial_profile in profiles_result: - # Compare ignoring case and whitespace - if partial_profile.name.lower().strip() == profile_name.lower().strip(): - logger.debug( - f"Found matching profile: {partial_profile.name} (id={partial_profile.id})" - ) - full_profile = await async_get_profile(partial_profile.id) - if not (hasattr(full_profile, "error") and full_profile.error): - # Convert profile object to dict - profile_data = { - "name": full_profile.name, - "temperature": getattr(full_profile, "temperature", None), - "final_weight": getattr(full_profile, "final_weight", None), - "variables": [], - "stages": [], - } - - # Extract variables if present - if hasattr(full_profile, "variables") and full_profile.variables: - for var in full_profile.variables: - var_dict = { - "key": getattr(var, "key", ""), - "name": getattr(var, "name", ""), - "type": getattr(var, "type", ""), - "value": getattr(var, "value", 0), - } - profile_data["variables"].append(var_dict) - - # Extract full stage data including dynamics and triggers - if hasattr(full_profile, "stages") and full_profile.stages: - for stage in full_profile.stages: - stage_dict = { - "name": getattr(stage, "name", "Unknown"), - "key": getattr(stage, "key", ""), - "type": getattr(stage, "type", "unknown"), - } - # Add dynamics - handle both direct attributes and dynamics object - if ( - hasattr(stage, "dynamics") - and stage.dynamics is not None - ): - dynamics = stage.dynamics - if hasattr(dynamics, "points") and dynamics.points: - stage_dict["dynamics_points"] = dynamics.points - if hasattr(dynamics, "over"): - stage_dict["dynamics_over"] = dynamics.over - if hasattr(dynamics, "interpolation"): - stage_dict["dynamics_interpolation"] = ( - dynamics.interpolation - ) - else: - # Fallback: check for direct attributes - for attr in [ - "dynamics_points", - "dynamics_over", - "dynamics_interpolation", - ]: - val = getattr(stage, attr, None) - if val is not None: - stage_dict[attr] = val - # Add exit triggers and limits - for attr in ["exit_triggers", "limits"]: - val = getattr(stage, attr, None) - if val is not None: - # Convert to list of dicts if needed - if isinstance(val, list): - stage_dict[attr] = [ - ( - dict(item) - if hasattr(item, "__dict__") - else item - ) - for item in val - ] - else: - stage_dict[attr] = val - profile_data["stages"].append(stage_dict) - break - - if not profile_data: - # Fallback: try to get profile from shot data itself - shot_profile = shot_data.get("profile", {}) - if shot_profile: - profile_data = shot_profile - else: - raise HTTPException( - status_code=404, - detail=f"Profile '{profile_name}' not found on machine or in shot data", - ) - - # Perform local analysis - analysis = _perform_local_shot_analysis(shot_data, profile_data) - - logger.info( - "Shot analysis completed successfully", - extra={ - "request_id": request_id, - "profile_name": profile_name, - "stages_analyzed": len(analysis.get("stage_analyses", [])), - "unreached_stages": len(analysis.get("unreached_stages", [])), - }, - ) - - return {"status": "success", "analysis": analysis} - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Shot analysis failed: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "profile_name": profile_name, - "shot_date": shot_date, - "shot_filename": shot_filename, - "error_type": type(e).__name__, - }, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Shot analysis failed", - }, - ) - - -@router.get("/api/shots/llm-analysis-cache") -async def get_llm_analysis_cache( - request: Request, profile_name: str, shot_date: str, shot_filename: str -): - """Check if a cached LLM analysis exists for the given shot. - - Returns the cached analysis if it exists and is not expired, - otherwise returns null. - """ - request_id = request.state.request_id - - logger.info( - "Checking LLM analysis cache", - extra={ - "request_id": request_id, - "profile_name": profile_name, - "shot_date": shot_date, - "shot_filename": shot_filename, - }, - ) - - cached = get_cached_llm_analysis(profile_name, shot_date, shot_filename) - - if cached: - logger.info( - "LLM analysis cache hit", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - return {"status": "success", "cached": True, "analysis": cached} - else: - logger.info( - "LLM analysis cache miss", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - return {"status": "success", "cached": False, "analysis": None} - - -@router.post("/api/shots/analyze-llm") -async def analyze_shot_with_llm( - request: Request, - profile_name: str = Form(...), - shot_date: str = Form(...), - shot_filename: str = Form(...), - profile_description: Optional[str] = Form(None), - force_refresh: bool = Form(False), - taste_x: Optional[float] = Form(None), - taste_y: Optional[float] = Form(None), - taste_descriptors: Optional[str] = Form(None), -): - """Analyze a shot using LLM with expert profiling knowledge. - - This endpoint performs a deep analysis of shot execution, combining: - - Local algorithmic analysis for data extraction - - Expert espresso profiling knowledge - - LLM reasoning for actionable recommendations - - Results are cached server-side for 3 days. - Use force_refresh=True to bypass cache and regenerate analysis. - - Returns structured analysis answering: - 1. How did the shot go and why? - 2. What should change about the setup (grind, filter, basket, prep)? - 3. What should change about the profile? - 4. Any issues found in the profile design itself? - """ - request_id = request.state.request_id - - # Validate taste coordinate bounds - if taste_x is not None and not (-1.0 <= taste_x <= 1.0): - raise HTTPException(status_code=422, detail="taste_x must be between -1 and 1") - if taste_y is not None and not (-1.0 <= taste_y <= 1.0): - raise HTTPException(status_code=422, detail="taste_y must be between -1 and 1") - - # Parse and validate taste descriptors from comma-separated string - _VALID_DESCRIPTORS = { - "sweet", - "clean", - "complex", - "juicy", - "smooth", - "balanced", - "floral", - "fruity", - "astringent", - "muddy", - "flat", - "chalky", - "harsh", - "watery", - "burnt", - "grassy", - } - parsed_descriptors: list[str] | None = None - if taste_descriptors: - parsed_descriptors = [ - d.strip().lower() - for d in taste_descriptors.split(",") - if d.strip() and d.strip().lower() in _VALID_DESCRIPTORS - ] or None - - # Compute taste hash for cache differentiation - taste_hash = compute_taste_hash(taste_x, taste_y, parsed_descriptors) - cache_filename = ( - f"{shot_filename}_taste_{taste_hash}" if taste_hash else shot_filename - ) - - try: - logger.info( - "Starting LLM shot analysis", - extra={ - "request_id": request_id, - "profile_name": profile_name, - "shot_date": shot_date, - "shot_filename": shot_filename, - "force_refresh": force_refresh, - "has_taste_data": taste_hash is not None, - }, - ) - - # Check cache first (unless force refresh) - if not force_refresh: - cached_analysis = get_cached_llm_analysis( - profile_name, shot_date, cache_filename - ) - if cached_analysis: - logger.info( - "Returning cached LLM analysis", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - return { - "status": "success", - "profile_name": profile_name, - "shot_date": shot_date, - "shot_filename": shot_filename, - "llm_analysis": cached_analysis, - "cached": True, - } - - # Fetch shot data - shot_data = await fetch_shot_data(shot_date, shot_filename) - - # Fetch profile from machine (with variables) - profiles_result = await async_list_profiles() - - profile_data = None - for partial_profile in profiles_result: - if partial_profile.name.lower() == profile_name.lower(): - full_profile = await async_get_profile(partial_profile.id) - if not (hasattr(full_profile, "error") and full_profile.error): - profile_data = { - "name": full_profile.name, - "temperature": getattr(full_profile, "temperature", None), - "final_weight": getattr(full_profile, "final_weight", None), - "variables": [], - "stages": [], - } - - # Extract variables - if hasattr(full_profile, "variables") and full_profile.variables: - for var in full_profile.variables: - profile_data["variables"].append( - { - "key": getattr(var, "key", ""), - "name": getattr(var, "name", ""), - "type": getattr(var, "type", ""), - "value": getattr(var, "value", 0), - } - ) - - # Extract stages with full details - if hasattr(full_profile, "stages") and full_profile.stages: - for stage in full_profile.stages: - stage_dict = { - "name": getattr(stage, "name", "Unknown"), - "key": getattr(stage, "key", ""), - "type": getattr(stage, "type", "unknown"), - } - for attr in [ - "dynamics_points", - "dynamics_over", - "dynamics_interpolation", - "exit_triggers", - "limits", - ]: - val = getattr(stage, attr, None) - if val is not None: - if isinstance(val, list): - stage_dict[attr] = [ - ( - dict(item) - if hasattr(item, "__dict__") - else item - ) - for item in val - ] - else: - stage_dict[attr] = val - profile_data["stages"].append(stage_dict) - break - - if not profile_data: - raise HTTPException( - status_code=404, detail=f"Profile '{profile_name}' not found on machine" - ) - - # Run local analysis first to extract data - local_analysis = _perform_local_shot_analysis(shot_data, profile_data) - shot_facts = local_analysis.get("shot_facts", {}) - fact_sheet = build_fact_sheet(shot_facts) - - # Prepare profile data (clean, no image) - clean_profile = _prepare_profile_for_llm(profile_data, profile_description) - - # Build the LLM prompt with FULL local analysis - taste_context = build_taste_context(taste_x, taste_y, parsed_descriptors) - has_taste = bool(taste_context) - - # Adjust section count and formatting rules based on taste data - section_count = "6" if has_taste else "5" - taste_section_template = "" - if has_taste: - taste_section_template = """ - -## 6. Taste-Based Recommendations - -**Taste-Extraction Correlation:** -- [How the reported taste aligns with extraction data] -- [Whether the data supports or contradicts the taste perception] - -**Recommended Adjustments:** -- [Specific changes to address the taste issues — grind, dose, temp, time] -- [Expected taste impact of each change] - -**Coffee Science:** -- [Brief explanation of why these adjustments should work] -""" - - prompt = f"""You are an expert espresso barista and profiling specialist analyzing a shot from a Meticulous Espresso Machine. - -## Expert Knowledge -{PROFILING_KNOWLEDGE} - -## Analysis Framework -{ANALYSIS_KNOWLEDGE} - -## Profile Being Used -Name: {clean_profile["name"]} -Temperature: {clean_profile.get("temperature", "Not set")}°C -Target Weight: {clean_profile.get("final_weight", "Not set")}g - -### Profile Description -{profile_description or "No description provided - analyze the profile structure to understand intent."} - -### Profile Variables -{json.dumps(clean_profile.get("variables", []), indent=2)} - -### Profile Stages -{json.dumps(clean_profile.get("stages", []), indent=2)} - -## Shot Facts (digested — authoritative; trust over raw telemetry) -Each stage lists its exit classification. A Targeted exit means the stage reached its intended -outcome (e.g. its weight target); a Failsafe exit means a backstop fired before the real target. -A Targeted exit — including a short stage that hit its weight target — is NORMAL and CORRECT -behavior; never describe it as "early termination". The final weight reflects the settled weight -after the machine's piston retraction completes, so do NOT penalize weight deviation unless it -exceeds ±5%. - -{fact_sheet} -{taste_context} - ---- - -{FEW_SHOT_ANALYSIS_EXAMPLE} - ---- - -Based on this data, provide a detailed expert analysis. - -CRITICAL FORMATTING RULES: -1. You MUST use EXACTLY these {section_count} section headers with the exact format shown (## followed by number, period, space, then title) -2. Each section MUST have the subsection headers shown (bold text with colon, like **What Happened:**) -3. ALL content under subsections MUST be bullet points starting with "- " -4. Keep bullet points concise (1-2 sentences max per bullet) -5. Do NOT add extra sections or subsections beyond what's specified - -## 1. Shot Performance - -**What Happened:** -- [Stage-by-stage description of the extraction] -- [Notable events: pressure spikes, flow restrictions, early/late stage exits] -- [Final weight accuracy relative to target] - -**Assessment:** [Choose exactly one: Good / Acceptable / Needs Improvement / Problematic] - -## 2. Root Cause Analysis - -**Primary Factors:** -- [Most likely cause with brief explanation] -- [Second most likely cause if applicable] - -**Secondary Considerations:** -- [Other contributing factors] -- [Environmental or equipment factors if relevant] - -## 3. Setup Recommendations - -**Priority Changes:** -- [Most important change - be specific with numbers when possible] -- [Second priority change] - -**Additional Suggestions:** -- [Other tweaks to consider] - -## 4. Profile Recommendations - -**Recommended Adjustments:** -- [Specific profile changes: timing, triggers, targets] -- [Variable value changes if applicable] - -**Reasoning:** -- [Why these changes would improve the shot] - -## 5. Profile Design Observations - -**Strengths:** -- [Well-designed aspects of this profile] - -**Potential Improvements:** -- [Exit trigger or safety limit suggestions] -- [Robustness improvements] - -Focus on actionable insights. Be specific with numbers where possible (e.g., "grind 1-2 steps finer" not just "grind finer"). -{taste_section_template} ---- - -INTERNAL INSTRUCTION — Structured Recommendations (do NOT include this heading in your response): - -After your analysis sections, you MUST output a structured JSON block with specific, actionable profile variable recommendations. -Use EXACTLY this format — the markers are parsed programmatically: - -RECOMMENDATIONS_JSON: -[ - {{ - "variable": "", - "current_value": , - "recommended_value": , - "stage": "", - "confidence": "", - "reason": "" - }} -] -END_RECOMMENDATIONS_JSON - -Rules for recommendations: -- Only include recommendations where you have a SPECIFIC numeric change to suggest -- The "variable" MUST be copied verbatim from the "key" field of an entry in the Profile Variables section above (for example "pressure_Max Pressure"). Do NOT invent positional names like "pressure_2" or "flow_0", and do NOT use the display name. -- Always include the variable's existing value as "current_value" so the change can be verified -- For top-level settings (temperature, final_weight), use stage="global" -- For stage-specific changes, use the stage name from Profile Stages -- For exit trigger changes, use these variable keys with the stage name: - - "exit_weight" — change the weight exit trigger value - - "exit_time" — change the time exit trigger value - - "exit_pressure" — change the pressure exit trigger value - - "exit_flow" — change the flow exit trigger value -- For stage limit changes, use these variable keys with the stage name: - - "limit_pressure" — change the pressure limit value - - "limit_flow" — change the flow limit value -- confidence: "high" = strong evidence from data, "medium" = likely beneficial, "low" = worth trying -- If no recommendations apply, output an empty array: RECOMMENDATIONS_JSON:\n[]\nEND_RECOMMENDATIONS_JSON -""" - - # Call LLM - try: - model = get_vision_model() - except ValueError as e: - raise HTTPException( - status_code=503, - detail={ - "status": "error", - "message": str(e), - "error": "AI features are unavailable until GEMINI_API_KEY is configured", - }, - ) from e - response = await model.async_generate_content(prompt) - - llm_analysis = response.text if response else "Analysis generation failed" - first_ok = ( - validate_against_facts(llm_analysis, shot_facts)["valid"] - and check_structure(llm_analysis)["valid"] - ) - if not first_ok: - retry = await model.async_generate_content(prompt) - retry_text = retry.text if retry else llm_analysis - if ( - validate_against_facts(retry_text, shot_facts)["valid"] - and check_structure(retry_text)["valid"] - ): - llm_analysis = retry_text - - # Save to cache - save_llm_analysis_to_cache( - profile_name, shot_date, cache_filename, llm_analysis - ) - - logger.info( - "LLM shot analysis completed and cached", - extra={ - "request_id": request_id, - "profile_name": profile_name, - "response_length": len(llm_analysis), - }, - ) - - return { - "status": "success", - "profile_name": profile_name, - "shot_date": shot_date, - "shot_filename": shot_filename, - "llm_analysis": llm_analysis, - "cached": False, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"LLM shot analysis failed: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "profile_name": profile_name}, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "LLM shot analysis failed", - }, - ) - - -# ============================================================================ -# Recent Shots (cross-profile) -# ============================================================================ - -# In-memory cache for recent shots -_recent_shots_cache: dict = {} -_RECENT_SHOTS_TTL = 30 # seconds -_RECENT_SHOTS_MAX_ENTRIES = 50 - - -def _cache_recent_shots(key: str, data: dict) -> None: - """Insert into the recent-shots cache with bounded size.""" - if len(_recent_shots_cache) > _RECENT_SHOTS_MAX_ENTRIES: - now = time.time() - expired = [ - k - for k, v in _recent_shots_cache.items() - if now - v["ts"] >= _RECENT_SHOTS_TTL - ] - for k in expired: - del _recent_shots_cache[k] - if len(_recent_shots_cache) > _RECENT_SHOTS_MAX_ENTRIES: - _recent_shots_cache.clear() - _recent_shots_cache[key] = {"data": data, "ts": time.time()} - - -@router.get("/shots/recent") -@router.get("/api/shots/recent") -async def get_recent_shots(request: Request, limit: int = 50, offset: int = 0): - """Return recent shots across ALL profiles, sorted chronologically (latest first). - - Query params: - limit: max items to return (default 50) - offset: pagination offset (default 0) - """ - request_id = request.state.request_id - cache_key = f"recent:{min(limit, 100)}:{min(offset, 10000)}" - now = time.time() - - # Check cache - cached = _recent_shots_cache.get(cache_key) - if cached and (now - cached["ts"]) < _RECENT_SHOTS_TTL: - return cached["data"] - - try: - from services.shot_annotations_service import get_annotation - - dates_result = await async_get_history_dates() - if hasattr(dates_result, "error") and dates_result.error: - raise HTTPException( - status_code=502, detail=f"Machine API error: {dates_result.error}" - ) - - dates = ( - sorted([d.name for d in dates_result], reverse=True) if dates_result else [] - ) - - all_shots: list[dict] = [] - sem = asyncio.Semaphore(6) - - async def _fetch_shot_info(date: str, filename: str): - async with sem: - try: - shot_data = await fetch_shot_data(date, filename) - except Exception: - return None - - profile_name = shot_data.get("profile_name", "") - if not profile_name and isinstance(shot_data.get("profile"), dict): - profile_name = shot_data["profile"].get("name", "") - - profile_id = "" - if isinstance(shot_data.get("profile"), dict): - profile_id = shot_data["profile"].get("id", "") - - data_entries = shot_data.get("data", []) - final_weight = None - total_time_ms = None - if data_entries: - last_entry = data_entries[-1] - if isinstance(last_entry.get("shot"), dict): - final_weight = last_entry["shot"].get("weight") - total_time_ms = last_entry.get("time") - - timestamp = shot_data.get("time") - annotation = get_annotation(date, filename) - - return { - "profile_name": profile_name, - "profile_id": profile_id, - "date": date, - "filename": filename, - "timestamp": timestamp, - "final_weight": final_weight, - "total_time": total_time_ms / 1000 if total_time_ms else None, - "has_annotation": annotation is not None, - } - - # We need enough shots for offset + limit; collect greedily - needed = offset + limit - - # ---- Try shot profile index for instant results ---- - indexed_dates = get_indexed_dates() - if set(dates).issubset(indexed_dates): - indexed_all = get_all_indexed_shots(limit=needed + 10, offset=0) - if indexed_all is not None: - # Enrich with annotation data - for shot in indexed_all: - annotation = get_annotation(shot["date"], shot["filename"]) - shot["has_annotation"] = annotation is not None - page = indexed_all[offset : offset + limit] - response_data = {"shots": page} - _cache_recent_shots(cache_key, response_data) - return response_data - - # ---- Parallel scan for un-indexed dates ---- - new_dates = [d for d in dates if d not in indexed_dates] - - sem_files = asyncio.Semaphore(20) - - async def _list_files_recent(date: str): - async with sem_files: - try: - result = await async_get_shot_files(date) - if hasattr(result, "error") and result.error: - return date, [] - return date, ( - sorted([f.name for f in result], reverse=True) - if result - else (date, []) - ) - except Exception: - return date, [] - - # Fetch file listings for new dates in parallel - file_results = await asyncio.gather( - *[_list_files_recent(d) for d in sorted(new_dates, reverse=True)], - return_exceptions=True, - ) - - shots_to_scan = [] - for result in file_results: - if isinstance(result, Exception): - continue - date, files = result - for fn in files: - shots_to_scan.append((date, fn)) - - # Fetch shot data in parallel - new_index_entries = {} - - async def _fetch_and_index_recent(date: str, filename: str): - async with sem: - try: - shot_data = await fetch_shot_data(date, filename) - except Exception: - return None - - p_name = shot_data.get("profile_name", "") - if not p_name and isinstance(shot_data.get("profile"), dict): - p_name = shot_data["profile"].get("name", "") - - p_id = "" - if isinstance(shot_data.get("profile"), dict): - p_id = shot_data["profile"].get("id", "") - - data_entries = shot_data.get("data", []) - final_weight = None - total_time_ms = None - if data_entries: - last_entry = data_entries[-1] - if isinstance(last_entry.get("shot"), dict): - final_weight = last_entry["shot"].get("weight") - total_time_ms = last_entry.get("time") - - timestamp = shot_data.get("time") - key = f"{date}/{filename}" - new_index_entries[key] = { - "name": p_name, - "profile_id": p_id, - "weight": final_weight, - "time_ms": total_time_ms, - "timestamp": timestamp, - } - - annotation = get_annotation(date, filename) - return { - "profile_name": p_name, - "profile_id": p_id, - "date": date, - "filename": filename, - "timestamp": timestamp, - "final_weight": final_weight, - "total_time": total_time_ms / 1000 if total_time_ms else None, - "has_annotation": annotation is not None, - } - - scan_results = await asyncio.gather( - *[_fetch_and_index_recent(d, fn) for d, fn in shots_to_scan], - return_exceptions=True, - ) - - # Persist index - if new_index_entries: - update_shot_index(new_index_entries, new_dates) - - # Combine new scans with indexed data - new_shots = [ - r for r in scan_results if r is not None and not isinstance(r, Exception) - ] - # Also include already-indexed shots - idx_shots = get_all_indexed_shots(limit=needed + 10, offset=0) or [] - for shot in idx_shots: - annotation = get_annotation(shot["date"], shot["filename"]) - shot["has_annotation"] = annotation is not None - - all_shots = new_shots + [s for s in idx_shots if s["date"] in indexed_dates] - - # Sort by timestamp descending (handle None timestamps) - all_shots.sort( - key=lambda s: _safe_float(s["timestamp"]), - reverse=True, - ) - - page = all_shots[offset : offset + limit] - - response_data = {"shots": page} - _cache_recent_shots(cache_key, response_data) - return response_data - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to fetch recent shots: {e}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/shots/recent/by-profile") -@router.get("/api/shots/recent/by-profile") -async def get_recent_shots_by_profile( - request: Request, limit: int = 50, offset: int = 0 -): - """Same data as /shots/recent but grouped by profile. - - Response: { profiles: [{ profile_name, profile_id, shots: [...], shot_count }] } - """ - request_id = request.state.request_id - cache_key = f"recent_by_profile:{min(limit, 100)}:{min(offset, 10000)}" - now = time.time() - - cached = _recent_shots_cache.get(cache_key) - if cached and (now - cached["ts"]) < _RECENT_SHOTS_TTL: - return cached["data"] - - try: - # Reuse the flat recent-shots logic - flat_response = await get_recent_shots(request, limit=limit + offset, offset=0) - flat_shots = flat_response["shots"][offset : offset + limit] - - # Group by profile_name - grouped: dict[str, dict] = {} - for shot in flat_shots: - pname = shot.get("profile_name") or "Unknown" - if pname not in grouped: - grouped[pname] = { - "profile_name": pname, - "profile_id": shot.get("profile_id", ""), - "shots": [], - "shot_count": 0, - } - grouped[pname]["shots"].append(shot) - grouped[pname]["shot_count"] += 1 - - profiles = sorted(grouped.values(), key=lambda g: g["shot_count"], reverse=True) - - response_data = {"profiles": profiles} - _cache_recent_shots(cache_key, response_data) - return response_data - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to fetch recent shots by profile: {e}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException(status_code=500, detail=str(e)) - - -# ============================================================================ -# Shot Annotations -# ============================================================================ - - -@router.get("/api/shots/{date}/{filename}/annotation") -async def get_shot_annotation(date: str, filename: str, request: Request): - """Get the user annotation for a specific shot. - - Args: - date: Shot date (e.g., "2024-01-15") - filename: Shot filename (e.g., "shot_001.json") - - Returns: - Annotation text, rating, and updated_at if exists, null otherwise. - """ - from services.shot_annotations_service import get_annotation - - entry = get_annotation(date, filename) - return { - "status": "success", - "annotation": entry.get("annotation") if entry else None, - "rating": entry.get("rating") if entry else None, - "updated_at": entry.get("updated_at") if entry else None, - } - - -@router.patch("/api/shots/{date}/{filename}/annotation") -async def update_shot_annotation(date: str, filename: str, request: Request): - """Update the user annotation for a specific shot. - - Args: - date: Shot date (e.g., "2024-01-15") - filename: Shot filename (e.g., "shot_001.json") - - Body: - annotation: Markdown text for the annotation (empty to clear) - rating: Star rating 1-5 (null to leave unchanged, explicit null/0 to clear) - - Returns: - Updated annotation entry. - """ - from services.shot_annotations_service import set_annotation, set_rating - - request_id = request.state.request_id - - try: - try: - body = await request.json() - except (json.JSONDecodeError, ValueError): - raise HTTPException( - status_code=400, - detail={"status": "error", "error": "Invalid JSON body"}, - ) - - # If only rating is provided (no annotation key), update just the rating - if "rating" in body and "annotation" not in body: - result = set_rating(date, filename, body.get("rating")) - else: - annotation_text = body.get("annotation", "") - rating = body.get("rating") - result = set_annotation(date, filename, annotation_text, rating) - - return { - "status": "success", - "annotation": result.get("annotation"), - "rating": result.get("rating"), - "updated_at": result.get("updated_at"), - } - except ValueError as e: - raise HTTPException( - status_code=422, detail={"status": "error", "error": str(e)} - ) - except Exception as e: - logger.error( - f"Failed to update shot annotation: {str(e)}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.delete("/api/shots/{date}/{filename}/annotation") -async def delete_shot_annotation(date: str, filename: str, request: Request): - """Delete the annotation for a specific shot. - - Args: - date: Shot date (e.g., "2024-01-15") - filename: Shot filename (e.g., "shot_001.json") - - Returns: - Success status. - """ - from services.shot_annotations_service import delete_annotation - - request_id = request.state.request_id - - try: - deleted = delete_annotation(date, filename) - return { - "status": "success", - "deleted": deleted, - } - except Exception as e: - logger.error( - f"Failed to delete shot annotation: {str(e)}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.get("/api/shots/annotations") -async def get_all_shot_annotations(request: Request): - """Get all shot annotations (for shot list indicators). - - Returns: - Dict mapping shot keys to annotation summaries. - """ - from services.shot_annotations_service import get_all_annotations - - annotations = get_all_annotations() - # Return lightweight summaries for the shot list - summaries = {} - for key, entry in annotations.items(): - if isinstance(entry, dict): - summaries[key] = { - "has_annotation": bool(entry.get("annotation")), - "rating": entry.get("rating"), - } - return { - "status": "success", - "annotations": summaries, - } - - -# ============================================================================ -# Recommendation Extraction -# ============================================================================ - - -def _locate_recommendations_json(analysis_text: str) -> str | None: - """Locate the recommendations JSON array text (delimited or bare). - - Weak models (small on-device / non-Gemini LLMs) frequently omit the - RECOMMENDATIONS_JSON / END_RECOMMENDATIONS_JSON delimiters and emit a bare - array, sometimes embedded in prose. Prefer the delimited block; otherwise - fall back to bracket-matching a bare array that contains a - recommendation-shaped object. - """ - match = re.search( - r"RECOMMENDATIONS_JSON:\s*\n\s*(\[.*?\])\s*\n?\s*END_RECOMMENDATIONS_JSON", - analysis_text, - re.DOTALL, - ) - if match: - return match.group(1) - - key = re.search(r'"(?:variable|recommended_value)"\s*:', analysis_text) - if not key: - return None - open_idx = analysis_text.rfind("[", 0, key.start()) - if open_idx == -1: - return None - - depth = 0 - in_str = False - esc = False - for i in range(open_idx, len(analysis_text)): - ch = analysis_text[i] - if in_str: - if esc: - esc = False - elif ch == "\\": - esc = True - elif ch == '"': - in_str = False - continue - if ch == '"': - in_str = True - elif ch == "[": - depth += 1 - elif ch == "]": - depth -= 1 - if depth == 0: - return analysis_text[open_idx : i + 1] - return None - - -def _is_actionable_recommendation(rec: dict) -> bool: - """Drop hallucinated / non-actionable recommendations. - - Weak on-device models sometimes emit garbage variable ids (e.g. "flow_0") - with NaN values that render as "adjust from NaN to NaN". A recommendation is - only usable if it names a variable and its numeric values (when present) are - finite. Missing values are treated as 0 (kept, for advisory recommendations). - """ - if not str(rec.get("variable", "")).strip(): - return False - for key in ("current_value", "recommended_value"): - raw = rec.get(key) - if raw is None: - continue - try: - if not math.isfinite(float(raw)): - return False - except (TypeError, ValueError): - return False - return True - - -def _parse_recommendations_json(analysis_text: str) -> list[dict]: - """Extract and parse the recommendations JSON (delimited or bare).""" - json_text = _locate_recommendations_json(analysis_text) - if not json_text: - return [] - # Tolerate trailing commas that weak models emit. - cleaned = re.sub(r",(\s*[\]}])", r"\1", json_text) - try: - recs = json.loads(cleaned) - if not isinstance(recs, list): - return [] - return [ - r for r in recs if isinstance(r, dict) and _is_actionable_recommendation(r) - ] - except (json.JSONDecodeError, TypeError): - return [] - - -def _classify_recommendation_patchable( - rec: dict, profile_variables: list[dict] -) -> bool: - """Determine if a recommendation targets a patchable (adjustable) variable. - - A variable is patchable when: - - Its key does NOT start with 'info_' - - It is not marked adjustable=false - - OR the recommendation targets a global setting (temperature, final_weight) - - OR it targets a stage exit trigger (exit_weight, exit_time, etc.) - - OR it targets a stage limit (limit_pressure, limit_flow, etc.) - - OR the variable is not found in the profile (LLM may use descriptive names) - """ - variable = rec.get("variable", "") - stage = rec.get("stage", "") - - # Global top-level settings are always patchable - if stage == "global" and variable in ("temperature", "final_weight"): - return True - - # Stage exit triggers and limits are patchable - stage_variables = { - "exit_weight", - "exit_time", - "exit_pressure", - "exit_flow", - "exit_volume", - "limit_pressure", - "limit_flow", - "limit_weight", - } - if variable in stage_variables and stage and stage != "global": - return True - - # Check against profile variables - for var in profile_variables: - var_key = var.get("key", "") - if var_key == variable: - if var_key.startswith("info_"): - return False - if var.get("adjustable") is False: - return False - return True - - # Variable not found by exact key — try fuzzy match (LLM may use descriptive names) - variable_lower = variable.lower().replace("_", " ").replace("-", " ") - for var in profile_variables: - var_key = var.get("key", "").lower().replace("_", " ").replace("-", " ") - var_name = var.get("name", "").lower() - if ( - variable_lower in var_key - or var_key in variable_lower - or variable_lower in var_name - ): - if var.get("key", "").startswith("info_"): - return False - if var.get("adjustable") is False: - return False - return True - - # Variable not found in profile at all — consider patchable - # The LLM may use descriptive names for stage-level settings - return True - - -@router.post("/shots/analyze-recommendations") -@router.post("/api/shots/analyze-recommendations") -async def analyze_recommendations( - request: Request, - profile_name: str = Form(...), - shot_filename: str = Form(...), - force_refresh: bool = Form(default=False), -): - """Extract structured recommendations from an existing analysis. - - Returns the RECOMMENDATIONS_JSON block parsed as a list of dicts, - each annotated with ``is_patchable``. - """ - request_id = request.state.request_id - - try: - logger.info( - "Extracting recommendations from analysis", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - - # Try to find existing cached analysis for any date - # We search the cache by profile + filename - from services.cache_service import get_cached_llm_analysis - - # Derive shot_date from filename (format: YYYY-MM-DDTHH:MM:SS.json typically) - shot_date_match = re.match(r"(\d{4}-\d{2}-\d{2})", shot_filename) - shot_date = shot_date_match.group(1) if shot_date_match else "" - - cached_analysis = get_cached_llm_analysis( - profile_name, shot_date, shot_filename - ) - - if not cached_analysis: - raise HTTPException( - status_code=404, - detail={ - "status": "no_analysis", - "message": "No cached analysis found. Run a full analysis first.", - }, - ) - - if force_refresh: - # Re-parse the cached text to get fresh structured data - logger.info( - "Force refresh requested, re-parsing cached analysis", - extra={"request_id": request_id}, - ) - - # Parse recommendations from analysis text - recs = _parse_recommendations_json(cached_analysis) - - # Fetch profile to classify patchability - profile_variables: list[dict] = [] - try: - profiles_result = await async_list_profiles() - for p in profiles_result: - if p.name.lower() == profile_name.lower(): - full_profile = await async_get_profile(p.id) - if hasattr(full_profile, "variables") and full_profile.variables: - for var in full_profile.variables: - profile_variables.append( - { - "key": getattr(var, "key", ""), - "name": getattr(var, "name", ""), - "type": getattr(var, "type", ""), - "value": getattr(var, "value", 0), - "adjustable": getattr(var, "adjustable", None), - } - ) - break - except Exception: - logger.warning( - "Could not fetch profile for patchability check", exc_info=True - ) - - # Annotate each recommendation - for rec in recs: - rec["is_patchable"] = _classify_recommendation_patchable( - rec, profile_variables - ) - - return { - "status": "success", - "profile_name": profile_name, - "recommendations": recs, - "total": len(recs), - "patchable_count": sum(1 for r in recs if r.get("is_patchable")), - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to extract recommendations: {e}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, - detail={"status": "error", "error": str(e)}, - ) diff --git a/apps/server/api/routes/system.py b/apps/server/api/routes/system.py deleted file mode 100644 index b5c91090..00000000 --- a/apps/server/api/routes/system.py +++ /dev/null @@ -1,1875 +0,0 @@ -"""System management endpoints.""" - -from fastapi import APIRouter, Request, HTTPException -from typing import Optional -from pathlib import Path -from datetime import datetime, timezone, timedelta -import json -import asyncio -import socket -import subprocess -import logging -import os -import re -import tempfile - -from services.settings_service import load_settings, save_settings - -router = APIRouter() -logger = logging.getLogger(__name__) - - -@router.get("/api/available-models") -async def get_available_models_endpoint(): - """Return list of available Gemini models.""" - from services.gemini_service import get_available_models, get_model_name - - models = await get_available_models() - current = get_model_name() - return {"models": models, "current": current} - - -@router.get("/api/health") -async def health_check(): - """Health check endpoint for Docker and load balancer probes.""" - return {"status": "ok"} - - -# Data directory configuration -TEST_MODE = os.environ.get("TEST_MODE") == "true" -if TEST_MODE: - DATA_DIR = Path(tempfile.gettempdir()) / "meticai_test_data" - DATA_DIR.mkdir(parents=True, exist_ok=True) -else: - DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data")) - -# Regex pattern for extracting version from pyproject.toml or setup.py -VERSION_PATTERN = re.compile(r'^\s*version\s*=\s*["\']([^"\']+)["\']', re.MULTILINE) - -# Changelog cache -_changelog_cache: Optional[dict] = None -_changelog_cache_time: Optional[datetime] = None -CHANGELOG_CACHE_DURATION = timedelta(hours=1) # Cache for 1 hour - - -# Cached update status (avoid hammering the GitHub API) -_update_cache: Optional[dict] = None -_update_cache_time: Optional[datetime] = None -UPDATE_CACHE_DURATION = timedelta(minutes=15) - -GITHUB_RELEASES_URL = "https://api.github.com/repos/hessius/MeticAI/releases/latest" -GITHUB_ALL_RELEASES_URL = "https://api.github.com/repos/hessius/MeticAI/releases" -WATCHTOWER_API_ENDPOINTS = ( - "http://watchtower:8080/v1/update", - "http://meticai-watchtower:8080/v1/update", - "http://localhost:18088/v1/update", - "http://localhost:8088/v1/update", -) - -WATCHTOWER_REACHABLE_STATUS_CODES = {200, 204, 401, 403, 404, 405} -WATCHTOWER_TRIGGERABLE_STATUS_CODES = {200, 204} - - -async def _probe_watchtower_api(method: str = "get") -> dict: - """Probe known Watchtower API endpoints. - - Sends an Authorization header when the ``WATCHTOWER_TOKEN`` env-var is - set so that the probe (and trigger) succeeds on token-protected instances. - - Returns: - { - "reachable": bool, - "can_trigger": bool, - "endpoint": str | None, - "status_code": int | None, - "error": str | None, - } - """ - import httpx - - token = os.environ.get("WATCHTOWER_TOKEN", "").strip() - headers: dict[str, str] = {} - if token: - headers["Authorization"] = f"Bearer {token}" - - errors: list[str] = [] - - async def _probe_one(client: httpx.AsyncClient, endpoint: str) -> dict: - timeout = 3.0 if method == "post" else 2.0 - try: - if method == "post": - response = await client.post(endpoint, timeout=timeout, headers=headers) - else: - response = await client.get(endpoint, timeout=timeout, headers=headers) - - status_code = response.status_code - return { - "endpoint": endpoint, - "status_code": status_code, - "reachable": status_code in WATCHTOWER_REACHABLE_STATUS_CODES, - "can_trigger": status_code in WATCHTOWER_TRIGGERABLE_STATUS_CODES, - "error": None, - } - except Exception as exc: - return { - "endpoint": endpoint, - "status_code": None, - "reachable": False, - "can_trigger": False, - "error": f"{endpoint}: {exc}", - } - - async with httpx.AsyncClient() as client: - tasks = [ - asyncio.create_task(_probe_one(client, endpoint)) - for endpoint in WATCHTOWER_API_ENDPOINTS - ] - try: - for completed in asyncio.as_completed(tasks): - result = await completed - if result["reachable"]: - for task in tasks: - if not task.done(): - task.cancel() - return { - "reachable": True, - "can_trigger": result["can_trigger"], - "endpoint": result["endpoint"], - "status_code": result["status_code"], - "error": None, - } - if result["error"]: - errors.append(result["error"]) - finally: - for task in tasks: - if not task.done(): - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - - return { - "reachable": False, - "can_trigger": False, - "endpoint": None, - "status_code": None, - "error": "; ".join(errors) if errors else "Watchtower API not reachable", - } - - -def _get_running_version() -> str: - """Read the version baked into the container image.""" - version_file = Path(__file__).parent.parent.parent / "VERSION" - if version_file.exists(): - return version_file.read_text().strip() - return "unknown" - - -def _version_tuple(v: str): - """Parse a semver string like '2.0.0' into a tuple for comparison.""" - v = v.lstrip("v") - parts = v.split("-")[0].split(".") # strip pre-release suffixes - try: - return tuple(int(p) for p in parts) - except (ValueError, TypeError): - return (0, 0, 0) - - -def _is_prerelease_version(v: str) -> bool: - """Check if a version string has a pre-release tag (beta, alpha, rc).""" - v = v.lstrip("v") - return any(tag in v.lower() for tag in ["-beta", "-alpha", "-rc"]) - - -async def _fetch_latest_release() -> dict: - """Query GitHub Releases API for the latest published version. - - Fetches all recent releases to determine the latest stable and beta - versions separately, enabling cross-channel notifications. - """ - global _update_cache, _update_cache_time - - now = datetime.now(timezone.utc) - if ( - _update_cache is not None - and _update_cache_time is not None - and (now - _update_cache_time) < UPDATE_CACHE_DURATION - ): - return _update_cache - - try: - import httpx - - async with httpx.AsyncClient() as client: - resp = await client.get( - GITHUB_ALL_RELEASES_URL, - params={"per_page": 30}, - headers={"Accept": "application/vnd.github+json"}, - timeout=10.0, - ) - if resp.status_code == 200: - releases = resp.json() - running_version = _get_running_version() - - # Find the latest stable and beta releases - latest_stable_version = None - latest_beta_version = None - latest_release_url = None - - for release in releases: - tag = release.get("tag_name", "").lstrip("v") - if not tag: - continue - is_pre = release.get("prerelease", False) or _is_prerelease_version( - tag - ) - if is_pre: - if latest_beta_version is None: - latest_beta_version = tag - else: - if latest_stable_version is None: - latest_stable_version = tag - latest_release_url = release.get("html_url") - if latest_stable_version and latest_beta_version: - break - - # Primary update_available uses the latest stable release - # (matches the previous /releases/latest behaviour) - latest_version = latest_stable_version or "" - update_available = ( - latest_version != "" - and running_version != "unknown" - and _version_tuple(latest_version) > _version_tuple(running_version) - ) - - result = { - "update_available": update_available, - "latest_version": latest_version, - "current_version": running_version, - "last_check": now.isoformat(), - "release_url": latest_release_url, - "latest_stable_version": latest_stable_version, - "latest_beta_version": latest_beta_version, - } - _update_cache = result - _update_cache_time = now - return result - except Exception as e: - logger.debug(f"Failed to query GitHub releases: {e}") - - # Return stale cache if available, otherwise a safe fallback - if _update_cache is not None: - return _update_cache - return { - "update_available": False, - "current_version": _get_running_version(), - "latest_version": None, - "last_check": None, - "latest_stable_version": None, - "latest_beta_version": None, - "error": "Could not reach GitHub API", - } - - -@router.post("/api/check-updates") -async def check_updates(request: Request): - """Trigger a fresh update check against the GitHub Releases API. - - Queries the latest published release on GitHub and compares it to - the version baked into the running container image. - - Returns: - - update_available: Whether a newer version exists - - current_version: Version running in this container - - latest_version: Latest version on GitHub - - last_check: Timestamp of this check - - fresh_check: Always True for this endpoint - """ - request_id = request.state.request_id - - try: - global _update_cache, _update_cache_time - # Bust the cache so we get a fresh result - _update_cache = None - _update_cache_time = None - - logger.info( - "Checking for updates via GitHub Releases API", - extra={"request_id": request_id, "endpoint": "/api/check-updates"}, - ) - - result = await _fetch_latest_release() - result["fresh_check"] = True - return result - - except Exception as e: - logger.error( - f"Failed to check for updates: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - return { - "update_available": False, - "error": str(e), - "message": "Failed to check for updates", - } - - -@router.get("/api/status") -async def get_status(request: Request): - """Get system status including update availability. - - Returns cached update information (refreshed every 15 minutes) comparing - the running container version to the latest GitHub release. - - Returns: - - update_available: Whether a newer version exists - - current_version: Version running in this container - - latest_version: Latest version on GitHub (if known) - - last_check: Timestamp of last update check - """ - request_id = request.state.request_id - - try: - logger.debug("Checking system status", extra={"request_id": request_id}) - return await _fetch_latest_release() - - except Exception as e: - logger.error( - f"Failed to read system status: {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "endpoint": "/status", - "error_type": type(e).__name__, - }, - ) - return { - "update_available": False, - "error": str(e), - "message": "Could not read update status", - } - - -@router.get("/api/update-method") -async def get_update_method(request: Request): - """Detect how MeticAI updates are managed. - - Checks whether Watchtower is running (automatic Docker image updates) - or whether updates must be applied manually. - - Returns: - - method: "watchtower" | "manual" - - watchtower_running: bool - - can_trigger_update: bool - - watchtower_endpoint: str | None — the reachable Watchtower API endpoint URL, or None - - watchtower_error: str | None — error message if the probe failed or container reported an error - """ - request_id = request.state.request_id - - try: - probe = await _probe_watchtower_api(method="get") - watchtower_running = probe["reachable"] - can_trigger_update = probe["can_trigger"] - watchtower_error = probe["error"] - - if not watchtower_running: - try: - result = subprocess.run( - [ - "docker", - "inspect", - "--format", - "{{.State.Status}}|{{.State.Error}}", - "meticai-watchtower", - ], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - state_parts = result.stdout.strip().split("|", 1) - state_status = state_parts[0].strip() if state_parts else "" - state_error = state_parts[1].strip() if len(state_parts) > 1 else "" - watchtower_running = state_status == "running" - if state_error: - watchtower_error = state_error - except Exception: - pass - - method = "watchtower" if watchtower_running else "manual" - - return { - "method": method, - "watchtower_running": watchtower_running, - "can_trigger_update": can_trigger_update, - "watchtower_endpoint": probe["endpoint"], - "watchtower_error": watchtower_error, - } - except Exception as e: - logger.error( - f"Failed to detect update method: {str(e)}", - exc_info=True, - extra={"request_id": request_id}, - ) - return { - "method": "manual", - "watchtower_running": False, - "can_trigger_update": False, - } - - -@router.get("/api/tailscale-status") -async def get_tailscale_status(request: Request): - """Get Tailscale connection status and configuration. - - Checks if Tailscale is running and provides status information. - Also returns the user's Tailscale configuration preferences from settings. - Works both when Tailscale runs as a sidecar container and when - it runs natively on the host. - - Returns: - - enabled: Whether user has enabled Tailscale in settings - - auth_key_configured: Whether an auth key is saved - - installed: Whether Tailscale is available/running - - connected: Whether Tailscale is connected - - hostname: Tailscale hostname if connected - - ip: Tailscale IP if connected - - auth_key_expired: Whether the auth key has expired - - login_url: URL to re-authenticate if needed - """ - request_id = request.state.request_id - - try: - # Load user config from settings - from services.settings_service import load_settings - - stored_settings = load_settings() - settings = dict(stored_settings) - ts_enabled = settings.get("tailscaleEnabled", False) - ts_auth_key = settings.get("tailscaleAuthKey", "") - # Also check env var fallback - if not ts_auth_key: - ts_auth_key = os.environ.get("TAILSCALE_AUTHKEY", "") - - status = { - "enabled": ts_enabled, - "auth_key_configured": bool(ts_auth_key), - "installed": False, - "connected": False, - "hostname": None, - "dns_name": None, - "ip": None, - "external_url": None, - "auth_key_expired": False, - "login_url": None, - } - - def _parse_ts_status(ts_data: dict) -> None: - """Fill status dict from tailscale status JSON.""" - status["installed"] = True - backend_state = ts_data.get("BackendState", "") - status["connected"] = backend_state == "Running" - self_info = ts_data.get("Self", {}) - status["hostname"] = self_info.get("HostName") - dns_name = self_info.get("DNSName", "") - if dns_name: - status["dns_name"] = dns_name.rstrip(".") - status["external_url"] = f"https://{status['dns_name']}" - ts_ips = self_info.get("TailscaleIPs", []) - if ts_ips: - status["ip"] = ts_ips[0] - if backend_state == "NeedsLogin": - status["auth_key_expired"] = True - status["connected"] = False - status["login_url"] = "https://login.tailscale.com/admin/settings/keys" - - # Strategy 1: native tailscale binary (host install) - try: - result = subprocess.run( - ["tailscale", "status", "--json"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - _parse_ts_status(json.loads(result.stdout)) - elif result.returncode == 1 and "not running" in result.stderr.lower(): - status["installed"] = True - status["connected"] = False - except FileNotFoundError: - pass - except Exception as e: - logger.debug( - f"Tailscale status check failed: {e}", extra={"request_id": request_id} - ) - - # Strategy 2: shared Tailscale socket (sidecar with volume mount) - if not status["installed"]: - ts_socket = "/var/run/tailscale/tailscaled.sock" - if os.path.exists(ts_socket): - try: - result = subprocess.run( - [ - "curl", - "-sf", - "--unix-socket", - ts_socket, - "http://local-tailscaled.sock/localapi/v0/status", - ], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - _parse_ts_status(json.loads(result.stdout)) - except Exception as e: - logger.debug( - f"Tailscale socket check failed: {e}", - extra={"request_id": request_id}, - ) - - # Strategy 3: docker exec on sidecar container - if not status["installed"]: - try: - result = subprocess.run( - [ - "docker", - "exec", - "meticai-tailscale", - "tailscale", - "status", - "--json", - ], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - _parse_ts_status(json.loads(result.stdout)) - except Exception: - pass - - return status - - except Exception as e: - logger.error( - f"Failed to check Tailscale status: {str(e)}", - exc_info=True, - extra={"request_id": request_id}, - ) - return { - "enabled": False, - "auth_key_configured": False, - "installed": False, - "connected": False, - "hostname": None, - "dns_name": None, - "ip": None, - "external_url": None, - "auth_key_expired": False, - "login_url": None, - "error": str(e), - } - - -@router.post("/api/tailscale/configure") -async def configure_tailscale(request: Request): - """Configure Tailscale remote access settings. - - Saves Tailscale preferences (enabled state and auth key) to settings. - When enabled/disabled or auth key is changed, updates the .env file - and COMPOSE_FILES variable, then signals a restart so the host - picks up the new compose configuration. - - Body: - - enabled: bool — Whether Tailscale should be active - - authKey: str (optional) — Tailscale auth key - """ - request_id = request.state.request_id - - try: - body = await request.json() - - from services.settings_service import load_settings, save_settings - - current_settings = load_settings() - - changed = False - compose_changed = False - - # Handle enabled toggle - if "enabled" in body: - new_enabled = bool(body["enabled"]) - old_enabled = current_settings.get("tailscaleEnabled", False) - current_settings["tailscaleEnabled"] = new_enabled - changed = True - if new_enabled != old_enabled: - compose_changed = True - - # Handle auth key update - if "authKey" in body: - new_key = body["authKey"].strip() if body["authKey"] else "" - # Don't save masked values - if new_key and "*" not in new_key and "..." not in new_key: - current_settings["tailscaleAuthKey"] = new_key - changed = True - elif not new_key: - current_settings["tailscaleAuthKey"] = "" - changed = True - - if not changed: - return {"status": "success", "message": "No changes to apply"} - - # Save to settings.json (persisted on /data volume) - save_settings(current_settings) - - # Update .env file for compose-level changes - env_path = Path("/app/.env") - env_content = "" - if env_path.exists(): - env_content = env_path.read_text() - - env_updated = False - ts_enabled = current_settings.get("tailscaleEnabled", False) - ts_auth_key = current_settings.get("tailscaleAuthKey", "") - - # Update TAILSCALE_AUTHKEY in .env - if ts_auth_key: - if "TAILSCALE_AUTHKEY=" in env_content: - env_content = re.sub( - r"TAILSCALE_AUTHKEY=.*", - f"TAILSCALE_AUTHKEY={ts_auth_key}", - env_content, - ) - else: - env_content += f"\nTAILSCALE_AUTHKEY={ts_auth_key}" - env_updated = True - - # Update COMPOSE_FILES to add/remove tailscale overlay - if compose_changed: - compose_match = re.search(r'COMPOSE_FILES="([^"]*)"', env_content) - if compose_match: - current_compose = compose_match.group(1) - else: - current_compose = "-f docker-compose.yml" - - ts_flag = "-f docker-compose.tailscale.yml" - - if ts_enabled and ts_flag not in current_compose: - current_compose = f"{current_compose} {ts_flag}" - elif not ts_enabled and ts_flag in current_compose: - current_compose = current_compose.replace(f" {ts_flag}", "").replace( - ts_flag, "" - ) - current_compose = current_compose.strip() - - if "COMPOSE_FILES=" in env_content: - env_content = re.sub( - r'COMPOSE_FILES="[^"]*"', - f'COMPOSE_FILES="{current_compose}"', - env_content, - ) - else: - env_content += f'\nCOMPOSE_FILES="{current_compose}"' - env_updated = True - - if env_updated: - try: - env_path.write_text(env_content) - logger.info( - "Updated .env with Tailscale config", - extra={"request_id": request_id}, - ) - except (PermissionError, FileNotFoundError, OSError) as e: - logger.warning( - f".env file not writable ({type(e).__name__}), " - "Tailscale config saved to settings.json only", - extra={"request_id": request_id}, - ) - - # Signal restart if compose config changed (needs container recreation) - restart_signaled = False - if compose_changed: - try: - import signal as sig - - async def _deferred_restart(): - """Wait briefly then kill PID 1 for restart.""" - await asyncio.sleep(2.0) - try: - os.kill(1, sig.SIGTERM) - except ProcessLookupError: - pass - - asyncio.get_running_loop().create_task(_deferred_restart()) - restart_signaled = True - logger.info( - "Scheduled container restart for Tailscale config change", - extra={"request_id": request_id}, - ) - except Exception as e: - logger.warning( - f"Could not schedule restart: {e}", extra={"request_id": request_id} - ) - - action = "enabled" if ts_enabled else "disabled" - logger.info( - f"Tailscale configuration updated: {action}", - extra={ - "request_id": request_id, - "tailscale_enabled": ts_enabled, - "auth_key_configured": bool(ts_auth_key), - "compose_changed": compose_changed, - "restart_signaled": restart_signaled, - }, - ) - - return { - "status": "success", - "message": f"Tailscale {action}", - "enabled": ts_enabled, - "auth_key_configured": bool(ts_auth_key), - "restart_required": compose_changed, - "restart_signaled": restart_signaled, - } - - except Exception as e: - logger.error( - f"Failed to configure Tailscale: {str(e)}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to configure Tailscale", - }, - ) - - -@router.post("/api/trigger-update") -async def trigger_update(request: Request): - """Trigger an immediate update check via Watchtower. - - Calls Watchtower's HTTP API to request an immediate image-update check. - If Watchtower is not running, returns an error indicating manual update - is required. - - Returns: - - status: "success" or "error" - - message: Description of what happened - """ - request_id = request.state.request_id - - try: - logger.info( - "Triggering update via Watchtower HTTP API", - extra={"request_id": request_id, "endpoint": "/api/trigger-update"}, - ) - - probe = await _probe_watchtower_api(method="post") - - if probe["can_trigger"]: - logger.info( - "Update triggered via Watchtower", - extra={ - "request_id": request_id, - "endpoint": probe["endpoint"], - "status_code": probe["status_code"], - }, - ) - return { - "status": "success", - "message": "Update triggered. Watchtower will pull the latest image and restart the container.", - } - - if probe["reachable"]: - raise HTTPException( - status_code=503, - detail={ - "status": "error", - "error": f"Watchtower endpoint reachable but update not authorized (HTTP {probe['status_code']})", - "message": "Automatic updates are available, but this Watchtower endpoint rejected the trigger request.", - }, - ) - - # No Watchtower — manual update required - raise HTTPException( - status_code=503, - detail={ - "status": "error", - "error": "Watchtower not available", - "message": "Automatic updates require Watchtower. Update manually with: docker compose pull && docker compose up -d", - }, - ) - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to trigger update: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to trigger update", - }, - ) - - -@router.post("/api/restart") -async def restart_system(request: Request): - """Restart the MeticAI container. - - Sends SIGTERM to PID 1 (s6-overlay init) after a short delay to allow - the HTTP response to be sent. Docker's ``restart: unless-stopped`` policy - will automatically bring the container back up. - - Returns: - - status: "success" or "error" - - message: Description of what happened - """ - request_id = request.state.request_id - - try: - import signal - - logger.info( - "Triggering container restart via SIGTERM to PID 1", - extra={"request_id": request_id, "endpoint": "/api/restart"}, - ) - - async def _kill_pid1(): - """Wait briefly for the HTTP response to flush, then kill PID 1.""" - await asyncio.sleep(1.5) - logger.info("Sending SIGTERM to PID 1 (s6-overlay) for restart") - try: - os.kill(1, signal.SIGTERM) - except ProcessLookupError: - # In test/dev environments PID 1 may not be s6 - logger.warning("PID 1 not found — not running inside container?") - - # Schedule the kill in the background so the response can be sent first - asyncio.get_running_loop().create_task(_kill_pid1()) - - return { - "status": "success", - "message": "Restart triggered. The system will restart momentarily.", - } - except Exception as e: - logger.error( - f"Failed to trigger restart: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to trigger restart", - }, - ) - - -@router.get("/api/logs") -async def get_logs( - request: Request, - lines: int = 100, - level: Optional[str] = None, - log_type: str = "all", -): - """Retrieve recent log entries for debugging and diagnostics. - - Args: - lines: Number of lines to retrieve (default: 100, max: 1000) - level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) - log_type: Type of logs to retrieve - "all" or "errors" (default: "all") - - Returns: - - logs: List of log entries (most recent first) - - total_lines: Total number of log lines returned - - log_file: Path to the log file - """ - request_id = request.state.request_id - - try: - logger.info( - "Log retrieval requested", - extra={ - "request_id": request_id, - "lines": lines, - "level": level, - "log_type": log_type, - }, - ) - - # Limit lines to prevent overwhelming responses - lines = min(lines, 1000) - - # Determine which log file to read - log_dir = Path("/app/logs") - if log_type == "errors": - log_file = log_dir / "meticai-server-errors.log" - else: - log_file = log_dir / "meticai-server.log" - - if not log_file.exists(): - logger.warning( - f"Log file not found: {log_file}", - extra={"request_id": request_id, "log_file": str(log_file)}, - ) - return { - "logs": [], - "total_lines": 0, - "log_file": str(log_file), - "message": "Log file not found - logging may not be initialized yet", - } - - # Read log file (last N lines) — use deque for memory-efficient tail - from collections import deque - - with open(log_file, "r", encoding="utf-8") as f: - recent_lines = deque(f, maxlen=lines) - - # Parse JSON log entries - log_entries = [] - for line in reversed(recent_lines): # Most recent first - try: - log_entry = json.loads(line.strip()) - - # Filter by level if specified - if level and log_entry.get("level") != level.upper(): - continue - - log_entries.append(log_entry) - except json.JSONDecodeError: - # Skip malformed lines - continue - - logger.debug( - f"Retrieved {len(log_entries)} log entries", - extra={"request_id": request_id, "log_file": str(log_file)}, - ) - - return { - "logs": log_entries, - "total_lines": len(log_entries), - "log_file": str(log_file), - "filters": {"lines_requested": lines, "level": level, "log_type": log_type}, - } - - except Exception as e: - logger.error( - f"Failed to retrieve logs: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to retrieve logs", - }, - ) - - -@router.get("/api/version") -async def get_version_info(request: Request): - """Get unified version information for MeticAI. - - In v2 all components run in a single container, so there is one version. - Includes beta channel status from settings. - """ - request_id = request.state.request_id - - try: - # Read unified version from VERSION file - version = "unknown" - version_file = Path(__file__).parent.parent.parent / "VERSION" - if version_file.exists(): - version = version_file.read_text().strip() - - # Try to get git commit hash - commit = None - try: - result = subprocess.run( - ["git", "rev-parse", "--short", "HEAD"], - cwd=Path(__file__).parent.parent.parent, - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and result.stdout.strip(): - commit = result.stdout.strip() - except Exception: - pass - - # Determine if this is a beta version (contains -beta, -alpha, or -rc) - is_beta_version = any( - suffix in version.lower() for suffix in ["-beta", "-alpha", "-rc"] - ) - - # Get beta channel preference from settings - settings = load_settings() - beta_channel = settings.get("betaChannel", False) - - return { - "version": version, - "commit": commit, - "repo_url": "https://github.com/hessius/MeticAI", - "is_beta_version": is_beta_version, - "beta_channel_enabled": beta_channel, - "channel": "beta" if beta_channel else "stable", - } - except Exception as e: - logger.error( - f"Failed to get version info: {str(e)}", - extra={"request_id": request_id}, - exc_info=True, - ) - return { - "version": "unknown", - "commit": None, - "repo_url": "https://github.com/hessius/MeticAI", - "is_beta_version": False, - "beta_channel_enabled": False, - "channel": "stable", - } - - -@router.get("/api/network-ip") -async def get_network_ip(request: Request): - """Auto-detect the server's LAN IP address for cross-device QR codes. - - Uses a UDP socket trick (no data is actually sent) to discover - the default-route interface address. Falls back to hostname - resolution and finally to ``127.0.0.1``. - """ - request_id = request.state.request_id - ip = "127.0.0.1" - - try: - # Preferred: open a UDP socket to a public IP (no traffic sent) - # This gives us the address of the interface with the default route. - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: - s.settimeout(1) - s.connect(("8.8.8.8", 80)) - ip = s.getsockname()[0] - except Exception: - # Fallback: hostname lookup - try: - hostname = socket.gethostname() - resolved = socket.gethostbyname(hostname) - if resolved and not resolved.startswith("127."): - ip = resolved - except Exception: - pass - - logger.debug("Network IP detected", extra={"request_id": request_id, "ip": ip}) - return {"ip": ip} - - -@router.get("/api/changelog") -async def get_changelog(request: Request): - """Get release notes from GitHub. - - Returns cached release notes if available and fresh, - otherwise fetches from GitHub API and caches the result. - """ - global _changelog_cache, _changelog_cache_time - request_id = request.state.request_id - - try: - # Check if we have a valid cache - now = datetime.now(timezone.utc) - if _changelog_cache and _changelog_cache_time: - cache_age = now - _changelog_cache_time - if cache_age < CHANGELOG_CACHE_DURATION: - logger.debug( - f"Returning cached changelog (age: {cache_age.total_seconds():.0f}s)", - extra={"request_id": request_id}, - ) - return _changelog_cache - - # Fetch from GitHub API - import httpx - - async with httpx.AsyncClient() as client: - response = await client.get( - "https://api.github.com/repos/hessius/MeticAI/releases", - params={"per_page": 10}, - headers={"Accept": "application/vnd.github.v3+json"}, - timeout=10.0, - ) - - if response.status_code == 200: - releases = response.json() - - def strip_installation_section(body: str) -> str: - """Remove the Installation section from release notes.""" - if not body: - return body - # Find where Installation section starts (### Installation or ## Installation) - # Match "### Installation" or "## Installation" and everything after until end or next major section - pattern = r"\n---\n+### Installation.*$" - cleaned = re.sub(pattern, "", body, flags=re.DOTALL | re.IGNORECASE) - # Also try without the --- separator - pattern2 = r"\n### Installation.*$" - cleaned = re.sub( - pattern2, "", cleaned, flags=re.DOTALL | re.IGNORECASE - ) - return cleaned.strip() - - changelog_data = { - "releases": [ - { - "version": release.get("tag_name", ""), - "date": ( - release.get("published_at", "")[:10] - if release.get("published_at") - else "" - ), - "body": strip_installation_section( - release.get("body", "No release notes available.") - ), - } - for release in releases - ], - "cached_at": now.isoformat(), - } - - # Update cache - _changelog_cache = changelog_data - _changelog_cache_time = now - - logger.info( - f"Fetched and cached {len(releases)} releases from GitHub", - extra={"request_id": request_id}, - ) - - return changelog_data - elif response.status_code in (403, 429): - # Rate limited - logger.warning( - f"GitHub API rate limit reached: {response.status_code}", - extra={"request_id": request_id}, - ) - # Return cached data if available, even if stale - if _changelog_cache: - return _changelog_cache - return { - "releases": [], - "error": "GitHub API rate limit reached. Please try again later.", - "cached_at": None, - } - else: - logger.error( - f"GitHub API error: {response.status_code}", - extra={"request_id": request_id}, - ) - if _changelog_cache: - return _changelog_cache - return { - "releases": [], - "error": f"Failed to fetch releases (status {response.status_code})", - "cached_at": None, - } - - except Exception as e: - logger.error( - f"Failed to fetch changelog: {str(e)}", - exc_info=True, - extra={"request_id": request_id}, - ) - # Return cached data if available - if _changelog_cache: - return _changelog_cache - return {"releases": [], "error": str(e), "cached_at": None} - - -@router.get("/api/settings") -async def get_settings(request: Request): - """Get current settings. - - Returns settings with API key masked for security. - """ - request_id = request.state.request_id - - try: - logger.info( - "Fetching settings", - extra={"request_id": request_id, "endpoint": "/api/settings"}, - ) - - stored_settings = load_settings() - settings = dict(stored_settings) - - # Active AI provider (#491). Defaults to gemini for backward compat. - from services.ai_providers import ( - get_active_provider_id, - get_provider_api_key, - get_provider_model, - get_provider_descriptor, - ) - - active_provider = get_active_provider_id() - settings["aiProvider"] = active_provider - is_gemini_provider = active_provider == "gemini" - - # Read current values from environment - env_api_key = os.environ.get("GEMINI_API_KEY", "") - env_meticulous_ip = os.environ.get("METICULOUS_IP", "") - env_server_ip = os.environ.get("PI_IP", "") - - if is_gemini_provider: - stored_api_key = str(stored_settings.get("geminiApiKey", "") or "").strip() - effective_api_key = env_api_key.strip() or stored_api_key - else: - stored_api_key = str(stored_settings.get("aiApiKey", "") or "").strip() - effective_api_key = get_provider_api_key(active_provider) or stored_api_key - - # Always show API key as stars if set (never expose the actual key) - if effective_api_key: - settings["geminiApiKey"] = "*" * min(len(effective_api_key), 20) - settings["geminiApiKeyMasked"] = True - settings["geminiApiKeyConfigured"] = True - else: - settings["geminiApiKey"] = "" - settings["geminiApiKeyMasked"] = False - settings["geminiApiKeyConfigured"] = False - - # Never expose the raw BYO provider key (#491). The masked representation - # is already carried by geminiApiKey/geminiApiKeyMasked above. - settings.pop("aiApiKey", None) - - # Always show current IP values from environment (env takes precedence) - if env_meticulous_ip: - settings["meticulousIp"] = env_meticulous_ip - - if env_server_ip: - settings["serverIp"] = env_server_ip - - # MQTT enabled flag (env var takes precedence over stored setting) - mqtt_env = os.environ.get("MQTT_ENABLED", "") - if mqtt_env: - settings["mqttEnabled"] = mqtt_env.lower() == "true" - elif "mqttEnabled" not in settings: - settings["mqttEnabled"] = True - - # Model (env var takes precedence over stored setting). The web client - # reads `geminiModel` generically, so report the active provider's model. - if is_gemini_provider: - gemini_model_env = os.environ.get("GEMINI_MODEL", "").strip() - if gemini_model_env: - settings["geminiModel"] = gemini_model_env - elif "geminiModel" not in settings: - settings["geminiModel"] = "gemini-2.5-flash" - else: - settings["geminiModel"] = get_provider_model(active_provider) - settings["aiModelDefault"] = get_provider_descriptor(active_provider)[ - "default_model" - ] - - return settings - - except Exception as e: - logger.error( - f"Failed to fetch settings: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to fetch settings", - }, - ) - - -def _update_s6_env(var_name: str, value: str, request_id: str = "") -> None: - """Write an env var to the s6 container environment directory. - - Delegates to the shared ``utils.s6_env.update_s6_env`` implementation. - """ - from utils.s6_env import update_s6_env - - update_s6_env(var_name, value, request_id=request_id) - - -@router.post("/api/settings") -async def save_settings_endpoint(request: Request): - """Save settings. - - Updates the settings.json file and optionally updates the .env file - for system-level settings (requires container restart to take effect). - """ - request_id = request.state.request_id - - try: - body = await request.json() - - logger.info( - "Saving settings", - extra={ - "request_id": request_id, - "endpoint": "/api/settings", - "has_api_key": bool(body.get("geminiApiKey")), - "has_meticulous_ip": bool(body.get("meticulousIp")), - "has_server_ip": bool(body.get("serverIp")), - "has_author": bool(body.get("authorName")), - }, - ) - - # Load current settings - current_settings = load_settings() - - # Active AI provider (#491). Defaults to gemini for backward compat. - from services.ai_providers import PROVIDERS, DEFAULT_PROVIDER - - ai_provider = str(body.get("aiProvider", "") or "").strip().lower() - if ai_provider not in PROVIDERS: - stored_provider = str(current_settings.get("aiProvider", "") or "") - ai_provider = ( - stored_provider if stored_provider in PROVIDERS else DEFAULT_PROVIDER - ) - current_settings["aiProvider"] = ai_provider - is_gemini_provider = ai_provider == "gemini" - - # Update only provided fields - if "authorName" in body: - current_settings["authorName"] = body["authorName"].strip() - - # Boolean preference fields - for bool_key in ("autoSync", "autoSyncAiDescription"): - if bool_key in body: - current_settings[bool_key] = bool(body[bool_key]) - - # Model selection (stored per active provider). - if "geminiModel" in body: - model_value = str(body["geminiModel"]).strip() - if is_gemini_provider: - current_settings["geminiModel"] = model_value or "gemini-2.5-flash" - else: - current_settings["aiModel"] = model_value - - # For IP and API key changes, also update .env file - env_updated = False - env_path = Path("/app/.env") - - # Read current .env content - env_content = "" - if env_path.exists(): - env_content = env_path.read_text() - - def _upsert_env(name: str, value: str) -> None: - """Insert or replace ``name=value`` in the in-memory .env content.""" - nonlocal env_content - if f"{name}=" in env_content: - env_content = re.sub( - rf"{re.escape(name)}=.*", - lambda _m: f"{name}={value}", - env_content, - ) - else: - env_content += f"\n{name}={value}" - - # Persist the active provider id to .env (only written when another - # change already triggers an .env write; s6 env + settings.json below - # guarantee persistence regardless). - _upsert_env("AI_PROVIDER", ai_provider) - - # Handle API key update (routed to the active provider's env var). - if body.get("geminiApiKey") and not body.get("geminiApiKeyMasked"): - new_api_key = body["geminiApiKey"].strip() - if ( - new_api_key and "..." not in new_api_key and "*" not in new_api_key - ): # Not a masked value - key_env = "GEMINI_API_KEY" if is_gemini_provider else "AI_API_KEY" - if is_gemini_provider: - current_settings["geminiApiKey"] = new_api_key - else: - current_settings["aiApiKey"] = new_api_key - _upsert_env(key_env, new_api_key) - env_updated = True - - # Handle Meticulous IP update - if body.get("meticulousIp"): - new_ip = body["meticulousIp"].strip() - current_settings["meticulousIp"] = new_ip - if "METICULOUS_IP=" in env_content: - env_content = re.sub( - r"METICULOUS_IP=.*", f"METICULOUS_IP={new_ip}", env_content - ) - else: - env_content += f"\nMETICULOUS_IP={new_ip}" - env_updated = True - - # Handle Server IP update - if body.get("serverIp"): - new_ip = body["serverIp"].strip() - current_settings["serverIp"] = new_ip - if "PI_IP=" in env_content: - env_content = re.sub(r"PI_IP=.*", f"PI_IP={new_ip}", env_content) - else: - env_content += f"\nPI_IP={new_ip}" - env_updated = True - - # Save settings to JSON file. - # Strip display-only keys that should never be persisted. - for _display_key in ("geminiApiKeyConfigured", "geminiApiKeyMasked"): - current_settings.pop(_display_key, None) - save_settings(current_settings) - - # Write .env file if updated (note: may fail if read-only mount) - if env_updated: - try: - env_path.write_text(env_content) - logger.info("Updated .env file", extra={"request_id": request_id}) - except PermissionError: - logger.warning( - ".env file is read-only, changes saved to settings.json only", - extra={"request_id": request_id}, - ) - - # Hot-reload: update running process environment and restart services - services_restarted = [] - - if body.get("meticulousIp"): - new_ip = body["meticulousIp"].strip() - os.environ["METICULOUS_IP"] = new_ip - - # Write to s6 container environment so restarted services pick it up. - # s6-overlay services using `with-contenv` read from this directory, - # NOT from the FastAPI process environment. - _update_s6_env("METICULOUS_IP", new_ip, request_id) - - # Reset cached FastAPI Meticulous client - try: - from services.meticulous_service import reset_meticulous_api - - reset_meticulous_api() - services_restarted.append("meticulous_api") - except Exception as e: - logger.warning( - f"Failed to reset Meticulous API client: {e}", - extra={"request_id": request_id}, - ) - - # Restart MCP server s6 service so it picks up the new IP - try: - result = subprocess.run( - ["s6-svc", "-r", "/run/service/mcp-server"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - services_restarted.append("mcp-server") - logger.info( - "Restarted MCP server with new METICULOUS_IP", - extra={"request_id": request_id, "new_ip": new_ip}, - ) - else: - logger.warning( - f"Failed to restart MCP server: {result.stderr}", - extra={"request_id": request_id}, - ) - except Exception as e: - logger.warning( - f"Could not restart MCP server: {e}", - extra={"request_id": request_id}, - ) - - # Also restart the bridge (it needs the new IP for Socket.IO) - try: - from services.bridge_service import restart_bridge_service - - if restart_bridge_service(): - services_restarted.append("meticulous-bridge") - except Exception as e: - logger.warning( - f"Could not restart bridge: {e}", extra={"request_id": request_id} - ) - - # Hot-reload the active provider into the process environment so the - # change takes effect without a container restart. - os.environ["AI_PROVIDER"] = ai_provider - _update_s6_env("AI_PROVIDER", ai_provider, request_id) - - if body.get("geminiApiKey") and not body.get("geminiApiKeyMasked"): - new_api_key = body["geminiApiKey"].strip() - if new_api_key and "..." not in new_api_key and "*" not in new_api_key: - key_env = "GEMINI_API_KEY" if is_gemini_provider else "AI_API_KEY" - os.environ[key_env] = new_api_key - _update_s6_env(key_env, new_api_key, request_id) - # Reset cached vision model so it re-configures with the new key - try: - from services.gemini_service import reset_vision_model - - reset_vision_model() - services_restarted.append("vision_model") - except Exception as e: - logger.warning( - f"Failed to reset vision model: {e}", - extra={"request_id": request_id}, - ) - services_restarted.append("ai_env") - logger.info( - "Updated %s in process environment", - key_env, - extra={"request_id": request_id}, - ) - - # Handle MQTT enabled toggle - if "mqttEnabled" in body: - mqtt_enabled = bool(body["mqttEnabled"]) - current_settings["mqttEnabled"] = mqtt_enabled - os.environ["MQTT_ENABLED"] = str(mqtt_enabled).lower() - - # Update .env file - if "MQTT_ENABLED=" in env_content: - env_content = re.sub( - r"MQTT_ENABLED=.*", - f"MQTT_ENABLED={str(mqtt_enabled).lower()}", - env_content, - ) - else: - env_content += f"\nMQTT_ENABLED={str(mqtt_enabled).lower()}" - env_updated = True - - # Restart bridge and reset MQTT subscriber - try: - from services.bridge_service import restart_bridge_service - - restart_bridge_service() - services_restarted.append("meticulous-bridge") - except Exception as e: - logger.warning( - f"Failed to restart bridge: {e}", extra={"request_id": request_id} - ) - - try: - from services.mqtt_service import ( - reset_mqtt_subscriber, - get_mqtt_subscriber, - ) - - reset_mqtt_subscriber() - if mqtt_enabled: - import asyncio - - sub = get_mqtt_subscriber() - sub.start(asyncio.get_running_loop()) - services_restarted.append("mqtt_subscriber") - except Exception as e: - logger.warning( - f"Failed to reset MQTT subscriber: {e}", - extra={"request_id": request_id}, - ) - - logger.info( - "MQTT enabled=%s", mqtt_enabled, extra={"request_id": request_id} - ) - - # Hot-reload the model into the process environment (per active provider). - if "geminiModel" in body: - new_model = str(body["geminiModel"]).strip() - if new_model: - model_env = "GEMINI_MODEL" if is_gemini_provider else "AI_MODEL" - os.environ[model_env] = new_model - _update_s6_env(model_env, new_model, request_id) - services_restarted.append("ai_model") - logger.info( - "Updated %s to %s", - model_env, - new_model, - extra={"request_id": request_id}, - ) - - return { - "status": "success", - "message": "Settings saved successfully", - "env_updated": env_updated, - "services_restarted": services_restarted, - } - - except Exception as e: - logger.error( - f"Failed to save settings: {str(e)}", - exc_info=True, - extra={"request_id": request_id, "error_type": type(e).__name__}, - ) - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "error": str(e), - "message": "Failed to save settings", - }, - ) - - -@router.post("/api/beta-channel") -async def switch_beta_channel(request: Request): - """Switch between beta and stable update channels. - - Updates the betaChannel setting and optionally creates a docker-compose override - to pull from the beta tag instead of latest. - - Request body: - enabled (bool): Whether to enable beta channel - - Returns: - status: success/error - channel: Current channel after switch ("beta" or "stable") - message: Informational message - """ - request_id = request.state.request_id - - try: - body = await request.json() - enabled = body.get("enabled", False) - - logger.info( - f"Switching beta channel: enabled={enabled}", - extra={"request_id": request_id}, - ) - - # Update settings - current_settings = load_settings() - current_settings["betaChannel"] = enabled - save_settings(current_settings) - - # Create or update docker-compose override for image tag - override_path = Path("/app/docker-compose.channel.yml") - compose_updated = False - - try: - if enabled: - # Create override to use beta tag - override_content = """# Auto-generated by MeticAI beta channel switch -# DO NOT EDIT - this file is managed automatically -version: '3.8' -services: - meticai: - image: ghcr.io/hessius/meticai:beta -""" - override_path.write_text(override_content) - compose_updated = True - logger.info( - "Created docker-compose.channel.yml for beta tag", - extra={"request_id": request_id}, - ) - else: - # Remove override to use latest (default) - if override_path.exists(): - override_path.unlink() - compose_updated = True - logger.info( - "Removed docker-compose.channel.yml, reverting to latest", - extra={"request_id": request_id}, - ) - except PermissionError: - logger.warning( - "Cannot modify docker-compose override - filesystem may be read-only", - extra={"request_id": request_id}, - ) - except Exception as e: - logger.warning( - f"Failed to update docker-compose override: {e}", - extra={"request_id": request_id}, - ) - - channel = "beta" if enabled else "stable" - - return { - "status": "success", - "channel": channel, - "compose_updated": compose_updated, - "message": f"Switched to {channel} channel. " - + ( - "Watchtower will pull the next beta update automatically." - if enabled - else "Watchtower will pull from the stable channel." - ) - + ( - " Container restart may be required for changes to take effect." - if compose_updated - else "" - ), - } - - except Exception as e: - logger.error( - f"Failed to switch beta channel: {str(e)}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) - - -@router.post("/api/feedback") -async def send_feedback(request: Request): - """Submit beta feedback to create a GitHub issue. - - Request body: - type (str): Feedback type - "bug", "feature", "question", or "general" - title (str): Short summary of the feedback - description (str): Detailed description - include_logs (bool, optional): Whether to include recent server logs - - Returns: - status: success/error - issue_url: URL to the created GitHub issue (if successful) - message: Informational message - - Note: This creates an issue via the GitHub API. Requires the GITHUB_TOKEN - environment variable to be set, or falls back to returning a pre-filled - issue URL that the user can open manually. - """ - request_id = request.state.request_id - - try: - body = await request.json() - feedback_type = body.get("type", "general") - title = body.get("title", "").strip() - description = body.get("description", "").strip() - include_logs = body.get("include_logs", False) - - if not title or not description: - raise HTTPException( - status_code=400, detail={"error": "Title and description are required"} - ) - - # Get version info for context - version = "unknown" - version_file = Path(__file__).parent.parent.parent / "VERSION" - if version_file.exists(): - version = version_file.read_text().strip() - - # Check if running beta - settings = load_settings() - is_beta = settings.get("betaChannel", False) - - # Build issue body - issue_body_parts = [ - f"## Feedback Type\n{feedback_type.capitalize()}", - f"\n## Description\n{description}", - "\n## Environment", - f"- **Version**: {version}", - f"- **Channel**: {'Beta' if is_beta else 'Stable'}", - ] - - # Include recent logs if requested - if include_logs: - try: - # Get last 50 lines of logs - import subprocess - - result = subprocess.run( - ["journalctl", "-u", "meticai", "-n", "50", "--no-pager"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and result.stdout.strip(): - issue_body_parts.append( - f"\n## Recent Logs\n```\n{result.stdout[:2000]}\n```" - ) - except Exception: - pass # Skip logs if unavailable - - issue_body = "\n".join(issue_body_parts) - - # Add labels based on type - labels = ["beta-feedback"] - if feedback_type == "bug": - labels.append("bug") - elif feedback_type == "feature": - labels.append("enhancement") - - # Try to create issue via GitHub API - github_token = os.environ.get("GITHUB_TOKEN") - - if github_token: - import httpx - - try: - async with httpx.AsyncClient() as client: - response = await client.post( - "https://api.github.com/repos/hessius/MeticAI/issues", - headers={ - "Authorization": f"Bearer {github_token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - json={ - "title": f"[{feedback_type.upper()}] {title}", - "body": issue_body, - "labels": labels, - }, - timeout=30.0, - ) - - if response.status_code == 201: - issue_data = response.json() - logger.info( - f"Created GitHub issue #{issue_data.get('number')}", - extra={"request_id": request_id}, - ) - return { - "status": "success", - "issue_url": issue_data.get("html_url"), - "issue_number": issue_data.get("number"), - "message": "Feedback submitted successfully!", - } - else: - logger.warning( - f"GitHub API returned {response.status_code}: {response.text}", - extra={"request_id": request_id}, - ) - except Exception as e: - logger.warning( - f"Failed to create GitHub issue: {e}", - extra={"request_id": request_id}, - ) - - # Fallback: return a URL for manual issue creation - import urllib.parse - - params = urllib.parse.urlencode( - { - "title": f"[{feedback_type.upper()}] {title}", - "body": issue_body, - "labels": ",".join(labels), - } - ) - manual_url = f"https://github.com/hessius/MeticAI/issues/new?{params}" - - return { - "status": "manual", - "issue_url": manual_url, - "message": "Please click the link to submit your feedback on GitHub.", - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to submit feedback: {str(e)}", - exc_info=True, - extra={"request_id": request_id}, - ) - raise HTTPException( - status_code=500, detail={"status": "error", "error": str(e)} - ) diff --git a/apps/server/api/routes/websocket.py b/apps/server/api/routes/websocket.py deleted file mode 100644 index 4e835c17..00000000 --- a/apps/server/api/routes/websocket.py +++ /dev/null @@ -1,127 +0,0 @@ -"""WebSocket endpoint for live machine telemetry. - -Streams the latest MQTT sensor snapshot to connected browser clients -at a capped rate of ~10 frames per second to protect low-powered hosts -(e.g. Raspberry Pi). - -Route: ws://host:3550/api/ws/live -""" - -import asyncio -import logging -import os -import time -from fastapi import APIRouter, WebSocket, WebSocketDisconnect - -from services.mqtt_service import get_mqtt_subscriber - -router = APIRouter() -logger = logging.getLogger(__name__) - -# Max update rate — 10 FPS → 100 ms between frames -FRAME_INTERVAL = 0.1 # seconds - -TEST_MODE = os.environ.get("TEST_MODE") == "true" - - -@router.websocket("/api/ws/live") -async def live_telemetry(ws: WebSocket): - """Stream live machine telemetry over WebSocket. - - Protocol (server → client): - Each message is a JSON object with the full sensor snapshot, - plus a ``_ts`` field (Unix epoch float) for client-side staleness - detection. - - The server rate-limits to ~10 FPS. If no new data arrives from MQTT - the connection stays open but silent (no empty keepalives). - """ - await ws.accept() - - # Fetch subscriber fresh each time — survives MQTT hot-reload/reset - subscriber = get_mqtt_subscriber() - ws_id = id(ws) - subscriber.register_ws(ws_id) - - logger.info( - "WebSocket client connected (id=%d, total=%d)", - ws_id, - subscriber.ws_client_count, - ) - - try: - last_sent: dict = {} - - # Send the current MQTT snapshot immediately so the browser - # does not have to wait for the next MQTT delta. - initial = subscriber.get_snapshot() - if initial: - initial["_ts"] = time.time() - try: - await ws.send_json(initial) - last_sent = {k: v for k, v in initial.items() if k != "_ts"} - except Exception: - return # Client already gone - - while True: - # Re-fetch subscriber to survive hot-reloads (reset_mqtt_subscriber) - subscriber = get_mqtt_subscriber() - - # In TEST_MODE there's no MQTT data — just wait for the - # client to close. We use receive_text() which will raise - # WebSocketDisconnect when the client closes the socket. - if TEST_MODE or subscriber.data_event is None: - try: - await asyncio.wait_for(ws.receive_text(), timeout=1.0) - except asyncio.TimeoutError: - continue - except WebSocketDisconnect: - break - continue - - # Clear the event *before* waiting so that any signal arriving - # between wait() returning and the next iteration is not lost. - subscriber.data_event.clear() - - # Wait for new data from the MQTT thread (or timeout) - try: - await asyncio.wait_for(subscriber.data_event.wait(), timeout=5.0) - except asyncio.TimeoutError: - # No data in 5 s — send a heartbeat so the browser knows - # the connection is still alive - try: - await ws.send_json({"_heartbeat": True, "_ts": time.time()}) - except Exception: - break - continue - - # Rate-limit: sleep until at least FRAME_INTERVAL since last send - snapshot = subscriber.get_snapshot() - if snapshot == last_sent: - continue # No actual change - - await asyncio.sleep(FRAME_INTERVAL) - - snapshot["_ts"] = time.time() - try: - await ws.send_json(snapshot) - except Exception: - break - - last_sent = {k: v for k, v in snapshot.items() if k != "_ts"} - - except WebSocketDisconnect: - pass - except Exception as exc: - logger.warning("WebSocket error: %s", exc) - finally: - subscriber.unregister_ws(ws_id) - logger.info( - "WebSocket client disconnected (id=%d, remaining=%d)", - ws_id, - subscriber.ws_client_count, - ) - try: - await ws.close() - except Exception: - pass # Already closed by client or error diff --git a/apps/server/config.py b/apps/server/config.py deleted file mode 100644 index df3f1dac..00000000 --- a/apps/server/config.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Configuration management for MeticAI server. - -This module centralizes all configuration constants and environment variables -for easier management and testing. - -Usage: - from config import config, DATA_DIR, MAX_UPLOAD_SIZE - - # Access via config object - api_key = config.GEMINI_API_KEY - - # Or use exported constants - upload_limit = MAX_UPLOAD_SIZE - -Attributes: - TEST_MODE: Boolean flag for test environment (affects DATA_DIR) - DATA_DIR: Path to data directory (temp dir in test mode, /app/data otherwise) - LOG_DIR: Path to log directory - GEMINI_API_KEY: Google Gemini API key from environment - METICULOUS_IP: IP address of Meticulous espresso machine - PI_IP: IP address of this server - UPDATE_CHECK_INTERVAL: Seconds between update checks (default: 7200 = 2 hours) - MAX_UPLOAD_SIZE: Maximum file upload size in bytes (default: 10 MB) - LLM_CACHE_TTL_SECONDS: TTL for LLM analysis cache (default: 259200 = 3 days) - SHOT_CACHE_STALE_SECONDS: Staleness threshold for shot cache (default: 3600 = 1 hour) - VERSION_PATTERN: Compiled regex for version extraction - STAGE_STATUS_RETRACTING: Constant for stage status - -Note: - DATA_DIR is automatically set to a temporary directory when TEST_MODE="true", - enabling isolated testing without affecting production data. -""" - -import os -import tempfile -from pathlib import Path -import re - - -class Config: - """Central configuration for MeticAI server.""" - - # Test Mode - TEST_MODE = os.environ.get("TEST_MODE") == "true" - - # Data Directories - if TEST_MODE: - data_dir_env = os.environ.get("DATA_DIR") - if data_dir_env: - DATA_DIR = Path(data_dir_env) - else: - DATA_DIR = Path(tempfile.gettempdir()) / "meticai_test_data" - DATA_DIR.mkdir(parents=True, exist_ok=True) - else: - DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data")) - - LOG_DIR = Path(os.environ.get("LOG_DIR", "/app/logs")) - - # API Keys and Endpoints - GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") - METICULOUS_IP = os.environ.get("METICULOUS_IP", "") - PI_IP = os.environ.get("PI_IP", "") - - # Application Settings - UPDATE_CHECK_INTERVAL = 7200 # 2 hours in seconds - MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB in bytes - - # Cache Settings - LLM_CACHE_TTL_SECONDS = 259200 # 3 days (72 hours) - SHOT_CACHE_STALE_SECONDS = 3600 # 1 hour - - # Stage Status Constants - STAGE_STATUS_RETRACTING = "retracting" - - # Regex Patterns (pre-compiled for performance) - VERSION_PATTERN = re.compile(r'^\s*version\s*=\s*["\']([^"\']+)["\']', re.MULTILINE) - - -# Convenience access to config -config = Config() - - -# Backward compatibility - export commonly used constants -DATA_DIR = config.DATA_DIR -TEST_MODE = config.TEST_MODE -UPDATE_CHECK_INTERVAL = config.UPDATE_CHECK_INTERVAL -MAX_UPLOAD_SIZE = config.MAX_UPLOAD_SIZE -VERSION_PATTERN = config.VERSION_PATTERN -STAGE_STATUS_RETRACTING = config.STAGE_STATUS_RETRACTING -LLM_CACHE_TTL_SECONDS = config.LLM_CACHE_TTL_SECONDS -SHOT_CACHE_STALE_SECONDS = config.SHOT_CACHE_STALE_SECONDS diff --git a/apps/server/conftest.py b/apps/server/conftest.py deleted file mode 100644 index bcfa1025..00000000 --- a/apps/server/conftest.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Pytest configuration and shared fixtures for meticai-server tests. - -This module MUST be loaded before main.py to set up test environment variables. -""" - -import os -import tempfile -import shutil -import pytest -from unittest.mock import Mock, patch - -# Set test environment variables BEFORE main.py is imported -# This runs at import time, ensuring environment is set up early -os.environ["TEST_MODE"] = "true" - -# Create a temporary directory for test data -test_data_dir = tempfile.mkdtemp(prefix="meticai_test_") -os.environ["DATA_DIR"] = test_data_dir - - -@pytest.fixture(scope="session", autouse=True) -def cleanup_test_data(): - """Clean up temporary test data directory after all tests complete.""" - yield - # Cleanup after all tests finish - if os.path.exists(test_data_dir): - try: - shutil.rmtree(test_data_dir) - except (PermissionError, OSError): - # If cleanup fails, it's not critical for tests - pass - - -@pytest.fixture(autouse=True) -def _reset_in_memory_caches(): - """Reset all in-memory service caches between tests. - - This prevents stale data from leaking across test boundaries when - tests write directly to on-disk files and expect fresh reads. - """ - import services.cache_service as _cs - import services.settings_service as _ss - import services.history_service as _hs - import services.meticulous_service as _ms - import services.temp_profile_service as _tps - import services.pour_over_preferences as _pop - - _cs._llm_cache = None - _cs._shot_cache = None - _ss._settings_cache = None - _hs._history_cache = None - _ms._profile_list_cache = None - _ms._profile_list_cache_time = 0.0 - _tps._set_active(None) - _tps._reset_lock() - _pop._cache = None - - # Also reset settings file on disk to defaults to prevent cross-test leaks - from config import DATA_DIR - - settings_file = DATA_DIR / "settings.json" - if settings_file.exists(): - settings_file.unlink() - - yield - - -@pytest.fixture() -def mock_validate_profile(): - """Mock validate_profile to return valid for tests that need it. - - Tests that trigger profile generation through /analyze_and_profile - should request this fixture explicitly. Validation-specific tests - (e.g. TestValidationRetry) should NOT use this fixture so they - exercise the real validator or supply their own patch. - """ - result = Mock() - result.is_valid = True - result.errors = [] - with patch("api.routes.coffee.validate_profile", return_value=result): - yield - - -@pytest.fixture(autouse=True) -def _reset_generation_progress(): - """Clear in-memory generation state between tests.""" - from services.generation_progress import _active_generations - - _active_generations.clear() - yield - _active_generations.clear() diff --git a/apps/server/conftest_integration.py b/apps/server/conftest_integration.py deleted file mode 100644 index face1f38..00000000 --- a/apps/server/conftest_integration.py +++ /dev/null @@ -1,180 +0,0 @@ -""" -Pytest configuration for integration tests with real Meticulous machine. - -This module provides fixtures for integration tests that require a real -Meticulous machine connection. Integration tests are opt-in and require -the TEST_INTEGRATION environment variable to be set. - -Note: Pytest does not auto-load this file because it is not named - ``conftest.py``. You must explicitly load it as a plugin. - -Usage: - export METICULOUS_IP=192.168.x.x - export TEST_INTEGRATION=true - # From apps/server/, explicitly load this module as a Pytest plugin - pytest -p conftest_integration test_integration*.py -v - - # Alternatively, set the environment variable: - export PYTEST_PLUGINS=conftest_integration - pytest test_integration*.py -v -""" - -import os -import pytest -import time - - -def pytest_configure(config): - """Register custom markers.""" - config.addinivalue_line( - "markers", "integration: mark test as integration test requiring real machine" - ) - - -def pytest_collection_modifyitems(config, items): - """Skip integration tests unless TEST_INTEGRATION is set.""" - skip_integration = pytest.mark.skip( - reason="Integration tests require TEST_INTEGRATION=true environment variable" - ) - - integration_enabled = os.environ.get("TEST_INTEGRATION", "").lower() in ( - "true", - "1", - "yes", - ) - - for item in items: - if "integration" in item.keywords and not integration_enabled: - item.add_marker(skip_integration) - - -@pytest.fixture(scope="session") -def meticulous_ip(): - """Get the Meticulous machine IP from environment.""" - ip = os.environ.get("METICULOUS_IP", "").strip() - if not ip: - pytest.skip("METICULOUS_IP environment variable not set") - return ip - - -@pytest.fixture(scope="session") -def meticulous_base_url(meticulous_ip): - """Get the base URL for the Meticulous machine.""" - return f"http://{meticulous_ip}" - - -@pytest.fixture(scope="session") -def integration_api(meticulous_ip): - """Get a Meticulous API client for integration tests.""" - # Set the IP in environment for the service to pick up - os.environ["METICULOUS_IP"] = meticulous_ip - - # Import after setting env var - from services.meticulous_service import get_meticulous_api, reset_meticulous_api - - # Reset to pick up the new IP - reset_meticulous_api() - - api = get_meticulous_api() - yield api - - # Cleanup - reset the API client - reset_meticulous_api() - - -@pytest.fixture(scope="function") -def wait_for_machine(integration_api): - """Wait for machine to be in a stable state before running test.""" - import httpx - - base_url = integration_api.base_url - - # Use /api/v1/settings as liveness check (machine has no /api/v1/machine/state) - try: - response = httpx.get(f"{base_url}/api/v1/settings", timeout=5.0) - response.raise_for_status() - except Exception as e: - pytest.skip(f"Machine not reachable: {e}") - - return integration_api - - -@pytest.fixture(scope="session") -def mqtt_host(): - """Get MQTT broker host for integration tests.""" - return os.environ.get("MQTT_HOST", "127.0.0.1") - - -@pytest.fixture(scope="session") -def mqtt_port(): - """Get MQTT broker port for integration tests.""" - return int(os.environ.get("MQTT_PORT", "1883")) - - -class IntegrationTestHelpers: - """Helper utilities for integration tests.""" - - @staticmethod - def wait_for_weight_stable( - api, timeout: float = 5.0, tolerance: float = 0.1 - ) -> float: - """Wait for scale weight to stabilize using Socket.IO status events.""" - - start_time = time.time() - last_weight = None - stable_count = 0 - result = {"weight": None, "error": None} - - def on_status(data): - nonlocal last_weight, stable_count - try: - weight = ( - data.get("sensors", {}).get("w", 0) - if isinstance(data, dict) - else getattr(getattr(data, "sensors", None), "w", 0) - ) - - if last_weight is not None and abs(weight - last_weight) < tolerance: - stable_count += 1 - if stable_count >= 3: - result["weight"] = weight - else: - stable_count = 0 - last_weight = weight - except Exception: - pass - - api.sio.on("status", on_status) - try: - if not api.sio.connected: - api.connect_to_socket(retries=2) - - while time.time() - start_time < timeout: - if result["weight"] is not None: - return result["weight"] - time.sleep(0.2) - - raise TimeoutError("Weight did not stabilize within timeout") - finally: - api.sio.on("status", None) - - @staticmethod - def wait_for_connection(host: str, port: int, timeout: float = 10.0) -> bool: - """Wait for a TCP connection to become available.""" - import socket - - start_time = time.time() - while time.time() - start_time < timeout: - try: - with socket.create_connection((host, port), timeout=1.0): - return True - except (socket.error, socket.timeout): - time.sleep(0.5) - - return False - - -@pytest.fixture -def helpers(): - """Provide integration test helper utilities.""" - return IntegrationTestHelpers() diff --git a/apps/server/logging_config.py b/apps/server/logging_config.py deleted file mode 100644 index f214e25f..00000000 --- a/apps/server/logging_config.py +++ /dev/null @@ -1,215 +0,0 @@ -""" -Logging configuration for the Coffee Relay application. - -Provides structured logging with: -- Rotating file handlers (size and count limits) -- JSON-formatted logs for easy parsing -- Context information (timestamp, level, user info, etc.) -- Separate handlers for different log levels -""" - -import logging -import logging.handlers -import json -import sys -from pathlib import Path -from datetime import datetime -import traceback - - -class JSONFormatter(logging.Formatter): - """Custom JSON formatter for structured logging.""" - - def format(self, record: logging.LogRecord) -> str: - """Format log record as JSON with all relevant context.""" - log_data = { - "timestamp": datetime.utcnow().isoformat() + "Z", - "level": record.levelname, - "logger": record.name, - "message": record.getMessage(), - "module": record.module, - "function": record.funcName, - "line": record.lineno, - } - - # Add exception info if present - if record.exc_info: - log_data["exception"] = { - "type": record.exc_info[0].__name__ if record.exc_info[0] else None, - "message": str(record.exc_info[1]) if record.exc_info[1] else None, - "traceback": traceback.format_exception(*record.exc_info), - } - - # Add extra context from record - if hasattr(record, "request_id"): - log_data["request_id"] = record.request_id - if hasattr(record, "endpoint"): - log_data["endpoint"] = record.endpoint - if hasattr(record, "user_agent"): - log_data["user_agent"] = record.user_agent - if hasattr(record, "client_ip"): - log_data["client_ip"] = record.client_ip - if hasattr(record, "duration_ms"): - log_data["duration_ms"] = record.duration_ms - if hasattr(record, "status_code"): - log_data["status_code"] = record.status_code - - # Add any custom extra fields - for key, value in record.__dict__.items(): - if key not in [ - "name", - "msg", - "args", - "created", - "filename", - "funcName", - "levelname", - "levelno", - "lineno", - "module", - "msecs", - "message", - "pathname", - "process", - "processName", - "relativeCreated", - "thread", - "threadName", - "exc_info", - "exc_text", - "stack_info", - "request_id", - "endpoint", - "user_agent", - "client_ip", - "duration_ms", - "status_code", - ]: - log_data[key] = value - - return json.dumps(log_data) - - -class HumanReadableFormatter(logging.Formatter): - """Human-readable formatter for console output.""" - - def __init__(self): - super().__init__( - fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - - -def setup_logging( - log_dir: str = "/app/logs", - max_bytes: int = 10 * 1024 * 1024, # 10 MB per file - backup_count: int = 5, # Keep 5 backup files (total 60 MB max) - log_level: str = "INFO", -) -> logging.Logger: - """ - Set up logging configuration with rotating file handlers. - - Args: - log_dir: Directory to store log files - max_bytes: Maximum size of each log file before rotation - backup_count: Number of backup files to keep - log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) - - Returns: - Configured logger instance - """ - # Create logs directory if it doesn't exist - log_path = Path(log_dir) - log_path.mkdir(parents=True, exist_ok=True) - - # Get the named logger (used by main.py directly) - logger = logging.getLogger("meticai-server") - logger.setLevel(getattr(logging, log_level.upper())) - logger.propagate = False # Don't double-log to root - - # Clear any existing handlers - logger.handlers = [] - - # Console handler with human-readable format - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setLevel(logging.INFO) - console_handler.setFormatter(HumanReadableFormatter()) - logger.addHandler(console_handler) - - # Also configure the root logger so that child loggers (services.*, - # api.routes.*) created via logging.getLogger(__name__) inherit the - # same handlers. Without this, logs from mqtt_service, websocket, - # bridge_service, etc. are silently dropped. - root = logging.getLogger() - root.setLevel(getattr(logging, log_level.upper())) - # Avoid duplicate handlers if setup_logging is called more than once - if not any( - isinstance(h, logging.StreamHandler) and h.stream is sys.stdout - for h in root.handlers - ): - root_console = logging.StreamHandler(sys.stdout) - root_console.setLevel(logging.INFO) - root_console.setFormatter(HumanReadableFormatter()) - root.addHandler(root_console) - - # All logs file (JSON format) - rotating - all_logs_file = log_path / "meticai-server.log" - all_logs_str = str(all_logs_file) - _all_handlers = logger.handlers + root.handlers - if not any( - isinstance(h, logging.handlers.RotatingFileHandler) - and h.baseFilename == all_logs_str - for h in _all_handlers - ): - all_logs_handler = logging.handlers.RotatingFileHandler( - all_logs_file, - maxBytes=max_bytes, - backupCount=backup_count, - encoding="utf-8", - ) - all_logs_handler.setLevel(logging.DEBUG) - all_logs_handler.setFormatter(JSONFormatter()) - logger.addHandler(all_logs_handler) - root.addHandler(all_logs_handler) - - # Error logs file (JSON format) - rotating, errors only - error_logs_file = log_path / "meticai-server-errors.log" - error_logs_str = str(error_logs_file) - _err_handlers = logger.handlers + root.handlers - if not any( - isinstance(h, logging.handlers.RotatingFileHandler) - and h.baseFilename == error_logs_str - for h in _err_handlers - ): - error_logs_handler = logging.handlers.RotatingFileHandler( - error_logs_file, - maxBytes=max_bytes, - backupCount=backup_count, - encoding="utf-8", - ) - error_logs_handler.setLevel(logging.ERROR) - error_logs_handler.setFormatter(JSONFormatter()) - logger.addHandler(error_logs_handler) - root.addHandler(error_logs_handler) - - # Suppress noisy third-party loggers - logging.getLogger("uvicorn").setLevel(logging.WARNING) - logging.getLogger("uvicorn.access").setLevel(logging.WARNING) - logging.getLogger("uvicorn.error").setLevel(logging.WARNING) - - logger.info( - "Logging system initialized", - extra={ - "log_dir": str(log_dir), - "max_bytes": max_bytes, - "backup_count": backup_count, - "log_level": log_level, - }, - ) - - return logger - - -def get_logger() -> logging.Logger: - """Get the configured logger instance.""" - return logging.getLogger("meticai-server") diff --git a/apps/server/main.py b/apps/server/main.py deleted file mode 100644 index 73bc8c92..00000000 --- a/apps/server/main.py +++ /dev/null @@ -1,503 +0,0 @@ -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -from contextlib import asynccontextmanager -import os -import subprocess -import asyncio -from pathlib import Path -import uuid -import time -import tempfile -import requests.exceptions -import httpx -from logging_config import setup_logging -from config import UPDATE_CHECK_INTERVAL - -# Initialize logging system with environment-aware defaults -log_dir = os.environ.get("LOG_DIR", "/app/logs") -try: - logger = setup_logging(log_dir=log_dir) -except (PermissionError, OSError) as e: - # Fallback to temp directory for testing - log_dir = tempfile.mkdtemp() - logger = setup_logging(log_dir=log_dir) - logger.warning( - f"Failed to create log directory at {os.environ.get('LOG_DIR', '/app/logs')}, " - f"using temporary directory: {log_dir}", - extra={"original_error": str(e)}, - ) - - -async def check_for_updates_task(): - """Background task to check for updates by running update.sh --check-only.""" - script_path = Path("/app/update.sh") - - if not script_path.exists(): - logger.warning( - "Update script not found at /app/update.sh - skipping update check" - ) - return - - try: - logger.info("Running scheduled update check...") - # Run update script with --check-only flag - # Use asyncio.to_thread to avoid blocking the event loop - result = await asyncio.to_thread( - subprocess.run, - ["bash", str(script_path), "--check-only"], - capture_output=True, - text=True, - cwd="/app", - timeout=120, # 2 minutes timeout for check - ) - - if result.returncode == 0: - logger.info("Update check completed successfully") - else: - logger.warning( - f"Update check returned non-zero exit code: {result.returncode}" - ) - if result.stderr: - logger.warning(f"stderr: {result.stderr}") - - except subprocess.TimeoutExpired: - logger.error("Update check timed out after 2 minutes") - except Exception as e: - logger.error(f"Error running update check: {e}") - - -async def periodic_update_checker(): - """Periodically check for updates in the background.""" - # Initial check on startup (with small delay to let app fully start) - await asyncio.sleep(10) - await check_for_updates_task() - - # Then check periodically - while True: - await asyncio.sleep(UPDATE_CHECK_INTERVAL) - await check_for_updates_task() - - -def _is_masked(value: str) -> bool: - """Return True if *value* looks like a masked/placeholder secret (e.g. all stars).""" - return bool(value) and (set(value) <= {"*", "."} or "..." in value) - - -def _write_s6_env(var_name: str, value: str) -> None: - """Write an env var to the s6 container environment directory. - - Thin wrapper around the shared utility for use during lifespan hydration. - """ - from utils.s6_env import update_s6_env - - update_s6_env(var_name, value) - - -def _sync_defaults() -> None: - """Sync bundled defaults from /app/defaults into DATA_DIR. - - - PourOverBase.json: always overwritten (read-only template) - - Recipes: always overwritten — bundled recipes are read-only managed defaults - """ - import shutil - from config import DATA_DIR - - defaults_base = Path("/app/defaults") - if not defaults_base.exists(): - return # Not running in Docker — nothing to sync - - # PourOverBase.json — always overwrite from bundled defaults so template - # updates are applied on container restart (this is a read-only template, - # not user-editable data) - src_template = defaults_base / "PourOverBase.json" - dst_template = DATA_DIR / "PourOverBase.json" - if src_template.exists(): - dst_template.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src_template, dst_template) - logger.info("Synced PourOverBase.json to %s", dst_template) - - # Recipes — always overwrite so new/updated bundled recipes appear on upgrade - src_recipes = defaults_base / "recipes" - dst_recipes = DATA_DIR / "recipes" - if src_recipes.exists(): - dst_recipes.mkdir(parents=True, exist_ok=True) - synced = 0 - for recipe_file in src_recipes.glob("*.json"): - shutil.copy2(recipe_file, dst_recipes / recipe_file.name) - synced += 1 - if synced: - logger.info("Synced %d default recipe(s) to %s", synced, dst_recipes) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Manage application lifespan - start background tasks on startup.""" - # ── Sync bundled defaults into the data volume ─────────────────────── - _sync_defaults() - - # ── Hydrate os.environ from persisted settings ────────────────────── - # When the container starts, GEMINI_API_KEY / METICULOUS_IP may be - # empty in the environment (user configured them via the Settings UI, - # which writes to /data/settings.json but the Docker env stays blank). - # Load them now so that all services see them. - try: - from services.settings_service import load_settings - - stored = load_settings() - _ENV_SETTINGS_MAP = { - "geminiApiKey": "GEMINI_API_KEY", - "geminiModel": "GEMINI_MODEL", - "meticulousIp": "METICULOUS_IP", - "authorName": "AUTHOR_NAME", - } - for settings_key, env_var in _ENV_SETTINGS_MAP.items(): - stored_value = stored.get(settings_key, "") - # Skip masked/placeholder values (e.g. "********************") - if ( - stored_value - and not os.environ.get(env_var) - and not _is_masked(stored_value) - ): - os.environ[env_var] = stored_value - # Also update s6 container environment so child services see it - _write_s6_env(env_var, stored_value) - logger.info( - "Hydrated %s from stored settings", - env_var, - extra={"source": "settings.json"}, - ) - except Exception as e: - logger.warning("Failed to hydrate settings into environment: %s", e) - - # Start the periodic update checker - logger.info( - f"Starting periodic update checker " - f"(runs on startup and every {UPDATE_CHECK_INTERVAL} seconds)" - ) - update_task = asyncio.create_task(periodic_update_checker()) - - # Restore scheduled shots from persistence - logger.info("Restoring scheduled shots from persistence") - await _restore_scheduled_shots() - - # Load recurring schedules and schedule next occurrences - logger.info("Loading recurring schedules from persistence") - await _load_recurring_schedules() - for schedule_id, schedule in _recurring_schedules.items(): - if schedule.get("enabled", True): - await _schedule_next_recurring(schedule_id, schedule) - - # Start recurring schedule checker (runs every hour to ensure schedules stay current) - recurring_task = asyncio.create_task(_recurring_schedule_checker()) - - # Validate configured Gemini model on startup - try: - from services.gemini_service import get_working_model - - _model = await get_working_model() - logger.info("✅ Using Gemini model: %s", _model) - except Exception as e: - logger.debug("Skipped model validation at startup: %s", e) - - # Start MQTT subscriber for live telemetry - from services.mqtt_service import get_mqtt_subscriber - - mqtt_sub = get_mqtt_subscriber() - mqtt_sub.start(asyncio.get_running_loop()) - - # Clean up any orphaned temp profiles from previous sessions - try: - from services.temp_profile_service import cleanup_stale - - stale_result = await cleanup_stale() - if stale_result.get("deleted", 0) > 0: - logger.info( - "Cleaned %d stale temp profile(s) at startup", - stale_result["deleted"], - ) - except Exception as e: - logger.warning("Failed to clean stale temp profiles at startup: %s", e) - - # Restore dial-in sessions from persistence - try: - from services.dialin_service import _load as load_dialin_sessions - - await load_dialin_sessions() - except Exception as e: - logger.warning("Failed to restore dial-in sessions at startup: %s", e) - - # Pre-warm shot profile index in background (avoids cold-start on first request) - async def _prewarm_shot_index(): - """Build shot→profile index in the background on startup.""" - try: - await asyncio.sleep(5) # Wait for machine connection - from services.cache_service import get_indexed_dates, update_shot_index - from services.meticulous_service import ( - async_get_history_dates, - async_get_shot_files, - fetch_shot_data, - ) - - dates_result = await async_get_history_dates() - if not dates_result or (hasattr(dates_result, "error") and dates_result.error): - return - - dates = [d.name for d in dates_result] - indexed = get_indexed_dates() - new_dates = [d for d in dates if d not in indexed] - if not new_dates: - logger.info("Shot profile index already complete (%d dates)", len(dates)) - return - - logger.info("Pre-warming shot index for %d new dates...", len(new_dates)) - sem = asyncio.Semaphore(15) - - async def _list(date): - async with sem: - try: - r = await async_get_shot_files(date) - return date, [f.name for f in r] if r and not (hasattr(r, "error") and r.error) else [] - except Exception: - return date, [] - - file_results = await asyncio.gather(*[_list(d) for d in new_dates]) - shots_to_scan = [] - for date, files in file_results: - for fn in files: - shots_to_scan.append((date, fn)) - - new_entries = {} - - async def _index(date, filename): - async with sem: - try: - data = await fetch_shot_data(date, filename) - name = data.get("profile_name", "") - if not name and isinstance(data.get("profile"), dict): - name = data["profile"].get("name", "") - pid = data["profile"].get("id", "") if isinstance(data.get("profile"), dict) else "" - entries = data.get("data", []) - w, t = None, None - if entries: - last = entries[-1] - if isinstance(last.get("shot"), dict): - w = last["shot"].get("weight") - t = last.get("time") - new_entries[f"{date}/{filename}"] = { - "name": name, "profile_id": pid, - "weight": w, "time_ms": t, - "timestamp": data.get("time"), - } - except Exception: - pass - - await asyncio.gather(*[_index(d, fn) for d, fn in shots_to_scan]) - if new_entries: - update_shot_index(new_entries, new_dates) - logger.info("Shot index pre-warmed: %d shots across %d dates", len(new_entries), len(new_dates)) - except Exception as e: - logger.warning("Shot index pre-warm failed (non-critical): %s", e) - - shot_index_task = asyncio.create_task(_prewarm_shot_index()) - - yield - - # Cleanup on shutdown - update_task.cancel() - recurring_task.cancel() - shot_index_task.cancel() - try: - await update_task - except asyncio.CancelledError: - logger.info("Periodic update checker stopped") - - try: - await recurring_task - except asyncio.CancelledError: - logger.info("Recurring schedule checker stopped") - - # Cancel all scheduled shot tasks - for task in _scheduled_tasks.values(): - task.cancel() - - # Wait for all tasks to complete - if _scheduled_tasks: - await asyncio.gather(*_scheduled_tasks.values(), return_exceptions=True) - logger.info("All scheduled shot tasks cancelled") - - # Close the singleton httpx client - from services.meticulous_service import close_http_client - - await close_http_client() - - # Stop MQTT subscriber - mqtt_sub.stop() - - -app = FastAPI( - lifespan=lifespan, - # FastAPI 0.132+ rejects JSON bodies without a valid Content-Type header. - # Disable to keep backward-compat with iOS Shortcuts, curl, and other callers. - strict_content_type=False, -) - - -# --------------------------------------------------------------------------- -# Global exception handlers — convert machine-connection errors to 503 -# --------------------------------------------------------------------------- -_MACHINE_UNREACHABLE_MSG = ( - "Espresso machine is unreachable. " - "Check that the machine is powered on and METICULOUS_IP is correct in Settings." -) - - -@app.exception_handler(requests.exceptions.ConnectionError) -async def _requests_connection_error( - _request: Request, exc: requests.exceptions.ConnectionError -): - logger.warning(f"Machine connection failed (requests): {exc}") - return JSONResponse(status_code=503, content={"detail": _MACHINE_UNREACHABLE_MSG}) - - -@app.exception_handler(httpx.ConnectError) -async def _httpx_connect_error(_request: Request, exc: httpx.ConnectError): - logger.warning(f"Machine connection failed (httpx): {exc}") - return JSONResponse(status_code=503, content={"detail": _MACHINE_UNREACHABLE_MSG}) - - -@app.exception_handler(httpx.ConnectTimeout) -async def _httpx_connect_timeout(_request: Request, exc: httpx.ConnectTimeout): - logger.warning(f"Machine connection timed out (httpx): {exc}") - return JSONResponse(status_code=503, content={"detail": _MACHINE_UNREACHABLE_MSG}) - - -# Import route modules -from api.routes import ( # noqa: E402 - coffee, - system, - history, - shots, - profiles, - scheduling, - bridge, - websocket, - commands, - pour_over, - recipes, - dialin, - machine_status, -) - - -# Middleware for request logging and tracking -@app.middleware("http") -async def log_requests(request: Request, call_next): - """Log all requests with context and timing.""" - request_id = str(uuid.uuid4()) - start_time = time.time() - - # Extract request metadata - client_ip = request.client.host if request.client else "unknown" - user_agent = request.headers.get("user-agent", "unknown") - - # Log incoming request - logger.info( - f"Incoming request: {request.method} {request.url.path}", - extra={ - "request_id": request_id, - "endpoint": request.url.path, - "method": request.method, - "client_ip": client_ip, - "user_agent": user_agent, - }, - ) - - # Store request_id in request state for use in endpoints - request.state.request_id = request_id - - try: - response = await call_next(request) - - # Calculate duration - duration_ms = int((time.time() - start_time) * 1000) - - # Log response - logger.info( - f"Request completed: {request.method} {request.url.path} - {response.status_code}", - extra={ - "request_id": request_id, - "endpoint": request.url.path, - "method": request.method, - "status_code": response.status_code, - "duration_ms": duration_ms, - "client_ip": client_ip, - "user_agent": user_agent, - }, - ) - - return response - except Exception as e: - # Calculate duration even for errors - duration_ms = int((time.time() - start_time) * 1000) - - # Log error with full context - logger.error( - f"Request failed: {request.method} {request.url.path} - {str(e)}", - exc_info=True, - extra={ - "request_id": request_id, - "endpoint": request.url.path, - "method": request.method, - "client_ip": client_ip, - "user_agent": user_agent, - "duration_ms": duration_ms, - "error_type": type(e).__name__, - }, - ) - - # Re-raise to let FastAPI handle it - raise - - -# Configure CORS middleware to allow web app interactions. -# allow_credentials=True is incompatible with allow_origins=["*"] per the CORS -# spec (browsers reject it), so credentials are disabled for the wildcard case. -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=False, - allow_methods=["*"], - allow_headers=["*"], -) - -# Register API routers -app.include_router(coffee.router) -app.include_router(system.router) -app.include_router(history.router) -app.include_router(shots.router) -app.include_router(profiles.router) -app.include_router(scheduling.router) -app.include_router(bridge.router) -app.include_router(websocket.router) -app.include_router(commands.router) -app.include_router(pour_over.router) -app.include_router(recipes.router) -app.include_router(dialin.router) -app.include_router(machine_status.router) - - -# ============================================================================ -# Imports used by lifespan() -# ============================================================================ -from services.scheduling_state import ( # noqa: E402 - _scheduled_shots, # noqa: F401 — accessed by tests via main._scheduled_shots - _scheduled_tasks, - _recurring_schedules, - restore_scheduled_shots as _restore_scheduled_shots, - load_recurring_schedules as _load_recurring_schedules, -) -from api.routes.profiles import ( # noqa: E402 - _schedule_next_recurring, - _recurring_schedule_checker, -) diff --git a/apps/server/models/__init__.py b/apps/server/models/__init__.py deleted file mode 100644 index bbb6df0d..00000000 --- a/apps/server/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Data models for MeticAI server.""" diff --git a/apps/server/models/dialin.py b/apps/server/models/dialin.py deleted file mode 100644 index dcb316be..00000000 --- a/apps/server/models/dialin.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Data models for the Dial-In Guide feature.""" - -from __future__ import annotations - -from datetime import datetime, timezone -from enum import Enum -from typing import Optional - -from pydantic import BaseModel, Field - - -class RoastLevel(str, Enum): - """Coffee roast level.""" - - LIGHT = "light" - MEDIUM_LIGHT = "medium-light" - MEDIUM = "medium" - MEDIUM_DARK = "medium-dark" - DARK = "dark" - - -class CoffeeProcess(str, Enum): - """Coffee processing method.""" - - WASHED = "washed" - NATURAL = "natural" - HONEY = "honey" - ANAEROBIC = "anaerobic" - OTHER = "other" - - -class SessionStatus(str, Enum): - """Dial-in session lifecycle status.""" - - ACTIVE = "active" - COMPLETED = "completed" - ABANDONED = "abandoned" - - -class CoffeeDetails(BaseModel): - """Describes the coffee being dialled in.""" - - roast_level: RoastLevel - origin: Optional[str] = None - process: Optional[CoffeeProcess] = None - roast_date: Optional[str] = None - - -class TasteFeedback(BaseModel): - """User taste feedback from the Espresso Compass widget.""" - - x: float = Field(..., ge=-1, le=1, description="Sour (-1) to Bitter (1)") - y: float = Field(..., ge=-1, le=1, description="Weak (-1) to Strong (1)") - descriptors: list[str] = Field(default_factory=list) - notes: Optional[str] = None - - -class DialInIteration(BaseModel): - """A single shot-taste-adjust iteration within a session.""" - - iteration_number: int - shot_ref: Optional[str] = None - taste: TasteFeedback - recommendations: list[str] = Field(default_factory=list) - timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - - -class DialInSession(BaseModel): - """A complete dial-in guide session.""" - - id: str - coffee: CoffeeDetails - profile_name: Optional[str] = None - iterations: list[DialInIteration] = Field(default_factory=list) - status: SessionStatus = SessionStatus.ACTIVE - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/apps/server/prompt_builder.py b/apps/server/prompt_builder.py deleted file mode 100644 index bd78cdf6..00000000 --- a/apps/server/prompt_builder.py +++ /dev/null @@ -1,1034 +0,0 @@ -""" -Prompt Builder for AI Image Generation - -This module provides a sophisticated prompt architecture for generating -coffee-themed images based on profile names and tags. - -The system combines: -- Core base prompts with safety constraints -- Tag-specific influences (colors, elements, compositions, moods) -- Style modifiers that respect user's art style choice -- Random sub-options for variety across invocations -- Profile name emphasis techniques -""" - -import random -from typing import List, Dict, Optional, Set, Sequence -from dataclasses import dataclass, field - - -@dataclass -class TagInfluence: - """Defines visual influences for a specific tag.""" - - colors: List[str] = field(default_factory=list) - elements: List[str] = field(default_factory=list) - compositions: List[str] = field(default_factory=list) - moods: List[str] = field(default_factory=list) - textures: List[str] = field(default_factory=list) - - -# ============================================================================= -# TAG INFLUENCE MAPPINGS -# ============================================================================= -# Extensible structure - add new tags by adding entries to these dictionaries - -# Roast Level Influences -ROAST_INFLUENCES: Dict[str, TagInfluence] = { - "light": TagInfluence( - colors=["pale gold", "honey amber", "soft cream", "light caramel", "champagne"], - elements=[ - "delicate wisps", - "ethereal light rays", - "morning dew", - "translucent layers", - ], - compositions=["airy open space", "floating elements", "ascending movement"], - moods=["bright", "fresh", "delicate", "awakening"], - textures=["smooth gradients", "soft edges", "gossamer"], - ), - "medium": TagInfluence( - colors=[ - "warm bronze", - "rich amber", - "toasted copper", - "chestnut brown", - "maple", - ], - elements=[ - "balanced forms", - "interlocking shapes", - "flowing curves", - "harmonious patterns", - ], - compositions=["centered balance", "symmetrical arrangement", "golden ratio"], - moods=["balanced", "comforting", "approachable", "harmonious"], - textures=["velvet", "brushed metal", "polished wood grain"], - ), - "dark": TagInfluence( - colors=[ - "deep espresso", - "charcoal black", - "dark chocolate", - "midnight brown", - "obsidian", - ], - elements=[ - "bold shadows", - "dramatic contrasts", - "dense forms", - "powerful shapes", - ], - compositions=["heavy bottom weight", "grounded elements", "strong verticals"], - moods=["intense", "bold", "mysterious", "commanding"], - textures=["rough hewn", "carbon fiber", "volcanic rock"], - ), -} - -# Flavor Note Influences -FLAVOR_INFLUENCES: Dict[str, TagInfluence] = { - "fruity": TagInfluence( - colors=[ - "berry purple", - "citrus orange", - "apple red", - "tropical yellow", - "peach coral", - ], - elements=[ - "organic spheres", - "juice droplets", - "curved petals", - "fruit silhouettes", - ], - compositions=[ - "scattered arrangement", - "bursting from center", - "playful asymmetry", - ], - moods=["vibrant", "joyful", "lively", "refreshing"], - textures=["glossy", "juicy sheen", "organic surfaces"], - ), - "chocolate": TagInfluence( - colors=[ - "cocoa brown", - "dark truffle", - "milk chocolate", - "mocha cream", - "ganache", - ], - elements=["swirling ribbons", "melting forms", "layered depths", "rich pools"], - compositions=["flowing downward", "pooling at base", "cascading layers"], - moods=["indulgent", "luxurious", "comforting", "rich"], - textures=["molten", "silky smooth", "velvety"], - ), - "nutty": TagInfluence( - colors=[ - "hazelnut tan", - "almond beige", - "walnut brown", - "peanut gold", - "pecan amber", - ], - elements=["organic shapes", "shell curves", "natural fragments", "seed forms"], - compositions=["clustered groups", "natural scatter", "grounded arrangement"], - moods=["earthy", "warm", "rustic", "wholesome"], - textures=["rough bark", "shell patterns", "granular"], - ), - "floral": TagInfluence( - colors=[ - "lavender purple", - "rose pink", - "jasmine white", - "hibiscus red", - "chamomile yellow", - ], - elements=[ - "petal shapes", - "botanical curves", - "stamen details", - "garden silhouettes", - ], - compositions=["radiating from center", "upward growth", "organic sprawl"], - moods=["elegant", "delicate", "romantic", "fragrant"], - textures=["soft petals", "dewy surfaces", "silk"], - ), - "spicy": TagInfluence( - colors=[ - "cinnamon red", - "ginger orange", - "cardamom green", - "pepper black", - "clove brown", - ], - elements=["sharp angles", "flame shapes", "dynamic spirals", "pointed forms"], - compositions=["explosive center", "radiating energy", "dynamic movement"], - moods=["energetic", "warm", "exotic", "intense"], - textures=["crystalline", "rough ground spice", "heat shimmer"], - ), - "citrus": TagInfluence( - colors=[ - "lemon yellow", - "lime green", - "orange zest", - "grapefruit pink", - "tangerine", - ], - elements=["segment curves", "zest sprays", "droplet splashes", "wedge shapes"], - compositions=["bright center", "outward splash", "fresh arrangement"], - moods=["zesty", "bright", "invigorating", "clean"], - textures=["bumpy rind", "translucent flesh", "sparkling"], - ), - "caramel": TagInfluence( - colors=[ - "golden caramel", - "butterscotch", - "toffee brown", - "burnt sugar", - "honey amber", - ], - elements=[ - "dripping forms", - "stretched ribbons", - "pooling shapes", - "crystalline edges", - ], - compositions=["flowing downward", "sticky connections", "warm pools"], - moods=["sweet", "indulgent", "warm", "inviting"], - textures=["glossy", "sticky", "crystallized edges"], - ), - "berry": TagInfluence( - colors=[ - "blueberry indigo", - "raspberry pink", - "blackberry purple", - "strawberry red", - "cranberry", - ], - elements=[ - "clustered spheres", - "juice splashes", - "organic clusters", - "seed patterns", - ], - compositions=["grouped clusters", "scattered arrangement", "overflowing"], - moods=["sweet", "tart", "vibrant", "fresh"], - textures=["glossy skin", "juice sheen", "soft flesh"], - ), - "earthy": TagInfluence( - colors=[ - "soil brown", - "moss green", - "clay terracotta", - "stone grey", - "forest floor", - ], - elements=["root shapes", "geological layers", "organic decay", "mineral forms"], - compositions=["grounded base", "layered strata", "deep foundation"], - moods=["grounded", "primal", "natural", "ancient"], - textures=["rough earth", "weathered stone", "organic matter"], - ), - "honey": TagInfluence( - colors=["golden honey", "amber nectar", "honeycomb gold", "bee pollen yellow"], - elements=[ - "hexagonal patterns", - "dripping forms", - "flowing streams", - "cellular grids", - ], - compositions=["structured patterns", "flowing movement", "organic geometry"], - moods=["sweet", "natural", "golden", "precious"], - textures=["viscous", "translucent", "crystalline"], - ), -} - -# Profile Characteristic Influences -CHARACTERISTIC_INFLUENCES: Dict[str, TagInfluence] = { - "acidity": TagInfluence( - colors=["electric yellow", "sharp green", "citrus orange", "bright white"], - elements=[ - "lightning bolts", - "sharp edges", - "angular forms", - "crystalline structures", - ], - compositions=["pointed focus", "high contrast", "dynamic angles"], - moods=["bright", "sharp", "lively", "electric"], - textures=["crisp edges", "faceted", "sparkling"], - ), - "body": TagInfluence( - colors=["deep burgundy", "rich mahogany", "dense brown", "substantial ochre"], - elements=["weighty forms", "solid masses", "grounded shapes", "dense layers"], - compositions=["bottom-heavy", "substantial center", "anchored"], - moods=["full", "substantial", "enveloping", "rich"], - textures=["thick", "viscous", "substantial"], - ), - "bloom": TagInfluence( - colors=["effervescent cream", "bubble white", "foam beige", "rising tan"], - elements=[ - "expanding circles", - "rising bubbles", - "blooming forms", - "opening shapes", - ], - compositions=["upward expansion", "opening outward", "ascending movement"], - moods=["alive", "awakening", "expanding", "fresh"], - textures=["foamy", "airy", "effervescent"], - ), - "sweetness": TagInfluence( - colors=["candy pink", "sugar white", "cotton candy", "soft peach"], - elements=["soft curves", "rounded forms", "gentle waves", "plush shapes"], - compositions=["soft focus", "gentle arrangement", "welcoming openness"], - moods=["sweet", "gentle", "pleasant", "inviting"], - textures=["soft", "pillowy", "smooth"], - ), - "complexity": TagInfluence( - colors=["layered gradients", "shifting hues", "iridescent", "multichromatic"], - elements=[ - "interwoven patterns", - "nested shapes", - "fractal elements", - "layered depths", - ], - compositions=["multiple focal points", "depth layers", "intricate arrangement"], - moods=["sophisticated", "intriguing", "multifaceted", "deep"], - textures=["layered", "multidimensional", "intricate"], - ), - "clarity": TagInfluence( - colors=["crystal clear", "pure white", "glass blue", "transparent"], - elements=["clean lines", "defined edges", "simple forms", "precise geometry"], - compositions=["uncluttered space", "clear hierarchy", "minimal arrangement"], - moods=["pure", "clean", "transparent", "precise"], - textures=["glass-like", "polished", "pristine"], - ), -} - -# Processing Method Influences -PROCESSING_INFLUENCES: Dict[str, TagInfluence] = { - "washed": TagInfluence( - colors=["clean blue", "pure white", "crystal clear", "fresh green"], - elements=["water droplets", "clean lines", "flowing streams", "pristine forms"], - compositions=["clean arrangement", "clear separation", "defined boundaries"], - moods=["clean", "pure", "refined", "precise"], - textures=["polished", "smooth", "wet sheen"], - ), - "natural": TagInfluence( - colors=["sun-dried amber", "fruit red", "wild berry", "organic brown"], - elements=["sun rays", "dried textures", "fruit forms", "organic shapes"], - compositions=["natural scatter", "sun-touched", "organic flow"], - moods=["wild", "fruity", "natural", "sun-kissed"], - textures=["dried", "sun-baked", "natural grain"], - ), - "honey": TagInfluence( - colors=["sticky amber", "mucilage gold", "sweet brown", "nectar yellow"], - elements=["sticky threads", "dripping forms", "viscous flows", "sweet pools"], - compositions=["connected elements", "flowing transitions", "sticky bonds"], - moods=["sweet", "complex", "sticky", "layered"], - textures=["mucilaginous", "sticky", "semi-dried"], - ), -} - -# Origin Influences -ORIGIN_INFLUENCES: Dict[str, TagInfluence] = { - "ethiopian": TagInfluence( - colors=["wild berry purple", "jasmine white", "highland green", "ancient gold"], - elements=[ - "coffee cherry shapes", - "ancient patterns", - "wild flora", - "heritage symbols", - ], - compositions=[ - "birthplace reverence", - "wild natural arrangement", - "ancient geometry", - ], - moods=["ancestral", "wild", "floral", "exotic"], - textures=["ancient stone", "wild growth", "heritage"], - ), - "colombian": TagInfluence( - colors=["emerald green", "mountain blue", "coffee cherry red", "andean gold"], - elements=[ - "mountain peaks", - "terraced hillsides", - "lush vegetation", - "altitude symbols", - ], - compositions=[ - "ascending layers", - "mountain silhouettes", - "terraced arrangement", - ], - moods=["balanced", "approachable", "classic", "reliable"], - textures=["mountain mist", "fertile soil", "lush green"], - ), - "brazilian": TagInfluence( - colors=["sunset orange", "nutty brown", "chocolate", "tropical yellow"], - elements=["sun shapes", "vast horizons", "bold forms", "tropical elements"], - compositions=["expansive arrangement", "bold presence", "substantial forms"], - moods=["bold", "nutty", "substantial", "warm"], - textures=["sun-warmed", "substantial", "smooth"], - ), - "kenyan": TagInfluence( - colors=[ - "bright tomato red", - "citrus yellow", - "black currant purple", - "savanna gold", - ], - elements=[ - "bold punctuation", - "bright splashes", - "African patterns", - "wildlife silhouettes", - ], - compositions=["bright focal points", "bold contrast", "dynamic arrangement"], - moods=["bright", "bold", "complex", "striking"], - textures=["juicy", "vibrant", "bold"], - ), - "guatemalan": TagInfluence( - colors=["volcanic grey", "chocolate brown", "spice red", "ancient jade"], - elements=["volcanic forms", "ancient motifs", "smoke wisps", "temple geometry"], - compositions=["dramatic depth", "ancient proportion", "smoky layers"], - moods=["complex", "smoky", "ancient", "dramatic"], - textures=["volcanic", "ancient stone", "smoky"], - ), - "indonesian": TagInfluence( - colors=["earthy brown", "spice orange", "forest green", "island blue"], - elements=[ - "island shapes", - "spice forms", - "tropical foliage", - "monsoon patterns", - ], - compositions=["layered depth", "island clusters", "tropical arrangement"], - moods=["earthy", "exotic", "full-bodied", "mysterious"], - textures=["earthy", "tropical", "monsoon-touched"], - ), -} - -# Technique/Style Influences -TECHNIQUE_INFLUENCES: Dict[str, TagInfluence] = { - "espresso": TagInfluence( - colors=[ - "crema gold", - "espresso black", - "tiger stripe amber", - "pressure bronze", - ], - elements=[ - "pressure gauges", - "extraction streams", - "crema swirls", - "portafilter shapes", - ], - compositions=["concentrated center", "downward flow", "pressure focus"], - moods=["intense", "concentrated", "precise", "powerful"], - textures=["crema foam", "oily sheen", "dense liquid"], - ), - "pour-over": TagInfluence( - colors=["clarity amber", "bloom cream", "filter paper white", "gentle brown"], - elements=[ - "spiral patterns", - "blooming circles", - "gentle streams", - "cone shapes", - ], - compositions=["centered spiral", "gentle descent", "meditative arrangement"], - moods=["meditative", "precise", "patient", "delicate"], - textures=["paper texture", "gentle flow", "clear liquid"], - ), - "cold brew": TagInfluence( - colors=["ice blue", "cold black", "refreshing amber", "frost white"], - elements=["ice crystals", "slow drips", "cold condensation", "time symbols"], - compositions=["vertical descent", "patient layers", "cool arrangement"], - moods=["refreshing", "patient", "smooth", "cool"], - textures=["icy", "smooth", "condensation"], - ), - "modern": TagInfluence( - colors=["minimalist white", "tech silver", "innovation blue", "clean grey"], - elements=[ - "geometric precision", - "clean lines", - "modern curves", - "tech elements", - ], - compositions=["grid-based", "precise spacing", "contemporary balance"], - moods=["innovative", "clean", "forward-thinking", "precise"], - textures=["polished metal", "matte surfaces", "precision edges"], - ), - "traditional": TagInfluence( - colors=["heritage brown", "antique gold", "classic cream", "warm sepia"], - elements=[ - "vintage motifs", - "classic shapes", - "heritage patterns", - "time-worn forms", - ], - compositions=[ - "classic proportion", - "time-honored arrangement", - "balanced tradition", - ], - moods=["nostalgic", "classic", "timeless", "warm"], - textures=["aged patina", "worn wood", "classic materials"], - ), -} - - -# ============================================================================= -# STYLE MODIFIERS -# These enhance the base style chosen by the user -# ============================================================================= - -STYLE_MODIFIERS: Dict[str, Dict[str, List[str]]] = { - "abstract": { - "techniques": [ - "non-representational forms", - "emotional color fields", - "gestural marks", - "pure abstraction", - ], - "artists": [ - "inspired by Kandinsky", - "Rothko-esque color depth", - "Pollock energy", - "Mondrian geometry", - ], - "approaches": [ - "deconstructed reality", - "essence over appearance", - "emotional interpretation", - "pure visual rhythm", - ], - }, - "minimalist": { - "techniques": [ - "negative space emphasis", - "essential forms only", - "reductive approach", - "stark simplicity", - ], - "artists": [ - "Malevich inspired", - "Agnes Martin subtlety", - "Donald Judd precision", - ], - "approaches": [ - "less is more", - "purposeful emptiness", - "quiet power", - "essential expression", - ], - }, - "pixel-art": { - "techniques": [ - "deliberate pixelation", - "limited color palette", - "retro game aesthetic", - "8-bit charm", - ], - "artists": ["classic arcade style", "indie game art", "demoscene influence"], - "approaches": [ - "nostalgic digital", - "precise pixel placement", - "retro-futurism", - "digital mosaic", - ], - }, - "watercolor": { - "techniques": [ - "wet-on-wet bleeding", - "organic color spread", - "paper texture visible", - "transparent layers", - ], - "artists": [ - "Turner atmospheric", - "Sargent fluidity", - "Winslow Homer naturalism", - ], - "approaches": [ - "controlled accidents", - "luminous transparency", - "soft edge bleeding", - "natural flow", - ], - }, - "modern": { - "techniques": [ - "contemporary digital art", - "clean vector lines", - "bold graphic design", - "modern illustration", - ], - "artists": [ - "contemporary design", - "modern poster art", - "digital illustration masters", - ], - "approaches": [ - "fresh perspective", - "current aesthetics", - "bold simplification", - "graphic impact", - ], - }, - "vintage": { - "techniques": [ - "aged color palette", - "retro printing effects", - "nostalgic grain", - "period-accurate style", - ], - "artists": ["art deco influence", "mid-century modern", "vintage poster art"], - "approaches": [ - "timeless quality", - "nostalgic warmth", - "classic craftsmanship", - "heritage aesthetic", - ], - }, -} - - -# ============================================================================= -# PROFILE NAME EMPHASIS TECHNIQUES -# Ways to make the profile name concept central to the image -# ============================================================================= - -PROFILE_EMPHASIS_TECHNIQUES: List[str] = [ - "as the dominant central subject", - "expressed through symbolic visual metaphor", - "manifested as the core visual element", - "represented through evocative imagery", - "interpreted as an abstract visual concept", - "translated into powerful visual symbolism", - "embodied in the composition's focal point", - "expressed through color and form", - "as the driving visual narrative", - "captured in abstract essence", -] - - -# ============================================================================= -# CORE PROMPT ELEMENTS -# ============================================================================= - -CORE_SAFETY_CONSTRAINTS: List[str] = [ - "The image must contain absolutely no text, words, letters, numbers, labels, watermarks, signatures, or typography of any kind — purely visual art only.", - "No realistic human faces.", - "Abstract artistic interpretation.", -] - -CORE_COFFEE_THEMES: List[str] = [ - "coffee and espresso essence", - "brewing artistry", - "coffee culture aesthetic", - "espresso craft", - "coffee bean origins", - "the ritual of coffee", - "coffee as art form", -] - -COMPOSITION_ENHANCERS: List[str] = [ - "visually striking composition", - "dynamic visual balance", - "harmonious arrangement", - "compelling focal point", - "artistic visual flow", - "engaging visual hierarchy", - "powerful visual presence", -] - - -# ============================================================================= -# PROMPT BUILDER CLASS -# ============================================================================= - - -class PromptBuilder: - """ - Builds varied, tag-influenced prompts for coffee-themed image generation. - - Each invocation produces different results through random selection - of sub-options while maintaining relevance to the profile and tags. - """ - - def __init__(self, profile_name: str, style: str, tags: List[str]): - self.profile_name = profile_name - self.style = style.lower() - self.tags = [tag.lower().strip() for tag in tags] - self.collected_influences: List[TagInfluence] = [] - - def _collect_influences(self) -> None: - """Gather all relevant influences based on tags.""" - self.collected_influences = [] - - for tag in self.tags: - # Check each influence dictionary - for influence_dict in [ - ROAST_INFLUENCES, - FLAVOR_INFLUENCES, - CHARACTERISTIC_INFLUENCES, - PROCESSING_INFLUENCES, - ORIGIN_INFLUENCES, - TECHNIQUE_INFLUENCES, - ]: - # Check for exact match or partial match - for key, influence in influence_dict.items(): - if key in tag or tag in key: - self.collected_influences.append(influence) - break - - # If no influences found, add some defaults - if not self.collected_influences: - self.collected_influences.append( - FLAVOR_INFLUENCES.get("caramel", TagInfluence()) - ) - self.collected_influences.append( - TECHNIQUE_INFLUENCES.get("espresso", TagInfluence()) - ) - - def _random_select(self, items: List[str], count: int = 1) -> List[str]: - """Randomly select items from a list.""" - if not items: - return [] - count = min(count, len(items)) - return random.sample(items, count) - - def _gather_from_influences(self, attribute: str, count: int = 2) -> List[str]: - """Gather random items from a specific attribute across all influences.""" - all_items: Set[str] = set() - for influence in self.collected_influences: - items = getattr(influence, attribute, []) - all_items.update(items) - return self._random_select(list(all_items), count) - - def _get_style_modifiers(self) -> List[str]: - """Get random style modifiers for the chosen art style.""" - style_data = STYLE_MODIFIERS.get(self.style, STYLE_MODIFIERS["abstract"]) - modifiers = [] - - for category, options in style_data.items(): - selected = self._random_select(options, 1) - modifiers.extend(selected) - - return modifiers - - def build(self) -> str: - """ - Build the complete prompt with randomized elements. - - Returns: - A fully constructed prompt string optimized for image generation. - """ - self._collect_influences() - - # Gather randomized elements from influences - colors = self._gather_from_influences("colors", 2) - elements = self._gather_from_influences("elements", 2) - compositions = self._gather_from_influences("compositions", 1) - moods = self._gather_from_influences("moods", 2) - textures = self._gather_from_influences("textures", 1) - - # Get style modifiers - style_modifiers = self._get_style_modifiers() - - # Random selections from core elements - coffee_theme = self._random_select(CORE_COFFEE_THEMES, 1)[0] - composition_enhancer = self._random_select(COMPOSITION_ENHANCERS, 1)[0] - profile_emphasis = self._random_select(PROFILE_EMPHASIS_TECHNIQUES, 1)[0] - - # Build the prompt sections - prompt_parts = [] - - # 0. Strong no-text directive up front for maximum model adherence - prompt_parts.append( - "IMPORTANT: Generate an image with absolutely no text, words, letters, numbers, or typography of any kind" - ) - - # 1. Profile name as central concept - prompt_parts.append(f'"{self.profile_name}" {profile_emphasis}') - - # 2. Art style with modifiers - prompt_parts.append(f"{self.style} art style, {', '.join(style_modifiers)}") - - # 3. Color palette from influences - if colors: - prompt_parts.append(f"color palette featuring {', '.join(colors)}") - - # 4. Visual elements from influences - if elements: - prompt_parts.append(f"incorporating {', '.join(elements)}") - - # 5. Mood and atmosphere - if moods: - prompt_parts.append(f"{', '.join(moods)} atmosphere") - - # 6. Composition guidance - if compositions: - prompt_parts.append(compositions[0]) - prompt_parts.append(composition_enhancer) - - # 7. Texture hints - if textures: - prompt_parts.append(f"{textures[0]} textures") - - # 8. Coffee theme connection - prompt_parts.append(f"evoking {coffee_theme}") - - # 9. Format requirement - prompt_parts.append("square format") - - # 10. Safety constraints - prompt_parts.extend(CORE_SAFETY_CONSTRAINTS) - - # Combine all parts - full_prompt = ". ".join(prompt_parts) - - return full_prompt - - def build_with_metadata(self) -> Dict: - """ - Build prompt and return with metadata about what was selected. - - Useful for debugging and understanding prompt construction. - """ - self._collect_influences() - - colors = self._gather_from_influences("colors", 2) - elements = self._gather_from_influences("elements", 2) - moods = self._gather_from_influences("moods", 2) - - prompt = self.build() - - return { - "prompt": prompt, - "metadata": { - "profile_name": self.profile_name, - "style": self.style, - "tags_used": self.tags, - "influences_found": len(self.collected_influences), - "selected_colors": colors, - "selected_elements": elements, - "selected_moods": moods, - }, - } - - -def build_image_prompt(profile_name: str, style: str, tags: List[str]) -> str: - """ - Convenience function to build an image generation prompt. - - Args: - profile_name: The name of the coffee profile - style: The art style (abstract, minimalist, pixel-art, etc.) - tags: List of tags associated with the profile - - Returns: - A complete prompt string for image generation - """ - builder = PromptBuilder(profile_name, style, tags) - return builder.build() - - -def build_image_prompt_with_metadata( - profile_name: str, style: str, tags: List[str] -) -> Dict: - """ - Build a prompt and return with construction metadata. - - Args: - profile_name: The name of the coffee profile - style: The art style - tags: List of tags associated with the profile - - Returns: - Dictionary with prompt and metadata - """ - builder = PromptBuilder(profile_name, style, tags) - return builder.build_with_metadata() - - -# ============================================================================= -# TASTE COMPASS — Espresso Compass prompt context -# ============================================================================= - - -def _describe_axis_value(value: float, negative_label: str, positive_label: str) -> str: - """Convert a -1..1 axis value to a human-readable description.""" - abs_val = abs(value) - if abs_val < 0.15: - return "Balanced" - if abs_val < 0.4: - intensity = "Slightly" - elif abs_val < 0.7: - intensity = "Moderately" - else: - intensity = "Very" - direction = positive_label if value > 0 else negative_label - return f"{intensity} {direction}" - - -def build_taste_context( - taste_x: float | None, - taste_y: float | None, - taste_descriptors: list[str] | None, -) -> str: - """Build taste feedback context for the analysis prompt. - - Args: - taste_x: -1 (sour) to 1 (bitter), or None - taste_y: -1 (weak/thin) to 1 (strong/heavy), or None - taste_descriptors: optional list of taste descriptor strings - - Returns: - A prompt section string, or empty string when no data is present. - """ - has_coords = taste_x is not None and taste_y is not None - has_descriptors = bool(taste_descriptors) - - if not has_coords and not has_descriptors: - return "" - - lines: List[str] = [ - "", - "## User Taste Feedback (Espresso Compass)", - "The user reports this shot tasted:", - ] - - if has_coords: - balance_desc = _describe_axis_value(taste_x, "Sour", "Bitter") - body_desc = _describe_axis_value(taste_y, "Weak/Thin", "Strong/Heavy") - lines.append(f"- Balance: {balance_desc} (X: {taste_x:.2f})") - lines.append(f"- Body: {body_desc} (Y: {taste_y:.2f})") - - if has_descriptors: - lines.append(f"- Descriptors: {', '.join(taste_descriptors)}") - - lines.append("") - lines.append("### Espresso Compass Domain Knowledge") - lines.append( - "- Sour (negative X) typically indicates under-extraction → increase temperature, pressure, or contact time" - ) - lines.append( - "- Bitter (positive X) typically indicates over-extraction → decrease temperature, pressure, or contact time" - ) - lines.append( - "- Weak/Thin (negative Y) → increase dose or decrease water volume (lower ratio)" - ) - lines.append( - "- Strong/Heavy (positive Y) → decrease dose or increase water volume (higher ratio)" - ) - lines.append( - "- Quadrant: Sour + Weak = severely under-extracted, needs major grind/temp adjustment" - ) - lines.append( - "- Quadrant: Sour + Strong = high-dose under-extraction, grind finer or increase temp" - ) - lines.append( - "- Quadrant: Bitter + Weak = channeling likely, improve puck prep and lower temp" - ) - lines.append( - "- Quadrant: Bitter + Strong = over-extracted and over-dosed, coarsen grind and reduce dose" - ) - lines.append("") - lines.append( - "Based on this taste feedback, include a '## 6. Taste-Based Recommendations' section in your analysis that:" - ) - lines.append("1. Correlates the taste perception with the extraction data") - lines.append("2. Suggests specific variable adjustments to address taste issues") - lines.append("3. Explains the coffee science behind the recommendations") - - return "\n".join(lines) - - -# ============================================================================ -# Dial-In Guide prompt -# ============================================================================ - - -def build_dialin_recommendation_prompt( - *, - roast_level: str, - origin: Optional[str] = None, - process: Optional[str] = None, - roast_date: Optional[str] = None, - profile_name: Optional[str] = None, - iterations: Sequence[Dict] = (), -) -> str: - """Build a prompt asking the AI for dial-in adjustment recommendations. - - Each element of *iterations* is expected to contain at least - ``taste`` (with ``x``, ``y``, ``descriptors``) and ``iteration_number``. - """ - lines: List[str] = [ - "# Espresso Dial-In Recommendation", - "", - "You are an expert barista helping the user dial in a new bag of coffee.", - "Analyse the coffee details and all taste-feedback iterations below,", - "then provide **concrete, actionable** adjustment recommendations.", - "", - "## Coffee Details", - f"- Roast level: {roast_level}", - ] - - if origin: - lines.append(f"- Origin: {origin}") - if process: - lines.append(f"- Process: {process}") - if roast_date: - lines.append(f"- Roast date: {roast_date}") - if profile_name: - lines.append(f"- Profile: {profile_name}") - - if iterations: - lines.append("") - lines.append("## Taste Iteration History") - for it in iterations: - taste = it.get("taste", {}) - num = it.get("iteration_number", "?") - balance_desc = _describe_axis_value(taste.get("x", 0), "Sour", "Bitter") - body_desc = _describe_axis_value( - taste.get("y", 0), "Weak/Thin", "Strong/Heavy" - ) - lines.append(f"### Iteration {num}") - lines.append(f"- Balance: {balance_desc} (X: {taste.get('x', 0):.2f})") - lines.append(f"- Body: {body_desc} (Y: {taste.get('y', 0):.2f})") - descriptors = taste.get("descriptors", []) - if descriptors: - lines.append(f"- Descriptors: {', '.join(descriptors)}") - notes = taste.get("notes") - if notes: - lines.append(f"- Notes: {notes}") - prev_recs = it.get("recommendations", []) - if prev_recs: - lines.append(f"- Previous recommendations: {'; '.join(prev_recs)}") - - lines.append("") - lines.append("## Instructions") - lines.append( - "Return a JSON object with a single key `recommendations` whose value is" - ) - lines.append("an array of short, actionable recommendation strings (max 6).") - lines.append( - "Each recommendation should be a single sentence describing one specific" - ) - lines.append("adjustment (e.g. 'Grind 2 steps finer', 'Reduce dose by 0.5 g').") - lines.append( - "Consider the full iteration history to track progress and avoid repeating" - ) - lines.append( - "adjustments that did not help. Base your reasoning on extraction science:" - ) - lines.append( - "- Sour → under-extracted → finer grind, higher temp, longer pre-infusion" - ) - lines.append( - "- Bitter → over-extracted → coarser grind, lower temp, shorter contact" - ) - lines.append("- Weak → increase dose or decrease yield") - lines.append("- Strong → decrease dose or increase yield") - - return "\n".join(lines) diff --git a/apps/server/pytest.ini b/apps/server/pytest.ini deleted file mode 100644 index 40f2b6ea..00000000 --- a/apps/server/pytest.ini +++ /dev/null @@ -1,17 +0,0 @@ -[pytest] -testpaths = . -python_files = test_*.py -python_classes = Test* -python_functions = test_* -asyncio_mode = auto -asyncio_default_fixture_loop_scope = function -addopts = - -v - --strict-markers - --tb=short - --disable-warnings - --timeout=30 -markers = - slow: marks tests as slow (deselect with '-m "not slow"') - integration: marks tests as integration tests - unit: marks tests as unit tests diff --git a/apps/server/requirements-test.txt b/apps/server/requirements-test.txt deleted file mode 100644 index 70a4ab27..00000000 --- a/apps/server/requirements-test.txt +++ /dev/null @@ -1,8 +0,0 @@ --r requirements.txt -pytest>=9.0.3 -pytest-asyncio>=1.3.0 -pytest-cov>=7.1.0 -pytest-timeout>=2.4.0 - -# Optional: for MQTT integration tests -paho-mqtt>=2.1.0 diff --git a/apps/server/requirements.txt b/apps/server/requirements.txt deleted file mode 100644 index d3e9691d..00000000 --- a/apps/server/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -fastapi==0.138.2 -uvicorn==0.47.0 -google-genai>=2.9.0 -pillow>=12.2.0 -pillow-heif>=1.3.0 -python-multipart==0.0.32 -pyMeticulous>=0.3.1 -zstandard>=0.25.0 -httpx==0.28.1 -sse-starlette==3.4.5 -zeroconf==0.150.0 diff --git a/apps/server/services/__init__.py b/apps/server/services/__init__.py deleted file mode 100644 index b0635057..00000000 --- a/apps/server/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Services layer for MeticAI server.""" diff --git a/apps/server/services/ai_providers.py b/apps/server/services/ai_providers.py deleted file mode 100644 index 783a65e2..00000000 --- a/apps/server/services/ai_providers.py +++ /dev/null @@ -1,453 +0,0 @@ -"""OpenAI-compatible AI provider support for server mode (#491 PR3). - -Mirrors the web/native provider registry -(``apps/web/src/services/ai/providers/providerRegistry.ts`` + -``OpenAICompatProvider.ts``) so that server/proxy deployments can use a -non-Gemini, OpenAI-compatible LLM (OpenAI, DeepSeek, Kimi, OpenRouter) in -addition to Gemini. - -The Gemini path remains handled by ``gemini_service`` via the official SDK and -is intentionally left untouched. This module only implements the generic -OpenAI-compatible chat-completions path used when ``AI_PROVIDER`` selects a -non-Gemini provider. - -Environment variables (non-Gemini providers): -- ``AI_PROVIDER`` active provider id (``gemini`` | ``openai`` | ``deepseek`` | - ``kimi`` | ``openrouter``). Defaults to ``gemini``. -- ``AI_API_KEY`` API key for the active non-Gemini provider. -- ``AI_MODEL`` model id for the active non-Gemini provider (optional; - falls back to the provider default). -""" - -import asyncio -import base64 -import io -import os -from typing import Optional - -import httpx - -from logging_config import get_logger - -logger = get_logger() - -DEFAULT_PROVIDER = "gemini" - -# Provider descriptors mirror the web registry. ``gemini`` is listed for -# completeness/validation but is served by gemini_service, not this module. -PROVIDERS: dict[str, dict] = { - "gemini": { - "label": "Google Gemini", - "base_url": None, - "vision": True, - "default_model": "gemini-2.5-flash", - }, - "openai": { - "label": "OpenAI", - "base_url": "https://api.openai.com/v1", - "vision": True, - "default_model": "gpt-4o-mini", - "image_gen": { - "endpoint": "openai-images", - "models": ["gpt-image-1", "dall-e-3"], - }, - }, - "deepseek": { - "label": "DeepSeek", - "base_url": "https://api.deepseek.com/v1", - "vision": False, - "default_model": "deepseek-chat", - }, - "kimi": { - "label": "Kimi (Moonshot)", - "base_url": "https://api.moonshot.ai/v1", - "vision": False, - "default_model": "kimi-k2-0905-preview", - }, - "openrouter": { - "label": "OpenRouter", - "base_url": "https://openrouter.ai/api/v1", - "vision": True, - "default_model": "openai/gpt-4o-mini", - "image_gen": { - "endpoint": "openrouter-images", - "models": ["google/gemini-2.5-flash-image"], - }, - }, -} - -# Request timeout for provider HTTP calls (seconds). -_HTTP_TIMEOUT = 300.0 - - -class ProviderError(RuntimeError): - """Raised when an OpenAI-compatible provider request fails.""" - - -class ProviderImageError(ProviderError): - """Raised when an OpenAI-compatible image-generation request fails. - - Carries the HTTP ``status_code`` (when available) so the caller can decide - whether to fall back to the next configured image model (auth/availability - errors) or surface the failure immediately. - """ - - def __init__(self, status_code: Optional[int], message: str): - super().__init__(message) - self.status_code = status_code - - -def get_active_provider_id() -> str: - """Return the configured active provider id, defaulting to ``gemini``. - - Reads ``AI_PROVIDER`` from the environment on every call so hot-reloaded - service restarts pick up changes. Unknown values fall back to the default. - """ - value = os.environ.get("AI_PROVIDER", "").strip().lower() - if value in PROVIDERS: - return value - return DEFAULT_PROVIDER - - -def is_gemini_active() -> bool: - """Return True when the active provider is Gemini (the SDK path).""" - return get_active_provider_id() == "gemini" - - -def get_provider_descriptor(provider_id: Optional[str] = None) -> dict: - """Return the descriptor for ``provider_id`` (or the active provider).""" - pid = provider_id or get_active_provider_id() - return PROVIDERS.get(pid, PROVIDERS[DEFAULT_PROVIDER]) - - -def get_provider_api_key(provider_id: Optional[str] = None) -> str: - """Return the API key for the given provider from the environment. - - Gemini uses ``GEMINI_API_KEY``; every other provider uses ``AI_API_KEY``. - """ - pid = provider_id or get_active_provider_id() - if pid == "gemini": - return os.environ.get("GEMINI_API_KEY", "").strip() - return os.environ.get("AI_API_KEY", "").strip() - - -def get_provider_model(provider_id: Optional[str] = None) -> str: - """Return the configured model id for the given provider. - - Gemini reads ``GEMINI_MODEL`` (handled in gemini_service); other providers - read ``AI_MODEL`` and fall back to the provider's default model. - """ - pid = provider_id or get_active_provider_id() - descriptor = get_provider_descriptor(pid) - if pid == "gemini": - value = os.environ.get("GEMINI_MODEL", "").strip() - return value or descriptor["default_model"] - value = os.environ.get("AI_MODEL", "").strip() - return value or descriptor["default_model"] - - -def is_provider_available(provider_id: Optional[str] = None) -> bool: - """Return True when an API key is configured for the active provider.""" - return bool(get_provider_api_key(provider_id)) - - -def provider_supports_image(provider_id: Optional[str] = None) -> bool: - """Return True when the provider can generate images (#505). - - Gemini generates via the native SDK (handled in the image route); the - OpenAI-compatible providers (OpenAI, OpenRouter) carry an ``image_gen`` - descriptor. Text-only providers (DeepSeek, Kimi) return False. - """ - pid = provider_id or get_active_provider_id() - if pid == "gemini": - return True - return "image_gen" in get_provider_descriptor(pid) - - -def _image_headers(provider_id: str) -> dict[str, str]: - api_key = get_provider_api_key(provider_id) - if not api_key: - raise ProviderError(f"No API key configured for provider '{provider_id}'.") - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } - if provider_id == "openrouter": - headers["HTTP-Referer"] = "https://github.com/hessius/MeticAI" - headers["X-Title"] = "Metic" - return headers - - -def _request_image_sync( - provider_id: str, endpoint: str, model: str, prompt: str, base_url: str -) -> bytes: - """Call a single OpenAI-compatible image endpoint and return raw bytes. - - OpenAI uses ``POST /images/generations``; OpenRouter a dedicated - ``POST /images``. Both return base64 in ``data[0].b64_json``. - """ - if endpoint == "openai-images": - path = "/images/generations" - payload: dict = {"model": model, "prompt": prompt, "n": 1, "size": "1024x1024"} - # gpt-image-1 always returns base64 and rejects response_format; the - # dall-e-* models default to a remote URL and need it to return base64. - if not model.startswith("gpt-image"): - payload["response_format"] = "b64_json" - else: - path = "/images" - payload = {"model": model, "prompt": prompt} - - try: - with httpx.Client(timeout=_HTTP_TIMEOUT) as client: - resp = client.post( - f"{base_url}{path}", - headers=_image_headers(provider_id), - json=payload, - ) - except httpx.HTTPError as exc: - raise ProviderImageError( - None, f"Request to {provider_id} failed: {exc}" - ) from exc - - if resp.status_code >= 400: - raise ProviderImageError( - resp.status_code, - f"{provider_id} image gen returned {resp.status_code}: {resp.text[:500]}", - ) - - try: - data = resp.json() - item = (data.get("data") or [])[0] - b64 = item.get("b64_json") - except (KeyError, IndexError, TypeError, ValueError): - b64 = None - if not b64: - raise ProviderImageError(None, f"No image returned by {provider_id}") - return base64.b64decode(b64) - - -def _generate_image_bytes_sync(prompt: str, provider_id: str) -> bytes: - descriptor = get_provider_descriptor(provider_id) - cfg = descriptor.get("image_gen") - if not cfg: - raise ProviderError( - f"Provider '{provider_id}' does not support image generation." - ) - base_url = descriptor["base_url"] - models = cfg["models"] - last_exc: Optional[ProviderImageError] = None - for index, model in enumerate(models): - try: - return _request_image_sync( - provider_id, cfg["endpoint"], model, prompt, base_url - ) - except ProviderImageError as exc: - last_exc = exc - is_last = index == len(models) - 1 - # Only fall through to the next model on auth/availability errors - # (e.g. gpt-image-1 → dall-e-3 when the org isn't verified). - recoverable = exc.status_code in (401, 403, 404) - if is_last or not recoverable: - raise - if last_exc: - raise last_exc - raise ProviderError(f"Image generation failed for provider '{provider_id}'.") - - -async def generate_image_bytes(prompt: str, provider_id: Optional[str] = None) -> bytes: - """Generate an image via an OpenAI-compatible provider (#505). - - Returns raw image bytes (PNG/JPEG/WebP). Tries the provider's configured - image models in order so OpenAI can fall back from ``gpt-image-1`` to - ``dall-e-3``. Raises :class:`ProviderError` on failure. - """ - pid = provider_id or get_active_provider_id() - return await asyncio.to_thread(_generate_image_bytes_sync, prompt, pid) - - -def _image_to_data_url(image) -> str: - """Encode a PIL image as a base64 PNG data URL for vision messages.""" - buffer = io.BytesIO() - fmt = (getattr(image, "format", None) or "PNG").upper() - if fmt not in ("PNG", "JPEG", "WEBP"): - fmt = "PNG" - image.save(buffer, format=fmt) - mime = {"PNG": "image/png", "JPEG": "image/jpeg", "WEBP": "image/webp"}[fmt] - encoded = base64.b64encode(buffer.getvalue()).decode("ascii") - return f"data:{mime};base64,{encoded}" - - -def _contents_to_messages(contents, vision: bool) -> list[dict]: - """Translate Gemini-style ``contents`` into OpenAI chat messages. - - Server callers pass a string, or a (possibly nested) list mixing strings - and PIL images. Everything is collapsed into a single user message. When the - provider supports vision, PIL images become ``image_url`` parts; otherwise - they are dropped (text-only providers cannot accept images). - """ - # Normalise to a flat list of parts. - if isinstance(contents, (str, bytes)): - parts = [contents] - elif isinstance(contents, list): - parts = [] - for item in contents: - if isinstance(item, list): - parts.extend(item) - else: - parts.append(item) - else: - parts = [contents] - - text_chunks: list[str] = [] - image_urls: list[str] = [] - for part in parts: - if part is None: - continue - if isinstance(part, str): - text_chunks.append(part) - elif hasattr(part, "save") and hasattr(part, "size"): - # PIL image-like object. - if vision: - image_urls.append(_image_to_data_url(part)) - else: - logger.warning( - "Active provider does not support image input; dropping image" - ) - else: - text_chunks.append(str(part)) - - joined_text = "\n\n".join(c for c in text_chunks if c) - - if image_urls: - content: list[dict] = [] - if joined_text: - content.append({"type": "text", "text": joined_text}) - for url in image_urls: - content.append({"type": "image_url", "image_url": {"url": url}}) - return [{"role": "user", "content": content}] - - return [{"role": "user", "content": joined_text}] - - -class _OpenAICompatResponse: - """Minimal response object exposing ``.text`` like the Gemini wrapper.""" - - def __init__(self, text: str): - self.text = text - - -class OpenAICompatModel: - """Generate text via an OpenAI-compatible ``/chat/completions`` endpoint. - - Provides the same ``generate_content`` / ``async_generate_content`` - interface as ``gemini_service._GeminiModelWrapper`` so existing call sites - work unchanged. - """ - - def __init__(self, provider_id: Optional[str] = None): - self._provider_id = provider_id or get_active_provider_id() - self._descriptor = get_provider_descriptor(self._provider_id) - - def _headers(self) -> dict[str, str]: - api_key = get_provider_api_key(self._provider_id) - if not api_key: - raise ProviderError( - f"No API key configured for provider '{self._provider_id}'." - ) - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } - if self._provider_id == "openrouter": - headers["HTTP-Referer"] = "https://github.com/hessius/MeticAI" - headers["X-Title"] = "Metic" - return headers - - def generate_content(self, contents) -> _OpenAICompatResponse: - """Synchronously call the provider's chat-completions endpoint.""" - base_url = self._descriptor["base_url"] - if not base_url: - raise ProviderError( - f"Provider '{self._provider_id}' has no OpenAI-compatible base URL." - ) - messages = _contents_to_messages(contents, self._descriptor["vision"]) - payload = {"model": get_provider_model(self._provider_id), "messages": messages} - try: - with httpx.Client(timeout=_HTTP_TIMEOUT) as client: - resp = client.post( - f"{base_url}/chat/completions", - headers=self._headers(), - json=payload, - ) - except httpx.HTTPError as exc: - raise ProviderError( - f"Request to {self._provider_id} failed: {exc}" - ) from exc - - if resp.status_code >= 400: - raise ProviderError( - f"{self._provider_id} returned {resp.status_code}: {resp.text[:500]}" - ) - data = resp.json() - try: - text = data["choices"][0]["message"]["content"] or "" - except (KeyError, IndexError, TypeError) as exc: - raise ProviderError( - f"Unexpected response from {self._provider_id}: {str(data)[:500]}" - ) from exc - return _OpenAICompatResponse(text) - - async def async_generate_content(self, contents) -> _OpenAICompatResponse: - """Non-blocking wrapper around :meth:`generate_content`.""" - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, self.generate_content, contents) - - -async def list_provider_models(provider_id: Optional[str] = None) -> list[dict]: - """Return available models from the provider's ``/models`` endpoint. - - Falls back to the provider's default model on any error so the UI always has - at least one selectable option. - """ - pid = provider_id or get_active_provider_id() - descriptor = get_provider_descriptor(pid) - base_url = descriptor["base_url"] - api_key = get_provider_api_key(pid) - fallback = [ - { - "id": descriptor["default_model"], - "display_name": descriptor["default_model"], - "description": "", - } - ] - if not base_url or not api_key: - return fallback - - def _fetch() -> list[dict]: - with httpx.Client(timeout=30.0) as client: - resp = client.get( - f"{base_url}/models", - headers={"Authorization": f"Bearer {api_key}"}, - ) - resp.raise_for_status() - body = resp.json() - items = body.get("data", body) if isinstance(body, dict) else body - result = [] - for item in items or []: - model_id = item.get("id") if isinstance(item, dict) else None - if not model_id: - continue - result.append( - { - "id": model_id, - "display_name": model_id, - "description": "", - } - ) - return result or fallback - - try: - return await asyncio.to_thread(_fetch) - except Exception as exc: # noqa: BLE001 - degrade gracefully to fallback - logger.warning("Failed to list models for provider %s: %s", pid, exc) - return fallback diff --git a/apps/server/services/analysis_service.py b/apps/server/services/analysis_service.py deleted file mode 100644 index 8cba4334..00000000 --- a/apps/server/services/analysis_service.py +++ /dev/null @@ -1,1908 +0,0 @@ -""" -Shot Analysis Service - -This module provides shot analysis functionality including: -- Profile formatting and utilities for display -- Shot stage analysis and execution comparison -- Local algorithmic shot analysis -- Profile description generation -""" - -import json -import re -from typing import Any, Optional - -from services.gemini_service import get_vision_model, PROFILING_KNOWLEDGE -from services.shot_facts import build_shot_facts -from logging_config import get_logger - -logger = get_logger() - -# Sensory tag vocabulary the AI may infer during description generation (#400). -# Mirrors apps/web/src/lib/tags.ts AI_TAG_LABELS (body / flavor / mouthfeel / -# roast / characteristic categories). Structural / temperature / weight / -# pressure tags are derived deterministically elsewhere, not requested here. -AI_TAG_LABELS = [ - "Light Body", - "Medium Body", - "Heavy Body", - "Florals", - "Acidity", - "Fruitiness", - "Chocolate", - "Nutty", - "Caramel", - "Berry", - "Citrus", - "Funky", - "Thin", - "Mouthfeel", - "Creamy", - "Syrupy", - "Light Roast", - "Medium Roast", - "Dark Roast", - "Sweet", - "Balanced", -] - -_AI_TAG_LOOKUP = {label.lower(): label for label in AI_TAG_LABELS} - -# Instruction appended to AI description prompts to request sensory tags (#400). -_AI_TAGS_PROMPT = ( - "\n\nFinally, on a separate last line, output:\n" - "Tags: [comma-separated subset of EXACTLY these labels that match the " - "coffee's likely sensory profile, or leave empty if unsure: " - + ", ".join(AI_TAG_LABELS) - + "]\nOnly use labels from that list; do not invent new ones." -) - -# Small on-device models often decorate the line with markdown (e.g. "**Tags:**" -# or "- Tags:"), so tolerate leading bullets/quotes and bold/italic markers. -_TAGS_LINE_RE = re.compile( - r"^[ \t]*(?:[>*+\-#]+[ \t]*)?(?:\*\*|__|\*|_)?[ \t]*Tags[ \t]*(?:\*\*|__|\*|_)?[ \t]*:[ \t]*(.*)$", - re.IGNORECASE | re.MULTILINE, -) - - -def parse_ai_tags(text: Optional[str]) -> list[str]: - """Extract and validate sensory tags from a description's ``Tags:`` line.""" - if not text: - return [] - match = _TAGS_LINE_RE.search(text) - if not match: - return [] - result: list[str] = [] - seen: set[str] = set() - for raw in match.group(1).split(","): - candidate = ( - raw.replace("[", "").replace("]", "").replace("*", "").replace("_", "") - .strip() - .rstrip(".") - .strip() - ) - canonical = _AI_TAG_LOOKUP.get(candidate.lower()) - if canonical and canonical not in seen: - seen.add(canonical) - result.append(canonical) - return result - - -def strip_tags_line(text: str) -> str: - """Remove the trailing ``Tags:`` line from a generated description body.""" - if not text: - return text - return re.sub(r"[ \t]*$", "", _TAGS_LINE_RE.sub("", text)).rstrip() - - -_UNIT_BY_TYPE = { - "pressure": " bar", - "flow": " ml/s", - "time": " s", - "weight": " g", -} - -_PAIRED_PLACEHOLDER_RE = re.compile(r"\$[^\s$]{1,60}\$") - - -def resolve_description_placeholders( - text: Optional[str], variables: Optional[list] = None -) -> str: - """Resolve profile variable references in generated prose and strip invented - placeholders. Small models sometimes echo the profile's variable references - (``$pressure_Max Pressure``) or invent tokens (``$pressure_1$``) into the - text. Mirror of resolveDescriptionPlaceholders in - apps/web/src/lib/descriptionText.ts — keep the two in sync. - """ - if not text: - return text or "" - # Models often markdown-escape underscores inside placeholders ($a\_1$). - out = text.replace("\\_", "_").replace("\\*", "*") - # Resolve real variable references, longest keys first so a key that is a - # prefix of another does not partially replace it. - resolved = [ - v - for v in (variables or []) - if isinstance(v, dict) - and isinstance(v.get("key"), str) - and v.get("value") is not None - ] - for v in sorted(resolved, key=lambda x: len(x["key"]), reverse=True): - key = v["key"] - unit = _UNIT_BY_TYPE.get(key.split("_")[0], "") - val = f"{v['value']}{unit}" - out = out.replace(f"${key}$", val).replace(f"${key}", val) - # Strip any remaining paired $...$ placeholder tokens the model invented. - out = _PAIRED_PLACEHOLDER_RE.sub("", out) - # Tidy whitespace left behind by removals. The preceding collapse guarantees - # at most a single space/tab before punctuation, so match exactly one char - # (no `+`) to avoid polynomial backtracking on long whitespace runs. - out = re.sub(r"[ \t]{2,}", " ", out) - out = re.sub(r"[ \t]([.,;:)])", r"\1", out) - return out - - -class DescriptionResult(str): - """A profile description string that also carries inferred AI sensory tags. - - Subclassing ``str`` keeps the existing call sites (which treat the return - value as a plain description and JSON-serialize it) working unchanged, while - exposing parsed tags via ``.ai_tags`` for the regenerate endpoint that - persists them. Static fallbacks return a plain ``str``, so consumers that - need the tags must use ``getattr(value, "ai_tags", [])``. - """ - - ai_tags: list[str] - - def __new__(cls, value: str, ai_tags: Optional[list[str]] = None): - obj = super().__new__(cls, value) - obj.ai_tags = ai_tags or [] - return obj - -# Constants -STAGE_STATUS_RETRACTING = "retracting" -PREINFUSION_KEYWORDS = [ - "bloom", - "soak", - "preinfusion", - "pre-infusion", - "pre infusion", - "wet", - "fill", - "landing", -] - - -# ============================================================================ -# Profile Formatting & Utilities -# ============================================================================ - - -def _stage_dynamics(stage: dict) -> tuple[list, str]: - """Return ``(points, over)`` for a stage, handling both profile formats. - - Meticulous profiles come in two shapes: a flat one - (``dynamics_points`` / ``dynamics_over``) and the canonical nested one - (``dynamics: {points, over}``). Readers that only looked at the flat keys - silently failed on nested profiles (e.g. "Slayer at Home"), returning no - target — which broke curve-adherence deltas and #423 effective-mode - detection. Mirror of the native ``stageDynamicsPoints`` helper. - """ - points = stage.get("dynamics_points") - over = stage.get("dynamics_over") - if not points: - dynamics = stage.get("dynamics") - if isinstance(dynamics, dict): - points = dynamics.get("points") - if over is None: - over = dynamics.get("over") - return (points or [], over or "time") - - -def _format_dynamics_description(stage: dict, variables: list | None = None) -> str: - """Format a human-readable description of the stage dynamics. - - Resolves $variable references in dynamics points using the provided variables list. - """ - variables = variables or [] - stage_type = stage.get("type", "unknown") - dynamics_points, dynamics_over = _stage_dynamics(stage) - - if not dynamics_points: - return f"{stage_type} stage (no dynamics data)" - - unit = "bar" if stage_type == "pressure" else "ml/s" - over_unit = "s" if dynamics_over == "time" else "g" - - def _resolve_dp_value(val): - """Resolve a dynamics point value, handling $variable references.""" - if isinstance(val, str) and val.startswith("$"): - resolved, _ = _resolve_variable(val, variables) - return _safe_float(resolved) - return _safe_float(val) - - if len(dynamics_points) == 1: - # Constant value - raw_value = ( - dynamics_points[0][1] - if len(dynamics_points[0]) > 1 - else dynamics_points[0][0] - ) - value = _resolve_dp_value(raw_value) - return f"Constant {stage_type} at {value} {unit}" - elif len(dynamics_points) == 2: - _safe_float(dynamics_points[0][0]) - start_y = _resolve_dp_value(dynamics_points[0][1]) - end_x = _safe_float(dynamics_points[1][0]) - end_y = _resolve_dp_value(dynamics_points[1][1]) - if start_y == end_y: - return f"Constant {stage_type} at {start_y} {unit} for {end_x}{over_unit}" - else: - direction = "ramp up" if end_y > start_y else "ramp down" - return f"{stage_type.capitalize()} {direction} from {start_y} to {end_y} {unit} over {end_x}{over_unit}" - else: - # Multiple points - describe curve - values = [_resolve_dp_value(p[1]) for p in dynamics_points if len(p) > 1] - if values: - return f"{stage_type.capitalize()} curve: {' → '.join(str(v) for v in values)} {unit}" - return f"Multi-point {stage_type} curve" - - -def _generate_execution_description( - stage_type: str, - duration: float, - start_pressure: float, - end_pressure: float, - max_pressure: float, - start_flow: float, - end_flow: float, - max_flow: float, - weight_gain: float, -) -> str: - """Generate a human-readable description of what actually happened during stage execution. - - This describes the actual behavior observed, not the target. - Examples: - - "Pressure rose from 2.1 bar to 8.5 bar over 4.2s" - - "Declining pressure from 9.0 bar to 6.2 bar" - - "Steady flow at 2.1 ml/s, extracted 18.5g" - """ - descriptions = [] - - # Determine pressure behavior - pressure_delta = end_pressure - start_pressure - if abs(pressure_delta) > 0.5: - if pressure_delta > 0: - descriptions.append( - f"Pressure rose from {start_pressure:.1f} to {end_pressure:.1f} bar" - ) - else: - descriptions.append( - f"Pressure declined from {start_pressure:.1f} to {end_pressure:.1f} bar" - ) - elif max_pressure > 0: - descriptions.append( - f"Pressure held around {(start_pressure + end_pressure) / 2:.1f} bar" - ) - - # Determine flow behavior - flow_delta = end_flow - start_flow - if abs(flow_delta) > 0.3: - if flow_delta > 0: - descriptions.append( - f"Flow increased from {start_flow:.1f} to {end_flow:.1f} ml/s" - ) - else: - descriptions.append( - f"Flow decreased from {start_flow:.1f} to {end_flow:.1f} ml/s" - ) - elif max_flow > 0: - descriptions.append(f"Flow steady at {(start_flow + end_flow) / 2:.1f} ml/s") - - # Add weight info if significant - if weight_gain > 1.0: - descriptions.append(f"extracted {weight_gain:.1f}g") - - # Add duration - if duration > 0: - descriptions.append(f"over {duration:.1f}s") - - if descriptions: - # Capitalize first letter and join - result = ", ".join(descriptions) - return result[0].upper() + result[1:] - - return f"Stage executed for {duration:.1f}s" - - -def _safe_float(val, default: float = 0.0) -> float: - """Safely convert a value to float, handling strings and None.""" - if val is None: - return default - try: - return float(val) - except (ValueError, TypeError): - return default - - -def _resolve_variable(value, variables: list) -> tuple[Any, str | None]: - """Resolve a variable reference like '$flow_hold limit' to its actual value. - - Returns: - Tuple of (resolved_value, variable_name or None if not a variable) - """ - if not isinstance(value, str) or not value.startswith("$"): - return value, None - - # Extract variable key (remove the $) - var_key = value[1:] - - # Search for matching variable - for var in variables: - if var.get("key") == var_key: - return var.get("value", value), var.get("name", var_key) - - # Variable not found - return original - return value, var_key - - -def _mean_dynamics_target(stage: dict, variables: list | None = None) -> float | None: - """Mean of a pressure/flow stage's resolved dynamics setpoints. - - Returns the intended scalar target (bar or ml/s) used by - shot_facts._curve_adherence, or None for non-pressure/flow stages or when - no numeric setpoints are present. Mirror of the native - DirectModeInterceptor.meanDynamicsTarget — keep the two in sync. - """ - stage_type = stage.get("type", "unknown") - if stage_type not in ("pressure", "flow"): - return None - variables = variables or [] - values: list[float] = [] - for point in _stage_dynamics(stage)[0]: - if not isinstance(point, (list, tuple)) or len(point) == 0: - continue - raw = point[1] if len(point) > 1 else point[0] - resolved, _ = _resolve_variable(raw, variables) - try: - values.append(float(resolved)) - except (TypeError, ValueError): - continue - if not values: - return None - return round(sum(values) / len(values), 2) - - -def _max_dynamics_target(stage: dict, variables: list | None = None) -> float | None: - """Peak of a pressure/flow stage's resolved dynamics setpoints. - - Used by shot_facts.effective_control_mode (#423) to detect stages whose - control intent differs from their declared type — e.g. an aggressive flow - stage (high flow target) paired with a pressure limit is effectively - pressure-controlled. Mirror of the native DirectModeInterceptor.maxDynamicsTarget. - """ - stage_type = stage.get("type", "unknown") - if stage_type not in ("pressure", "flow"): - return None - variables = variables or [] - values: list[float] = [] - for point in _stage_dynamics(stage)[0]: - if not isinstance(point, (list, tuple)) or len(point) == 0: - continue - raw = point[1] if len(point) > 1 else point[0] - resolved, _ = _resolve_variable(raw, variables) - try: - values.append(float(resolved)) - except (TypeError, ValueError): - continue - if not values: - return None - return round(max(values), 2) - - -def _format_exit_triggers( - exit_triggers: list, variables: list | None = None -) -> list[dict]: - """Format exit triggers into structured descriptions.""" - variables = variables or [] - formatted = [] - for trigger in exit_triggers: - trigger_type = trigger.get("type", "unknown") - raw_value = trigger.get("value", 0) - comparison = trigger.get("comparison", ">=") - - # Resolve variable reference if present - resolved_value, var_name = _resolve_variable(raw_value, variables) - display_value = _safe_float(resolved_value, 0) - - comp_text = {">=": "≥", "<=": "≤", ">": ">", "<": "<", "==": "="}.get( - comparison, comparison - ) - - unit = {"time": "s", "weight": "g", "pressure": "bar", "flow": "ml/s", - "flow_dose_correlation": "×dose", "pressure_rise": "bar"}.get( - trigger_type, "" - ) - - formatted.append( - { - "type": trigger_type, - "value": display_value, - "comparison": comparison, - "description": f"{trigger_type} {comp_text} {display_value}{unit}", - } - ) - - return formatted - - -def _format_limits(limits: list, variables: list | None = None) -> list[dict]: - """Format stage limits into structured descriptions.""" - variables = variables or [] - formatted = [] - for limit in limits: - limit_type = limit.get("type", "unknown") - raw_value = limit.get("value", 0) - - # Resolve variable reference if present - resolved_value, var_name = _resolve_variable(raw_value, variables) - display_value = _safe_float(resolved_value, 0) - - unit = {"time": "s", "weight": "g", "pressure": "bar", "flow": "ml/s"}.get( - limit_type, "" - ) - - formatted.append( - { - "type": limit_type, - "value": display_value, - "description": f"Limit {limit_type} to {display_value}{unit}", - } - ) - - return formatted - - -# ============================================================================ -# Shot Analysis Core -# ============================================================================ - - -def _determine_exit_trigger_hit( - stage_data: dict, - exit_triggers: list, - next_stage_start: float | None = None, - variables: list | None = None, -) -> dict: - """Determine which exit trigger caused the stage to end. - - Returns: - Dict with 'triggered' (the exit that fired) and 'not_triggered' (exits that didn't fire) - """ - variables = variables or [] - duration = _safe_float(stage_data.get("duration", 0)) - end_weight = _safe_float(stage_data.get("end_weight", 0)) - # Pressure values for different comparison types - max_pressure = _safe_float(stage_data.get("max_pressure", 0)) - end_pressure = _safe_float(stage_data.get("end_pressure", 0)) - # Flow values for different comparison types - max_flow = _safe_float(stage_data.get("max_flow", 0)) - end_flow = _safe_float(stage_data.get("end_flow", 0)) - - # Boundary (transition) values: the first sample of the next stage is the - # sample at which this stage's exit trigger actually fired (see - # _extract_shot_stage_data). Include it so a stage that exits exactly when a - # rising target is reached is credited for the crossing value instead of the - # last in-stage sample (which the machine already advanced past). - start_time = _safe_float(stage_data.get("start_time", 0)) - boundary_pressure = stage_data.get("boundary_pressure") - boundary_flow = stage_data.get("boundary_flow") - boundary_weight = stage_data.get("boundary_weight") - boundary_time = stage_data.get("boundary_time") - - triggered = None - not_triggered = [] - - for trigger in exit_triggers: - trigger_type = trigger.get("type", "") - raw_value = trigger.get("value", 0) - comparison = trigger.get("comparison", ">=") - - # Resolve variable reference if present - resolved_value, _ = _resolve_variable(raw_value, variables) - value = _safe_float(resolved_value) - - # Check if this trigger was satisfied - # Select the in-stage actual value based on comparison operator, plus the - # boundary (transition-sample) value used to rescue a trigger that the - # in-stage value falls short of. - actual_value = 0.0 - boundary_actual = None - if trigger_type == "time": - actual_value = duration - if boundary_time is not None: - boundary_actual = _safe_float(boundary_time) - start_time - elif trigger_type == "weight": - actual_value = end_weight - if boundary_weight is not None: - boundary_actual = _safe_float(boundary_weight) - elif trigger_type == "pressure": - # For >= or >: we want to know if max reached the target - # For <= or <: we want to know if pressure dropped below target (use end) - actual_value = max_pressure if comparison in (">=", ">") else end_pressure - if boundary_pressure is not None: - boundary_actual = _safe_float(boundary_pressure) - elif trigger_type == "flow": - # For >= or >: we want to know if max reached the target - # For <= or <: we want to know if flow dropped below target (use end) - actual_value = max_flow if comparison in (">=", ">") else end_flow - if boundary_flow is not None: - boundary_actual = _safe_float(boundary_flow) - - # Evaluate comparison with small tolerance - tolerance = 0.5 if trigger_type in ["time", "weight"] else 0.2 - - def _eval_hit(actual: float) -> bool: - if comparison == ">=": - return actual >= (value - tolerance) - if comparison == ">": - return actual > value - if comparison == "<=": - return actual <= (value + tolerance) - if comparison == "<": - return actual < value - if comparison == "==": - return abs(actual - value) < tolerance - return False - - was_hit = _eval_hit(actual_value) - # The machine advances stages on the tick where the exit condition fires, - # so the satisfying sample is labeled as the next stage. If the in-stage - # value falls short, rescue the trigger with that transition (boundary) - # value rather than falsely reporting the stage as failed. - if not was_hit and boundary_actual is not None and _eval_hit(boundary_actual): - actual_value = boundary_actual - was_hit = True - - # Build a proper description with the resolved value - unit = {"time": "s", "weight": "g", "pressure": "bar", "flow": "ml/s", - "flow_dose_correlation": "×dose", "pressure_rise": "bar"}.get( - trigger_type, "" - ) - trigger_info = { - "type": trigger_type, - "target": value, - "actual": round(actual_value, 1), - "description": f"{trigger_type} >= {value}{unit}", - } - - if was_hit: - if triggered is None: # First trigger that was hit - triggered = trigger_info - else: - not_triggered.append(trigger_info) - - return {"triggered": triggered, "not_triggered": not_triggered} - - -def _analyze_stage_execution( - profile_stage: dict, - shot_stage_data: dict | None, - total_shot_duration: float, - variables: list | None = None, -) -> dict: - """Analyze how a single stage executed compared to its profile definition.""" - variables = variables or [] - stage_name = profile_stage.get("name", "Unknown") - stage_type = profile_stage.get("type", "unknown") - stage_key = profile_stage.get("key", "") - - # Build profile target description - dynamics_desc = _format_dynamics_description(profile_stage, variables) - exit_triggers = _format_exit_triggers( - profile_stage.get("exit_triggers", []), variables - ) - limits = _format_limits(profile_stage.get("limits", []), variables) - - result = { - "stage_name": stage_name, - "stage_key": stage_key, - "stage_type": stage_type, - "profile_target": dynamics_desc, - "profile_target_value": _mean_dynamics_target(profile_stage, variables), - "profile_max_target": _max_dynamics_target(profile_stage, variables), - "exit_triggers": exit_triggers, - "limits": limits, - "executed": shot_stage_data is not None, - "execution_data": None, - "exit_trigger_result": None, - "limit_hit": None, - "assessment": None, - } - - if shot_stage_data is None: - result["assessment"] = { - "status": "not_reached", - "message": "This stage was never executed during the shot", - } - return result - - # Stage was executed - analyze it - duration = _safe_float(shot_stage_data.get("duration", 0)) - start_weight = _safe_float(shot_stage_data.get("start_weight", 0)) - end_weight = _safe_float(shot_stage_data.get("end_weight", 0)) - weight_gain = end_weight - start_weight - start_pressure = _safe_float(shot_stage_data.get("start_pressure", 0)) - end_pressure = _safe_float(shot_stage_data.get("end_pressure", 0)) - avg_pressure = _safe_float(shot_stage_data.get("avg_pressure", 0)) - max_pressure = _safe_float(shot_stage_data.get("max_pressure", 0)) - min_pressure = _safe_float(shot_stage_data.get("min_pressure", 0)) - start_flow = _safe_float(shot_stage_data.get("start_flow", 0)) - end_flow = _safe_float(shot_stage_data.get("end_flow", 0)) - avg_flow = _safe_float(shot_stage_data.get("avg_flow", 0)) - max_flow = _safe_float(shot_stage_data.get("max_flow", 0)) - - # Generate execution description based on what actually happened - execution_description = _generate_execution_description( - stage_type, - duration, - start_pressure, - end_pressure, - max_pressure, - start_flow, - end_flow, - max_flow, - weight_gain, - ) - - result["execution_data"] = { - "duration": round(duration, 1), - "weight_gain": round(weight_gain, 1), - "start_weight": round(start_weight, 1), - "end_weight": round(end_weight, 1), - "start_pressure": round(start_pressure, 1), - "end_pressure": round(end_pressure, 1), - "avg_pressure": round(avg_pressure, 1), - "max_pressure": round(max_pressure, 1), - "min_pressure": round(min_pressure, 1), - "start_flow": round(start_flow, 1), - "end_flow": round(end_flow, 1), - "avg_flow": round(avg_flow, 1), - "max_flow": round(max_flow, 1), - "description": execution_description, - } - - # Determine which exit trigger was hit - if profile_stage.get("exit_triggers"): - exit_result = _determine_exit_trigger_hit( - shot_stage_data, profile_stage.get("exit_triggers", []), variables=variables - ) - result["exit_trigger_result"] = exit_result - - # Check if any limits were hit - stage_limits = profile_stage.get("limits", []) - for limit in stage_limits: - limit_type = limit.get("type", "") - raw_limit_value = limit.get("value", 0) - - # Resolve variable reference if present - resolved_limit_value, _ = _resolve_variable(raw_limit_value, variables) - limit_value = _safe_float(resolved_limit_value) - - actual = 0.0 - if limit_type == "flow": - actual = max_flow - elif limit_type == "pressure": - actual = max_pressure - elif limit_type == "time": - actual = duration - elif limit_type == "weight": - actual = end_weight - - # Check if limit was hit (within small tolerance) - unit = {"time": "s", "weight": "g", "pressure": "bar", "flow": "ml/s"}.get( - limit_type, "" - ) - if actual >= limit_value - 0.2: - result["limit_hit"] = { - "type": limit_type, - "limit_value": limit_value, - "actual_value": round(actual, 1), - "description": f"Hit {limit_type} limit of {limit_value}{unit}", - } - break - - # Generate assessment - if result["exit_trigger_result"] and result["exit_trigger_result"]["triggered"]: - if result["limit_hit"]: - result["assessment"] = { - "status": "hit_limit", - "message": f"Stage exited but hit a limit ({result['limit_hit']['description']})", - } - else: - result["assessment"] = { - "status": "reached_goal", - "message": f"Exited via: {result['exit_trigger_result']['triggered']['description']}", - } - elif ( - result["exit_trigger_result"] and result["exit_trigger_result"]["not_triggered"] - ): - # No trigger was hit - stage ended prematurely, this is a failure - # Check if the dynamics goal was reached (e.g., target pressure) - goal_reached = False - goal_message = "" - - dynamics_points = _stage_dynamics(profile_stage)[0] - if dynamics_points and len(dynamics_points) >= 1: - # Get the target value (last point in dynamics) - raw_target = ( - dynamics_points[-1][1] - if len(dynamics_points[-1]) > 1 - else dynamics_points[-1][0] - ) - - # Resolve variable reference if present (e.g. "$decline_pressure") - if isinstance(raw_target, str) and raw_target.startswith("$"): - resolved, _ = _resolve_variable(raw_target, variables) - target_value = _safe_float(resolved) - else: - target_value = _safe_float(raw_target) - - if stage_type == "pressure": - # Check if we reached target pressure - if ( - target_value > 0 and max_pressure >= target_value * 0.95 - ): # Within 5% - goal_reached = True - goal_message = f"Target pressure of {target_value} bar was reached ({max_pressure:.1f} bar achieved)" - elif target_value > 0: - goal_message = f"Target pressure of {target_value} bar was NOT reached (only {max_pressure:.1f} bar achieved)" - elif stage_type == "flow": - # For flow stages, use end_flow (not max_flow) since initial peak is just piston movement - if target_value > 0 and end_flow >= target_value * 0.95: - goal_reached = True - goal_message = f"Target flow of {target_value} ml/s was reached ({end_flow:.1f} ml/s at end)" - elif target_value > 0: - goal_message = f"Target flow of {target_value} ml/s was NOT reached ({end_flow:.1f} ml/s at end)" - - if goal_reached: - result["assessment"] = { - "status": "incomplete", - "message": f"Stage ended before exit triggers were satisfied, but {goal_message.lower()}", - } - else: - result["assessment"] = { - "status": "failed", - "message": f"Stage ended before exit triggers were satisfied. {goal_message}" - if goal_message - else "Stage ended before exit triggers were satisfied", - } - else: - result["assessment"] = { - "status": "executed", - "message": "Stage executed (no exit triggers defined)", - } - - return result - - -def _extract_shot_stage_data(shot_data: dict) -> dict[str, dict]: - """Extract per-stage telemetry from shot data. - - Returns a dict mapping stage names to their execution data. - """ - data_entries = shot_data.get("data", []) - if not data_entries: - return {} - - # Group data by stage - stage_data = {} - current_stage = None - stage_entries = [] - - for entry in data_entries: - status = entry.get("status", "") - - # Skip retracting - it's machine cleanup - if status.lower().strip() == STAGE_STATUS_RETRACTING: - continue - - if status and status != current_stage: - # Save previous stage data - if current_stage and stage_entries: - stage_data[current_stage] = _compute_stage_stats(stage_entries) - - current_stage = status - stage_entries = [] - - if current_stage: - stage_entries.append(entry) - - # Save final stage - if current_stage and stage_entries: - stage_data[current_stage] = _compute_stage_stats(stage_entries) - - # Attach boundary (transition) values. The machine flips ``status`` to the - # next stage on the control tick where the current stage's exit condition - # becomes true, so the sample that actually satisfied the trigger is labeled - # as the FIRST sample of the next stage. Expose that sample (the next - # stage's start_* values) so exit-trigger evaluation can credit the stage - # for the value that caused it to end. Without this, a stage that exits on a - # rising ``pressure >= X`` / ``flow >= X`` trigger under-reports its exit - # metric and is falsely assessed as "failed". - stage_names = list(stage_data.keys()) - for i, name in enumerate(stage_names): - if i + 1 < len(stage_names): - nxt = stage_data[stage_names[i + 1]] - stage_data[name]["boundary_pressure"] = nxt.get("start_pressure") - stage_data[name]["boundary_flow"] = nxt.get("start_flow") - stage_data[name]["boundary_weight"] = nxt.get("start_weight") - stage_data[name]["boundary_time"] = nxt.get("start_time") - - return stage_data - - -def _compute_stage_stats(entries: list) -> dict: - """Compute statistics for a stage from its telemetry entries. - - Flow statistics (max_flow, avg_flow) ignore the first 3.5 seconds of - absolute shot time to avoid false-positive peaks caused by the rush - of water at the end of plunger retraction. - """ - if not entries: - return {} - - FLOW_IGNORE_WINDOW = 3.5 # seconds of absolute shot time to skip for flow - - times = [] - pressures = [] - flows = [] - flows_filtered = [] # Excludes first 3.5 s of absolute shot time - weights = [] - - for entry in entries: - t = entry.get("time", 0) / 1000 # Convert to seconds - times.append(t) - - shot = entry.get("shot", {}) - pressures.append(shot.get("pressure", 0)) - flow_val = shot.get("flow", 0) or shot.get("gravimetric_flow", 0) - flows.append(flow_val) - if t >= FLOW_IGNORE_WINDOW: - flows_filtered.append(flow_val) - weights.append(shot.get("weight", 0)) - - start_time = min(times) if times else 0 - end_time = max(times) if times else 0 - - # Use filtered flows for max/avg if available, else fall back to all flows - flow_stats_src = flows_filtered if flows_filtered else flows - - return { - "start_time": start_time, - "end_time": end_time, - "duration": end_time - start_time, - "start_weight": weights[0] if weights else 0, - "end_weight": weights[-1] if weights else 0, - "start_pressure": pressures[0] if pressures else 0, - "end_pressure": pressures[-1] if pressures else 0, - "min_pressure": min(pressures) if pressures else 0, - "max_pressure": max(pressures) if pressures else 0, - "avg_pressure": sum(pressures) / len(pressures) if pressures else 0, - "start_flow": flows[0] if flows else 0, - "end_flow": flows[-1] if flows else 0, - "min_flow": min(flow_stats_src) if flow_stats_src else 0, - "max_flow": max(flow_stats_src) if flow_stats_src else 0, - "avg_flow": sum(flow_stats_src) / len(flow_stats_src) if flow_stats_src else 0, - "entry_count": len(entries), - } - - -def _interpolate_weight_to_time( - target_weight: float, weight_time_pairs: list[tuple[float, float]] -) -> Optional[float]: - """Interpolate time value for a given weight using linear interpolation. - - Args: - target_weight: The weight value to find the corresponding time for - weight_time_pairs: List of (weight, time) tuples sorted by weight - - Returns: - Interpolated time value, or None if no data available - """ - if not weight_time_pairs: - return None - - # Find bracketing weight values - for i in range(len(weight_time_pairs)): - weight_actual, time_actual = weight_time_pairs[i] - - if weight_actual >= target_weight: - if i == 0: - # Before first point, use first time - return time_actual - else: - # Interpolate between i-1 and i - weight_prev, time_prev = weight_time_pairs[i - 1] - if weight_actual > weight_prev: - # Linear interpolation - weight_fraction = (target_weight - weight_prev) / ( - weight_actual - weight_prev - ) - return time_prev + weight_fraction * (time_actual - time_prev) - else: - # Same weight, use current time - return time_actual - - # If not found, use last time (weight exceeds all actual weights) - return weight_time_pairs[-1][1] - - -def _target_key_for_type(stage_type: str) -> Optional[str]: - """Return the target-curve data key for a stage type, or None if unsupported.""" - if stage_type == "pressure": - return "target_pressure" - if stage_type == "flow": - return "target_flow" - if stage_type == "power": - return "target_power" - return None - - -def _build_time_based_curve_points( - dynamics_points: list, - variables: list, - stage_name: str, - stage_type: str, - stage_start: float, - stage_end: float, -) -> list[dict]: - """Build target-curve points for a time-based, multi-point stage. - - Dynamics point x-values are absolute seconds measured from the start of the - stage; they describe the real-time target curve the machine follows. They - must therefore be plotted at their actual offset (``stage_start + x``) and - NOT rescaled to fill the stage duration. Rescaling distorts ramps: a short - ramp inside a longer stage gets stretched, and a ramp followed by a long - hold gets compressed until the ramp looks instantaneous (the reported bug). - - Behaviour: - * Each point is emitted at its absolute time within the stage. - * If the final dynamics point ends before ``stage_end``, the last value is - held flat until ``stage_end`` (the machine holds the final target). - * If a dynamics point lies beyond ``stage_end`` (the stage exited early via - another trigger), the curve is linearly clipped at the boundary. - """ - key = _target_key_for_type(stage_type) - if key is None: - return [] - - stage_duration = stage_end - stage_start - - def _resolve(raw): - if isinstance(raw, str) and raw.startswith("$"): - resolved, _ = _resolve_variable(raw, variables) - return _safe_float(resolved) - return _safe_float(raw) - - points: list[dict] = [] - prev_t: Optional[float] = None - prev_v: Optional[float] = None - last_t: Optional[float] = None - last_v: Optional[float] = None - - for dp in dynamics_points: - dp_t = _safe_float(dp[0]) - dp_v = _resolve(dp[1] if len(dp) > 1 else dp[0]) - - if dp_t > stage_duration: - # Stage exited before reaching this point — clip at the boundary. - if prev_t is not None and dp_t > prev_t: - frac = (stage_duration - prev_t) / (dp_t - prev_t) - boundary_v = prev_v + (dp_v - prev_v) * frac - else: - boundary_v = dp_v - points.append( - { - "time": round(stage_end, 2), - "stage_name": stage_name, - key: round(boundary_v, 1), - } - ) - return points - - points.append( - { - "time": round(stage_start + dp_t, 2), - "stage_name": stage_name, - key: round(dp_v, 1), - } - ) - prev_t, prev_v = dp_t, dp_v - last_t, last_v = dp_t, dp_v - - # Hold the final target value until the stage ends, if the curve finished early. - if last_t is not None and last_t < stage_duration - 1e-6: - points.append( - { - "time": round(stage_end, 2), - "stage_name": stage_name, - key: round(last_v, 1), - } - ) - - return points - - -def _generate_profile_target_curves( - profile_data: dict, shot_stage_times: dict, shot_data: dict -) -> list[dict]: - """Generate target curves for profile overlay on shot chart. - - Creates data points representing what the profile was targeting at each time point. - Uses actual shot stage times to align the profile curves with the shot execution. - Supports both time-based and weight-based dynamics. - - Args: - profile_data: The profile configuration - shot_stage_times: Dict mapping stage names to (start_time, end_time) tuples - shot_data: The complete shot data including telemetry entries - - Returns: - List of data points: [{time, target_pressure, target_flow, target_power, stage_name}, ...] - """ - stages = profile_data.get("stages", []) - variables = profile_data.get("variables", []) - data_points = [] - - # Build weight-to-time mappings for each stage from shot data - # This enables weight-based dynamics interpolation - stage_weight_to_time = {} - data_entries = shot_data.get("data", []) - - for entry in data_entries: - status = entry.get("status", "") - if not status or status.lower().strip() == STAGE_STATUS_RETRACTING: - continue - - time_sec = entry.get("time", 0) / 1000 # Convert to seconds - weight = entry.get("shot", {}).get("weight", 0) - - # Normalize stage name for matching - normalized_status = status.lower().strip() - - if normalized_status not in stage_weight_to_time: - stage_weight_to_time[normalized_status] = [] - - stage_weight_to_time[normalized_status].append((weight, time_sec)) - - for stage in stages: - stage_name = stage.get("name", "") - stage_type = stage.get("type", "") # pressure or flow - - # Handle both flat format (dynamics_points) and nested format (dynamics.points) - dynamics_points = stage.get("dynamics_points", []) - dynamics_over = stage.get("dynamics_over", "time") # time or weight - - # If flat format not found, try nested dynamics object - if not dynamics_points: - dynamics_obj = stage.get("dynamics", {}) - if isinstance(dynamics_obj, dict): - dynamics_points = dynamics_obj.get("points", []) - dynamics_over = dynamics_obj.get("over", "time") - - if not dynamics_points: - continue - - # Get actual stage timing from shot - # Match using either stage name or stage key (for consistency with main analysis) - identifiers = set() - if stage_name: - identifiers.add(stage_name.lower().strip()) - stage_key_field = stage.get("key", "") - if stage_key_field: - identifiers.add(stage_key_field.lower().strip()) - - stage_timing = None - for shot_stage_name, timing in shot_stage_times.items(): - normalized_shot_stage_name = shot_stage_name.lower().strip() - if normalized_shot_stage_name in identifiers: - stage_timing = timing - break - - if not stage_timing: - continue - - stage_start, stage_end = stage_timing - stage_duration = stage_end - stage_start - - if stage_duration <= 0: - continue - - # Generate points along the stage duration - # For time-based dynamics, interpolate directly - if dynamics_over == "time": - # Get the dynamics point times (x values) and target values (y values) - if len(dynamics_points) == 1: - # Constant value throughout stage - value = ( - dynamics_points[0][1] - if len(dynamics_points[0]) > 1 - else dynamics_points[0][0] - ) - # Resolve variable if needed - if isinstance(value, str) and value.startswith("$"): - resolved, _ = _resolve_variable(value, variables) - value = _safe_float(resolved) - else: - value = _safe_float(value) - - # Add start and end points - point_start = {"time": round(stage_start, 2), "stage_name": stage_name} - point_end = {"time": round(stage_end, 2), "stage_name": stage_name} - - if stage_type == "pressure": - point_start["target_pressure"] = round(value, 1) - point_end["target_pressure"] = round(value, 1) - elif stage_type == "flow": - point_start["target_flow"] = round(value, 1) - point_end["target_flow"] = round(value, 1) - elif stage_type == "power": - point_start["target_power"] = round(value, 1) - point_end["target_power"] = round(value, 1) - - data_points.append(point_start) - data_points.append(point_end) - else: - # Multiple points define a real-time target curve. Dynamics - # times are absolute seconds within the stage, so plot them at - # their true offsets instead of rescaling to the stage length - # (rescaling distorts ramps — see _build_time_based_curve_points). - data_points.extend( - _build_time_based_curve_points( - dynamics_points, - variables, - stage_name, - stage_type, - stage_start, - stage_end, - ) - ) - - # For weight-based dynamics, map weight values to time using actual shot data - elif dynamics_over == "weight": - # Get weight-to-time mapping for this stage - stage_key_normalized = None - for identifier in identifiers: - if identifier in stage_weight_to_time: - stage_key_normalized = identifier - break - - if ( - not stage_key_normalized - or not stage_weight_to_time[stage_key_normalized] - ): - # No weight data available for this stage - continue - - weight_time_pairs = stage_weight_to_time[stage_key_normalized] - - # Sort by weight to enable interpolation - weight_time_pairs.sort(key=lambda x: x[0]) - - if len(dynamics_points) == 1: - # Constant value throughout stage - value = ( - dynamics_points[0][1] - if len(dynamics_points[0]) > 1 - else dynamics_points[0][0] - ) - - # Resolve variable if needed - if isinstance(value, str) and value.startswith("$"): - resolved, _ = _resolve_variable(value, variables) - value = _safe_float(resolved) - else: - value = _safe_float(value) - - # Add start and end points - point_start = {"time": round(stage_start, 2), "stage_name": stage_name} - point_end = {"time": round(stage_end, 2), "stage_name": stage_name} - - if stage_type == "pressure": - point_start["target_pressure"] = round(value, 1) - point_end["target_pressure"] = round(value, 1) - elif stage_type == "flow": - point_start["target_flow"] = round(value, 1) - point_end["target_flow"] = round(value, 1) - elif stage_type == "power": - point_start["target_power"] = round(value, 1) - point_end["target_power"] = round(value, 1) - - data_points.append(point_start) - data_points.append(point_end) - else: - # Multiple points - interpolate weight values to time - # dynamics_points format: [[weight1, value1], [weight2, value2], ...] - for dp in dynamics_points: - dp_weight = _safe_float(dp[0]) - dp_value = dp[1] if len(dp) > 1 else dp[0] - - # Resolve variable if needed - if isinstance(dp_value, str) and dp_value.startswith("$"): - resolved, _ = _resolve_variable(dp_value, variables) - dp_value = _safe_float(resolved) - else: - dp_value = _safe_float(dp_value) - - # Find time corresponding to this weight using linear interpolation - actual_time = _interpolate_weight_to_time( - dp_weight, weight_time_pairs - ) - - if actual_time is not None: - point = { - "time": round(actual_time, 2), - "stage_name": stage_name, - } - if stage_type == "pressure": - point["target_pressure"] = round(dp_value, 1) - elif stage_type == "flow": - point["target_flow"] = round(dp_value, 1) - elif stage_type == "power": - point["target_power"] = round(dp_value, 1) - - data_points.append(point) - - # Sort by time - data_points.sort(key=lambda x: x["time"]) - - return data_points - - -def generate_estimated_target_curves(profile_data: dict) -> list[dict]: - """Generate *estimated* target curves from a profile without actual shot data. - - Used by the live-view to show goal overlays before/during a shot. - Stage durations are estimated from exit-trigger time values (or a - fallback of 10 seconds per stage when no time trigger exists). - Weight-based dynamics_over stages use estimated stage duration for - linear interpolation of their points. - - Returns the same [{time, target_pressure?, target_flow?, stage_name}] format - as _generate_profile_target_curves. - """ - stages = profile_data.get("stages", []) - variables = profile_data.get("variables", []) - data_points: list[dict] = [] - - DEFAULT_STAGE_DURATION = 10.0 # seconds when no time-exit exists - # When a stage has BOTH weight and time exit triggers, the time trigger - # is a safety maximum (not the expected duration). In practice the - # weight trigger fires first, so we cap the estimated stage duration to - # this value for stages that have a weight trigger alongside a time one. - WEIGHT_STAGE_MAX_ESTIMATE = 15.0 # realistic stage length when weight exits first - - # First pass — estimate duration of each stage from exit triggers - stage_durations: list[float] = [] - for stage in stages: - exit_triggers = stage.get("exit_triggers", []) - time_trigger_val: float | None = None - has_weight_trigger = False - for trig in exit_triggers: - if trig.get("type") == "time": - raw = trig.get("value", 0) - resolved, _ = _resolve_variable(raw, variables) - time_trigger_val = _safe_float(resolved, DEFAULT_STAGE_DURATION) - if trig.get("type") == "weight": - has_weight_trigger = True - - if time_trigger_val is not None: - # If a weight trigger also exists, the time trigger is only - # a safety ceiling — the actual stage is much shorter. - if has_weight_trigger: - duration = min(time_trigger_val, WEIGHT_STAGE_MAX_ESTIMATE) - else: - duration = time_trigger_val - else: - duration = DEFAULT_STAGE_DURATION - - stage_durations.append(duration) - - # Second pass — build target curve points - running_time = 0.0 - for idx, stage in enumerate(stages): - stage_name = stage.get("name", f"Stage {idx + 1}") - stage_type = stage.get("type", "") # pressure or flow - duration = stage_durations[idx] - - dynamics_points = stage.get("dynamics_points", []) - stage.get("dynamics_over", "time") - - if not dynamics_points: - dynamics_obj = stage.get("dynamics", {}) - if isinstance(dynamics_obj, dict): - dynamics_points = dynamics_obj.get("points", []) - dynamics_obj.get("over", "time") - - if not dynamics_points: - running_time += duration - continue - - stage_start = running_time - stage_end = running_time + duration - - if len(dynamics_points) == 1: - value = ( - dynamics_points[0][1] - if len(dynamics_points[0]) > 1 - else dynamics_points[0][0] - ) - if isinstance(value, str) and value.startswith("$"): - resolved, _ = _resolve_variable(value, variables) - value = _safe_float(resolved) - else: - value = _safe_float(value) - - pt_s = {"time": round(stage_start, 2), "stage_name": stage_name} - pt_e = {"time": round(stage_end, 2), "stage_name": stage_name} - key = ( - "target_pressure" - if stage_type == "pressure" - else ("target_power" if stage_type == "power" else "target_flow") - ) - pt_s[key] = round(value, 1) - pt_e[key] = round(value, 1) - data_points += [pt_s, pt_e] - else: - data_points.extend( - _build_time_based_curve_points( - dynamics_points, - variables, - stage_name, - stage_type, - stage_start, - stage_end, - ) - ) - - running_time = stage_end - - data_points.sort(key=lambda x: x["time"]) - return data_points - - -def _perform_local_shot_analysis(shot_data: dict, profile_data: dict) -> dict: - """Perform complete local analysis of shot vs profile. - - This is a purely algorithmic analysis - no LLM involved. - """ - # Extract overall shot metrics - data_entries = shot_data.get("data", []) - - FLOW_IGNORE_WINDOW = 3.5 # seconds — ignore retraction water rush - - final_weight = 0 - total_time = 0 - max_pressure = 0 - max_flow = 0 - - for entry in data_entries: - shot = entry.get("shot", {}) - weight = shot.get("weight", 0) - pressure = shot.get("pressure", 0) - flow = shot.get("flow", 0) or shot.get("gravimetric_flow", 0) - t = entry.get("time", 0) / 1000 - - final_weight = max(final_weight, weight) - total_time = max(total_time, t) - max_pressure = max(max_pressure, pressure) - if t >= FLOW_IGNORE_WINDOW: - max_flow = max(max_flow, flow) - - target_weight = profile_data.get("final_weight", 0) or 0 - - # Weight analysis - weight_deviation = 0 - weight_status = "on_target" - if target_weight > 0: - weight_deviation = ((final_weight - target_weight) / target_weight) * 100 - if final_weight < target_weight * 0.95: # More than 5% under - weight_status = "under" - elif final_weight > target_weight * 1.1: # More than 10% over - weight_status = "over" - - # Extract shot stage data - shot_stages = _extract_shot_stage_data(shot_data) - - # Build shot stage times for profile curve generation - shot_stage_times = {} - for stage_name, stage_data in shot_stages.items(): - start_time = stage_data.get("start_time", 0) - end_time = stage_data.get("end_time", 0) - shot_stage_times[stage_name] = (start_time, end_time) - - # Generate profile target curves for chart overlay - profile_target_curves = _generate_profile_target_curves( - profile_data, shot_stage_times, shot_data - ) - - # Profile stages - profile_stages = profile_data.get("stages", []) - profile_variables = profile_data.get("variables", []) - - # Analyze each profile stage - stage_analyses = [] - executed_stages = set() - unreached_stages = [] - preinfusion_time = 0 - preinfusion_stages = [] - - for profile_stage in profile_stages: - stage_name = profile_stage.get("name", "") - stage_key = profile_stage.get("key", "").lower() - - # Find matching shot stage (by name, case-insensitive) - shot_stage_data = None - for shot_stage_name, data in shot_stages.items(): - if shot_stage_name.lower().strip() == stage_name.lower().strip(): - shot_stage_data = data - executed_stages.add(stage_name) - break - - analysis = _analyze_stage_execution( - profile_stage, shot_stage_data, total_time, profile_variables - ) - stage_analyses.append(analysis) - - # Track unreached - if not analysis["executed"]: - unreached_stages.append(stage_name) - - # Track preinfusion time - name_lower = stage_name.lower() - is_preinfusion = any(kw in name_lower for kw in PREINFUSION_KEYWORDS) or any( - kw in stage_key for kw in ["preinfusion", "bloom", "soak", "fill"] - ) - - if is_preinfusion and shot_stage_data: - preinfusion_time += _safe_float(shot_stage_data.get("duration", 0)) - preinfusion_stages.append( - { - "name": stage_name, - "duration": _safe_float(shot_stage_data.get("duration", 0)), - "start_weight": _safe_float(shot_stage_data.get("start_weight", 0)), - "end_weight": _safe_float(shot_stage_data.get("end_weight", 0)), - "max_flow": _safe_float(shot_stage_data.get("max_flow", 0)), - "avg_flow": _safe_float(shot_stage_data.get("avg_flow", 0)), - "exit_triggers": profile_stage.get("exit_triggers", []), - } - ) - - # Preinfusion analysis - preinfusion_proportion = ( - (preinfusion_time / total_time * 100) if total_time > 0 else 0 - ) - - # Calculate total weight accumulated during preinfusion - preinfusion_weight = 0 - for pi_stage in preinfusion_stages: - # Weight gained in this stage - stage_weight_gain = pi_stage["end_weight"] - pi_stage["start_weight"] - preinfusion_weight += max(0, stage_weight_gain) - - # Preinfusion weight analysis - preinfusion_weight_percent = ( - (preinfusion_weight / final_weight * 100) if final_weight > 0 else 0 - ) - preinfusion_issues = [] - preinfusion_recommendations = [] - - if preinfusion_weight_percent > 10: - preinfusion_issues.append( - { - "type": "excessive_preinfusion_volume", - "severity": "warning" - if preinfusion_weight_percent <= 15 - else "concern", - "message": f"Pre-infusion accounted for {preinfusion_weight_percent:.1f}% of total shot volume (target: ≤10%)", - "detail": f"{preinfusion_weight:.1f}g of {final_weight:.1f}g total", - } - ) - - # Check for high flow during preinfusion - max_preinfusion_flow = max( - (s["max_flow"] for s in preinfusion_stages), default=0 - ) - avg_preinfusion_flow = ( - sum(s["avg_flow"] for s in preinfusion_stages) / len(preinfusion_stages) - if preinfusion_stages - else 0 - ) - - if max_preinfusion_flow > 2.0 or avg_preinfusion_flow > 1.0: - preinfusion_issues.append( - { - "type": "high_preinfusion_flow", - "severity": "warning", - "message": f"High flow during pre-infusion (max: {max_preinfusion_flow:.1f} ml/s, avg: {avg_preinfusion_flow:.1f} ml/s)", - "detail": "May indicate grind is too coarse", - } - ) - preinfusion_recommendations.append( - "Consider using a finer grind to slow early flow" - ) - - # Check if exit triggers include weight/flow protection - has_weight_exit = False - has_flow_exit = False - for pi_stage in preinfusion_stages: - for trigger in pi_stage.get("exit_triggers", []): - trigger_type = ( - trigger.get("type", "") if isinstance(trigger, dict) else "" - ) - if "weight" in trigger_type.lower(): - has_weight_exit = True - if "flow" in trigger_type.lower(): - has_flow_exit = True - - if not has_weight_exit and not has_flow_exit: - preinfusion_recommendations.append( - "Consider adding a weight or flow exit trigger to pre-infusion stages to prevent excessive early volume" - ) - elif not has_weight_exit: - preinfusion_recommendations.append( - "Consider adding a weight-based exit trigger to limit pre-infusion volume" - ) - - analysis_result = { - "shot_summary": { - "final_weight": round(final_weight, 1), - "target_weight": round(target_weight, 1) if target_weight else None, - "total_time": round(total_time, 1), - "max_pressure": round(max_pressure, 1), - "max_flow": round(max_flow, 1), - }, - "weight_analysis": { - "status": weight_status, - "target": round(target_weight, 1) if target_weight else None, - "actual": round(final_weight, 1), - "deviation_percent": round(weight_deviation, 1), - }, - "stage_analyses": stage_analyses, - "unreached_stages": unreached_stages, - "preinfusion_summary": { - "stages": [s["name"] for s in preinfusion_stages], - "total_time": round(preinfusion_time, 1), - "proportion_of_shot": round(preinfusion_proportion, 1), - "weight_accumulated": round(preinfusion_weight, 1), - "weight_percent_of_total": round(preinfusion_weight_percent, 1), - "issues": preinfusion_issues, - "recommendations": preinfusion_recommendations, - }, - "profile_info": { - "name": profile_data.get("name", "Unknown"), - "temperature": profile_data.get("temperature"), - "stage_count": len(profile_stages), - }, - "profile_target_curves": profile_target_curves, - } - analysis_result["shot_facts"] = build_shot_facts(analysis_result) - return analysis_result - - -def _prepare_shot_summary_for_llm( - shot_data: dict, profile_data: dict, local_analysis: dict -) -> dict: - """Prepare a token-efficient summary of shot data for LLM analysis. - - Extracts only key data points to minimize token usage while providing - enough context for meaningful analysis. - """ - # Basic shot metrics - overall = local_analysis.get("overall_metrics", {}) - weight_analysis = local_analysis.get("weight_analysis", {}) - preinfusion = local_analysis.get("preinfusion_summary", {}) - - # Stage summary (compact format) - stage_summaries = [] - total_time = overall.get("total_time", 0) - - for stage in local_analysis.get("stage_analyses", []): - exec_data = stage.get("execution_data") - if exec_data: - duration = exec_data.get("duration", 0) - pct_of_shot = round( - (duration / total_time * 100) if total_time > 0 else 0, 1 - ) - # Safely extract exit trigger and limit hit descriptions - exit_trigger_desc = None - exit_trigger_result = stage.get("exit_trigger_result") - if exit_trigger_result: - triggered = exit_trigger_result.get("triggered") - if triggered and isinstance(triggered, dict): - exit_trigger_desc = triggered.get("description") - - limit_hit_desc = None - limit_hit = stage.get("limit_hit") - if limit_hit and isinstance(limit_hit, dict): - limit_hit_desc = limit_hit.get("description") - - stage_summaries.append( - { - "name": stage.get("stage_name"), - "duration_s": round(duration, 1), - "percent_of_shot": pct_of_shot, - "avg_pressure": exec_data.get("avg_pressure"), - "avg_flow": exec_data.get("avg_flow"), - "weight_gain": exec_data.get("weight_gain"), - "cumulative_weight_at_end": exec_data.get( - "end_weight" - ), # Added: cumulative weight when stage ended - "exit_trigger": exit_trigger_desc, - "limit_hit": limit_hit_desc, - } - ) - else: - stage_summaries.append( - {"name": stage.get("stage_name"), "status": "NOT REACHED"} - ) - - # Profile variables (resolved values) - variables = [] - for var in profile_data.get("variables", []): - variables.append( - { - "name": var.get("name"), - "type": var.get("type"), - "value": var.get("value"), - } - ) - - # Simplified graph data - sample key points from the shot - data_entries = shot_data.get("data", []) - graph_summary = [] - - if data_entries: - # Sample at key points: start, 25%, 50%, 75%, end, and any stage transitions - sample_indices = [0] - n = len(data_entries) - for pct in [0.25, 0.5, 0.75]: - idx = int(n * pct) - if idx not in sample_indices: - sample_indices.append(idx) - sample_indices.append(n - 1) - - for idx in sorted(set(sample_indices)): - entry = data_entries[idx] - shot = entry.get("shot", {}) - graph_summary.append( - { - "time_s": round(entry.get("time", 0) / 1000, 1), - "pressure": round(shot.get("pressure", 0), 1), - "flow": round( - shot.get("flow", 0) or shot.get("gravimetric_flow", 0), 1 - ), - "weight": round(shot.get("weight", 0), 1), - "stage": entry.get("status", ""), - } - ) - - return { - "shot_summary": { - "total_time_s": overall.get("total_time"), - "final_weight_g": weight_analysis.get("actual"), - "target_weight_g": weight_analysis.get("target"), - "weight_deviation_pct": weight_analysis.get("deviation_percent"), - "max_pressure_bar": overall.get("max_pressure"), - "max_flow_mls": overall.get("max_flow"), - "temperature_c": profile_data.get("temperature"), - }, - "stages": stage_summaries, - "unreached_stages": local_analysis.get("unreached_stages", []), - "preinfusion": { - "total_time_s": preinfusion.get("total_time"), - "percent_of_shot": preinfusion.get("proportion_of_shot"), - "weight_accumulated_g": preinfusion.get("weight_accumulated"), - }, - "variables": variables, - "graph_samples": graph_summary, - } - - -# ============================================================================ -# Profile Description -# ============================================================================ - - -async def _generate_profile_description(profile_json: dict, request_id: str) -> str: - """Generate a description for a profile using the LLM with profiling knowledge.""" - - profile_name = profile_json.get("name", "Unknown Profile") - - # Build a prompt with profiling knowledge and profile details - prompt = f"""You are an expert espresso barista analysing profiles for the Meticulous Espresso Machine. - -## Expert Profiling Knowledge -{PROFILING_KNOWLEDGE} - -Analyze this Meticulous Espresso profile and generate a description in the standard MeticAI format. - -PROFILE JSON: -```json -{json.dumps(profile_json, indent=2)} -``` - -Generate a response in this exact format: - -Profile Created: {profile_name} - -Description: -[Describe what makes this profile unique and what flavor characteristics it targets. Be specific about the extraction approach.] - -Preparation: -• Dose: [Recommended dose based on profile settings] -• Grind: [Grind recommendation based on flow rates and pressure curves] -• Temperature: [From profile or recommendation] -• Target Yield: [From profile final_weight or recommendation] -• Expected Time: [Based on stage durations] - -Why This Works: -[Explain the science behind the profile design - why the pressure curves, flow rates, and staging work together] - -Special Notes: -[Any specific requirements or tips for using this profile] - -Be concise but informative. Focus on actionable barista guidance. - -Use concrete numeric values with units (for example 9 bar, 2.0 ml/s, 30 s). Never output raw variable placeholders such as $name$ and never mention internal stage keys.""" - - prompt += _AI_TAGS_PROMPT - - try: - model = get_vision_model() - response = await model.async_generate_content(prompt) - text = getattr(response, "text", "") if response else "" - if text and text.strip(): - variables = profile_json.get("variables") or [] - return DescriptionResult( - resolve_description_placeholders( - strip_tags_line(text.strip()), variables - ), - parse_ai_tags(text), - ) - except ValueError: - logger.info( - "Gemini API key not configured, using static profile description fallback", - extra={"request_id": request_id, "profile_name": profile_name}, - ) - except Exception: - logger.warning( - "AI profile description generation failed, using static fallback", - extra={"request_id": request_id, "profile_name": profile_name}, - exc_info=True, - ) - - return _build_static_profile_description(profile_json) - - -def _build_static_profile_description(profile_json: dict) -> str: - """Build a non-AI profile description for environments without Gemini. - - Uses profile metadata and stage analysis to identify common extraction - techniques (pre-infusion, bloom, flat, ramp, etc.) so the description - reads more naturally than a plain parameter dump. - """ - profile_name = profile_json.get("name", "Imported Profile") - temperature = profile_json.get("temperature") - final_weight = profile_json.get("final_weight") - stages = profile_json.get("stages") or [] - existing_description = ( - profile_json.get("description") - or profile_json.get("notes") - or profile_json.get("summary") - ) - - if existing_description: - description = str(existing_description).strip() - else: - # ── Identify common shot types from stage structure ────────────── - shot_traits: list[str] = [] - for i, stage in enumerate(stages): - if not isinstance(stage, dict): - continue - sname = (stage.get("name") or "").lower() - dynamics = stage.get("dynamics", "") - stage.get("sensor", "") - points = stage.get("dynamics_points") if isinstance(stage, dict) else None - - # Pre-infusion: first stage with low pressure or named so - if i == 0 and ("pre" in sname or "infus" in sname): - shot_traits.append("pre-infusion") - elif "bloom" in sname or "soak" in sname: - shot_traits.append("bloom") - elif "ramp" in sname or dynamics == "ramp": - shot_traits.append("ramp") - elif "flat" in sname or dynamics == "flat": - shot_traits.append("flat") - elif "decline" in sname or "taper" in sname: - shot_traits.append("decline") - - # Detect flat pressure at ~9 bar (classic espresso) - if isinstance(points, list) and len(points) >= 2: - try: - pressures = [ - float(p[1]) - for p in points - if isinstance(p, list) and len(p) >= 2 - ] - if pressures and all( - abs(p - pressures[0]) < 0.3 for p in pressures - ): - if 8.0 <= pressures[0] <= 10.0 and "flat" not in shot_traits: - shot_traits.append("flat") - except (ValueError, TypeError): - pass - - # Build a natural-sounding description - description_parts = [] - if stages: - stage_count_text = f"{len(stages)}-stage" - if shot_traits: - trait_str = ", ".join( - dict.fromkeys(shot_traits) - ) # dedupe, preserve order - description_parts.append( - f"A {stage_count_text} extraction featuring {trait_str}" - ) - else: - description_parts.append(f"A {stage_count_text} extraction profile") - if temperature is not None: - description_parts.append(f"brewed at {temperature}°C") - if final_weight is not None: - description_parts.append(f"targeting ~{final_weight}g yield") - description = ( - " ".join(description_parts) + "." - if description_parts - else "Profile imported successfully." - ) - - expected_time = "Not specified" - try: - total_stage_time = 0.0 - for stage in stages: - points = stage.get("dynamics_points") if isinstance(stage, dict) else None - if isinstance(points, list) and points: - last = points[-1] - if isinstance(last, list) and last: - total_stage_time += float(last[0]) - if total_stage_time > 0: - expected_time = f"~{round(total_stage_time)}s" - except Exception: - pass - - temp_text = f"{temperature}°C" if temperature is not None else "Use profile default" - yield_text = ( - f"{final_weight}g" if final_weight is not None else "Use profile default" - ) - - return ( - f"Profile Created: {profile_name}\n\n" - f"Description:\n" - f"{description}\n\n" - f"Preparation:\n" - f"• Dose: Use your standard recipe dose\n" - f"• Grind: Dial in to hit target flow and pressure\n" - f"• Temperature: {temp_text}\n" - f"• Target Yield: {yield_text}\n" - f"• Expected Time: {expected_time}\n\n" - f"Why This Works:\n" - f"This is a summary generated from the profile's stage structure and metadata. " - f"Enable AI features in Settings and configure a Gemini API key for a detailed " - f"barista-level analysis with expert brewing recommendations.\n\n" - f"Special Notes:\n" - f"This description was generated without AI assistance and may not capture " - f"all nuances of the extraction design. You can generate a full AI-powered " - f'description using the "Generate AI descriptions" button in the profile view ' - f"(requires AI features to be enabled in Settings)." - ) diff --git a/apps/server/services/analysis_validator.py b/apps/server/services/analysis_validator.py deleted file mode 100644 index 6529c6ab..00000000 --- a/apps/server/services/analysis_validator.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Anti-hallucination / semantic validator for shot-analysis output (K4 server parity). - -Mirror of validateAgainstFacts in apps/web/src/lib/analysisLint.ts — keep rules identical. -High precision: only flag clear contradictions of the deterministic ShotFacts. -""" - -from __future__ import annotations - -import re - -from analysis_schema import REQUIRED_ANALYSIS_SECTIONS - -_EARLY_EXIT_PATTERNS = [ - re.compile(r"terminat\w*\s+early", re.I), - re.compile(r"\bearly\s+terminat", re.I), - re.compile(r"ended?\s+(too\s+)?(early|prematurely)", re.I), - re.compile(r"\bcut\s+short\b", re.I), - re.compile(r"stopped?\s+before\s+reaching", re.I), -] -_CHANNELING_ASSERTION = re.compile(r"\bchannel(?:ing|ed|s)?\b", re.I) -_CHANNELING_NEGATION = re.compile( - r"\b(no|not|without|absence of|isn'?t|wasn'?t)\b[^.]{0,30}channel", re.I -) - - -def validate_against_facts(text: str, facts: dict) -> dict: - """Return {'valid': bool, 'issues': list[str]}.""" - issues: list[str] = [] - body = text or "" - stages = facts.get("stages", []) - - has_targeted_weight_exit = any( - s.get("reached") - and s.get("trigger_type") == "weight" - and (s.get("trigger_class") or {}).get("kind") == "targeted" - for s in stages - ) - if has_targeted_weight_exit and any(p.search(body) for p in _EARLY_EXIT_PATTERNS): - issues.append("mischaracterized-targeted-exit") - - any_channeling = any((s.get("channeling") or {}).get("channeling") for s in stages) - if ( - not any_channeling - and _CHANNELING_ASSERTION.search(body) - and not _CHANNELING_NEGATION.search(body) - ): - issues.append("unsupported-channeling") - - return {"valid": len(issues) == 0, "issues": issues} - - -def check_structure(text: str) -> dict: - """Verify the analysis contains each required section title (L1).""" - body = (text or "").lower() - missing = [s for s in REQUIRED_ANALYSIS_SECTIONS if s.lower() not in body] - return {"valid": not missing, "issues": ["missing-sections"] if missing else []} diff --git a/apps/server/services/bridge_service.py b/apps/server/services/bridge_service.py deleted file mode 100644 index b8109e0f..00000000 --- a/apps/server/services/bridge_service.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Bridge service — monitors MQTT broker and meticulous-bridge health.""" - -import logging -import os -import subprocess -from typing import Any, Dict - -logger = logging.getLogger(__name__) - -TEST_MODE = os.environ.get("TEST_MODE") == "true" - - -def _is_process_running(process_name: str) -> bool: - """Check if a process is running by name (via pgrep).""" - if TEST_MODE: - return False - try: - result = subprocess.run( - ["pgrep", "-f", process_name], - capture_output=True, - text=True, - timeout=5, - ) - return result.returncode == 0 - except (subprocess.TimeoutExpired, FileNotFoundError): - return False - - -def _check_s6_service(service_name: str) -> str: - """Check s6 service status. Returns 'running', 'down', or 'unknown'.""" - if TEST_MODE: - return "unknown" - service_path = f"/run/service/{service_name}" - try: - result = subprocess.run( - ["s6-svstat", service_path], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - output = result.stdout.strip() - if "up" in output: - return "running" - elif "down" in output: - return "down" - return "unknown" - except (subprocess.TimeoutExpired, FileNotFoundError): - return "unknown" - - -def _check_mqtt_port(host: str = "127.0.0.1", port: int = 1883) -> bool: - """Check if the MQTT broker port is accepting connections.""" - if TEST_MODE: - return False - import socket as sock - - try: - with sock.create_connection((host, port), timeout=2): - return True - except (OSError, ConnectionRefusedError): - return False - - -def get_bridge_status() -> Dict[str, Any]: - """Get the full status of the MQTT bridge infrastructure. - - Returns a dict with: - - mqtt_enabled: whether MQTT is configured to be enabled - - mosquitto: status of the MQTT broker service - - bridge: status of the meticulous-bridge service - - mqtt_port_open: whether the MQTT port is accepting connections - """ - mqtt_enabled = os.environ.get("MQTT_ENABLED", "true").lower() == "true" - mqtt_host = os.environ.get("MQTT_HOST", "127.0.0.1") - mqtt_port = int(os.environ.get("MQTT_PORT", "1883")) - - return { - "mqtt_enabled": mqtt_enabled, - "mosquitto": { - "service": _check_s6_service("mosquitto"), - "port_open": _check_mqtt_port(mqtt_host, mqtt_port), - "host": mqtt_host, - "port": mqtt_port, - }, - "bridge": { - "service": _check_s6_service("meticulous-bridge"), - }, - } - - -def restart_bridge_service() -> bool: - """Restart the meticulous-bridge s6 service. - - Returns True if the restart command succeeded. - """ - if TEST_MODE: - return True - try: - logger.info("Restarting meticulous-bridge s6 service…") - result = subprocess.run( - ["s6-svc", "-r", "/run/service/meticulous-bridge"], - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode == 0: - logger.info("Bridge service restart initiated successfully") - else: - logger.warning( - "Bridge restart failed (rc=%d): %s", - result.returncode, - result.stderr.strip() or result.stdout.strip(), - ) - return result.returncode == 0 - except subprocess.TimeoutExpired: - logger.error("Bridge restart timed out after 10 s") - return False - except FileNotFoundError: - logger.error("s6-svc not found — not running inside s6-overlay container") - return False diff --git a/apps/server/services/cache_service.py b/apps/server/services/cache_service.py deleted file mode 100644 index 61df282e..00000000 --- a/apps/server/services/cache_service.py +++ /dev/null @@ -1,368 +0,0 @@ -"""Cache service for managing LLM analysis, shot history, and profile image caches. - -This module provides caching functionality for: -- LLM analysis results (with TTL-based expiration) -- Shot history data (with staleness tracking) -- Profile images (binary file cache) -""" - -import json -import time -from typing import Optional -from logging_config import get_logger - -from config import DATA_DIR, LLM_CACHE_TTL_SECONDS, SHOT_CACHE_STALE_SECONDS -from utils.file_utils import atomic_write_json -from utils.sanitization import sanitize_profile_name_for_filename - -logger = get_logger() - -# ============================================ -# LLM Analysis Cache Configuration -# ============================================ - -LLM_CACHE_FILE = DATA_DIR / "llm_analysis_cache.json" - -# In-memory cache (loaded from disk on first access, write-through on save) -_llm_cache: Optional[dict] = None - - -def _ensure_llm_cache_file(): - """Ensure the LLM cache file and directory exist.""" - LLM_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) - if not LLM_CACHE_FILE.exists(): - LLM_CACHE_FILE.write_text("{}") - - -def _load_llm_cache() -> dict: - """Load LLM analysis cache, using in-memory copy when available.""" - global _llm_cache - if _llm_cache is not None: - return _llm_cache - _ensure_llm_cache_file() - try: - data = json.loads(LLM_CACHE_FILE.read_text()) - except (json.JSONDecodeError, IOError): - data = None - if not isinstance(data, dict): - data = {} - _llm_cache = data - return _llm_cache - - -def _save_llm_cache(cache: dict): - """Write-through: update in-memory cache and persist to disk.""" - global _llm_cache - _llm_cache = cache - _ensure_llm_cache_file() - atomic_write_json(LLM_CACHE_FILE, cache) - - -def _get_llm_cache_key(profile_name: str, shot_date: str, shot_filename: str) -> str: - """Generate a cache key for LLM analysis.""" - return f"{profile_name}_{shot_date}_{shot_filename}" - - -def get_cached_llm_analysis( - profile_name: str, shot_date: str, shot_filename: str -) -> Optional[str]: - """Get cached LLM analysis if it exists and is not expired.""" - cache = _load_llm_cache() - key = _get_llm_cache_key(profile_name, shot_date, shot_filename) - - if key in cache: - entry = cache[key] - timestamp = entry.get("timestamp", 0) - now = time.time() - - if now - timestamp < LLM_CACHE_TTL_SECONDS: - return entry.get("analysis") - else: - # Expired - remove from cache - del cache[key] - _save_llm_cache(cache) - - return None - - -def save_llm_analysis_to_cache( - profile_name: str, shot_date: str, shot_filename: str, analysis: str -): - """Save LLM analysis to cache.""" - cache = _load_llm_cache() - key = _get_llm_cache_key(profile_name, shot_date, shot_filename) - - cache[key] = { - "analysis": analysis, - "timestamp": time.time(), - "profile_name": profile_name, - "shot_date": shot_date, - "shot_filename": shot_filename, - } - - _save_llm_cache(cache) - - -# ============================================ -# Shot History Cache Management -# ============================================ - -SHOT_CACHE_FILE = DATA_DIR / "shot_cache.json" - -# In-memory cache (loaded from disk on first access, write-through on save) -_shot_cache: Optional[dict] = None - - -def _ensure_shot_cache_file(): - """Ensure the shot cache file and directory exist.""" - SHOT_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) - if not SHOT_CACHE_FILE.exists(): - SHOT_CACHE_FILE.write_text("{}") - - -def _load_shot_cache() -> dict: - """Load shot cache, using in-memory copy when available.""" - global _shot_cache - if _shot_cache is not None: - return _shot_cache - _ensure_shot_cache_file() - try: - with open(SHOT_CACHE_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - except (json.JSONDecodeError, FileNotFoundError): - data = None - if not isinstance(data, dict): - data = {} - _shot_cache = data - return _shot_cache - - -def _save_shot_cache(cache: dict): - """Write-through: update in-memory cache and persist to disk.""" - global _shot_cache - _shot_cache = cache - _ensure_shot_cache_file() - atomic_write_json(SHOT_CACHE_FILE, cache) - - -def _get_cached_shots( - profile_name: str, limit: int -) -> tuple[Optional[dict], bool, Optional[float]]: - """Get cached shots for a profile. - - Returns a tuple of (cached_data, is_stale, cached_at_timestamp). - - cached_data: The cached response data, or None if no cache exists - - is_stale: True if cache is older than SHOT_CACHE_STALE_SECONDS - - cached_at: Unix timestamp of when cache was created - - Cache is stored indefinitely but marked stale after 60 minutes. - """ - cache = _load_shot_cache() - cache_key = profile_name.lower() - - if cache_key not in cache: - return None, False, None - - cached_entry = cache[cache_key] - cached_time = cached_entry.get("cached_at", 0) - cached_limit = cached_entry.get("limit", 0) - - # Check if limit matches (requesting more than cached = cache miss) - if limit > cached_limit: - return None, False, None - - # Check if cache is stale (older than 60 minutes) - is_stale = time.time() - cached_time > SHOT_CACHE_STALE_SECONDS - - return cached_entry.get("data"), is_stale, cached_time - - -def _set_cached_shots(profile_name: str, data: dict, limit: int): - """Store shots in cache for a profile.""" - cache = _load_shot_cache() - cache_key = profile_name.lower() - - cache[cache_key] = {"cached_at": time.time(), "limit": limit, "data": data} - - _save_shot_cache(cache) - - -# ============================================ -# Shot Profile Index -# ============================================ -# Persistent mapping: "date/filename" → {name, profile_id, weight, time_ms, timestamp} -# Built incrementally — each shot file is fetched at most once. - -SHOT_INDEX_FILE = DATA_DIR / "shot_profile_index.json" - -_shot_index: Optional[dict] = None - - -def _load_shot_index() -> dict: - """Load shot profile index from disk/memory.""" - global _shot_index - if _shot_index is not None: - return _shot_index - SHOT_INDEX_FILE.parent.mkdir(parents=True, exist_ok=True) - try: - if SHOT_INDEX_FILE.exists(): - with open(SHOT_INDEX_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - if isinstance(data, dict) and "entries" in data: - _shot_index = data - return _shot_index - except (json.JSONDecodeError, IOError): - pass - _shot_index = {"entries": {}, "indexed_dates": []} - return _shot_index - - -def _save_shot_index(): - """Write shot profile index to disk.""" - if _shot_index is None: - return - SHOT_INDEX_FILE.parent.mkdir(parents=True, exist_ok=True) - atomic_write_json(SHOT_INDEX_FILE, _shot_index) - - -def get_indexed_shot_metadata(key: str) -> Optional[dict]: - """Look up metadata for a single shot by 'date/filename' key.""" - idx = _load_shot_index() - return idx["entries"].get(key) - - -def get_indexed_dates() -> set: - """Return the set of dates already indexed.""" - idx = _load_shot_index() - return set(idx.get("indexed_dates", [])) - - -def update_shot_index(new_entries: dict, new_dates: list): - """Merge new entries into the index and persist.""" - idx = _load_shot_index() - idx["entries"].update(new_entries) - existing = set(idx.get("indexed_dates", [])) - existing.update(new_dates) - idx["indexed_dates"] = sorted(existing) - _save_shot_index() - - -def lookup_shots_by_profile(profile_name: str, limit: int = 20) -> Optional[list]: - """Return shots for a profile from the index, or None if index is empty.""" - idx = _load_shot_index() - entries = idx.get("entries", {}) - if not entries: - return None - - matches = [] - for key, meta in entries.items(): - if meta.get("name", "").lower() == profile_name.lower(): - parts = key.split("/", 1) - if len(parts) == 2: - matches.append({ - "date": parts[0], - "filename": parts[1], - "timestamp": meta.get("timestamp"), - "profile_name": meta["name"], - "profile_id": meta.get("profile_id", ""), - "final_weight": meta.get("weight"), - "total_time": meta["time_ms"] / 1000 if meta.get("time_ms") else None, - }) - - # Sort newest first - matches.sort(key=lambda x: x.get("timestamp") or "", reverse=True) - return matches[:limit] - - -def get_all_indexed_shots(limit: int = 50, offset: int = 0) -> Optional[list]: - """Return all indexed shots sorted newest-first, or None if empty.""" - idx = _load_shot_index() - entries = idx.get("entries", {}) - if not entries: - return None - - all_shots = [] - for key, meta in entries.items(): - parts = key.split("/", 1) - if len(parts) == 2: - all_shots.append({ - "date": parts[0], - "filename": parts[1], - "timestamp": meta.get("timestamp"), - "profile_name": meta.get("name", ""), - "profile_id": meta.get("profile_id", ""), - "final_weight": meta.get("weight"), - "total_time": meta["time_ms"] / 1000 if meta.get("time_ms") else None, - }) - - all_shots.sort(key=lambda x: x.get("timestamp") or "", reverse=True) - return all_shots[offset:offset + limit] - - -# ============================================ -# Profile Image Cache Management -# ============================================ - -IMAGE_CACHE_DIR = DATA_DIR / "image_cache" - - -def _ensure_image_cache_dir(): - """Ensure the image cache directory exists.""" - IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) - - -def _get_cached_image(profile_name: str) -> Optional[bytes]: - """Get cached image for a profile if it exists. - - Returns the image bytes or None if not cached. - """ - _ensure_image_cache_dir() - safe_name = sanitize_profile_name_for_filename(profile_name) - cache_file = IMAGE_CACHE_DIR / f"{safe_name}.png" - - # Security check: ensure the resolved path is still within IMAGE_CACHE_DIR - try: - cache_file_resolved = cache_file.resolve() - if not str(cache_file_resolved).startswith(str(IMAGE_CACHE_DIR.resolve())): - logger.warning( - f"Path traversal attempt detected for profile: {profile_name}" - ) - return None - except Exception as e: - logger.warning(f"Failed to resolve cache path for {profile_name}: {e}") - return None - - if cache_file.exists(): - try: - return cache_file.read_bytes() - except Exception as e: - logger.warning(f"Failed to read cached image for {profile_name}: {e}") - return None - return None - - -def _set_cached_image(profile_name: str, image_data: bytes): - """Store image in cache for a profile.""" - _ensure_image_cache_dir() - safe_name = sanitize_profile_name_for_filename(profile_name) - cache_file = IMAGE_CACHE_DIR / f"{safe_name}.png" - - # Security check: ensure the resolved path is still within IMAGE_CACHE_DIR - try: - cache_file_resolved = cache_file.resolve() - if not str(cache_file_resolved).startswith(str(IMAGE_CACHE_DIR.resolve())): - logger.warning( - f"Path traversal attempt detected for profile: {profile_name}" - ) - return - except Exception as e: - logger.warning(f"Failed to resolve cache path for {profile_name}: {e}") - return - - try: - cache_file.write_bytes(image_data) - logger.info( - f"Cached image for profile: {profile_name} ({len(image_data)} bytes)" - ) - except Exception as e: - logger.warning(f"Failed to cache image for {profile_name}: {e}") diff --git a/apps/server/services/compass_rules.py b/apps/server/services/compass_rules.py deleted file mode 100644 index a6826d85..00000000 --- a/apps/server/services/compass_rules.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Deterministic Espresso-Compass taste -> dial-in adjustment rules (D6). - -X axis: -1 sour ... +1 bitter. Y axis: -1 weak/thin ... +1 strong/heavy. -Mirrors apps/web/src/lib/compassRules.ts and the domain knowledge in -prompt_builder.build_taste_context. Pure function — no I/O. -""" - -from __future__ import annotations - -DEADBAND = 0.25 # within this radius of center, taste is considered balanced - - -def compass_adjustments(taste_x: float, taste_y: float) -> list[dict]: - """Return ordered, concrete adjustment suggestions for the given taste vector.""" - adjustments: list[dict] = [] - - # X axis — acidity/bitterness balance, primarily extraction yield. - if taste_x <= -DEADBAND: # too sour -> under-extracted -> extract more - adjustments.append( - { - "kind": "grind_finer", - "axis": "x", - "reason": "Sour/acidic indicates under-extraction; grind finer to raise yield.", - } - ) - adjustments.append( - { - "kind": "temp_up", - "axis": "x", - "reason": "A few degrees hotter increases extraction of sweet/bitter compounds.", - } - ) - elif taste_x >= DEADBAND: # too bitter -> over-extracted -> extract less - adjustments.append( - { - "kind": "grind_coarser", - "axis": "x", - "reason": "Bitter/harsh indicates over-extraction; grind coarser to lower yield.", - } - ) - adjustments.append( - { - "kind": "temp_down", - "axis": "x", - "reason": "A few degrees cooler reduces harsh bitter extraction.", - } - ) - - # Y axis — strength/body, primarily ratio/dose. - if taste_y <= -DEADBAND: # too weak/thin -> increase concentration - adjustments.append( - { - "kind": "ratio_up", - "axis": "y", - "reason": "Weak/thin body; lower the brew ratio (less water per dose) for more concentration.", - } - ) - adjustments.append( - { - "kind": "dose_up", - "axis": "y", - "reason": "A larger dose increases strength and body.", - } - ) - elif taste_y >= DEADBAND: # too strong/heavy -> dilute - adjustments.append( - { - "kind": "ratio_down", - "axis": "y", - "reason": "Strong/heavy; raise the brew ratio (more water per dose) to lighten.", - } - ) - - return adjustments diff --git a/apps/server/services/decent_converter.py b/apps/server/services/decent_converter.py deleted file mode 100644 index c39aec78..00000000 --- a/apps/server/services/decent_converter.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Decent Espresso profile converter. - -Converts Decent Espresso profile JSON (v2 format) to the Meticulous -espresso-profile-schema format used by the Meticulous machine. - -Decent profiles use a flat "steps" array with "pump" mode to indicate -whether a step targets flow or pressure. Meticulous profiles use a -"stages" array where each stage has a "type" field ("flow" or "pressure") -and structured "dynamics", "exit_triggers", and "limits" sub-objects. -""" - -import logging -import uuid -from typing import Any - -logger = logging.getLogger(__name__) - - -def detect_decent_format(data: Any) -> bool: - """Detect if a profile dict is in Decent Espresso format. - - Decent profiles are characterised by a top-level ``steps`` array - where individual steps contain ``pump`` and/or ``sensor`` fields - (neither of which exists in the Meticulous schema). - """ - if not isinstance(data, dict): - return False - steps = data.get("steps") - if not steps or not isinstance(steps, list): - return False - return any( - isinstance(s, dict) and ("pump" in s or "sensor" in s) - for s in steps - ) - - -def convert_decent_to_meticulous(data: dict) -> dict: - """Convert a Decent Espresso profile to Meticulous format. - - Returns a dict with two keys: - - ``profile``: the converted Meticulous profile dict (ready for - ``_normalize_profile_for_machine``). - - ``warnings``: a list of human-readable warning strings about - lossy or unsupported conversions. - """ - warnings: list[str] = [] - stages: list[dict] = [] - - for i, step in enumerate(data.get("steps", [])): - if not isinstance(step, dict): - warnings.append(f"Step {i}: not a dict, skipped") - continue - stage = _convert_step(step, i, warnings) - if stage: - stages.append(stage) - - if not stages: - warnings.append("No stages could be converted") - - # Build the top-level Meticulous profile - profile: dict[str, Any] = { - "id": str(uuid.uuid4()), - "name": data.get("title", "Imported Decent Profile"), - "author": data.get("author", "Decent Import"), - "temperature": _first_temperature(data), - "final_weight": _first_weight_target(data), - "stages": stages, - "variables": [], - "previous_authors": [], - } - - notes = data.get("notes", "") - if notes: - profile["display"] = { - "description": notes, - "shortDescription": notes[:99] if len(notes) > 99 else notes, - } - - return {"profile": profile, "warnings": warnings} - - -# ── Internal helpers ───────────────────────────────────────────────────────── - - -def _first_temperature(data: dict) -> float: - """Extract the first temperature value from a Decent profile.""" - for step in data.get("steps", []): - if isinstance(step, dict) and "temperature" in step: - try: - return float(step["temperature"]) - except (ValueError, TypeError): - pass - return 93.0 - - -def _first_weight_target(data: dict) -> float: - """Extract the first weight exit target (final_weight) from a Decent profile.""" - for step in data.get("steps", []): - if not isinstance(step, dict): - continue - exit_cond = step.get("exit", {}) - weight = _find_weight_in_exit(exit_cond) - if weight is not None: - return weight - return 36.0 - - -def _find_weight_in_exit(exit_data: dict | None) -> float | None: - """Recursively search exit conditions for a weight target.""" - if not exit_data or not isinstance(exit_data, dict): - return None - if exit_data.get("type") == "weight_over": - try: - return float(exit_data["condition"]) - except (ValueError, TypeError, KeyError): - pass - return _find_weight_in_exit(exit_data.get("or")) - - -def _convert_step(step: dict, index: int, warnings: list[str]) -> dict | None: - """Convert a single Decent step to a Meticulous stage.""" - pump = step.get("pump", "pressure") - - # Determine stage type - if pump == "flow": - stage_type = "flow" - elif pump == "pressure": - stage_type = "pressure" - else: - warnings.append( - f"Step {index}: unknown pump type '{pump}', defaulting to pressure" - ) - stage_type = "pressure" - - stage: dict[str, Any] = { - "name": step.get("name", f"Stage {index + 1}"), - "type": stage_type, - "key": f"{stage_type}_{index}", - "temperature": step.get("temperature", 93.0), - "limits": [], - } - - # Build dynamics - if stage_type == "flow": - target_value = _safe_float(step.get("flow"), 4.0) - else: - target_value = _safe_float(step.get("pressure"), 9.0) - - # Handle ramp transitions: if "smooth" and the step has seconds, - # create two points [0, start] → [seconds, target]. - transition = step.get("transition", "fast") - seconds = _safe_float(step.get("seconds"), 0) - - if transition == "smooth" and seconds > 0: - points = [[0.0, 0.0], [seconds, target_value]] - else: - points = [[0.0, target_value]] - - stage["dynamics"] = { - "type": stage_type, - "over": "time", - "interpolation": "linear", - "points": points, - } - - # Map exit conditions - exit_cond = step.get("exit", {}) - stage["exit_triggers"] = _convert_exit(exit_cond, index, warnings) - - # If there are no exit triggers but a seconds value, add a time trigger - if not stage["exit_triggers"] and seconds > 0: - stage["exit_triggers"] = [ - {"type": "time", "value": seconds, "relative": True, "comparison": ">="} - ] - - return stage - - -def _convert_exit( - exit_data: dict | None, step_index: int, warnings: list[str] -) -> list[dict]: - """Convert Decent exit conditions to Meticulous exit triggers.""" - triggers: list[dict] = [] - if not exit_data or not isinstance(exit_data, dict): - return triggers - - exit_type = exit_data.get("type", "") - condition = _safe_float(exit_data.get("condition"), 0) - - type_map: dict[str, tuple[str, str | None]] = { - "pressure_over": ("pressure", "above"), - "pressure_under": ("pressure", "below"), - "flow_over": ("flow", "above"), - "flow_under": ("flow", "below"), - "time_over": ("time", None), - "weight_over": ("weight", None), - "volume_over": ("volume", None), - } - - if exit_type in type_map: - mapped_type, direction = type_map[exit_type] - trigger: dict[str, Any] = { - "type": mapped_type, - "value": condition, - "relative": mapped_type == "time", - "comparison": ">=", - } - if direction: - trigger["direction"] = direction - triggers.append(trigger) - elif exit_type: - warnings.append(f"Step {step_index}: unknown exit type '{exit_type}'") - - # Handle chained OR conditions - or_cond = exit_data.get("or") - if or_cond and isinstance(or_cond, dict): - triggers.extend(_convert_exit(or_cond, step_index, warnings)) - - return triggers - - -def _safe_float(value: Any, default: float = 0.0) -> float: - """Safely convert a value to float, returning default on failure.""" - if value is None: - return default - try: - return float(value) - except (ValueError, TypeError): - return default diff --git a/apps/server/services/dialin_service.py b/apps/server/services/dialin_service.py deleted file mode 100644 index e6862b80..00000000 --- a/apps/server/services/dialin_service.py +++ /dev/null @@ -1,277 +0,0 @@ -"""Dial-In Guide session management service.""" - -from __future__ import annotations - -import asyncio -import json -from logging_config import get_logger -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Optional -from uuid import uuid4 - -from config import DATA_DIR -from models.dialin import ( - CoffeeDetails, - DialInIteration, - DialInSession, - SessionStatus, - TasteFeedback, -) - -logger = get_logger() - -# In-memory session store keyed by session id -_sessions: dict[str, DialInSession] = {} - -# Bounds: max sessions in memory and TTL for completed sessions -MAX_SESSIONS = 200 -COMPLETED_SESSION_TTL = timedelta(days=7) - -# Module-level asyncio lock with lazy creation. -# Recreated when the running event loop changes so that tests using a new -# loop per function don't hit "attached to a different loop". -_state_lock: Optional[asyncio.Lock] = None -_state_lock_loop: Optional[asyncio.AbstractEventLoop] = None - - -def _get_state_lock() -> asyncio.Lock: - """Return the module-level state lock, (re)creating it when needed.""" - global _state_lock, _state_lock_loop - try: - running_loop = asyncio.get_running_loop() - except RuntimeError: - running_loop = None - - if _state_lock is None or ( - running_loop is not None and running_loop is not _state_lock_loop - ): - _state_lock = asyncio.Lock() - _state_lock_loop = running_loop - return _state_lock - - -# --------------------------------------------------------------------------- -# Persistence helpers -# --------------------------------------------------------------------------- - -_PERSISTENCE_FILE: Path = DATA_DIR / "dialin_sessions.json" - - -async def _persist() -> None: - """Write active sessions to disk atomically (tmp + rename).""" - try: - DATA_DIR.mkdir(parents=True, exist_ok=True) - - active = { - sid: session.model_dump(mode="json") - for sid, session in _sessions.items() - if session.status == SessionStatus.ACTIVE - } - - tmp = _PERSISTENCE_FILE.with_suffix(".tmp") - with open(tmp, "w") as f: - json.dump(active, f, indent=2) - tmp.replace(_PERSISTENCE_FILE) - - logger.debug("Persisted %d active dial-in sessions", len(active)) - except Exception as e: - logger.error("Failed to persist dial-in sessions: %s", e, exc_info=True) - - -async def _load() -> None: - """Load sessions from disk on startup.""" - try: - if not _PERSISTENCE_FILE.exists(): - logger.info("No persisted dial-in sessions found (first run)") - return - - with open(_PERSISTENCE_FILE, "r") as f: - data = json.load(f) - - if not isinstance(data, dict): - logger.warning("Invalid dial-in sessions file format, ignoring") - return - - for sid, raw in data.items(): - try: - _sessions[sid] = DialInSession.model_validate(raw) - except Exception as e: - logger.warning("Skipping corrupt dial-in session %s: %s", sid, e) - - logger.info("Loaded %d dial-in sessions from disk", len(_sessions)) - except json.JSONDecodeError as e: - logger.error("Corrupt dial-in sessions file, ignoring: %s", e) - try: - backup = _PERSISTENCE_FILE.with_suffix(".corrupt") - _PERSISTENCE_FILE.rename(backup) - logger.info("Backed up corrupt file to %s", backup) - except Exception: - pass - except Exception as e: - logger.error("Failed to load dial-in sessions: %s", e, exc_info=True) - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - - -def _prune_expired_sessions() -> int: - """Remove completed sessions older than COMPLETED_SESSION_TTL. - - Must be called while holding _get_state_lock(). - Returns number of sessions removed. - """ - now = datetime.now(timezone.utc) - expired = [ - sid - for sid, s in _sessions.items() - if s.status in (SessionStatus.COMPLETED, SessionStatus.ABANDONED) - and (now - s.updated_at) > COMPLETED_SESSION_TTL - ] - for sid in expired: - del _sessions[sid] - if expired: - logger.info("Pruned %d expired dial-in session(s)", len(expired)) - return len(expired) - - -async def create_session( - coffee: CoffeeDetails, - profile_name: Optional[str] = None, -) -> DialInSession: - """Create a new dial-in session. Returns the session with a UUID id.""" - now = datetime.now(timezone.utc) - session = DialInSession( - id=uuid4().hex[:12], - coffee=coffee, - profile_name=profile_name, - iterations=[], - status=SessionStatus.ACTIVE, - created_at=now, - updated_at=now, - ) - - async with _get_state_lock(): - # Prune expired sessions and enforce bounds - _prune_expired_sessions() - if len(_sessions) >= MAX_SESSIONS: - # Drop oldest completed sessions first, then oldest abandoned - oldest = sorted( - ( - (sid, s) - for sid, s in _sessions.items() - if s.status != SessionStatus.ACTIVE - ), - key=lambda x: x[1].updated_at, - ) - if oldest: - del _sessions[oldest[0][0]] - logger.warning( - "Evicted oldest non-active session to stay within bounds" - ) - - _sessions[session.id] = session - await _persist() - - logger.info("Created dial-in session %s", session.id) - return session - - -async def get_session(session_id: str) -> Optional[DialInSession]: - """Get session by id. Returns None if not found.""" - async with _get_state_lock(): - return _sessions.get(session_id) - - -async def list_sessions( - status: Optional[SessionStatus] = None, -) -> list[DialInSession]: - """List all sessions, optionally filtered by status.""" - async with _get_state_lock(): - if status is None: - return list(_sessions.values()) - return [s for s in _sessions.values() if s.status == status] - - -async def add_iteration( - session_id: str, - taste: TasteFeedback, - shot_ref: Optional[str] = None, -) -> DialInIteration: - """Add a taste iteration to a session. Auto-increments iteration_number.""" - async with _get_state_lock(): - session = _sessions.get(session_id) - if session is None: - raise ValueError(f"Session {session_id} not found") - if session.status != SessionStatus.ACTIVE: - raise ValueError(f"Session {session_id} is not active") - - iteration = DialInIteration( - iteration_number=len(session.iterations) + 1, - shot_ref=shot_ref, - taste=taste, - recommendations=[], - timestamp=datetime.now(timezone.utc), - ) - session.iterations.append(iteration) - session.updated_at = datetime.now(timezone.utc) - await _persist() - - logger.info( - "Added iteration %d to session %s", - iteration.iteration_number, - session_id, - ) - return iteration - - -async def update_recommendations( - session_id: str, - iteration_number: int, - recommendations: list[str], -) -> DialInIteration: - """Update the recommendations for a specific iteration.""" - async with _get_state_lock(): - session = _sessions.get(session_id) - if session is None: - raise ValueError(f"Session {session_id} not found") - - for iteration in session.iterations: - if iteration.iteration_number == iteration_number: - iteration.recommendations = recommendations - session.updated_at = datetime.now(timezone.utc) - await _persist() - return iteration - - raise ValueError( - f"Iteration {iteration_number} not found in session {session_id}" - ) - - -async def complete_session(session_id: str) -> DialInSession: - """Mark session as completed.""" - async with _get_state_lock(): - session = _sessions.get(session_id) - if session is None: - raise ValueError(f"Session {session_id} not found") - - session.status = SessionStatus.COMPLETED - session.updated_at = datetime.now(timezone.utc) - await _persist() - - logger.info("Completed dial-in session %s", session_id) - return session - - -async def delete_session(session_id: str) -> bool: - """Delete a session. Returns True if found and deleted.""" - async with _get_state_lock(): - if session_id not in _sessions: - return False - del _sessions[session_id] - await _persist() - - logger.info("Deleted dial-in session %s", session_id) - return True diff --git a/apps/server/services/gemini_service.py b/apps/server/services/gemini_service.py deleted file mode 100644 index 788a8623..00000000 --- a/apps/server/services/gemini_service.py +++ /dev/null @@ -1,845 +0,0 @@ -"""Gemini service for AI model configuration and prompt building.""" - -from google import genai -import asyncio -import hashlib -import os -import re -from typing import Optional -from services.settings_service import get_author_name -from logging_config import get_logger - -logger = get_logger() - - -class ModelUnavailableError(RuntimeError): - """Raised when no compatible Gemini model can be found.""" - - -# Lazy-loaded Gemini client -_gemini_client: Optional[genai.Client] = None -_DEFAULT_MODEL = "gemini-2.5-flash" - - -def get_model_name() -> str: - """Return the configured model name for the active provider, resolved now. - - For Gemini, reads ``GEMINI_MODEL`` from the environment on every call so - that hot-reloaded service restarts pick up changes (blank values fall back - to the default). For non-Gemini providers, delegates to the provider - registry (``AI_MODEL`` / provider default). - """ - from services.ai_providers import get_provider_model, is_gemini_active - - if not is_gemini_active(): - return get_provider_model() - value = os.environ.get("GEMINI_MODEL", "").strip() - return value or _DEFAULT_MODEL - - -async def validate_model(model_name: str) -> bool: - """Check if a model is available via the Gemini API.""" - try: - client = get_gemini_client() - except ValueError: - return False - try: - await asyncio.to_thread(client.models.get, model=model_name) - return True - except Exception as e: - logger.warning("Model %s unavailable: %s", model_name, e) - return False - - -async def get_available_models() -> list[dict]: - """Return list of available models suitable for text generation. - - For non-Gemini providers, delegates to the OpenAI-compatible provider's - ``/models`` endpoint; for Gemini, queries the Gemini SDK and filters to - text-capable models. - """ - from services.ai_providers import is_gemini_active, list_provider_models - - if not is_gemini_active(): - return await list_provider_models() - - try: - client = get_gemini_client() - except ValueError: - return [] - try: - models = await asyncio.to_thread(lambda: list(client.models.list())) - result = [] - for m in models: - if hasattr(m, "supported_actions") and "generateContent" in ( - m.supported_actions or [] - ): - if not _is_text_model(_model_short_name(m.name or "")): - continue - result.append( - { - "id": m.name, - "display_name": m.display_name or m.name, - "description": m.description or "", - } - ) - return result - except Exception as e: - logger.error("Failed to list models: %s", e) - return [] - - -# Name fragments that identify non-text model families to skip. -_NON_TEXT_FRAGMENTS = ( - "embedding", - "aqa", - "imagen", - "image", - "tts", - "computer-use", - "robotics", -) -# Patterns that mark a model as preview/experimental/dated-snapshot (unstable). -_UNSTABLE_RE = re.compile(r"(preview|experimental|-exp\b|exp$|-\d{2}-\d{2}|-\d{3,4}$)") - - -def _model_short_name(name: str) -> str: - """Strip a leading 'models/' prefix and lowercase.""" - return name.split("/")[-1].strip().lower() - - -def _is_text_model(short: str) -> bool: - """True only for Gemini text-chat models. - - Requires the ``gemini-`` prefix (excludes non-Gemini families such as - gemma, lyria, nano-banana, deep-research and antigravity) and rejects - special-purpose Gemini variants (image, tts, computer-use, robotics, …) - via ``_NON_TEXT_FRAGMENTS``. - """ - if not short.startswith("gemini-"): - return False - return not any(f in short for f in _NON_TEXT_FRAGMENTS) - - -def rank_models(models: list[dict]) -> Optional[str]: - """Pick the best generateContent-capable model from a discovered list. - - Heuristic: prefer stable over preview/experimental; within a tier prefer - flash > flash-lite > pro > other; within a class prefer the highest - gemini-. version. Returns the model id (without the - 'models/' prefix) or None if nothing compatible remains. - - The ``-\\d{3,4}$`` / dated-snapshot patterns in ``_UNSTABLE_RE`` intentionally - treat pinned version snapshots (e.g. ``gemini-2.0-flash-001``) as lower-priority - than the floating stable alias (e.g. ``gemini-2.5-flash``); this is by design so - the resolver prefers the auto-updating stable alias over a frozen snapshot. - - Model *class* (flash > flash-lite > pro > other) takes precedence over version - number — a lower-tier model of any generation beats a higher-tier model, because - cost and latency outweigh marginal quality for this summarization use case. - """ - candidates = [] - for m in models: - raw = m.get("id") or "" - short = _model_short_name(raw) - if not _is_text_model(short): - continue - candidates.append(short) - if not candidates: - return None - - def score(short: str): - unstable = 1 if _UNSTABLE_RE.search(short) else 0 - if "flash-lite" in short: - cls = 1 - elif "flash" in short: - cls = 0 - elif "pro" in short: - cls = 2 - else: - cls = 3 - vm = re.search(r"gemini-(\d+)\.(\d+)", short) - major, minor = (int(vm.group(1)), int(vm.group(2))) if vm else (0, 0) - # Lower tuple sorts first: stable, then class, then highest version, then name. - return (unstable, cls, -major, -minor, short) - - return min(candidates, key=score) - - -async def get_working_model() -> str: - """Return a working model id, validating the configured model first and - falling back to dynamic discovery via the live models list. - - Raises ModelUnavailableError when no compatible model can be found. - """ - configured = get_model_name() - if await validate_model(configured): - _validated_model_cache["model"] = configured - return configured - - logger.warning("Configured model '%s' unavailable, discovering alternatives…", configured) - best = rank_models(await get_available_models()) - if best: - logger.info("Selected fallback model via discovery: %s", best) - _validated_model_cache["model"] = best - return best - - logger.error("No compatible Gemini model found via discovery!") - raise ModelUnavailableError("No compatible Gemini model is available for this API key.") - - -# Cache for the last validated working model -_validated_model_cache: dict[str, str] = {} - - -def get_working_model_sync() -> str: - """Return cached working model or fall back to get_model_name(). - - Avoids async validation in sync contexts. The cache is populated - by ``get_working_model()`` (called at startup and via the /available-models - endpoint) so the first generation request always uses a validated model. - """ - return _validated_model_cache.get("model", get_model_name()) - - -def get_working_model_force() -> str: - """Synchronously re-resolve a working model, bypassing the cache. - - Runs the async resolver in a fresh event loop (safe because the SDK call - is already off the asyncio loop in a thread executor). - """ - _validated_model_cache.pop("model", None) - return asyncio.run(get_working_model()) - - -# Noise prefixes to filter from error messages (used by parse_gemini_error) -_GEMINI_NOISE_PREFIXES = ( - "YOLO mode is enabled", - "Hook registry initialized", - "Error executing tool ", -) - -# Shared espresso profiling knowledge for LLM context. -# Used by shot analysis, profile description generation, and description conversion. -# This is the full Advanced Espresso Profiling Guide, also served by the -# get_profiling_knowledge MCP tool (topic="guide"). -PROFILING_KNOWLEDGE = """# Advanced Espresso Profiling Guide for the Meticulous Machine - -This reference is designed for creating and executing precise espresso profiles using the Meticulous Home Espresso machine, a digitally controlled robotic lever system that offers unparalleled control over flow, pressure, and temperature. - -## 1. Core Concepts: A Deeper Dive - -To build precise profiles, a granular understanding of the key variables and their interplay is essential. The Meticulous machine controls these variables directly, rather than through manual approximation. - -### Variable Control - -**Flow Rate (ml/s)** - The Primary Driver of Extraction -- Controls the speed of water delivery to the puck -- Higher flow rate increases extraction speed and highlights acidity and clarity -- Lower flow rate allows longer contact time, building body and sweetness -- Meticulous Control: Digital motor controls lever descent, allowing direct flow rate programming - -**Pressure (bar)** - The Result of Flow vs. Resistance -- Pressure builds as water flow meets puck resistance -- Crucial for creating texture, mouthfeel, and crema -- High pressure increases body but risks channeling if not managed -- Meticulous Control: Can target specific pressure with sensors measuring force and motor adjusting lever - -**Temperature (°C)** - The Catalyst for Solubility -- Dictates which flavor compounds dissolve from coffee grounds -- Lighter roasts: Higher temperatures (92-96°C) needed for sweetness -- Darker roasts: Lower temperatures (82-90°C) reduce bitterness -- Meticulous Control: High-precision PID temperature control for boiler and heated grouphead - -### Understanding Puck Dynamics - -The coffee puck evolves throughout extraction: - -1. **Initial Saturation**: Dry grounds swell and release CO2. Uneven wetting causes channeling. -2. **Peak Resistance**: Early in shot, puck offers maximum resistance. -3. **Puck Erosion**: As compounds dissolve, puck integrity weakens, resistance decreases. -4. **Fines Migration**: Microscopic particles can clog filter, temporarily increasing resistance. - -A flat, static profile fails to account for this evolution. Dynamic profiles adapt to the puck's changing state. - -## 2. A Phased Approach to Profile Building - -Break down every shot into four distinct, controllable phases: - -### Phase 1: Pre-infusion -- **Goal**: Gently and evenly saturate entire puck to prevent channeling -- **Control**: Flow Rate -- **Target Flow**: 2-4 ml/s -- **Target Pressure Limit**: ~2 bar -- **Duration**: Until first drops appear, or specific volume (5-8 ml) delivered -- **Exit (#420, native triggers)**: end on a native weight trigger at ≈ 2 × dose - (water/dose correlation) AND a native pressure trigger (~2 bar, pressure rise), - with a time safety backup — whichever fires first - -### Phase 2: Bloom (Dwell) - Optional -- **Goal**: Allow saturated puck to rest, releasing CO2, enabling deeper penetration -- **Control**: Time (zero flow) -- **Holding Pressure**: 0.5-1.5 bar (prevents puck unseating) -- **Duration**: 5-30 seconds (fresher coffee = longer bloom) - -### Phase 3: Infusion (Ramp & Hold) -- **Goal**: Extract core body, sweetness, and desired acidity -- **Control**: Pressure or Flow Rate -- **Pressure Target**: Ramp to 6-9 bar, hold until desired extraction ratio -- **Flow Target**: 1.5-3 ml/s, let pressure be variable -- **Most critical phase for flavor development** - -### Phase 4: Tapering (Ramp Down) -- **Goal**: Gently finish extraction, minimizing bitterness and astringency -- **Control**: Pressure or Flow Rate -- **Action**: Gradually decrease pressure (e.g., 9 bar to 4 bar) or reduce flow (e.g., 2 ml/s to 1 ml/s) -- **Duration**: Final 1/3 of shot's volume - -## 3. Espresso Profile Blueprints - -### Blueprint 1: The "Classic Lever" -**Best for**: Medium to Medium-Dark Roasts -**Goal**: Balanced, full-bodied shot with rich crema and chocolate/caramel notes - -**Profile Steps**: -1. Pre-infusion: Flow @ 3 ml/s, end when pressure reaches 2.0 bar OR weight reaches 2× dose (time backup) -2. Infusion: Pressure @ 9.0 bar, end when 25g yielded -3. Tapering: Linearly decrease pressure 9.0 bar to 5.0 bar, end when 36g total - -### Blueprint 2: The "Turbo Shot" -**Best for**: Light Roasts, Single Origins -**Goal**: Bright, clear, acidic shot highlighting floral and fruit notes - -**Profile Steps**: -1. Pre-infusion: Flow @ 6 ml/s, end when pressure reaches 1.5 bar OR weight reaches 2× dose (time backup) -2. Infusion: Pressure @ 6.0 bar, end after 15 seconds total -3. Tapering: Linearly decrease pressure 6.0 bar to 3.0 bar, end when 54g total (1:3 ratio) - -### Blueprint 3: The "Soup" Shot (Allongé) -**Best for**: Very Light / Experimental Roasts (high-acidity Geshas) -**Goal**: Tea-like, highly clarified extraction with no bitterness - -**Profile Steps**: -1. Pre-wet: Flow @ 4 ml/s, end when 10g yielded -2. Infusion: Flow @ 8 ml/s, end when 72g total (1:4 ratio) -Note: No pressure target, entirely flow-controlled - -### Blueprint 4: The "Bloom & Extract" -**Best for**: Very Freshly Roasted Coffee (<7 days from roast) -**Goal**: Manage excess CO2 for even extraction and sweetness - -**Profile Steps**: -1. Pre-infusion: Flow @ 3 ml/s, end when pressure reaches 2.0 bar OR weight reaches 2× dose (time backup) -2. Bloom: Hold lever position (zero flow) for 20 seconds -3. Infusion: Pressure @ 8.0 bar, end when 30g yielded -4. Tapering: Linearly decrease pressure 8.0 bar to 4.0 bar, end when 38g total - -## 4. Advanced Troubleshooting & Adaptation - -### Sour, thin, salty (Under-extracted) -**Likely Cause**: Insufficient contact time or energy -**Solutions**: -1. Increase Infusion Pressure/Flow: 8 bar -> 9 bar, or 2 ml/s -> 2.5 ml/s -2. Extend Infusion Time: Increase yield before tapering begins -3. Increase Temperature: 92°C -> 94°C - -### Bitter, astringent, dry (Over-extracted) -**Likely Cause**: Puck channeled or too much extraction at end -**Solutions**: -1. Lower Infusion Pressure: 9 bar -> 8 bar -2. Taper Earlier/Aggressively: Start ramp-down sooner or decrease to lower final pressure -3. Lower Temperature: 94°C -> 92°C - -### Shot starts too fast (gushing) -**Likely Cause**: Grind too coarse, or pre-infusion too aggressive -**Solutions**: -1. Grind Finer (primary fix) -2. Decrease Pre-infusion Flow: 4 ml/s -> 2 ml/s - -### Shot chokes (starts too slow) -**Likely Cause**: Grind too fine -**Solutions**: -1. Grind Coarser -2. Add bloom phase to help water penetrate -3. Increase initial infusion pressure to push through resistance - -## 5. Profile Design Principles & Best Practices - -### Control Strategy: Flow vs Pressure - -**Flow-Controlled Profiles**: -- More adaptive to puck resistance - automatically adjusts to grind variations -- Better for consistent results across different coffees -- Flow rate determines extraction speed directly -- Use when: Working with variable beans, different grinders, or seeking adaptability - -**Pressure-Controlled Profiles**: -- More predictable pressure curves, traditional espresso approach -- Requires precise grind matching for optimal results -- Better for: Specific flavor profile targeting, traditional lever machine emulation -- Use when: You have dialed-in grind and want precise control over texture/body - -**Hybrid Approach**: -- Use pressure control with flow limits (safety bounds) -- Use flow control with pressure limits (prevent channeling) -- Best of both worlds: responsive with safety guards - -### Stage Transition Design - -**Pre-infusion Exit Strategy**: -- Use pressure threshold (<= 2 bar) OR flow threshold (>= 0.2 ml/s) OR weight threshold (>= 0.3g) -- Multiple triggers ensure stage exits when saturation achieved, not on exact timing - -**Pre-infusion Saturation Rules (#420) — express with NATIVE machine triggers**: -The machine only honors native exit trigger types ("weight", "pressure", "flow", -"time"). Implement the following intent using those native types so the machine -actually acts on them — do NOT emit app-only pseudo triggers. -- **Dose/water correlation → native WEIGHT trigger**: A puck needs roughly twice - its dose in water to fully saturate (e.g., an 18 g dose absorbs ~36 ml). Add a - weight exit trigger with value ≈ 2 × dose (comparison ">="), computing it from - the actual dose in the request. This ends pre-infusion once enough water has - been delivered, regardless of grind. -- **Pressure rise → native PRESSURE trigger**: As the puck saturates, resistance - builds and pressure climbs. Add a pressure exit trigger at a low threshold - (~2 bar, ">=") so the stage ends the moment pressure starts to rise. -- Always pair both with a TIME safety backup; whichever native trigger fires - first ends pre-infusion. - -**Infusion/Hold Exit Strategy**: -- Always use weight threshold with >= comparison for target yield -- Always include time-based safety timeout (prevents infinite extraction) -- Weight should be primary trigger, time is backup - -**Tapering Exit Strategy**: -- Use final target weight with >= comparison -- Include time limit to prevent over-extraction - -### Dynamics Point Design - -**Minimum Points Required**: -- Start point: [0, initial_value] -- End point: [duration, final_value] - -**Interpolation Strategy**: -- "linear": Predictable, easy to understand, good for most cases -- "curve": Smoother transitions, can feel more natural, good for lever-style profiles -- "none": Instant transitions (rarely needed) - -**Pressure Ramp Design**: -- Gentle ramps (3-4 seconds) prevent channeling -- Aggressive ramps (<2 seconds) can cause channeling but faster extraction - -**Pressure Decline Design**: -- Gradual decline (over 10-15 seconds) = smoother finish -- Steep decline (over 3-5 seconds) = faster finish, may extract more fines - -### Exit Trigger Best Practices - -**Always Use Comparison Operators**: -- Use >= for weight thresholds (responsive, exits when reached) -- Use >= for flow thresholds (responsive, exits when achieved) -- Use <= for pressure thresholds when pressure should drop -- Never rely on exact matches - they're unreliable and slow - -**Multiple Triggers = Safety & Responsiveness**: -- Primary trigger: The main goal (weight, flow, etc.) -- Secondary trigger: Safety timeout (time-based) -- Logical OR ensures the stage exits on the FIRST condition met - -**Relative vs Absolute Values**: -- Time exit triggers: ALWAYS use "relative": true (time relative to stage start). Never use absolute time. -- Relative weight: Value relative to stage start (useful for multi-stage recipes) -- Absolute weight: Total weight from shot start (easier to understand) -- Use absolute weight for clarity, relative weight only when needed for complex recipes -- dynamics_points x-axis values are always relative to stage start (0 = beginning of stage) - -### Temperature Considerations - -**Roast Level Matching**: -- Light roasts: Higher temp (92-96°C) needed for proper extraction -- Medium roasts: Balanced temp (90-93°C) -- Dark roasts: Lower temp (82-90°C) prevents over-extraction bitterness - -### Yield Target Design - -**Espresso Range** (25-40g): -- Classic espresso: 30-36g yield -- Ristretto: 20-25g yield -- Lungo: 40-50g yield - -**Extended Range** (40-100g): -- Sprover: 40-60g (hybrid espresso/pour-over) -- Soup/Allongé: 60-100g+ (tea-like extraction) - -**Yield Distribution Across Stages**: -- Pre-infusion: 5-10% of total yield -- Infusion: 60-75% of total yield -- Tapering: Remaining 20-30% - -### Common Anti-Patterns to Avoid - -**❌ Single Exit Trigger**: Only weight OR only time = risky. Include multiple triggers for safety. -**❌ Exact Match Triggers**: Waiting for exact weight (e.g., 30.0g) = unreliable. Use >= comparison. -**❌ Too Many Stages**: More than 5-6 stages = overcomplicated. 3-4 stages is usually optimal. -**❌ No Safety Timeouts**: Missing time-based triggers = risk of infinite extraction. -**❌ Pressure Spikes**: Sudden pressure jumps = channeling risk. Use gentle ramps (3+ seconds). -**❌ Recommending Weight Exit Triggers**: All Meticulous profiles automatically have a weight-based exit trigger at the overall profile level. NEVER recommend adding a weight exit trigger for the overall profile or for the final stage — it is always already present and handled by the machine firmware. Only recommend weight triggers for intermediate stages when needed. - -## 6. Equipment Factors - -- **Grind setting**: Primary extraction control. Fine = slower, more extraction -- **Basket type**: VST/IMS precision baskets vs stock baskets affect flow distribution -- **Bottom filter**: Paper filters reduce sediment but also oils (cleaner but thinner) -- **Puck prep**: WDT, leveling, and tamp consistency affect channeling risk -- **Grinder characteristics**: Particle distribution varies by grinder model, may need profile adjustments -""" - - -# Distilled version of the profiling knowledge for token-optimized prompts. -# Focuses on decision heuristics and practical rules rather than theory. -# ~2.5K chars vs ~10.8K chars for the full version. -PROFILING_KNOWLEDGE_DISTILLED = """\ -# Espresso Profiling Quick Reference - -## Roast → Profile Matching -- Light roast: higher temp (92-96°C), lower pressure (6-7 bar), higher ratio (1:2.5-1:3+), flow-controlled, turbo-style -- Medium roast: balanced temp (90-93°C), classic 9 bar pressure, standard ratio (1:2-1:2.5) -- Dark roast: lower temp (82-90°C), gentler pressure (7-8 bar), shorter ratio (1:1.5-1:2), avoid over-extraction -- Very fresh (<7 days): add bloom/dwell phase (5-30s at zero flow) to release CO2 - -## Four-Phase Structure -1. **Pre-infusion**: Flow 2-4 ml/s, pressure limit ~2 bar, exit on pressure threshold or weight ~5-8g - Saturation rules (#420), as NATIVE triggers: native weight exit at ≈ 2 × dose (water/dose correlation) AND native pressure exit (~2 bar, pressure rise), plus a time backup -2. **Bloom** (optional): Zero flow, hold 0.5-1.5 bar, 5-30s. Use for fresh coffee or light roasts -3. **Infusion**: Ramp to target pressure/flow. This is where 60-75% of yield extracts -4. **Taper**: Decline pressure/flow over final 20-30% of yield. Reduces bitterness and astringency - -## Profile Blueprints (compressed) -- **Classic Lever** (medium-dark): Pre-infuse→9 bar hold→taper 9→5 bar. Ratio 1:2 -- **Turbo** (light/single origin): Pre-infuse→6 bar→taper 6→3 bar. Ratio 1:3. Fast, bright, clear -- **Allongé/Soup** (very light): Pre-wet→high flow (8 ml/s). Ratio 1:4. Tea-like, no pressure target -- **Bloom & Extract** (very fresh): Pre-infuse→20s bloom→8 bar→taper 8→4 bar - -## Troubleshooting -- Sour/thin → increase pressure, extend extraction, raise temp -- Bitter/astringent → lower pressure, taper earlier, lower temp -- Gushing → grind finer, reduce pre-infusion flow -- Choking → grind coarser, add bloom phase, increase initial pressure - -## Control Strategy -- **Flow-controlled**: Adapts to puck resistance, forgiving of grind variance. Better for consistency -- **Pressure-controlled**: Traditional approach, needs precise grind. Better for body/texture targeting -- **Hybrid** (recommended): Pressure control with flow limits, or flow control with pressure limits - -## Key Design Rules -- Dynamics points x-axis is ALWAYS relative to stage start (0 = stage start) -- Gentle pressure ramps (3-4s) prevent channeling; aggressive (<2s) risk it -- Keep profiles to 3-4 stages (5-6 max). Simpler = more reliable -- Pre-infusion: ~5-10% of yield. Infusion: 60-75%. Taper: remaining 20-30% -- NEVER recommend adding a weight exit trigger for the overall profile or the final stage — all Meticulous profiles already have an automatic weight-based exit trigger handled by firmware -""" - - -def parse_gemini_error(error_text: str) -> str: - """Parse Gemini SDK/API error output and return a user-friendly message. - - Extracts the meaningful error message from verbose error details - for display to end users. - - Args: - error_text: Raw stderr output from the Gemini CLI - - Returns: - A clean, user-friendly error message - """ - error_text_lower = error_text.lower() - - # Check for deprecated / unavailable model errors (404 NOT_FOUND) - if ( - "no longer available" in error_text_lower - or ("not_found" in error_text_lower and "model" in error_text_lower) - or ("404" in error_text_lower and "model" in error_text_lower) - or "deprecated" in error_text_lower - ): - return ( - "The AI model is no longer available. Please update MeticAI to the " - "latest version, or set a custom GEMINI_MODEL in your environment." - ) - - # Check for quota errors - if "quota" in error_text_lower or "exhausted" in error_text_lower: - return ( - "Daily API quota exhausted. The free Gemini API has usage limits. " - "Please wait until tomorrow for your quota to reset, or upgrade to " - "a paid API plan at https://aistudio.google.com/" - ) - - # Check for rate limiting - if "rate limit" in error_text_lower or "too many requests" in error_text_lower: - return ( - "Rate limit exceeded. Too many requests in a short time. " - "Please wait a minute and try again." - ) - - # Check for authentication errors - if ( - "api key" in error_text_lower - or "api_key" in error_text_lower - or "authentication" in error_text_lower - or "unauthorized" in error_text_lower - or "auth method" in error_text_lower - or "set an auth" in error_text_lower - ): - return ( - "Gemini API key is not configured. Please go to Settings and " - "enter a valid GEMINI_API_KEY, then try again." - ) - - # Check for long-running generation / model stall patterns - # (must come before the general network/connection check which also - # matches 'timeout' — we want the more specific message here.) - if ( - "timed out after" in error_text_lower - or "deadline exceeded" in error_text_lower - or "took too long" in error_text_lower - ): - return ( - "Profile generation timed out. Please retry; if this repeats, " - "reduce prompt complexity or use a stronger Gemini model." - ) - - # Check for network/connection errors - if ( - "network" in error_text_lower - or "connection" in error_text_lower - or "timeout" in error_text_lower - ): - return ( - "Network error connecting to Gemini API. Please check your " - "internet connection and try again." - ) - - # Check for schema/validation failures produced during profile creation - if ( - "validation" in error_text_lower - or "schema" in error_text_lower - or "invalid profile" in error_text_lower - or "failed to validate" in error_text_lower - ): - return ( - "The AI generated a profile that failed schema validation. " - "Please retry; if this keeps happening, simplify preferences " - "or try a stronger model." - ) - - # Check for MCP/Meticulous connection errors - if "mcp" in error_text_lower or "meticulous" in error_text_lower: - if ( - "connection refused" in error_text_lower - or "cannot connect" in error_text_lower - ): - return ( - "Cannot connect to the Meticulous machine. Please ensure your " - "espresso machine is powered on and connected to the network." - ) - - # Check for content safety errors - if "safety" in error_text_lower or "blocked" in error_text_lower: - return ( - "Request was blocked by content safety filters. " - "Please try rephrasing your preferences." - ) - - # Try to extract a clean error message from stack trace - # Look for common error patterns - patterns = [ - r"Error:\s*(.+?)(?:\n|$)", - r"error:\s*(.+?)(?:\n|$)", - r"Exception:\s*(.+?)(?:\n|$)", - ] - - for pattern in patterns: - match = re.search(pattern, error_text, re.IGNORECASE) - if match: - extracted = match.group(1).strip() - # Don't return if it's just a file path or technical detail - if ( - len(extracted) > 10 - and not extracted.startswith("/") - and not extracted.startswith("file:") - ): - return extracted[:200] # Limit length - - # Fallback: strip noise lines before returning - clean_error = error_text - for prefix in _GEMINI_NOISE_PREFIXES: - clean_error = "\n".join( - line - for line in clean_error.split("\n") - if not line.strip().startswith(prefix) - ) - clean_error = clean_error.strip() - - if not clean_error: - return "Profile generation failed unexpectedly. Please try again." - if len(clean_error) > 150: - return f"Profile generation failed. Technical details: {clean_error[:100]}..." - - return f"Profile generation failed: {clean_error}" - - -def reset_vision_model(): - """Reset the cached Gemini client. - - Call this when the GEMINI_API_KEY changes so the next call to - get_gemini_client() will re-create with the new key. - """ - global _gemini_client - _gemini_client = None - - -def get_gemini_client() -> genai.Client: - """Lazily initialize and return the Gemini client.""" - global _gemini_client - if _gemini_client is None: - api_key = os.environ.get("GEMINI_API_KEY") - if not api_key: - raise ValueError( - "GEMINI_API_KEY environment variable is required but not set. " - "Please set it before starting the server." - ) - _gemini_client = genai.Client(api_key=api_key) - return _gemini_client - - -def is_ai_available() -> bool: - """Return True when an API key is configured for the active provider.""" - from services.ai_providers import is_provider_available - - return is_provider_available() - - -def get_vision_model(): - """Return a wrapper that provides the old model.generate_content() interface. - - Routes to the OpenAI-compatible provider when a non-Gemini provider is - active, otherwise returns the Gemini SDK wrapper. Both expose the same - ``generate_content`` / ``async_generate_content`` interface: - model = get_vision_model() - response = model.generate_content([prompt, image]) - text = response.text - """ - from services.ai_providers import OpenAICompatModel, is_gemini_active - - if not is_gemini_active(): - return OpenAICompatModel() - return _GeminiModelWrapper(get_gemini_client()) - - -class _GeminiModelWrapper: - """Thin wrapper around google.genai.Client to provide the old GenerativeModel interface.""" - - def __init__(self, client: genai.Client): - self._client = client - - def generate_content(self, contents): - """Call generate_content on the Gemini API (synchronous). - - On a model-not-found error, re-resolves the working model once via - ``get_working_model_force()`` and retries exactly once. - - Args: - contents: A string, list of strings, PIL images, or mixed list - (same format accepted by both old and new SDK). - - Returns: - GenerateContentResponse with .text attribute. - """ - try: - return self._client.models.generate_content( - model=get_working_model_sync(), - contents=contents, - ) - except Exception as e: - text = str(e).lower() - is_model_gone = ("not_found" in text and "model" in text) or ( - "404" in text and "model" in text - ) - if not is_model_gone: - raise - logger.warning("Model not found during generation; re-resolving…") - new_model = get_working_model_force() - return self._client.models.generate_content( - model=new_model, - contents=contents, - ) - - async def async_generate_content(self, contents): - """Non-blocking wrapper around generate_content. - - Runs the synchronous Gemini SDK call in a thread pool executor - so it doesn't block the asyncio event loop. - """ - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, self.generate_content, contents) - - -def get_author_instruction() -> str: - """Get the author instruction for profile creation prompts.""" - author = get_author_name() - return ( - f"AUTHOR:\n" - f"• Set the 'author' field in the profile JSON to: \"{author}\"\n" - f"• This name will appear as the profile creator on the Meticulous device\n\n" - ) - - -def build_advanced_customization_section(advanced_customization: Optional[str]) -> str: - """Build the advanced customization section for the prompt. - - These are MANDATORY equipment and extraction parameters that MUST be followed. - """ - if not advanced_customization: - return "" - - return ( - f"⚠️ MANDATORY EQUIPMENT & EXTRACTION PARAMETERS (MUST BE USED EXACTLY):\n" - f"{advanced_customization}\n\n" - f"CRITICAL: You MUST configure the profile to use these EXACT values. " - f"These are non-negotiable hardware/extraction constraints:\n" - f"• If a temperature is specified, set the profile temperature to that EXACT value\n" - f"• If a dose is specified, the profile MUST be designed for that EXACT dose\n" - f"• If max pressure/flow is specified, NO stage should exceed those limits\n" - f"• If basket size/type is specified, account for it in your dose and extraction design\n" - f"• If bottom filter is specified, mention it in preparation notes\n\n" - ) - - -# ============================================================================= -# Taste Compass — cache key helpers -# ============================================================================= - - -def compute_taste_hash( - taste_x: float | None, - taste_y: float | None, - taste_descriptors: list[str] | None, -) -> str | None: - """Return a short hash for taste data, or None when no taste input is present. - - Used to differentiate cached analyses: same shot *without* taste data keeps - its original cache entry, while taste-aware analyses get a separate one. - """ - has_coords = taste_x is not None and taste_y is not None - has_desc = bool(taste_descriptors) - if not has_coords and not has_desc: - return None - - parts: list[str] = [] - if has_coords: - parts.append(f"{taste_x:.4f},{taste_y:.4f}") - if has_desc: - parts.append(",".join(sorted(taste_descriptors))) - - raw = "|".join(parts) - return hashlib.sha256(raw.encode()).hexdigest()[:12] diff --git a/apps/server/services/generation_progress.py b/apps/server/services/generation_progress.py deleted file mode 100644 index 0b96094a..00000000 --- a/apps/server/services/generation_progress.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Generation progress tracking for real-time status updates. - -Provides an async event emitter that the profile generation flow can use -to push phase updates to connected SSE clients. -""" - -import asyncio -import time -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, AsyncIterator, Dict, Optional - - -class GenerationPhase(str, Enum): - """Phases of profile generation.""" - - QUEUED = "queued" - ANALYZING = "analyzing" - GENERATING = "generating" - VALIDATING = "validating" - RETRYING = "retrying" - UPLOADING = "uploading" - COMPLETE = "complete" - FAILED = "failed" - KEEPALIVE = "keepalive" - - -@dataclass -class ProgressEvent: - """A single progress event.""" - - phase: GenerationPhase - message: str - attempt: int = 0 # retry attempt number (0 = first try) - max_attempts: int = 3 # total attempts allowed - elapsed: float = 0.0 # seconds since generation started - result: Optional[Dict[str, Any]] = None - error: Optional[str] = None - - -@dataclass -class GenerationState: - """Tracks the state of an in-progress generation.""" - - generation_id: str - created_at: float = field(default_factory=time.monotonic) - events: list = field(default_factory=list) - _waiters: list = field(default_factory=list, repr=False) - _completed: bool = False - - def emit(self, event: ProgressEvent): - """Push a new progress event and notify all waiters.""" - event.elapsed = round(time.monotonic() - self.created_at, 1) - self.events.append(event) - - if event.phase in (GenerationPhase.COMPLETE, GenerationPhase.FAILED): - self._completed = True - - # Wake up all waiting SSE consumers - for waiter in self._waiters: - if not waiter.done(): - waiter.set_result(event) - self._waiters.clear() - - async def stream(self) -> AsyncIterator[ProgressEvent]: - """Async generator that yields events as they arrive. - - First replays any events that have already been emitted, then - blocks until new events arrive. - """ - # Replay existing events - for event in list(self.events): - yield event - - # Stream new events as they arrive - while not self._completed: - waiter: asyncio.Future = asyncio.get_running_loop().create_future() - self._waiters.append(waiter) - try: - event = await asyncio.wait_for(waiter, timeout=60) - yield event - except asyncio.TimeoutError: - # Yield keepalive so the SSE connection stays open for - # long-running generations (retries, slow models, etc.) - yield ProgressEvent( - phase=GenerationPhase.KEEPALIVE, message="keepalive" - ) - continue - finally: - # Remove the waiter so emit() doesn't set results on orphans - try: - self._waiters.remove(waiter) - except ValueError: - pass # already removed by emit() - - def to_dict(self) -> Dict[str, Any]: - """Serialize current state for SSE.""" - latest = self.events[-1] if self.events else None - return { - "generation_id": self.generation_id, - "phase": latest.phase.value if latest else "queued", - "message": latest.message if latest else "Waiting...", - "attempt": latest.attempt if latest else 0, - "max_attempts": latest.max_attempts if latest else 3, - "elapsed": latest.elapsed if latest else 0, - "events_count": len(self.events), - } - - -# In-memory store of active generations (only one at a time due to lock) -_active_generations: Dict[str, GenerationState] = {} - - -def create_generation(generation_id: str) -> GenerationState: - """Create and register a new generation state.""" - state = GenerationState(generation_id=generation_id) - _active_generations[generation_id] = state - return state - - -def get_generation(generation_id: str) -> Optional[GenerationState]: - """Get an active generation state by ID.""" - return _active_generations.get(generation_id) - - -def remove_generation(generation_id: str) -> None: - """Remove a completed generation from the store.""" - _active_generations.pop(generation_id, None) - - -def get_latest_generation() -> Optional[GenerationState]: - """Get the most recently created generation state.""" - if not _active_generations: - return None - return list(_active_generations.values())[-1] diff --git a/apps/server/services/history_service.py b/apps/server/services/history_service.py deleted file mode 100644 index 7a728932..00000000 --- a/apps/server/services/history_service.py +++ /dev/null @@ -1,314 +0,0 @@ -"""History service for managing profile creation history.""" - -import hashlib -import json -import re -import threading -import uuid -from datetime import datetime, timezone -from typing import Optional - -from logging_config import get_logger -from config import DATA_DIR -from utils.file_utils import atomic_write_json -from utils.sanitization import clean_profile_name - -logger = get_logger() - -HISTORY_FILE = DATA_DIR / "profile_history.json" - -# Reentrant lock shared across all history mutations (load-modify-save cycles). -# Importers (e.g. profiles.py, coffee.py) may acquire this lock externally -# while also calling helpers that acquire it internally — RLock allows that. -_history_lock = threading.RLock() - -# In-memory cache (loaded from disk on first access, write-through on save) -_history_cache: Optional[list] = None - - -def ensure_history_file(): - """Ensure the history file and directory exist.""" - HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) - if not HISTORY_FILE.exists(): - HISTORY_FILE.write_text("[]") - - -def load_history() -> list: - """Load history, using in-memory copy when available. - - Filters out malformed entries that lack required fields (e.g. test data - or pre-v2 entries that were never migrated properly). - """ - global _history_cache - if _history_cache is not None: - return _history_cache - ensure_history_file() - try: - with open(HISTORY_FILE, "r", encoding="utf-8") as f: - raw = json.load(f) - except (json.JSONDecodeError, FileNotFoundError): - raw = [] - - # Guard against the top-level value being a non-list (e.g. {} or null) - if not isinstance(raw, list): - logger.warning( - "History file contained %s instead of list — resetting", - type(raw).__name__, - ) - raw = [] - - # Filter out entries missing critical fields. A valid v2 entry always - # has at least 'id' and 'profile_name' (or 'reply' from which the name - # can be derived). Entries like {"id": "test123", "name": "TestProfile"} - # are test artefacts and must be dropped. - valid = [] - for entry in raw: - if not isinstance(entry, dict): - continue - if not entry.get("id"): - continue - # Must have at least profile_name or reply - if not entry.get("profile_name") and not entry.get("reply"): - logger.warning( - "Dropping malformed history entry (no profile_name or reply): id=%s", - entry.get("id"), - ) - continue - valid.append(entry) - - if len(valid) != len(raw): - logger.info( - "Filtered %d malformed entries from history (kept %d)", - len(raw) - len(valid), - len(valid), - ) - # Persist the cleaned list so bad entries don't come back - _history_cache = valid - save_history(valid) - else: - _history_cache = valid - - return _history_cache - - -def save_history(history: list): - """Write-through: update in-memory cache and persist to disk.""" - global _history_cache - _history_cache = history - ensure_history_file() - atomic_write_json(HISTORY_FILE, history) - - -def _extract_profile_json(reply: str) -> Optional[dict]: - """Extract the profile JSON from the LLM reply. - - Searches for JSON blocks in the reply, trying different patterns. - """ - # Try to find JSON in a code block first - json_block_pattern = r"```json\s*([\s\S]*?)```" - matches = re.findall(json_block_pattern, reply, re.IGNORECASE) - - for match in matches: - try: - parsed = json.loads(match.strip()) - # Check if it looks like a profile (has name, stages, etc.) - if isinstance(parsed, dict) and ("name" in parsed or "stages" in parsed): - return parsed - except json.JSONDecodeError: - continue - - # Try to find a generic code block - code_block_pattern = r"```\s*([\s\S]*?)```" - matches = re.findall(code_block_pattern, reply) - - for match in matches: - try: - parsed = json.loads(match.strip()) - if isinstance(parsed, dict) and ("name" in parsed or "stages" in parsed): - return parsed - except json.JSONDecodeError: - continue - - return None - - -def _extract_profile_name(reply: str) -> str: - """Extract the profile name from the LLM reply.""" - # Handle both **Profile Created:** and Profile Created: formats, with 0 or 2 asterisks - match = re.search( - r"(?:\*\*)?Profile Created:(?:\*\*)?\s*(.+?)(?:\n|$)", reply, re.IGNORECASE - ) - if match: - return clean_profile_name(match.group(1)) - return "Untitled Profile" - - -def save_to_history( - coffee_analysis: Optional[str], - user_prefs: Optional[str], - reply: str, - image_preview: Optional[str] = None, - profile_json_override: Optional[dict] = None, -) -> dict: - """Save a generated profile to history. - - Args: - coffee_analysis: The coffee bag analysis text - user_prefs: User preferences provided - reply: The full LLM reply - image_preview: Optional base64 image preview (thumbnail) - profile_json_override: When provided, store this as the canonical - ``profile_json`` instead of parsing from the LLM reply. This - should be the normalised JSON that was actually sent to the - machine so exports always match. - - Returns: - The saved history entry - """ - history = load_history() - - # Generate a unique ID - entry_id = str(uuid.uuid4()) - - # Use override when available; fall back to LLM-text extraction - if profile_json_override is not None: - profile_json = profile_json_override - profile_name = profile_json_override.get("name") or _extract_profile_name(reply) - else: - profile_json = _extract_profile_json(reply) - profile_name = _extract_profile_name(reply) - - # Create history entry - entry = { - "id": entry_id, - "created_at": datetime.now(timezone.utc).isoformat(), - "profile_name": profile_name, - "coffee_analysis": coffee_analysis, - "user_preferences": user_prefs, - "reply": reply, - "profile_json": profile_json, - "image_preview": image_preview, # Optional thumbnail - } - - # Store initial content hash for sync change detection. - # This is a best-effort hash from the local profile JSON — it will be - # replaced by the machine-consistent hash after upload succeeds. - if profile_json and isinstance(profile_json, dict): - entry["content_hash"] = compute_content_hash(profile_json) - - # Add to beginning of list (most recent first) - history.insert(0, entry) - - # Keep only last 100 entries to prevent file from growing too large - history = history[:100] - - save_history(history) - - logger.info( - f"Saved profile to history: {profile_name}", - extra={"entry_id": entry_id, "has_json": profile_json is not None}, - ) - - return entry - - -def update_entry_notes(entry_id: str, notes: str) -> Optional[dict]: - """Update the notes field for a history entry. - - Args: - entry_id: The ID of the entry to update. - notes: The new notes content (Markdown). Empty string clears notes. - - Returns: - The updated entry, or None if not found. - """ - history = load_history() - - for entry in history: - if entry.get("id") == entry_id: - if notes and notes.strip(): - entry["notes"] = notes.strip() - entry["notes_updated_at"] = datetime.now(timezone.utc).isoformat() - else: - # Clear notes if empty - entry.pop("notes", None) - entry.pop("notes_updated_at", None) - - save_history(history) - - logger.info( - f"Updated notes for history entry: {entry.get('profile_name', entry_id)}", - extra={"entry_id": entry_id, "has_notes": bool(notes)}, - ) - return entry - - logger.warning(f"History entry not found for notes update: {entry_id}") - return None - - -def get_entry_by_id(entry_id: str) -> Optional[dict]: - """Get a history entry by its ID. - - Args: - entry_id: The ID of the entry to find. - - Returns: - The entry dict, or None if not found. - """ - history = load_history() - for entry in history: - if entry.get("id") == entry_id: - return entry - return None - - -def compute_content_hash(profile_dict: dict) -> str: - """Compute a SHA-256 hash of a profile's JSON content for change detection. - - Produces a stable hash by sorting keys before serialisation so that - semantically identical profiles always yield the same digest. - """ - canonical = json.dumps(profile_dict, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - -def update_entry_sync_fields( - entry_id: str, - *, - content_hash: Optional[str] = None, - machine_updated_at: Optional[str] = None, - profile_json: Optional[dict] = None, - reply: Optional[str] = None, -) -> Optional[dict]: - """Update sync-related fields on a history entry. - - Any keyword argument that is not ``None`` will be written to the entry. - Thread-safe: acquires ``_history_lock`` internally. - - Returns: - The updated entry, or ``None`` if not found. - """ - with _history_lock: - history = load_history() - - for entry in history: - if entry.get("id") == entry_id: - if content_hash is not None: - entry["content_hash"] = content_hash - if machine_updated_at is not None: - entry["machine_updated_at"] = machine_updated_at - if profile_json is not None: - entry["profile_json"] = profile_json - if reply is not None: - entry["reply"] = reply - - save_history(history) - - logger.info( - f"Updated sync fields for history entry: {entry.get('profile_name', entry_id)}", - extra={"entry_id": entry_id, "has_hash": content_hash is not None}, - ) - return entry - - logger.warning(f"History entry not found for sync update: {entry_id}") - return None diff --git a/apps/server/services/machine_discovery_service.py b/apps/server/services/machine_discovery_service.py deleted file mode 100644 index 739f7a43..00000000 --- a/apps/server/services/machine_discovery_service.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Machine discovery service for auto-detecting Meticulous espresso machines. - -Implements a multi-tier discovery strategy: -1. mDNS/Zeroconf browse for _meticulous._tcp.local. -2. Hostname resolution for meticulous.local -3. METICULOUS_IP environment variable (verified) -4. If no machine is found, return guidance for manual configuration. - -Discovery typically completes within several seconds under normal conditions. -""" - -import asyncio -import os -import socket -from dataclasses import dataclass -from typing import Optional - -from logging_config import get_logger - -logger = get_logger() - -# Overall discovery timeout -DISCOVERY_TIMEOUT_SECONDS = 10 - -# mDNS service type advertised by Meticulous machines -MDNS_SERVICE_TYPE = "_meticulous._tcp.local." - - -@dataclass -class DiscoveryResult: - """Result of machine discovery.""" - - found: bool - ip: Optional[str] = None - hostname: Optional[str] = None - method: Optional[str] = None - guidance: Optional[str] = None - guidance_key: Optional[str] = None - guidance_hints: Optional[list[str]] = None - - -async def discover_machine() -> DiscoveryResult: - """ - Attempt to discover a Meticulous machine on the local network. - - Tries multiple discovery methods in order: - 1. mDNS/Zeroconf service discovery - 2. Direct hostname resolution (meticulous.local) - 3. Guidance for manual configuration - - Returns: - DiscoveryResult with found=True if machine discovered, or - found=False with guidance on how to configure manually. - """ - # Try mDNS service discovery first - result = await _try_mdns_discovery() - if result.found: - return result - - # Fallback to direct hostname resolution - result = await _try_hostname_resolution() - if result.found: - return result - - # Fallback to configured METICULOUS_IP env var - result = await _try_env_ip() - if result.found: - return result - - # No machine found - provide guidance - logger.info("Machine discovery: no machine found via mDNS or hostname") - return DiscoveryResult( - found=False, - guidance_key="notFound", - guidance_hints=["machinePoweredOn", "sameNetwork", "manualIp"], - guidance=( - "Could not automatically detect your Meticulous machine. " - "Please ensure:\n" - "• Your machine is powered on and connected to WiFi\n" - "• Your device is on the same network as the machine\n" - "• Try entering the IP address manually (check your router's DHCP leases)" - ), - ) - - -async def _try_mdns_discovery() -> DiscoveryResult: - """ - Attempt mDNS/Zeroconf service discovery. - - Browses for _meticulous._tcp.local. services. - """ - try: - from zeroconf import Zeroconf, ServiceBrowser - from zeroconf.asyncio import AsyncZeroconf - - discovered: list[tuple[str, str]] = [] # (ip, hostname) pairs - - class Listener: - def add_service(self, zc: Zeroconf, service_type: str, name: str) -> None: - info = zc.get_service_info(service_type, name) - if info and info.addresses: - ip = socket.inet_ntoa(info.addresses[0]) - hostname = info.server.rstrip(".") - discovered.append((ip, hostname)) - logger.info(f"mDNS discovery found: {hostname} at {ip}") - - def remove_service( - self, zc: Zeroconf, service_type: str, name: str - ) -> None: - pass - - def update_service( - self, zc: Zeroconf, service_type: str, name: str - ) -> None: - pass - - azc = AsyncZeroconf() - try: - listener = Listener() - browser = ServiceBrowser(azc.zeroconf, MDNS_SERVICE_TYPE, listener) - - # Wait for discovery with timeout - await asyncio.sleep(min(3, DISCOVERY_TIMEOUT_SECONDS)) - browser.cancel() - - if discovered: - ip, hostname = discovered[0] - return DiscoveryResult( - found=True, ip=ip, hostname=hostname, method="mdns" - ) - finally: - await azc.async_close() - - except ImportError: - logger.warning("zeroconf package not available for mDNS discovery") - except Exception as e: - logger.warning(f"mDNS discovery failed: {e}") - - return DiscoveryResult(found=False) - - -async def _try_hostname_resolution() -> DiscoveryResult: - """ - Attempt to resolve meticulous.local hostname. - - This works when mDNS responder is available on the system. - """ - try: - loop = asyncio.get_running_loop() - - # Try to resolve meticulous.local - hostname = "meticulous.local" - try: - # Run blocking getaddrinfo in executor - result = await asyncio.wait_for( - loop.run_in_executor( - None, lambda: socket.getaddrinfo(hostname, 80, socket.AF_INET) - ), - timeout=5.0, - ) - if result: - ip = result[0][4][0] # Extract IP from first result - logger.info(f"Hostname resolution found: {hostname} at {ip}") - return DiscoveryResult( - found=True, ip=ip, hostname=hostname, method="hostname" - ) - except socket.gaierror: - logger.debug(f"Could not resolve {hostname}") - except asyncio.TimeoutError: - logger.debug(f"Hostname resolution timed out for {hostname}") - - except Exception as e: - logger.warning(f"Hostname resolution failed: {e}") - - return DiscoveryResult(found=False) - - -async def _try_env_ip() -> DiscoveryResult: - """ - Check the METICULOUS_IP environment variable and verify the machine is reachable. - """ - ip = os.environ.get("METICULOUS_IP", "").strip() - if not ip: - return DiscoveryResult(found=False) - - logger.info(f"Trying configured METICULOUS_IP: {ip}") - if await verify_machine(ip): - logger.info(f"Verified machine at configured IP: {ip}") - return DiscoveryResult(found=True, ip=ip, hostname=None, method="env") - - logger.debug(f"Machine at configured METICULOUS_IP {ip} did not respond") - return DiscoveryResult(found=False) - - -async def verify_machine(ip: str) -> bool: - """ - Verify that a machine at the given IP is actually responding. - - Makes a quick HTTP request to the machine's API. - """ - import httpx - - try: - async with httpx.AsyncClient(timeout=5.0) as client: - # Try to hit the machine's status endpoint - response = await client.get(f"http://{ip}:8080/api/getLastShotProfileJSON") - return response.status_code in ( - 200, - 404, - ) # 404 is OK - means API is responding - except Exception: - return False diff --git a/apps/server/services/meticulous_service.py b/apps/server/services/meticulous_service.py deleted file mode 100644 index 3d5d8a5a..00000000 --- a/apps/server/services/meticulous_service.py +++ /dev/null @@ -1,666 +0,0 @@ -"""Meticulous service for espresso machine API and shot data management.""" - -import json -import re -import time -import uuid -import zstandard -import httpx -import asyncio -import os -import functools -import requests.exceptions -from typing import Any, Dict, Optional -from fastapi import HTTPException -from logging_config import get_logger -from services.settings_service import load_settings - -logger = get_logger() - - -class MachineUnreachableError(HTTPException): - """Raised when the Meticulous espresso machine cannot be reached. - - Extends HTTPException so that routes with ``except HTTPException: raise`` - guards automatically propagate it as a 503 instead of wrapping it in a - generic 500. - """ - - def __init__(self, original: Exception | None = None): - detail = ( - "Espresso machine is unreachable. " - "Check that the machine is powered on and METICULOUS_IP is correct in Settings." - ) - super().__init__(status_code=503, detail=detail) - self.__cause__ = original - - -# Connection-error types that indicate the machine is down -_MACHINE_CONNECTION_ERRORS = ( - requests.exceptions.ConnectionError, - httpx.ConnectError, - httpx.ConnectTimeout, -) - - -def _wrap_machine_call(fn): - """Decorator that converts connection errors into MachineUnreachableError.""" - - @functools.wraps(fn) - async def wrapper(*args, **kwargs): - try: - return await fn(*args, **kwargs) - except _MACHINE_CONNECTION_ERRORS as exc: - logger.warning("Machine unreachable in %s: %s", fn.__name__, exc) - raise MachineUnreachableError(exc) from exc - - return wrapper - - -# Lazy-loaded Meticulous API client -_meticulous_api = None - -# Singleton httpx.AsyncClient — reused across all shot data fetches -_http_client: Optional[httpx.AsyncClient] = None - -# Short-lived profile list cache (avoids repeated blocking calls to the machine) -_PROFILE_CACHE_TTL = 10 # seconds -_profile_list_cache: Optional[list] = None -_profile_list_cache_time: float = 0.0 -_full_profile_cache: Optional[list] = None -_full_profile_cache_time: float = 0.0 - - -def _resolve_meticulous_base_url() -> str: - """Resolve the machine base URL from environment/settings with safe defaults.""" - meticulous_ip = os.environ.get("METICULOUS_IP", "").strip() - - if not meticulous_ip: - try: - settings = load_settings() - meticulous_ip = (settings.get("meticulousIp") or "").strip() - except Exception: - meticulous_ip = "" - - if not meticulous_ip: - meticulous_ip = "meticulous.local" - - if not meticulous_ip.startswith("http"): - meticulous_ip = f"http://{meticulous_ip}" - - return meticulous_ip.rstrip("/") - - -def _get_http_client() -> httpx.AsyncClient: - """Return the singleton httpx.AsyncClient, creating it if needed.""" - global _http_client - if _http_client is None or _http_client.is_closed: - _http_client = httpx.AsyncClient(timeout=30.0) - return _http_client - - -async def close_http_client(): - """Close the singleton httpx.AsyncClient (call during app shutdown).""" - global _http_client - if _http_client is not None and not _http_client.is_closed: - await _http_client.aclose() - _http_client = None - - -def get_meticulous_api(): - """Lazily initialize and return the Meticulous API client.""" - global _meticulous_api - desired_base_url = _resolve_meticulous_base_url() - - if _meticulous_api is None: - from meticulous.api import Api - - _meticulous_api = Api(base_url=desired_base_url) - return _meticulous_api - - current_base_url = str(getattr(_meticulous_api, "base_url", "")).rstrip("/") - if current_base_url != desired_base_url: - from meticulous.api import Api - - logger.info( - "Meticulous API target changed, reinitializing client", - extra={ - "from_base_url": current_base_url, - "to_base_url": desired_base_url, - }, - ) - _meticulous_api = Api(base_url=desired_base_url) - - return _meticulous_api - - -def reset_meticulous_api(): - """Reset the cached API client so the next call picks up new settings. - - Called when METICULOUS_IP is changed via the settings UI. - """ - global _meticulous_api - _meticulous_api = None - invalidate_profile_list_cache() - logger.info("Meticulous API client reset — will reinitialize on next request") - - -async def execute_scheduled_shot( - schedule_id: str, - shot_delay: float, - preheat: bool, - profile_id: Optional[str], - scheduled_shots_dict: dict, - scheduled_tasks_dict: dict, - preheat_duration_minutes: int = 10, -): - """Execute a scheduled shot with optional preheating. - - Args: - schedule_id: Unique identifier for this scheduled shot - shot_delay: Seconds to wait before executing the shot - preheat: Whether to preheat before the shot - profile_id: The profile ID to run (optional, can be None for preheat-only) - scheduled_shots_dict: Reference to the global scheduled shots dictionary - scheduled_tasks_dict: Reference to the global scheduled tasks dictionary - preheat_duration_minutes: Minutes to preheat (default: 10) - """ - from meticulous.api_types import ActionType - - loop = asyncio.get_running_loop() - - try: - api = get_meticulous_api() - - # If preheat is enabled, start it before the scheduled time - if preheat: - preheat_delay = shot_delay - (preheat_duration_minutes * 60) - if preheat_delay > 0: - await asyncio.sleep(preheat_delay) - scheduled_shots_dict[schedule_id]["status"] = "preheating" - - # Start preheat using ActionType.PREHEAT - try: - await loop.run_in_executor( - None, api.execute_action, ActionType.PREHEAT - ) - except Exception as e: - logger.warning( - f"Preheat failed for scheduled shot {schedule_id}: {e}" - ) - - # Wait for remaining time until shot - await asyncio.sleep(preheat_duration_minutes * 60) - else: - # Not enough time for full preheat, start immediately - scheduled_shots_dict[schedule_id]["status"] = "preheating" - try: - await loop.run_in_executor( - None, api.execute_action, ActionType.PREHEAT - ) - except Exception as e: - logger.warning( - f"Preheat failed for scheduled shot {schedule_id}: {e}" - ) - await asyncio.sleep(shot_delay) - else: - await asyncio.sleep(shot_delay) - - scheduled_shots_dict[schedule_id]["status"] = "running" - - # Load and run the profile (if profile_id was provided) - if profile_id: - load_result = await loop.run_in_executor( - None, api.load_profile_by_id, profile_id - ) - if not (hasattr(load_result, "error") and load_result.error): - await loop.run_in_executor(None, api.execute_action, ActionType.START) - scheduled_shots_dict[schedule_id]["status"] = "completed" - else: - scheduled_shots_dict[schedule_id]["status"] = "failed" - scheduled_shots_dict[schedule_id]["error"] = load_result.error - else: - # Preheat only mode - mark as completed - scheduled_shots_dict[schedule_id]["status"] = "completed" - - except asyncio.CancelledError: - scheduled_shots_dict[schedule_id]["status"] = "cancelled" - except Exception as e: - logger.error(f"Scheduled shot {schedule_id} failed: {e}") - scheduled_shots_dict[schedule_id]["status"] = "failed" - scheduled_shots_dict[schedule_id]["error"] = str(e) - finally: - # Clean up task reference - if schedule_id in scheduled_tasks_dict: - del scheduled_tasks_dict[schedule_id] - - -def decompress_shot_data(compressed_data: bytes) -> dict: - """Decompress zstandard-compressed shot data.""" - dctx = zstandard.ZstdDecompressor() - decompressed = dctx.decompress(compressed_data) - return json.loads(decompressed.decode("utf-8")) - - -@_wrap_machine_call -async def fetch_shot_data(date_str: str, filename: str) -> dict: - """Fetch and decompress shot data from the Meticulous machine.""" - api = get_meticulous_api() - url = f"{api.base_url}/api/v1/history/files/{date_str}/{filename}" - - client = _get_http_client() - response = await client.get(url) - response.raise_for_status() - - # Check if it's compressed (zstd) - if filename.endswith(".zst"): - return decompress_shot_data(response.content) - else: - return response.json() - - -# ============================================ -# Async wrappers for synchronous pyMeticulous API calls -# ============================================ -# The pyMeticulous library is fully synchronous. These helpers offload each -# blocking call to a thread-pool executor so the FastAPI event loop stays free. - - -@_wrap_machine_call -async def async_list_profiles(): - """list_profiles() offloaded to a thread, with short-lived TTL cache.""" - global _profile_list_cache, _profile_list_cache_time - now = time.monotonic() - if ( - _profile_list_cache is not None - and (now - _profile_list_cache_time) < _PROFILE_CACHE_TTL - ): - return _profile_list_cache - api = get_meticulous_api() - loop = asyncio.get_running_loop() - result = await loop.run_in_executor(None, api.list_profiles) - _profile_list_cache = result - _profile_list_cache_time = now - return result - - -def invalidate_profile_list_cache(): - """Clear the profile list cache (call after create / update / delete).""" - global _profile_list_cache, _profile_list_cache_time - global _full_profile_cache, _full_profile_cache_time - _profile_list_cache = None - _profile_list_cache_time = 0.0 - _full_profile_cache = None - _full_profile_cache_time = 0.0 - - -@_wrap_machine_call -async def async_fetch_all_profiles(): - """fetch_all_profiles() offloaded to a thread, with short-lived TTL cache. - - Returns full Profile objects including stages, dynamics, and variables. - """ - global _full_profile_cache, _full_profile_cache_time - now = time.monotonic() - if ( - _full_profile_cache is not None - and (now - _full_profile_cache_time) < _PROFILE_CACHE_TTL - ): - return _full_profile_cache - api = get_meticulous_api() - loop = asyncio.get_running_loop() - result = await loop.run_in_executor(None, api.fetch_all_profiles) - _full_profile_cache = result - _full_profile_cache_time = now - return result - - -@_wrap_machine_call -async def async_load_profile_by_id(profile_id: str): - """load_profile_by_id() offloaded to a thread.""" - api = get_meticulous_api() - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, api.load_profile_by_id, profile_id) - - -def _normalize_profile_for_machine(profile_json: Dict[str, Any]) -> Dict[str, Any]: - """Enrich and normalize espresso-profile-schema JSON for the machine REST API. - - The AI model produces JSON conforming to the OEPF espresso-profile-schema, - but the Meticulous machine's ``/api/v1/profile/save`` endpoint requires - several additional fields. This function — borrowing the same conventions - used by the MCP server's ``create_profile`` tool — fills in the gaps so the - profile can be saved directly. - - Added / defaulted fields: - - ``id`` — random UUID if missing - - ``author`` / ``author_id`` — ``"MeticAI"`` + random UUID - - ``temperature`` — defaults to 90.0 °C - - ``final_weight`` — defaults to 40.0 g - - ``variables`` — always present (empty list if missing) - - ``previous_authors`` — always present (empty list if missing) - - Per-stage ``key`` — auto-generated from type + index if missing - - Per-stage ``limits`` — always an array (never None / absent) - - ``dynamics.interpolation`` — defaults to ``"linear"`` - - ``dynamics.points`` — each point coerced to ``[x, y]`` list form - - Exit-trigger ``relative`` — defaults to ``True`` for time, ``False`` - otherwise (matches MCP profile_builder behaviour) - - Exit-trigger ``comparison`` — defaults to ``">="`` - """ - data = dict(profile_json) # shallow copy - - # ── Top-level identity & metadata ──────────────────────────────────── - # Profile ID MUST be a valid UUID - the machine's /api/v1/profile/get/{id} - # endpoint returns 404 for non-UUID IDs (e.g. slug-style IDs like "my-profile") - existing_id = data.get("id", "") - if existing_id: - try: - uuid.UUID(existing_id) # Validates UUID format - except (ValueError, AttributeError): - # Non-UUID ID - replace with a proper UUID to avoid 404 on fetch - logger.warning("Replacing non-UUID profile ID with UUID: %s", existing_id) - existing_id = "" - if not existing_id: - data["id"] = str(uuid.uuid4()) - data.setdefault("author", "Metic") - if "author_id" not in data or not data["author_id"]: - data["author_id"] = str(uuid.uuid4()) - data.setdefault("temperature", 90.0) - data.setdefault("final_weight", 40.0) - # The Meticulous app crashes if variables array is absent - if "variables" not in data or data.get("variables") is None: - data["variables"] = [] - data.setdefault("previous_authors", []) - - # ── Info variable emoji normalization ──────────────────────────────── - # Info variables (key starts with "info_") MUST have an emoji prefix in name. - # This distinguishes them from adjustable variables (no emoji, used in stages). - emoji_pattern = re.compile( - r"^[\U0001F300-\U0001F9FF" # Supplemental Symbols and Pictographs - r"\U0001F600-\U0001F64F" # Emoticons - r"\U0001F680-\U0001F6FF" # Transport and Map Symbols - r"\U00002600-\U000026FF" # Misc symbols - r"\U00002700-\U000027BF]" # Dingbats - ) - for var in data.get("variables") or []: - key = var.get("key", "") - name = var.get("name", "") - is_info = key.startswith("info_") or var.get("adjustable") is False - # If it's an info variable and name doesn't start with emoji, add default ℹ️ - if is_info and not emoji_pattern.match(name): - var["name"] = f"ℹ️ {name}" if name else "ℹ️ Info" - - # ── Display metadata ───────────────────────────────────────────────── - # The OEPF schema supports display.description / display.shortDescription - # which the Meticulous app stores alongside the profile. - display = data.get("display") or {} - if not isinstance(display, dict): - display = {} - # Truncate shortDescription to 99 chars — the Meticulous app rejects - # longer values and may display garbled text. - short_desc = display.get("shortDescription") - if isinstance(short_desc, str) and len(short_desc) > 99: - display["shortDescription"] = short_desc[:99] - # Preserve any existing description; normalise structure - data["display"] = display - - # ── Per-stage normalisation ────────────────────────────────────────── - for idx, stage in enumerate(data.get("stages") or []): - # type — default to "flow" if missing - stage.setdefault("type", "flow") - stage_type = stage["type"] - - # name — required, default to "Stage N" if missing - stage.setdefault("name", f"Stage {idx + 1}") - - # key — unique string identifier - if "key" not in stage or not stage["key"]: - stage["key"] = f"{stage_type}_{idx}" - - # exit_triggers — required, must be a list - if "exit_triggers" not in stage or stage.get("exit_triggers") is None: - stage["exit_triggers"] = [] - - # limits — must always be a list - if "limits" not in stage or stage.get("limits") is None: - stage["limits"] = [] - - # dynamics — required; ensure it exists with all required sub-fields - dynamics = stage.get("dynamics") or {} - dynamics.setdefault("interpolation", "linear") - dynamics.setdefault("over", "time") - dynamics.setdefault("points", []) - - # dynamics.points — coerce dicts like {"value": 2} to [0, 2] - raw_points = dynamics.get("points") or [] - normalised_points = [] - for pt in raw_points: - if isinstance(pt, dict): - # Single-value shorthand {"value": v} → [0, v] - normalised_points.append([0.0, pt.get("value", 0)]) - elif isinstance(pt, (list, tuple)): - normalised_points.append(list(pt)) - else: - normalised_points.append([0.0, float(pt)]) - dynamics["points"] = normalised_points - stage["dynamics"] = dynamics - - # exit_triggers — ensure relative & comparison are present - for trigger in stage.get("exit_triggers") or []: - if "relative" not in trigger or trigger.get("relative") is None: - trigger["relative"] = trigger.get("type") == "time" - trigger.setdefault("comparison", ">=") - - return data - - -class DuplicateProfileNameError(ValueError): - """Raised when attempting to create a profile with a name that already exists.""" - - pass - - -@_wrap_machine_call -async def async_create_profile(profile_json): - """Upload a profile to the machine via its REST API. - - Normalises the espresso-profile-schema JSON (as produced by the AI model) - into the machine-compatible format and POSTs it to ``/api/v1/profile/save``, - following the same approach used by the MCP server's ``create_profile`` tool. - - Returns a dict with keys: - - All keys from the machine response (typically ``id``, ``error``, etc.) - - ``_normalised_json``: the exact dict that was POSTed to the machine, - suitable for storage as the canonical ``profile_json``. - - Raises: - DuplicateProfileNameError: If a profile with the same name already exists. - """ - # Check for duplicate profile names (uses cached list, ~0ms if warm) - profile_name = profile_json.get("name") if isinstance(profile_json, dict) else None - if profile_name: - try: - existing_profiles = await async_list_profiles() - if existing_profiles and not isinstance(existing_profiles, dict): - existing_names = [getattr(p, "name", None) for p in existing_profiles] - if profile_name in existing_names: - raise DuplicateProfileNameError( - f"A profile named '{profile_name}' already exists. " - f"Please choose a unique name." - ) - except DuplicateProfileNameError: - raise - except Exception as e: - # Don't block creation if we can't check duplicates - logger.warning("Could not check for duplicate profile names: %s", e) - - normalised = _normalize_profile_for_machine(profile_json) - base_url = _resolve_meticulous_base_url() - client = _get_http_client() - response = await client.post( - f"{base_url}/api/v1/profile/save", - json=normalised, - timeout=30.0, - ) - if response.status_code != 200: - body = response.text - logger.error( - "Machine rejected profile save: %s", - body[:1000], - extra={ - "status": response.status_code, - "profile_name": normalised.get("name"), - "profile_keys": list(normalised.keys()), - "stage_count": len(normalised.get("stages", [])), - "stage_keys": [s.get("key") for s in normalised.get("stages", [])], - }, - ) - response.raise_for_status() - invalidate_profile_list_cache() - - result = response.json() - if isinstance(result, dict): - result["_normalised_json"] = normalised - return result - - -@_wrap_machine_call -async def async_load_profile_from_json(profile_json: Dict[str, Any]): - """Load a profile into the machine's memory without persisting to storage. - - POSTs to ``/api/v1/profile/load`` which makes the profile the active - selection for the next shot but does **not** save it to the machine's - profile catalogue. This is ideal for temporary variable overrides — - the original profile remains untouched on disk. - - Args: - profile_json: Full profile dict (will be normalised before sending). - - Returns: - The JSON response from the machine. - """ - normalised = _normalize_profile_for_machine(profile_json) - base_url = _resolve_meticulous_base_url() - client = _get_http_client() - response = await client.post( - f"{base_url}/api/v1/profile/load", - json=normalised, - timeout=30.0, - ) - if response.status_code != 200: - body = response.text - logger.error( - "Machine rejected ephemeral profile load: %s", - body[:1000], - extra={ - "status": response.status_code, - "profile_name": normalised.get("name"), - }, - ) - response.raise_for_status() - return response.json() - - -@_wrap_machine_call -async def async_delete_profile(profile_id: str): - """delete_profile() offloaded to a thread.""" - api = get_meticulous_api() - loop = asyncio.get_running_loop() - result = await loop.run_in_executor(None, api.delete_profile, profile_id) - invalidate_profile_list_cache() - return result - - -@_wrap_machine_call -async def async_get_last_profile(): - """get_last_profile() offloaded to a thread.""" - api = get_meticulous_api() - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, api.get_last_profile) - - -@_wrap_machine_call -async def async_get_settings(): - """get_settings() offloaded to a thread.""" - api = get_meticulous_api() - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, api.get_settings) - - -@_wrap_machine_call -async def async_execute_action(action_type): - """execute_action() offloaded to a thread.""" - api = get_meticulous_api() - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, api.execute_action, action_type) - - -@_wrap_machine_call -async def async_session_get(path: str): - """api.session.get() offloaded to a thread.""" - api = get_meticulous_api() - loop = asyncio.get_running_loop() - url = f"{api.base_url}{path}" - return await loop.run_in_executor(None, api.session.get, url) - - -@_wrap_machine_call -async def async_session_post(path: str, json_body: dict = None): - """api.session.post() offloaded to a thread.""" - api = get_meticulous_api() - loop = asyncio.get_running_loop() - url = f"{api.base_url}{path}" - fn = functools.partial(api.session.post, url, json=json_body) - return await loop.run_in_executor(None, fn) - - -@_wrap_machine_call -async def async_get_history_dates(): - """get_history_dates() offloaded to a thread.""" - api = get_meticulous_api() - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, api.get_history_dates) - - -@_wrap_machine_call -async def async_get_shot_files(date: str): - """get_shot_files() offloaded to a thread.""" - api = get_meticulous_api() - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, api.get_shot_files, date) - - -@_wrap_machine_call -async def async_get_profile(profile_id: str): - """get_profile() offloaded to a thread.""" - api = get_meticulous_api() - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, api.get_profile, profile_id) - - -@_wrap_machine_call -async def fetch_machine_profile_dict(profile_id: str) -> Dict[str, Any]: - """Fetch a profile from the machine by ID via raw HTTP and return as dict. - - Uses ``/api/v1/profile/get/{id}`` directly so the result is exactly - the JSON the machine stores — no SDK object conversion, no lost fields. - This is the canonical representation used for export. - """ - base_url = _resolve_meticulous_base_url() - client = _get_http_client() - response = await client.get( - f"{base_url}/api/v1/profile/get/{profile_id}", - timeout=15.0, - ) - response.raise_for_status() - return response.json() - - -@_wrap_machine_call -async def async_save_profile(profile): - """save_profile() offloaded to a thread.""" - api = get_meticulous_api() - loop = asyncio.get_running_loop() - result = await loop.run_in_executor(None, api.save_profile, profile) - invalidate_profile_list_cache() - return result diff --git a/apps/server/services/mqtt_service.py b/apps/server/services/mqtt_service.py deleted file mode 100644 index 5f9c2993..00000000 --- a/apps/server/services/mqtt_service.py +++ /dev/null @@ -1,274 +0,0 @@ -"""MQTT subscriber service — receives telemetry from mosquitto and exposes it -to WebSocket clients. - -Architecture: - mosquitto (:1883) ← meticulous-bridge (Socket.IO → MQTT) - FastAPI server subscribes to `meticulous_espresso/sensor/#` - WebSocket endpoint reads latest state dict and pushes to clients at ≤10 FPS. - -The subscriber runs in a background *thread* (paho-mqtt v1 uses its own -network loop) and bridges into asyncio via an `asyncio.Event` that is set -whenever new data arrives. -""" - -import asyncio -import json -import logging -import os -import threading -import time -from typing import Any, Dict, Optional, Set - -logger = logging.getLogger(__name__) - -TEST_MODE = os.environ.get("TEST_MODE") == "true" - -# --------------------------------------------------------------------------- -# Topic prefix published by the meticulous-addon bridge -# --------------------------------------------------------------------------- -TOPIC_PREFIX = "meticulous_espresso/sensor/" -AVAILABILITY_TOPIC = "meticulous_espresso/availability" -HEALTH_TOPIC = "meticulous_espresso/health" - -# --------------------------------------------------------------------------- -# Sensor key → value type coercion -# --------------------------------------------------------------------------- -_FLOAT_SENSORS = frozenset( - { - "boiler_temperature", - "brew_head_temperature", - "external_temp_1", - "external_temp_2", - "pressure", - "flow_rate", - "shot_weight", - "shot_timer", - "preheat_countdown", - "target_temperature", - "target_weight", - "power", - } -) - -_BOOL_SENSORS = frozenset( - { - "brewing", - "connected", - } -) - -_INT_SENSORS = frozenset( - { - "total_shots", - "voltage", - } -) - - -def _coerce_value(sensor_key: str, raw: str) -> Any: - """Convert a raw MQTT string payload to its appropriate Python type.""" - if sensor_key in _FLOAT_SENSORS: - try: - return round(float(raw), 2) - except (ValueError, TypeError): - return raw - if sensor_key in _BOOL_SENSORS: - return raw.lower() in ("true", "1", "on") - if sensor_key in _INT_SENSORS: - try: - return int(float(raw)) - except (ValueError, TypeError): - return raw - return raw - - -# ============================================================================ -# MQTTSubscriber — singleton -# ============================================================================ - - -class MQTTSubscriber: - """Thread-safe MQTT subscriber that keeps the latest sensor snapshot. - - Call `start()` during FastAPI lifespan startup and `stop()` on shutdown. - WebSocket handlers read `self.snapshot` and wait on `self.data_event`. - """ - - def __init__(self) -> None: - self._lock = threading.Lock() - self._ws_lock = threading.Lock() - self.snapshot: Dict[str, Any] = {} - self._availability: Optional[str] = None - self._health: Optional[dict] = None - self._client: Any = None # paho.mqtt.client.Client - self._thread: Optional[threading.Thread] = None - self._running = False - self._loop: Optional[asyncio.AbstractEventLoop] = None - self.data_event: Optional[asyncio.Event] = None - self._connected_ws: Set[int] = set() # track WebSocket client count - - # -- lifecycle ----------------------------------------------------------- - - def start(self, loop: asyncio.AbstractEventLoop) -> None: - """Connect to mosquitto in a background thread.""" - if TEST_MODE: - logger.info("MQTT subscriber skipped (TEST_MODE)") - return - - mqtt_enabled = os.environ.get("MQTT_ENABLED", "true").lower() == "true" - if not mqtt_enabled: - logger.info("MQTT subscriber disabled (MQTT_ENABLED=false)") - return - - self._loop = loop - self.data_event = asyncio.Event() - - mqtt_host = os.environ.get("MQTT_HOST", "127.0.0.1") - mqtt_port = int(os.environ.get("MQTT_PORT", "1883")) - - try: - import paho.mqtt.client as mqtt - except ImportError: - logger.warning("paho-mqtt not installed — MQTT subscriber disabled") - return - - self._client = mqtt.Client(client_id="meticai-server", clean_session=True) - self._client.on_connect = self._on_connect - self._client.on_message = self._on_message - self._client.on_disconnect = self._on_disconnect - - self._running = True - self._thread = threading.Thread( - target=self._run_loop, - args=(mqtt_host, mqtt_port), - daemon=True, - name="mqtt-subscriber", - ) - self._thread.start() - logger.info("MQTT subscriber started → %s:%d", mqtt_host, mqtt_port) - - def stop(self) -> None: - """Disconnect and join the background thread.""" - self._running = False - if self._client is not None: - try: - self._client.loop_stop(force=True) - except Exception: - pass - try: - self._client.disconnect() - except Exception: - pass - if self._thread is not None: - self._thread.join(timeout=5) - if self._thread.is_alive(): - logger.warning("MQTT subscriber thread did not exit within 5 s") - self._thread = None - logger.info("MQTT subscriber stopped") - - # -- paho callbacks (run in the background thread) ----------------------- - - def _run_loop(self, host: str, port: int) -> None: - """Blocking loop with auto-reconnect.""" - while self._running: - try: - self._client.connect(host, port, keepalive=60) - self._client.loop_forever() - except Exception as exc: - logger.warning("MQTT connection failed: %s — retrying in 5s", exc) - time.sleep(5) - - def _on_connect(self, client: Any, userdata: Any, flags: Any, rc: int) -> None: - if rc == 0: - logger.info("MQTT connected, subscribing to telemetry topics") - client.subscribe(f"{TOPIC_PREFIX}#", qos=1) - client.subscribe(AVAILABILITY_TOPIC, qos=1) - client.subscribe(HEALTH_TOPIC, qos=1) - else: - logger.warning("MQTT connect failed rc=%d", rc) - - def _on_message(self, client: Any, userdata: Any, msg: Any) -> None: - topic: str = msg.topic - payload = msg.payload.decode("utf-8", errors="replace") - - if topic == AVAILABILITY_TOPIC: - with self._lock: - self._availability = payload - self.snapshot["availability"] = payload - self._signal_update() - return - - if topic == HEALTH_TOPIC: - try: - data = json.loads(payload) - except json.JSONDecodeError: - data = payload - with self._lock: - self._health = data - self.snapshot["health"] = data - self._signal_update() - return - - # Sensor topics: meticulous_espresso/sensor/{key}/state - if topic.startswith(TOPIC_PREFIX) and topic.endswith("/state"): - sensor_key = topic[len(TOPIC_PREFIX) : -len("/state")] - value = _coerce_value(sensor_key, payload) - with self._lock: - self.snapshot[sensor_key] = value - self._signal_update() - - def _on_disconnect(self, client: Any, userdata: Any, rc: int) -> None: - if rc != 0: - logger.warning("MQTT disconnected unexpectedly rc=%d, will reconnect", rc) - - # -- helpers ------------------------------------------------------------- - - def _signal_update(self) -> None: - """Thread-safe: set the asyncio Event from the paho thread.""" - if self._loop and self.data_event: - self._loop.call_soon_threadsafe(self.data_event.set) - - def get_snapshot(self) -> Dict[str, Any]: - """Return a copy of the current sensor snapshot.""" - with self._lock: - return dict(self.snapshot) - - @property - def ws_client_count(self) -> int: - with self._ws_lock: - return len(self._connected_ws) - - def register_ws(self, ws_id: int) -> None: - with self._ws_lock: - self._connected_ws.add(ws_id) - - def unregister_ws(self, ws_id: int) -> None: - with self._ws_lock: - self._connected_ws.discard(ws_id) - - -# --------------------------------------------------------------------------- -# Module-level singleton -# --------------------------------------------------------------------------- - -_subscriber: Optional[MQTTSubscriber] = None -_subscriber_lock = threading.Lock() - - -def get_mqtt_subscriber() -> MQTTSubscriber: - """Return the global MQTTSubscriber (lazy-created, thread-safe).""" - global _subscriber - if _subscriber is None: - with _subscriber_lock: - if _subscriber is None: - _subscriber = MQTTSubscriber() - return _subscriber - - -def reset_mqtt_subscriber() -> None: - """Stop and discard the current subscriber (for hot-reload).""" - global _subscriber - with _subscriber_lock: - if _subscriber is not None: - _subscriber.stop() - _subscriber = None diff --git a/apps/server/services/pour_over_adapter.py b/apps/server/services/pour_over_adapter.py deleted file mode 100644 index f3f5e748..00000000 --- a/apps/server/services/pour_over_adapter.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Pour-over profile adaptation — pure parameter substitution, no LLM. - -Reads the ``PourOverBase.json`` template and adapts it for a specific brew: - - Sets ``final_weight`` and the Infusion exit-trigger weight - - Updates stage names (e.g. "Bloom (45s)", "Infusion (250g)") - - Removes the Bloom stage if bloom is disabled - - Sets the profile name to ``MeticAI Ratio Pour-Over`` - - Adds a 10-minute time backup to weight-based stages to prevent indefinite runs -""" - -import copy -import json -import logging -import uuid -from pathlib import Path -from typing import Any, Dict, Optional - -from config import DATA_DIR - -logger = logging.getLogger(__name__) - -# Path to the base template (lives in the mounted data volume) -_TEMPLATE_PATH = DATA_DIR / "PourOverBase.json" - -# Fallback if DATA_DIR doesn't have it (e.g. dev environment) -_FALLBACK_TEMPLATE_PATH = ( - Path(__file__).resolve().parent.parent.parent.parent / "data" / "PourOverBase.json" -) - -# Docker image ships the template here via COPY in Dockerfile -_DOCKER_TEMPLATE_PATH = Path("/app/defaults/PourOverBase.json") - -_SEARCH_PATHS = (_TEMPLATE_PATH, _FALLBACK_TEMPLATE_PATH, _DOCKER_TEMPLATE_PATH) - - -def load_pour_over_template() -> Dict[str, Any]: - """Load the PourOverBase.json template, trying DATA_DIR first.""" - # Deduplicate while preserving order (in Docker, _FALLBACK_TEMPLATE_PATH - # resolves to the same path as _TEMPLATE_PATH due to directory depth) - unique_paths = list(dict.fromkeys(_SEARCH_PATHS)) - for path in unique_paths: - if path.exists(): - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - raise FileNotFoundError( - f"PourOverBase.json not found at any of: {', '.join(str(p) for p in unique_paths)}" - ) - - -def adapt_pour_over_profile( - *, - target_weight: float, - bloom_enabled: bool = True, - bloom_seconds: float = 30.0, - dose_grams: Optional[float] = None, - brew_ratio: Optional[float] = None, -) -> Dict[str, Any]: - """Adapt the pour-over template for the given parameters. - - Args: - target_weight: Target brew weight in grams (final_weight). - bloom_enabled: Whether to include the bloom stage. - bloom_seconds: Duration of the bloom stage in seconds. - dose_grams: Dose in grams (informational, stored in display). - brew_ratio: Brew ratio (informational, stored in display). - - Returns: - A fully adapted profile dict ready for machine upload. - """ - template = load_pour_over_template() - profile = copy.deepcopy(template) - - # ── Unique identity ────────────────────────────────────────────────── - profile["id"] = str(uuid.uuid4()) - profile["author_id"] = str(uuid.uuid4()) - - # ── Name ───────────────────────────────────────────────────────────── - weight_label = f"{target_weight:.0f}g" - profile["name"] = "MeticAI Ratio Pour-Over" - - # ── Top-level weight ───────────────────────────────────────────────── - profile["final_weight"] = target_weight - - # ── Short description ──────────────────────────────────────────────── - parts = [f"Target: {weight_label}"] - if dose_grams is not None: - parts.append(f"Dose: {dose_grams:.1f}g") - if brew_ratio is not None: - parts.append(f"Ratio: 1:{brew_ratio:.1f}") - display = profile.get("display") or {} - display["shortDescription"] = " | ".join(parts)[:99] - profile["display"] = display - - # ── Stages ─────────────────────────────────────────────────────────── - stages = profile.get("stages", []) - - if bloom_enabled and len(stages) >= 2: - # Stage 0: Bloom — update time trigger - bloom_stage = stages[0] - bloom_stage["name"] = f"Bloom ({bloom_seconds:.0f}s)" - for trigger in bloom_stage.get("exit_triggers", []): - if trigger.get("type") == "time": - trigger["value"] = bloom_seconds - - # Stage 1: Infusion — update weight trigger and add time backup - infusion_stage = stages[1] - infusion_stage["name"] = f"Infusion ({weight_label})" - has_time_backup = False - for trigger in infusion_stage.get("exit_triggers", []): - if trigger.get("type") == "weight": - trigger["value"] = target_weight - elif trigger.get("type") == "time": - has_time_backup = True - # Add 10-minute time backup if not present - if not has_time_backup: - infusion_stage.setdefault("exit_triggers", []).append( - { - "type": "time", - "value": 600, # 10 minutes - "relative": True, - "comparison": ">=", - } - ) - - elif not bloom_enabled and len(stages) >= 2: - # Remove bloom stage, keep only infusion - infusion_stage = stages[1] - infusion_stage["name"] = f"Infusion ({weight_label})" - infusion_stage["key"] = "power_1" # Re-key since it's now first - has_time_backup = False - for trigger in infusion_stage.get("exit_triggers", []): - if trigger.get("type") == "weight": - trigger["value"] = target_weight - elif trigger.get("type") == "time": - has_time_backup = True - # Add 10-minute time backup if not present - if not has_time_backup: - infusion_stage.setdefault("exit_triggers", []).append( - { - "type": "time", - "value": 600, # 10 minutes - "relative": True, - "comparison": ">=", - } - ) - profile["stages"] = [infusion_stage] - - elif len(stages) == 1: - # Only one stage — treat as infusion - infusion_stage = stages[0] - infusion_stage["name"] = f"Infusion ({weight_label})" - has_time_backup = False - for trigger in infusion_stage.get("exit_triggers", []): - if trigger.get("type") == "weight": - trigger["value"] = target_weight - elif trigger.get("type") == "time": - has_time_backup = True - # Add 10-minute time backup if not present - if not has_time_backup: - infusion_stage.setdefault("exit_triggers", []).append( - { - "type": "time", - "value": 600, # 10 minutes - "relative": True, - "comparison": ">=", - } - ) - - return profile diff --git a/apps/server/services/pour_over_preferences.py b/apps/server/services/pour_over_preferences.py deleted file mode 100644 index e18daf78..00000000 --- a/apps/server/services/pour_over_preferences.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Pour-over preferences service. - -Persists per-mode (free / ratio) pour-over UI preferences so they survive -page reloads and are consistent across devices. - -Storage: ``DATA_DIR / pour_over_preferences.json`` -""" - -import json -import logging -from pathlib import Path -from typing import Optional - -from config import DATA_DIR -from utils.file_utils import atomic_write_json - -logger = logging.getLogger(__name__) - -PREFS_FILE: Path = DATA_DIR / "pour_over_preferences.json" - -_MODE_DEFAULTS = { - "autoStart": True, - "bloomEnabled": True, - "bloomSeconds": 30, - "bloomWeightMultiplier": 2, - "machineIntegration": False, - "doseGrams": None, - "brewRatio": None, -} - -_RECIPE_MODE_DEFAULTS = { - "machineIntegration": False, - "autoStart": True, - "progressionMode": "weight", # "weight" = advance pours on scale, "time" = advance all on timer -} - -_DEFAULT_PREFS = { - "free": dict(_MODE_DEFAULTS), - "ratio": dict(_MODE_DEFAULTS), - "recipe": dict(_RECIPE_MODE_DEFAULTS), -} - -# In-memory cache – loaded lazily, write-through on save. -_cache: Optional[dict] = None - - -def _ensure_file() -> None: - """Create the prefs file with defaults if it doesn't exist yet.""" - PREFS_FILE.parent.mkdir(parents=True, exist_ok=True) - if not PREFS_FILE.exists(): - atomic_write_json(PREFS_FILE, _DEFAULT_PREFS) - - -def load_preferences() -> dict: - """Return the full preferences dict, merging with defaults for safety.""" - global _cache - if _cache is not None: - return _cache - - _ensure_file() - - try: - with open(PREFS_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - except (json.JSONDecodeError, FileNotFoundError): - data = None - - if not isinstance(data, dict): - data = {} - - # Merge defaults per mode so missing keys always have a value. - result: dict = {} - for mode in ("free", "ratio"): - stored = data.get(mode, {}) - if not isinstance(stored, dict): - stored = {} - result[mode] = {**_MODE_DEFAULTS, **stored} - - # Recipe mode uses its own smaller defaults - stored_recipe = data.get("recipe", {}) - if not isinstance(stored_recipe, dict): - stored_recipe = {} - result["recipe"] = {**_RECIPE_MODE_DEFAULTS, **stored_recipe} - - _cache = result - return _cache - - -def save_preferences(prefs: dict) -> dict: - """Validate, persist, and return the normalised preferences. - - Only known keys are stored; unexpected keys are silently dropped. - """ - global _cache - _ensure_file() - - result: dict = {} - for mode in ("free", "ratio"): - incoming = prefs.get(mode, {}) - if not isinstance(incoming, dict): - incoming = {} - - merged = dict(_MODE_DEFAULTS) # start from defaults - for key in _MODE_DEFAULTS: - if key in incoming: - merged[key] = incoming[key] - result[mode] = merged - - # Recipe mode preferences - incoming_recipe = prefs.get("recipe", {}) - if not isinstance(incoming_recipe, dict): - incoming_recipe = {} - merged_recipe = dict(_RECIPE_MODE_DEFAULTS) - for key in _RECIPE_MODE_DEFAULTS: - if key in incoming_recipe: - merged_recipe[key] = incoming_recipe[key] - result["recipe"] = merged_recipe - - _cache = result - atomic_write_json(PREFS_FILE, result) - return result - - -def reset_cache() -> None: - """Clear the in-memory cache (useful in tests).""" - global _cache - _cache = None diff --git a/apps/server/services/profile_recommendation_service.py b/apps/server/services/profile_recommendation_service.py deleted file mode 100644 index 0f0eaebe..00000000 --- a/apps/server/services/profile_recommendation_service.py +++ /dev/null @@ -1,615 +0,0 @@ -"""Profile recommendation service — fully local, token-free scoring. - -Compares profiles by structural features (stage types, dynamics, -pressure/flow control), target weight, peak pressure, temperature, -and keyword tags. No AI/LLM calls — everything is deterministic. -""" - -import asyncio -import hashlib -import json -import threading -from collections import OrderedDict -from typing import Optional - -from logging_config import get_logger -from services.meticulous_service import async_fetch_all_profiles - -logger = get_logger() - -_MAX_CACHE_SIZE = 50 - - -# --------------------------------------------------------------------------- -# LRU cache (unchanged) -# --------------------------------------------------------------------------- - - -class _LRUCache: - """Thread-safe LRU cache with a fixed max size.""" - - def __init__(self, maxsize: int = _MAX_CACHE_SIZE): - self._maxsize = maxsize - self._cache: OrderedDict[str, list[dict]] = OrderedDict() - self._lock = threading.Lock() - - def get(self, key: str) -> Optional[list[dict]]: - with self._lock: - if key in self._cache: - self._cache.move_to_end(key) - return self._cache[key] - return None - - def put(self, key: str, value: list[dict]) -> None: - with self._lock: - if key in self._cache: - self._cache.move_to_end(key) - self._cache[key] = value - while len(self._cache) > self._maxsize: - self._cache.popitem(last=False) - - def clear(self) -> None: - with self._lock: - self._cache.clear() - - -def _cache_key(tags: list[str], limit: int) -> str: - raw = json.dumps({"tags": sorted(tags), "limit": limit}, sort_keys=True) - return hashlib.sha256(raw.encode()).hexdigest()[:16] - - -# --------------------------------------------------------------------------- -# Structural fingerprint extraction -# --------------------------------------------------------------------------- - - -def extract_fingerprint(profile: object) -> dict: - """Extract a structural fingerprint from a full profile. - - Returns a dict with: - stage_types: list[str] — e.g. ["pressure", "pressure", "flow"] - control_mode: str — "pressure" | "flow" | "mixed" - has_preinfusion: bool - has_bloom: bool - has_pulse: bool - is_flat: bool — single constant pressure/flow - peak_pressure: float — max bar across all stages - stage_count: int - technique_tags: set[str] — derived structural tags - temperature: float | None - final_weight: float | None - """ - stages = getattr(profile, "stages", None) or [] - temperature = getattr(profile, "temperature", None) - final_weight = getattr(profile, "final_weight", None) - - stage_types: list[str] = [] - peak_pressure = 0.0 - has_preinfusion = False - has_bloom = False - has_pulse = False - is_flat = True - technique_tags: set[str] = set() - - for stage in stages: - stype = (getattr(stage, "type", "") or "").lower() - stage_types.append(stype) - sname = (getattr(stage, "name", "") or "").lower() - - # Preinfusion detection - if "preinfusion" in sname or "pre-infusion" in sname or "pre infusion" in sname: - has_preinfusion = True - technique_tags.add("preinfusion") - - # Bloom detection - if "bloom" in sname or "soak" in sname: - has_bloom = True - technique_tags.add("bloom") - - # Pulse detection (stage name or many short stages) - if "pulse" in sname: - has_pulse = True - technique_tags.add("pulse") - - # Extract peak pressure from dynamics points - dynamics = getattr(stage, "dynamics", None) - if dynamics: - points = getattr(dynamics, "points", []) or [] - for point in points: - if len(point) >= 2: - try: - y_val = float(point[1]) - except (TypeError, ValueError): - continue - if stype == "pressure" and y_val > peak_pressure: - peak_pressure = y_val - - # Check for flatness (all y-values the same in dynamics) - if len(points) >= 2: - y_values = [] - for p in points: - if len(p) >= 2: - try: - y_values.append(float(p[1])) - except (TypeError, ValueError): - pass - if y_values and len(set(round(y, 1) for y in y_values)) > 1: - is_flat = False - - # Extract pressure limits - limits = getattr(stage, "limits", None) or [] - for limit_obj in limits: - ltype = (getattr(limit_obj, "type", "") or "").lower() - lval = getattr(limit_obj, "value", None) - if ltype == "pressure" and lval is not None: - try: - pval = float(lval) - if pval > peak_pressure: - peak_pressure = pval - except (TypeError, ValueError): - pass - - # Stage name technique keywords - for kw in ["lever", "turbo", "ramp", "decline", "taper"]: - if kw in sname: - technique_tags.add(kw) - - # Determine control mode - pressure_count = sum(1 for t in stage_types if t == "pressure") - flow_count = sum(1 for t in stage_types if t == "flow") - total = pressure_count + flow_count - if total == 0: - control_mode = "unknown" - elif pressure_count > 0 and flow_count == 0: - control_mode = "pressure" - technique_tags.add("pressure-profile") - elif flow_count > 0 and pressure_count == 0: - control_mode = "flow" - technique_tags.add("flow-profile") - else: - control_mode = "mixed" - technique_tags.add("mixed-profile") - - # Pulse heuristic: many short stages (>4 stages often indicates pulse-like) - if len(stages) >= 5 and not has_pulse: - has_pulse = True - technique_tags.add("pulse") - - if has_preinfusion: - technique_tags.add("preinfusion") - if has_bloom: - technique_tags.add("bloom") - if is_flat and len(stages) <= 2: - technique_tags.add("flat") - - return { - "stage_types": stage_types, - "control_mode": control_mode, - "has_preinfusion": has_preinfusion, - "has_bloom": has_bloom, - "has_pulse": has_pulse, - "is_flat": is_flat and len(stages) <= 2, - "peak_pressure": round(peak_pressure, 1), - "stage_count": len(stages), - "technique_tags": technique_tags, - "temperature": temperature, - "final_weight": final_weight, - } - - -# --------------------------------------------------------------------------- -# Keyword tag extraction (from profile name + stage names) -# --------------------------------------------------------------------------- - -_NAME_KEYWORDS = frozenset( - [ - "fruity", - "chocolate", - "nutty", - "floral", - "caramel", - "berry", - "citrus", - "sweet", - "balanced", - "creamy", - "syrupy", - "light", - "medium", - "dark", - "modern", - "italian", - "lever", - "turbo", - "bloom", - "long", - "short", - "pre-infusion", - "pulse", - "acidity", - "funky", - "thin", - "mouthfeel", - "ristretto", - "lungo", - "allonge", - "espresso", - "filter", - ] -) - -_STAGE_KEYWORDS = frozenset( - [ - "preinfusion", - "pre-infusion", - "bloom", - "ramp", - "soak", - "infusion", - "extraction", - "decline", - "taper", - "hold", - "pulse", - "turbo", - "lever", - "flat", - "pressure", - "flow", - ] -) - - -def _extract_name_tags(profile: object) -> set[str]: - """Extract keyword tags from profile name and stage names.""" - tags: set[str] = set() - name = (getattr(profile, "name", "") or "").lower() - for kw in _NAME_KEYWORDS: - if kw in name: - tags.add(kw) - - stages = getattr(profile, "stages", None) or [] - for stage in stages: - sname = (getattr(stage, "name", "") or "").lower() - for kw in _STAGE_KEYWORDS: - if kw in sname: - tags.add(kw) - - return tags - - -def _jaccard(a: set[str], b: set[str]) -> float: - if not a and not b: - return 0.0 - union = a | b - if not union: - return 0.0 - return len(a & b) / len(union) - - -# --------------------------------------------------------------------------- -# Proximity helpers -# --------------------------------------------------------------------------- - - -def _proximity_score( - a: float | None, - b: float | None, - full_range: float, - partial_range: float, - max_points: float, -) -> tuple[float, str | None]: - """Score how close two numeric values are. Returns (score, reason_or_None).""" - if a is None or b is None: - return 0.0, None - diff = abs(a - b) - if diff <= full_range: - return max_points, None - if diff <= partial_range: - frac = 1 - (diff - full_range) / (partial_range - full_range) - return round(max_points * frac, 1), None - return 0.0, None - - -# --------------------------------------------------------------------------- -# Main scoring function -# --------------------------------------------------------------------------- - - -def _score_profile( - user_tags: set[str], - user_fingerprint: dict | None, - candidate: object, -) -> tuple[float, list[str], str]: - """Score a candidate profile. Returns (score, match_reasons, explanation). - - Point allocation (100 max): - - Stage structure fingerprint: 35 - - Tag/keyword matching: 25 - - Target weight similarity: 15 - - Peak pressure similarity: 15 - - Temperature similarity: 10 - """ - reasons: list[str] = [] - score = 0.0 - - cand_fp = extract_fingerprint(candidate) - cand_tags = _extract_name_tags(candidate) - - # --- Stage structure (35 points) --- - if user_fingerprint: - struct_score = 0.0 - - # Control mode match (pressure/flow/mixed) — 12 pts - if user_fingerprint["control_mode"] == cand_fp["control_mode"]: - struct_score += 12 - reasons.append(f"{cand_fp['control_mode'].capitalize()}-controlled") - elif ( - user_fingerprint["control_mode"] != "unknown" - and cand_fp["control_mode"] != "unknown" - ): - # Partial credit for mixed vs pressure/flow - if "mixed" in (user_fingerprint["control_mode"], cand_fp["control_mode"]): - struct_score += 4 - - # Technique feature overlap — 15 pts - user_techniques = user_fingerprint.get("technique_tags", set()) - cand_techniques = cand_fp.get("technique_tags", set()) - if user_techniques or cand_techniques: - tech_sim = _jaccard(user_techniques, cand_techniques) - struct_score += tech_sim * 15 - overlap = user_techniques & cand_techniques - if overlap: - reasons.append(f"Techniques: {', '.join(sorted(overlap))}") - - # Stage count similarity — 4 pts - count_diff = abs(user_fingerprint["stage_count"] - cand_fp["stage_count"]) - if count_diff == 0: - struct_score += 4 - elif count_diff <= 1: - struct_score += 2 - elif count_diff <= 2: - struct_score += 1 - - # Flat profile match — 4 pts - if user_fingerprint["is_flat"] == cand_fp["is_flat"]: - struct_score += 4 - if cand_fp["is_flat"]: - reasons.append("Flat profile") - - score += min(struct_score, 35) - - # --- Tag matching (25 points) --- - user_lower = {t.lower() for t in user_tags} - # Merge structural technique tags into candidate tags for broader matching - all_cand_tags = cand_tags | cand_fp.get("technique_tags", set()) - if user_lower: - tag_sim = _jaccard(user_lower, all_cand_tags) - tag_pts = tag_sim * 25 - score += tag_pts - overlap = user_lower & all_cand_tags - if overlap: - # Filter out already-reported technique tags - tag_only = overlap - (user_fingerprint or {}).get("technique_tags", set()) - if tag_only: - reasons.append(f"Matching: {', '.join(sorted(tag_only))}") - - # --- Target weight (15 points) --- - user_weight = (user_fingerprint or {}).get("final_weight") - cand_weight = cand_fp.get("final_weight") - w_pts, _ = _proximity_score(user_weight, cand_weight, 2.0, 10.0, 15) - score += w_pts - if w_pts >= 10 and cand_weight is not None: - reasons.append(f"Target weight: {cand_weight:.0f}g") - - # --- Peak pressure (15 points) --- - user_peak = (user_fingerprint or {}).get("peak_pressure", 0) - cand_peak = cand_fp.get("peak_pressure", 0) - if user_peak > 0 and cand_peak > 0: - p_pts, _ = _proximity_score(user_peak, cand_peak, 0.5, 3.0, 15) - score += p_pts - if p_pts >= 10: - reasons.append(f"Peak pressure: {cand_peak:.1f} bar") - - # --- Temperature (10 points) --- - user_temp = (user_fingerprint or {}).get("temperature") - cand_temp = cand_fp.get("temperature") - t_pts, _ = _proximity_score(user_temp, cand_temp, 2.0, 5.0, 10) - score += t_pts - if t_pts >= 7 and cand_temp is not None: - reasons.append(f"Temperature: {cand_temp:.1f}°C") - - # Build explanation from reasons - explanation = "; ".join(reasons) if reasons else "" - - return min(round(score, 1), 100), reasons, explanation - - -# --------------------------------------------------------------------------- -# Service class -# --------------------------------------------------------------------------- - - -class ProfileRecommendationService: - """Local-only profile recommendation engine — zero AI tokens.""" - - def __init__(self) -> None: - self._cache = _LRUCache(_MAX_CACHE_SIZE) - self._async_lock: asyncio.Lock | None = None - - def _get_async_lock(self) -> asyncio.Lock: - if self._async_lock is None: - self._async_lock = asyncio.Lock() - return self._async_lock - - async def get_recommendations( - self, - tags: list[str], - limit: int = 5, - ) -> list[dict]: - """Return recommendations as list of {profile_name, score, explanation, match_reasons}.""" - key = _cache_key(tags, limit) - cached = self._cache.get(key) - if cached is not None: - return cached - - async with self._get_async_lock(): - cached = self._cache.get(key) - if cached is not None: - return cached - results = await self._compute(tags, limit) - self._cache.put(key, results) - return results - - async def _compute( - self, - tags: list[str], - limit: int, - ) -> list[dict]: - """Score all profiles and return top results.""" - profiles = await self._fetch_profiles() - if not profiles: - return [] - - user_tags = set(tags) if tags else set() - - # Build a synthetic "user fingerprint" by averaging the top tag-matching - # profiles, or just use tags as structural hints - user_fingerprint = self._build_user_fingerprint(user_tags, profiles) - - scored: list[dict] = [] - for p in profiles: - s, reasons, explanation = _score_profile(user_tags, user_fingerprint, p) - scored.append( - { - "profile_name": getattr(p, "name", "Unknown"), - "score": s, - "explanation": explanation, - "match_reasons": reasons, - } - ) - - scored.sort(key=lambda x: x["score"], reverse=True) - return [s for s in scored if s["score"] > 0][:limit] - - async def find_similar( - self, - source_profile_name: str, - limit: int = 10, - ) -> list[dict]: - """Find profiles structurally similar to a given profile.""" - profiles = await self._fetch_profiles() - if not profiles: - return [] - - # Find source - source = None - for p in profiles: - if getattr(p, "name", "") == source_profile_name: - source = p - break - if source is None: - return [] - - source_fp = extract_fingerprint(source) - source_tags = _extract_name_tags(source) - - scored: list[dict] = [] - for p in profiles: - if getattr(p, "name", "") == source_profile_name: - continue - s, reasons, explanation = _score_profile(source_tags, source_fp, p) - scored.append( - { - "profile_name": getattr(p, "name", "Unknown"), - "score": s, - "explanation": explanation, - "match_reasons": reasons, - } - ) - - scored.sort(key=lambda x: x["score"], reverse=True) - return [s for s in scored if s["score"] > 0][:limit] - - def invalidate_cache(self) -> None: - """Called when profiles are created/edited/deleted.""" - self._cache.clear() - logger.debug("Profile recommendation cache invalidated") - - @staticmethod - async def _fetch_profiles() -> list: - """Fetch full profiles (with stages) from the machine.""" - try: - result = await async_fetch_all_profiles() - if hasattr(result, "error") and result.error: - logger.warning(f"Failed to fetch profiles: {result.error}") - return [] - return list(result) - except Exception as e: - logger.warning(f"Failed to fetch profiles for recommendations: {e}") - return [] - - @staticmethod - def _build_user_fingerprint(user_tags: set[str], profiles: list) -> dict: - """Build a synthetic fingerprint from user tags. - - Uses structural hints in the tags (e.g. "preinfusion", "bloom", "pulse", - "pressure", "flow") to create a target fingerprint for comparison. - """ - technique_tags: set[str] = set() - - # Map user tags to structural features - tag_to_technique = { - "preinfusion": "preinfusion", - "pre-infusion": "preinfusion", - "bloom": "bloom", - "soak": "bloom", - "pulse": "pulse", - "lever": "lever", - "turbo": "turbo", - "ramp": "ramp", - "decline": "decline", - "taper": "taper", - "flat": "flat", - "pressure": "pressure-profile", - "flow": "flow-profile", - } - - for tag in user_tags: - t_lower = tag.lower() - if t_lower in tag_to_technique: - technique_tags.add(tag_to_technique[t_lower]) - - # Determine control mode from tags - control_mode = "unknown" - if "pressure-profile" in technique_tags: - control_mode = "pressure" - elif "flow-profile" in technique_tags: - control_mode = "flow" - - # Estimate stage count based on technique complexity - stage_count = 2 # baseline - if "preinfusion" in technique_tags: - stage_count += 1 - if "bloom" in technique_tags: - stage_count += 1 - if "pulse" in technique_tags: - stage_count = max(stage_count, 5) - - return { - "stage_types": [], - "control_mode": control_mode, - "has_preinfusion": "preinfusion" in technique_tags, - "has_bloom": "bloom" in technique_tags, - "has_pulse": "pulse" in technique_tags, - "is_flat": "flat" in technique_tags, - "peak_pressure": 0, # unknown from tags - "stage_count": stage_count, - "technique_tags": technique_tags, - "temperature": None, - "final_weight": None, - } - - -# Module-level singleton -recommendation_service = ProfileRecommendationService() diff --git a/apps/server/services/recipe_adapter.py b/apps/server/services/recipe_adapter.py deleted file mode 100644 index 725b799f..00000000 --- a/apps/server/services/recipe_adapter.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Recipe profile adaptation — converts OPOS recipes to Meticulous OEPF profiles. - -Reads a recipe from the bundled recipe library and builds a Meticulous machine -profile with one stage per OPOS protocol step: - - - ``bloom`` steps → time exit trigger (respects rest period) - - ``pour`` steps → weight exit trigger + 10-minute time backup - - ``wait`` / ``swirl`` / ``stir`` steps → time exit trigger - -All stages use ``type: power`` with zero power (passive pour-over flow). -""" - -import copy -import json -import logging -import uuid -from pathlib import Path -from typing import Any, Dict, List - -from config import DATA_DIR -from services.pour_over_adapter import load_pour_over_template - -logger = logging.getLogger(__name__) - -# Recipe search paths (mirroring the pour_over_adapter pattern) -_RECIPES_DATA_PATH = DATA_DIR / "recipes" -_RECIPES_FALLBACK_PATH = ( - Path(__file__).resolve().parent.parent.parent.parent / "data" / "recipes" -) -_RECIPES_DOCKER_PATH = Path("/app/defaults/recipes") - -_SEARCH_DIRS = (_RECIPES_DATA_PATH, _RECIPES_FALLBACK_PATH, _RECIPES_DOCKER_PATH) - -_STAGE_TEMPLATE = { - "type": "power", - "dynamics": { - "points": [[0, "$power_Zero"], [10, "$power_Zero"]], - "over": "time", - "interpolation": "curve", - }, - "limits": [], -} - - -def _get_recipes_dir() -> Path: - """Return the first recipes directory that exists.""" - for path in _SEARCH_DIRS: - if path.exists() and path.is_dir(): - return path - raise FileNotFoundError( - f"Recipes directory not found at any of: {', '.join(str(p) for p in _SEARCH_DIRS)}" - ) - - -def list_recipe_slugs() -> List[str]: - """Return sorted list of available recipe slugs.""" - try: - recipes_dir = _get_recipes_dir() - except FileNotFoundError: - return [] - return sorted(p.stem for p in recipes_dir.glob("*.json")) - - -def load_recipe(slug: str) -> Dict[str, Any]: - """Load and return a recipe dict by slug. - - Args: - slug: Recipe filename stem (e.g. ``"4-6-method"``). - - Returns: - Parsed recipe dict with a ``slug`` key added. - - Raises: - FileNotFoundError: If the recipe slug does not exist. - """ - try: - recipes_dir = _get_recipes_dir() - except FileNotFoundError as exc: - raise FileNotFoundError(f"Recipe '{slug}' not found: {exc}") from exc - - path = recipes_dir / f"{slug}.json" - if not path.exists(): - raise FileNotFoundError(f"Recipe '{slug}' not found") - - with open(path, "r", encoding="utf-8") as f: - recipe = json.load(f) - - recipe["slug"] = slug - return recipe - - -def list_recipes() -> List[Dict[str, Any]]: - """Return all available recipes, each with a ``slug`` key added.""" - try: - recipes_dir = _get_recipes_dir() - except FileNotFoundError: - return [] - - recipes = [] - for path in sorted(recipes_dir.glob("*.json")): - slug = path.stem - try: - with open(path, "r", encoding="utf-8") as f: - recipe = json.load(f) - recipe["slug"] = slug - recipes.append(recipe) - except Exception as exc: - logger.warning("Failed to load recipe '%s': %s", slug, exc) - return recipes - - -def adapt_recipe_to_profile(recipe: Dict[str, Any]) -> Dict[str, Any]: - """Convert an OPOS recipe dict to a Meticulous OEPF profile. - - Maps each protocol step to a machine stage: - - ``bloom``/``pour`` → exit when cumulative weight reached - - ``wait``/``swirl``/``stir`` → exit after ``duration_s`` seconds - - Args: - recipe: Parsed OPOS recipe dict (must include ``ingredients`` and ``protocol``). - - Returns: - A fully adapted profile dict ready for machine upload. - """ - template = load_pour_over_template() - profile = copy.deepcopy(template) - - # ── Unique identity ────────────────────────────────────────────────────── - profile["id"] = str(uuid.uuid4()) - profile["author_id"] = str(uuid.uuid4()) - - # ── Name ───────────────────────────────────────────────────────────────── - recipe_name = recipe.get("metadata", {}).get("name", "Recipe") - profile["name"] = f"MeticAI Recipe: {recipe_name}" - - # ── Top-level weight ────────────────────────────────────────────────────── - ingredients = recipe.get("ingredients", {}) - total_water = float(ingredients.get("water_g", 0)) - coffee_g = float(ingredients.get("coffee_g", 0)) or None - profile["final_weight"] = total_water - - # ── Short description ───────────────────────────────────────────────────── - parts = [f"Target: {total_water:.0f}g"] - if coffee_g: - parts.append(f"Dose: {coffee_g:.0f}g") - ratio = total_water / coffee_g - parts.append(f"Ratio: 1:{ratio:.1f}") - display = profile.get("display") or {} - display["shortDescription"] = " | ".join(parts)[:99] - profile["display"] = display - - # ── Build stages from OPOS protocol steps ───────────────────────────────── - stages: list = [] - cumulative_water = 0.0 - pour_count = 0 - - for step in recipe.get("protocol", []): - action = step.get("action", "") - water_g = float(step.get("water_g") or 0) - duration_s = float(step.get("duration_s") or 30) - notes = step.get("notes", "") - - stage = copy.deepcopy(_STAGE_TEMPLATE) - stage["key"] = f"power_{len(stages) + 1}" - - if action in ("bloom", "pour"): - cumulative_water += water_g - if action == "bloom": - stage["name"] = f"Bloom ({water_g:.0f}g / {duration_s:.0f}s)" - # Bloom exits on time so the rest period is honoured - stage["exit_triggers"] = [ - { - "type": "time", - "value": duration_s, - "relative": True, - "comparison": ">=", - } - ] - else: - pour_count += 1 - stage["name"] = f"Pour {pour_count} (to {cumulative_water:.0f}g)" - stage["exit_triggers"] = [ - { - "type": "weight", - "value": cumulative_water, - "relative": False, - "comparison": ">=", - }, - # 10-minute time backup to prevent indefinite runs - { - "type": "time", - "value": 600, - "relative": True, - "comparison": ">=", - }, - ] - - elif action in ("wait", "swirl", "stir"): - if action == "swirl": - stage["name"] = "Swirl" - elif action == "stir": - stage["name"] = "Stir" - else: - stage["name"] = f"Wait ({duration_s:.0f}s)" - - stage["exit_triggers"] = [ - { - "type": "time", - "value": duration_s, - "relative": True, - "comparison": ">=", - } - ] - - else: - logger.debug("Skipping unknown OPOS action '%s'", action) - continue - - if notes: - stage["notes"] = notes - - stages.append(stage) - - profile["stages"] = stages - return profile diff --git a/apps/server/services/scheduling_state.py b/apps/server/services/scheduling_state.py deleted file mode 100644 index b96778a9..00000000 --- a/apps/server/services/scheduling_state.py +++ /dev/null @@ -1,373 +0,0 @@ -"""Shared state, persistence, and helper functions for scheduling. - -This module is the single source of truth for: -- Scheduled shots state and persistence -- Recurring schedules state and persistence -- Helper functions for schedule timing calculations -""" - -from datetime import datetime, timezone, timedelta -from typing import Optional -import logging -import json -import asyncio -from pathlib import Path - -from config import DATA_DIR - -logger = logging.getLogger(__name__) - -# ============================================================================== -# Shared In-Memory State -# ============================================================================== -# These are the ONLY copies of these dictionaries - all modules should import from here - -_scheduled_shots: dict = {} -_scheduled_tasks: dict = {} -_recurring_schedules: dict = {} - -# Module-level lock protecting mutations to the above dicts. -# Callers that read-modify-write any of these dicts across an ``await`` -# boundary MUST hold this lock to prevent interleaved mutations. -# Created lazily and recreated when the running event loop changes so that -# tests using a new loop per function don't hit "attached to a different loop". -_state_lock: Optional[asyncio.Lock] = None -_state_lock_loop: Optional[asyncio.AbstractEventLoop] = None - - -def _get_state_lock() -> asyncio.Lock: - """Return the module-level state lock, (re)creating it when needed. - - The lock is recreated when the running event loop differs from the one - it was originally bound to. This avoids ``RuntimeError: ... attached to - a different loop`` in test suites that create a fresh loop per test. - """ - global _state_lock, _state_lock_loop - try: - running_loop = asyncio.get_running_loop() - except RuntimeError: - running_loop = None - - if _state_lock is None or ( - running_loop is not None and running_loop is not _state_lock_loop - ): - _state_lock = asyncio.Lock() - _state_lock_loop = running_loop - return _state_lock - - -# Constant for preheat duration -PREHEAT_DURATION_MINUTES = 10 - - -# ============================================================================== -# Scheduled Shots Persistence -# ============================================================================== - - -class ScheduledShotsPersistence: - """Manages persistence of scheduled shots to disk. - - Scheduled shots are stored in a JSON file to survive server restarts. - This ensures that scheduled shots are not lost during crashes, deploys, - or host reboots. - """ - - def __init__(self, persistence_file: str | Path | None = None): - """Initialize the persistence layer. - - Args: - persistence_file: Path to the JSON file for storing scheduled shots. - Defaults to DATA_DIR/scheduled_shots.json. - """ - if persistence_file is None: - self.persistence_file = DATA_DIR / "scheduled_shots.json" - else: - self.persistence_file = Path(persistence_file) - self._lock = asyncio.Lock() - - # Ensure the parent directory exists - self.persistence_file.parent.mkdir(parents=True, exist_ok=True) - - async def save(self, scheduled_shots: dict) -> None: - """Save scheduled shots to disk. - - Args: - scheduled_shots: Dictionary of scheduled shots to persist. - """ - async with self._lock: - try: - # Only save shots that are scheduled or preheating (not completed/failed/cancelled) - active_shots = { - shot_id: shot - for shot_id, shot in scheduled_shots.items() - if shot.get("status") in ["scheduled", "preheating"] - } - - # Write atomically using a temporary file - temp_file = self.persistence_file.with_suffix(".tmp") - with open(temp_file, "w") as f: - json.dump(active_shots, f, indent=2) - - # Atomic rename - temp_file.replace(self.persistence_file) - - logger.debug( - f"Persisted {len(active_shots)} scheduled shots to {self.persistence_file}" - ) - except Exception as e: - logger.error(f"Failed to save scheduled shots: {e}", exc_info=True) - - async def load(self) -> dict: - """Load scheduled shots from disk. - - Returns: - Dictionary of scheduled shots, or empty dict if file doesn't exist or is invalid. - """ - async with self._lock: - try: - if not self.persistence_file.exists(): - logger.info("No persisted scheduled shots found (first run)") - return {} - - with open(self.persistence_file, "r") as f: - data = json.load(f) - - if not isinstance(data, dict): - logger.warning("Invalid scheduled shots file format, ignoring") - return {} - - logger.info( - f"Loaded {len(data)} scheduled shots from {self.persistence_file}" - ) - return data - except json.JSONDecodeError as e: - logger.error(f"Corrupt scheduled shots file, ignoring: {e}") - # Backup the corrupt file - try: - backup_file = self.persistence_file.with_suffix(".corrupt") - self.persistence_file.rename(backup_file) - logger.info(f"Backed up corrupt file to {backup_file}") - except Exception: - # Ignore errors during backup (file system issues, permissions, etc.) - pass - return {} - except Exception as e: - logger.error(f"Failed to load scheduled shots: {e}", exc_info=True) - return {} - - async def clear(self) -> None: - """Clear all persisted scheduled shots.""" - async with self._lock: - try: - if self.persistence_file.exists(): - self.persistence_file.unlink() - logger.info("Cleared persisted scheduled shots") - except Exception as e: - logger.error(f"Failed to clear scheduled shots: {e}", exc_info=True) - - -# ============================================================================== -# Recurring Schedules Persistence -# ============================================================================== - - -class RecurringSchedulesPersistence: - """Manages persistence of recurring schedules to disk. - - Recurring schedules define repeated preheat/shot times (e.g., daily, weekdays). - """ - - def __init__(self, persistence_file: str | Path | None = None): - if persistence_file is None: - self.persistence_file = DATA_DIR / "recurring_schedules.json" - else: - self.persistence_file = Path(persistence_file) - self._lock = asyncio.Lock() - - self.persistence_file.parent.mkdir(parents=True, exist_ok=True) - - async def save(self, schedules: dict) -> None: - """Save recurring schedules to disk. - - Persists ALL schedules (enabled and disabled) so that disabled - schedules survive restarts and can be re-enabled later. - """ - async with self._lock: - try: - temp_file = self.persistence_file.with_suffix(".tmp") - with open(temp_file, "w") as f: - json.dump(schedules, f, indent=2) - temp_file.replace(self.persistence_file) - - enabled_count = sum( - 1 for s in schedules.values() if s.get("enabled", True) - ) - logger.debug( - f"Persisted {len(schedules)} recurring schedules ({enabled_count} enabled)" - ) - except Exception as e: - logger.error(f"Failed to save recurring schedules: {e}", exc_info=True) - - async def load(self) -> dict: - """Load recurring schedules from disk.""" - async with self._lock: - try: - if not self.persistence_file.exists(): - return {} - - with open(self.persistence_file, "r") as f: - data = json.load(f) - - if not isinstance(data, dict): - return {} - - logger.info(f"Loaded {len(data)} recurring schedules") - return data - except Exception as e: - logger.error(f"Failed to load recurring schedules: {e}", exc_info=True) - return {} - - -# ============================================================================== -# Persistence Instances and Helper Functions -# ============================================================================== - -# Initialize persistence layers -_scheduled_shots_persistence = ScheduledShotsPersistence() -_recurring_schedules_persistence = RecurringSchedulesPersistence() - -# Legacy alias for backward compatibility -SchedulePersistence = ScheduledShotsPersistence - - -async def save_scheduled_shots(): - """Save scheduled shots to persistence.""" - await _scheduled_shots_persistence.save(_scheduled_shots) - - -async def load_scheduled_shots() -> dict: - """Load scheduled shots from persistence.""" - return await _scheduled_shots_persistence.load() - - -async def save_recurring_schedules(): - """Save recurring schedules to persistence.""" - await _recurring_schedules_persistence.save(_recurring_schedules) - - -async def load_recurring_schedules(): - """Load recurring schedules from persistence. - - Note: We use clear()/update() to mutate the existing dict in place, - ensuring any module that imported _recurring_schedules directly - will see the loaded data. - """ - loaded = await _recurring_schedules_persistence.load() - async with _get_state_lock(): - _recurring_schedules.clear() - _recurring_schedules.update(loaded) - - -async def restore_scheduled_shots(): - """Restore scheduled shots from disk on startup. - - Note: We use clear()/update() to mutate the existing dict in place, - ensuring any module that imported _scheduled_shots directly - will see the loaded data. - """ - loaded = await _scheduled_shots_persistence.load() - async with _get_state_lock(): - _scheduled_shots.clear() - _scheduled_shots.update(loaded) - - if _scheduled_shots: - logger.info( - f"Restored {len(_scheduled_shots)} scheduled shots from persistence" - ) - - -# ============================================================================== -# Schedule Timing Calculations -# ============================================================================== - - -def get_next_occurrence(schedule: dict) -> Optional[datetime]: - """Calculate the next occurrence of a recurring schedule. - - Args: - schedule: Recurring schedule dict with: - - time: HH:MM format - - recurrence_type: 'daily', 'weekdays', 'weekends', 'interval', 'specific_days' - - interval_days: For 'interval' type, number of days between runs - - days_of_week: For 'specific_days' type, list of day numbers (0=Monday) - - Returns: - Next datetime when the schedule should run, or None if invalid. - """ - MAX_SCHEDULING_DAYS = 400 # Maximum ~1 year ahead to search for next occurrence - - try: - time_str = schedule.get("time", "07:00") - hour, minute = map(int, time_str.split(":")) - recurrence_type = schedule.get("recurrence_type", "daily") - - now = datetime.now(timezone.utc) - today = now.date() - - # Start checking from today - candidate = datetime( - today.year, today.month, today.day, hour, minute, tzinfo=timezone.utc - ) - - # If today's time has passed, start from tomorrow - if candidate <= now: - candidate += timedelta(days=1) - - # Find the next valid day based on recurrence type - for _ in range(MAX_SCHEDULING_DAYS): - weekday = candidate.weekday() # 0=Monday, 6=Sunday - - if recurrence_type == "daily": - return candidate - elif recurrence_type == "weekdays" and weekday < 5: # Mon-Fri - return candidate - elif recurrence_type == "weekends" and weekday >= 5: # Sat-Sun - return candidate - elif recurrence_type == "interval": - interval_days = schedule.get("interval_days", 1) - # Check if this day is valid based on last_run - last_run = schedule.get("last_run") - if last_run: - try: - # Handle ISO format with trailing Z (replace with +00:00 for fromisoformat) - last_run_str = ( - last_run.replace("Z", "+00:00") - if isinstance(last_run, str) - else str(last_run) - ) - last_run_dt = datetime.fromisoformat(last_run_str) - days_since = (candidate - last_run_dt).days - if days_since >= interval_days: - return candidate - except (ValueError, AttributeError): - # Invalid datetime format - treat as no last run - logger.warning( - f"Invalid last_run format for schedule {schedule.get('id')}: {last_run}" - ) - return candidate - else: - # No last run, so this is the first run - return candidate - elif recurrence_type == "specific_days": - days_of_week = schedule.get("days_of_week", []) - if weekday in days_of_week: - return candidate - - # Move to next day - candidate += timedelta(days=1) - - return None - except Exception as e: - logger.error(f"Failed to calculate next occurrence: {e}") - return None diff --git a/apps/server/services/settings_service.py b/apps/server/services/settings_service.py deleted file mode 100644 index d8aba8bc..00000000 --- a/apps/server/services/settings_service.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Settings service for managing application configuration.""" - -import json -from typing import Optional - -from config import DATA_DIR -from utils.file_utils import atomic_write_json - -SETTINGS_FILE = DATA_DIR / "settings.json" - -# In-memory cache (loaded from disk on first access, write-through on save) -_settings_cache: Optional[dict] = None - -_DEFAULT_SETTINGS = { - "geminiApiKey": "", - "geminiModel": "gemini-2.5-flash", - "meticulousIp": "", - "serverIp": "", - "authorName": "", - "mqttEnabled": True, - "tailscaleEnabled": False, - "tailscaleAuthKey": "", - "betaChannel": False, - "autoSync": False, - "autoSyncAiDescription": False, -} - - -def ensure_settings_file(): - """Ensure the settings file and directory exist.""" - SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) - if not SETTINGS_FILE.exists(): - SETTINGS_FILE.write_text(json.dumps(_DEFAULT_SETTINGS, indent=2)) - - -def load_settings() -> dict: - """Load settings, using in-memory copy when available. - - Validates that the on-disk data is a dict and merges with defaults - so missing keys always have a safe fallback value. - """ - global _settings_cache - if _settings_cache is not None: - return _settings_cache - ensure_settings_file() - try: - with open(SETTINGS_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - except (json.JSONDecodeError, FileNotFoundError): - data = None - - if not isinstance(data, dict): - data = {} - - # Merge with defaults so missing keys always have a safe value - _settings_cache = {**_DEFAULT_SETTINGS, **data} - return _settings_cache - - -def save_settings(settings: dict): - """Write-through: update in-memory cache and persist to disk.""" - global _settings_cache - _settings_cache = settings - ensure_settings_file() - atomic_write_json(SETTINGS_FILE, settings) - - -def get_author_name() -> str: - """Get the configured author name, defaulting to 'Metic' if not set.""" - settings = load_settings() - author = settings.get("authorName", "").strip() - return author if author else "Metic" diff --git a/apps/server/services/shot_annotations_service.py b/apps/server/services/shot_annotations_service.py deleted file mode 100644 index 45614635..00000000 --- a/apps/server/services/shot_annotations_service.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Shot annotations service for user notes on shot data. - -Stores user-provided annotations (markdown text and star ratings) for shots, -keyed by {date}/{filename} to match the machine's shot storage structure. - -Storage format: JSON object mapping shot keys to annotation objects: -{ - "2024-01-15/shot_001.json": { - "annotation": "Great flow, but extraction was slightly fast...", - "rating": 4, - "updated_at": "2024-01-15T10:30:00Z" - } -} -""" - -import json -import threading -from datetime import datetime, timezone -from typing import Optional - -from logging_config import get_logger -from config import DATA_DIR -from utils.file_utils import atomic_write_json - -logger = get_logger() - -ANNOTATIONS_FILE = DATA_DIR / "shot_annotations.json" - -# In-memory cache -_annotations_cache: Optional[dict] = None - -# Lock to prevent concurrent read/modify/write races -_annotations_lock = threading.Lock() - - -def _ensure_file(): - """Ensure the annotations file and directory exist.""" - ANNOTATIONS_FILE.parent.mkdir(parents=True, exist_ok=True) - if not ANNOTATIONS_FILE.exists(): - ANNOTATIONS_FILE.write_text("{}") - - -def _load_annotations() -> dict: - """Load annotations from disk, caching in memory.""" - global _annotations_cache - if _annotations_cache is not None: - return _annotations_cache - - _ensure_file() - try: - with open(ANNOTATIONS_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - except (json.JSONDecodeError, FileNotFoundError): - data = {} - - if not isinstance(data, dict): - logger.warning("Annotations file contained non-dict — resetting") - data = {} - - _annotations_cache = data - return _annotations_cache - - -def _save_annotations(data: dict) -> None: - """Save annotations to disk and update cache.""" - global _annotations_cache - _ensure_file() - atomic_write_json(ANNOTATIONS_FILE, data) - _annotations_cache = data - - -def make_shot_key(date: str, filename: str) -> str: - """Create a unique key for a shot from date and filename.""" - return f"{date}/{filename}" - - -def _validate_rating(rating) -> Optional[int]: - """Validate and normalise a rating value. - - Returns an int 1-5 or None. Raises ValueError for out-of-range values. - """ - if rating is None: - return None - try: - rating = int(rating) - except (TypeError, ValueError): - raise ValueError("Rating must be an integer between 1 and 5") - if rating < 1 or rating > 5: - raise ValueError("Rating must be between 1 and 5") - return rating - - -def get_annotation(date: str, filename: str) -> Optional[dict]: - """Get the full annotation entry for a specific shot. - - Args: - date: Shot date (e.g., "2024-01-15") - filename: Shot filename (e.g., "shot_001.json") - - Returns: - Dict with annotation text, rating, and updated_at if exists, None otherwise. - """ - annotations = _load_annotations() - key = make_shot_key(date, filename) - entry = annotations.get(key) - if entry and isinstance(entry, dict): - return { - "annotation": entry.get("annotation"), - "rating": entry.get("rating"), - "updated_at": entry.get("updated_at"), - } - return None - - -def set_annotation(date: str, filename: str, annotation: str, rating=None) -> dict: - """Set the annotation for a specific shot. - - Args: - date: Shot date - filename: Shot filename - annotation: Markdown annotation text (empty string to clear text) - rating: Star rating 1-5, or None to leave unchanged / clear - - Returns: - Updated annotation entry. - """ - validated_rating = _validate_rating(rating) - with _annotations_lock: - annotations = _load_annotations() - key = make_shot_key(date, filename) - - has_text = annotation and annotation.strip() - existing = ( - annotations.get(key, {}) if isinstance(annotations.get(key), dict) else {} - ) - - # Merge: keep existing rating if caller didn't provide one - new_annotation = annotation.strip() if has_text else None - new_rating = validated_rating if rating is not None else existing.get("rating") - - if not new_annotation and not new_rating: - # Nothing left — remove entry entirely - if key in annotations: - del annotations[key] - _save_annotations(annotations) - return {"annotation": None, "rating": None} - - entry = { - "annotation": new_annotation, - "rating": new_rating, - "updated_at": datetime.now(timezone.utc).isoformat(), - } - annotations[key] = entry - _save_annotations(annotations) - - logger.info(f"Saved annotation for shot {key}") - return entry - - -def set_rating(date: str, filename: str, rating) -> dict: - """Set only the star rating for a shot, preserving existing annotation text. - - Args: - date: Shot date - filename: Shot filename - rating: Star rating 1-5, or None to clear rating - - Returns: - Updated annotation entry. - """ - validated_rating = _validate_rating(rating) - with _annotations_lock: - annotations = _load_annotations() - key = make_shot_key(date, filename) - existing = ( - annotations.get(key, {}) if isinstance(annotations.get(key), dict) else {} - ) - - existing_text = existing.get("annotation") - - if not existing_text and not validated_rating: - # Nothing left — remove entry entirely - if key in annotations: - del annotations[key] - _save_annotations(annotations) - return {"annotation": None, "rating": None} - - entry = { - "annotation": existing_text, - "rating": validated_rating, - "updated_at": datetime.now(timezone.utc).isoformat(), - } - annotations[key] = entry - _save_annotations(annotations) - - logger.info(f"Saved rating for shot {key}") - return entry - - -def delete_annotation(date: str, filename: str) -> bool: - """Delete the entire annotation entry for a shot. - - Args: - date: Shot date - filename: Shot filename - - Returns: - True if an annotation was deleted, False if none existed. - """ - with _annotations_lock: - annotations = _load_annotations() - key = make_shot_key(date, filename) - if key in annotations: - del annotations[key] - _save_annotations(annotations) - logger.info(f"Deleted annotation for shot {key}") - return True - return False - - -def get_all_annotations() -> dict: - """Get all shot annotations. - - Returns: - Dict mapping shot keys to annotation entries. - """ - return _load_annotations().copy() - - -def has_annotation(date: str, filename: str) -> bool: - """Check whether a shot has any annotation (text or rating).""" - annotations = _load_annotations() - key = make_shot_key(date, filename) - return key in annotations - - -def invalidate_cache() -> None: - """Clear the in-memory cache (for testing).""" - global _annotations_cache - _annotations_cache = None diff --git a/apps/server/services/shot_facts.py b/apps/server/services/shot_facts.py deleted file mode 100644 index 3f9188a0..00000000 --- a/apps/server/services/shot_facts.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Deterministic shot-fact derivation (#423 + diagnostic signals). - -Pure functions: telemetry + profile + local analysis -> typed facts. No I/O, no LLM. -Mirrors apps/web/src/lib/shotFacts.ts — keep thresholds identical across runtimes. -""" - -from __future__ import annotations - - -def classify_trigger( - stage_control_mode: str, - trigger_type: str, - total_triggers: int, - *, - trigger_value: float | None = None, - global_target_weight: float | None = None, - on_target: bool | None = None, - is_terminal_stage: bool = False, - weight_on_target: bool | None = None, -) -> dict: - """Classify a stage's exit trigger as Targeted vs Failsafe (#423). - - Args: - stage_control_mode: the variable the stage effectively controls - ('pressure' | 'flow' | 'power' | other) — pass the *effective* mode. - trigger_type: the exit trigger that fired ('weight' | 'time' | 'pressure' | 'flow'). - total_triggers: number of exit triggers defined on the stage. - trigger_value: resolved value of the fired trigger (for weight triggers, - enables the final-yield vs stage-milestone distinction). - global_target_weight: the shot's final target weight (grams). - on_target: whether the stage tracked its intended target band. Only - meaningful for a weight-terminated pressure-governed stage: False - flags puck failure (yield reached off-curve). None = unknown. - - Returns: - {'kind': 'targeted'|'failsafe'|'unknown', 'label': str, 'reason': str} - """ - if not trigger_type: - # No exit trigger fired. A stage with no exit triggers defined - # transitions on its planned dynamics duration (intermediate stage) or - # ends when the shot reaches its global target weight (final stage) — - # both are intentional. - if total_triggers == 0: - if is_terminal_stage: - if weight_on_target: - return { - "kind": "targeted", - "label": "Targeted (yield reached)", - "reason": "The final stage ended when the shot reached its target weight.", - } - return { - "kind": "unknown", - "label": "Unknown", - "reason": "The final stage has no exit trigger and the shot did not reach its target weight (likely a manual stop).", - } - return { - "kind": "targeted", - "label": "Targeted (planned transition)", - "reason": "The stage has no exit trigger; it transitions to the next stage on its planned dynamics duration.", - } - # Exit triggers were defined but none of them fired. - return { - "kind": "unknown", - "label": "Unknown", - "reason": "The stage defined exit triggers but none of them fired.", - } - if trigger_type == "weight": - near_final = ( - global_target_weight is not None - and global_target_weight > 0 - and trigger_value is not None - and trigger_value >= global_target_weight * (1 - YIELD_THRESHOLD) - ) - if near_final and on_target is False: - return { - "kind": "failsafe", - "label": "Failsafe (puck failure — yield hit off-target)", - "reason": ( - "The final weight target was reached, but the stage never " - "built its intended pressure, so the yield came from an " - "uncontrolled extraction (likely channeling or a failed puck)." - ), - } - if ( - not near_final - and trigger_value is not None - and global_target_weight is not None - and global_target_weight > 0 - ): - return { - "kind": "targeted", - "label": "Targeted (stage yield / first-drip check)", - "reason": "Stage exited on an intermediate weight milestone below the final target.", - } - return { - "kind": "targeted", - "label": "Targeted (yield reached)", - "reason": "Weight is the ultimate goal of the shot.", - } - if trigger_type == "time": - if total_triggers == 1: - return { - "kind": "targeted", - "label": "Targeted (planned duration)", - "reason": "Time is the only trigger, so this is an intentional timed stage.", - } - return { - "kind": "targeted", - "label": "Targeted (timed transition)", - "reason": ( - "The stage transitioned when its planned time elapsed; the other " - "exit conditions simply did not fire first. A time exit is a valid, " - "intended transition — a genuine timeout with no extraction is " - "surfaced separately as a stall." - ), - } - if stage_control_mode in ("flow", "power") and trigger_type == "pressure": - return { - "kind": "targeted", - "label": "Targeted (puck resistance achieved)", - "reason": "Flow-controlled stage reached its intended pressure.", - } - if stage_control_mode in ("flow", "power") and trigger_type == "flow": - return { - "kind": "targeted", - "label": "Targeted (flow target reached)", - "reason": "Flow-controlled stage reached its intended flow condition.", - } - if stage_control_mode == "pressure" and trigger_type == "flow": - if total_triggers == 1: - return { - "kind": "targeted", - "label": "Targeted (planned flow transition)", - "reason": "Flow is the only trigger, so the transition is intentional.", - } - return { - "kind": "failsafe", - "label": "Failsafe (caught channeling or choking)", - "reason": "Pressure-controlled stage exited on a flow backstop.", - } - if stage_control_mode == "pressure" and trigger_type == "pressure": - return { - "kind": "targeted", - "label": "Targeted (pressure threshold reached)", - "reason": "Pressure-controlled stage reached its target pressure.", - } - return { - "kind": "unknown", - "label": "Unknown", - "reason": "Trigger/control-mode combination is not classified.", - } - - -# Threshold constants — keep identical to apps/web/src/lib/shotFacts.ts -STALL_MIN_WEIGHT_GAIN_G = 0.5 # below this on a time-failsafe = stalled -CHANNELING_PRESSURE_DROP_BAR = 1.5 # pressure fall within a stage -CHANNELING_FLOW_RISE_MLS = 1.5 # simultaneous flow rise -# #423 effective-mode thresholds (from the superseding issue comment): -# flow target ≥ MIN + pressure limit ⇒ pressure control -EFFECTIVE_FLOW_TARGET_MIN = 6.0 -# pressure stage + flow limit ≤ MAX ⇒ flow control -EFFECTIVE_FLOW_LIMIT_MAX = 3.0 -# #423 puck-failure detection thresholds: -# a weight trigger within YIELD_THRESHOLD of the global target = a final-yield stage -YIELD_THRESHOLD = 0.10 -# peak pressure must reach (1 - TOLERANCE_THRESHOLD)×target to count as "on-target" -TOLERANCE_THRESHOLD = 0.20 - - -def _stage_control_mode(stage: dict) -> str: - """Best-effort: which variable the stage nominally controls (declared type).""" - t = (stage.get("stage_type") or stage.get("type") or "").lower() - if "flow" in t: - return "flow" - if "pressure" in t: - return "pressure" - if "power" in t: - return "power" - return "unknown" - - -def _limit_value(stage: dict, limit_type: str) -> float | None: - """Resolved numeric value of a stage limit by type, or None if absent.""" - for lim in stage.get("limits") or []: - if lim.get("type") == limit_type: - try: - return float(lim.get("value")) - except (TypeError, ValueError): - return None - return None - - -def effective_control_mode(stage: dict) -> str: - """Determine a stage's *effective* control mode, correcting for #423 cases - where the declared type does not reflect the true intent. - - Overrides (from the superseding #423 comment): - 1. Aggressive flow (peak target ≥ 6 ml/s) with a pressure limit ⇒ the - pressure limit governs, so the stage is effectively pressure-controlled. - 2. A pressure stage with a highly restricted flow limit (≤ 3 ml/s) can't - build pressure, so it is effectively flow-controlled. - 3. Power stages are raw mechanical drive. - - Falls back to the declared control mode when no override applies. - """ - declared = _stage_control_mode(stage) - if declared == "power": - return "power" - max_target = stage.get("profile_max_target") - pressure_limit = _limit_value(stage, "pressure") - flow_limit = _limit_value(stage, "flow") - if ( - declared == "flow" - and isinstance(max_target, (int, float)) - and max_target >= EFFECTIVE_FLOW_TARGET_MIN - and pressure_limit is not None - ): - return "pressure" - if ( - declared == "pressure" - and flow_limit is not None - and flow_limit <= EFFECTIVE_FLOW_LIMIT_MAX - ): - return "flow" - return declared - - -def _yield_stage_on_target(stage: dict, effective_mode: str) -> bool | None: - """Whether a pressure-governed stage actually built its intended pressure (#423). - - Puck failure is only reliably detectable on pressure-governed extraction: if - the puck channels or collapses, pressure never reaches the intended band even - though weight still accrues. For flow-governed / power / target-less stages we - return None (can't prove failure — a volumetric shot hitting weight is normal). - """ - if effective_mode != "pressure": - return None - target = _limit_value(stage, "pressure") - if target is None and _stage_control_mode(stage) == "pressure": - target = stage.get("profile_target_value") - if not target: - return None - ed = stage.get("execution_data") or {} - max_pressure = ed.get("max_pressure") - if max_pressure is None: - return None - return float(max_pressure) >= (1 - TOLERANCE_THRESHOLD) * float(target) - - -def detect_stall(stage: dict) -> dict: - """A stage stalled if it timed out with another unmet target and negligible gain. - - A stall is a time-terminated stage that *had* another exit target it failed to - reach (total_triggers > 1) yet extracted almost nothing — i.e. the time trigger - acted as a backstop for a target that was never met. Purely timed stages - (total_triggers == 1, e.g. pre-infusion) are intentional and never stalled. - """ - result = stage.get("exit_trigger_result") or {} - triggered = result.get("triggered") or {} - trig_type = triggered.get("type", "") - total = len(stage.get("exit_triggers") or []) - ed = stage.get("execution_data") or {} - gain = float(ed.get("weight_gain", 0) or 0) - stalled = trig_type == "time" and total > 1 and gain < STALL_MIN_WEIGHT_GAIN_G - return {"stalled": stalled, "weight_gain": round(gain, 2)} - - -def detect_channeling(execution_data: dict) -> dict: - """Flag likely channeling: pressure falls while flow rises within the stage.""" - ed = execution_data or {} - p_drop = float(ed.get("start_pressure", 0) or 0) - float( - ed.get("end_pressure", 0) or 0 - ) - f_rise = float(ed.get("end_flow", 0) or 0) - float(ed.get("start_flow", 0) or 0) - channeling = ( - p_drop >= CHANNELING_PRESSURE_DROP_BAR and f_rise >= CHANNELING_FLOW_RISE_MLS - ) - return { - "channeling": channeling, - "pressure_drop": round(p_drop, 2), - "flow_rise": round(f_rise, 2), - } - - -def _build_phases(local_analysis: dict) -> list[dict]: - """Group stages into pre-infusion / ramp / peak / decline by avg pressure shape.""" - stages = [ - s for s in local_analysis.get("stage_analyses", []) if s.get("execution_data") - ] - phases: list[dict] = [] - for s in stages: - ed = s["execution_data"] - avg_p = float(ed.get("avg_pressure", 0) or 0) - if avg_p < 3.0: - phase = "pre-infusion" - elif float(ed.get("end_pressure", 0) or 0) > float( - ed.get("start_pressure", 0) or 0 - ): - phase = "ramp" - elif float(ed.get("end_pressure", 0) or 0) < float( - ed.get("start_pressure", 0) or 0 - ): - phase = "decline" - else: - phase = "peak" - phases.append( - { - "stage_name": s.get("stage_name"), - "phase": phase, - "avg_pressure": ed.get("avg_pressure"), - "avg_flow": ed.get("avg_flow"), - "weight_gain": ed.get("weight_gain"), - } - ) - return phases - - -def _curve_adherence(stage: dict) -> dict | None: - """Compare measured avg vs the stage's mean target setpoint, if numeric. - - The numeric target is precomputed at stage-build time as - ``profile_target_value`` (mean of the resolved dynamics setpoints); see - analysis_service._mean_dynamics_target and its native mirror - DirectModeInterceptor.meanDynamicsTarget. - """ - target = stage.get("profile_target_value") - ed = stage.get("execution_data") or {} - if target is None: - return None - mode = _stage_control_mode(stage) - measured = ed.get("avg_pressure") if mode == "pressure" else ed.get("avg_flow") - if measured is None: - return None - return { - "target": round(float(target), 2), - "measured": measured, - "delta": round(float(measured) - float(target), 2), - } - - -def build_shot_facts(local_analysis: dict) -> dict: - """Assemble the deterministic ShotFacts object consumed by the static view, the - LLM fact sheet, and the validator. Mirrors buildShotFacts() in shotFacts.ts.""" - stages_out: list[dict] = [] - wa = local_analysis.get("weight_analysis", {}) - global_target_weight = wa.get("target") - actual_weight = wa.get("actual") - weight_on_target = ( - actual_weight >= global_target_weight * (1 - YIELD_THRESHOLD) - if actual_weight is not None - and global_target_weight is not None - and global_target_weight > 0 - else None - ) - all_stages = local_analysis.get("stage_analyses", []) - last_executed_idx = -1 - for i, s in enumerate(all_stages): - if s.get("execution_data"): - last_executed_idx = i - for i, s in enumerate(all_stages): - ed = s.get("execution_data") - if not ed: - stages_out.append({"stage_name": s.get("stage_name"), "reached": False}) - continue - triggered = (s.get("exit_trigger_result") or {}).get("triggered") or {} - trig_type = triggered.get("type", "") - trig_value = triggered.get("target") - total = len(s.get("exit_triggers") or []) - declared_mode = _stage_control_mode(s) - effective_mode = effective_control_mode(s) - on_target = _yield_stage_on_target(s, effective_mode) - stages_out.append( - { - "stage_name": s.get("stage_name"), - "reached": True, - "control_mode": effective_mode, - "declared_mode": declared_mode, - "mode_overridden": effective_mode != declared_mode, - "trigger_type": trig_type, - "trigger_class": classify_trigger( - effective_mode, - trig_type, - total, - trigger_value=trig_value, - global_target_weight=global_target_weight, - on_target=on_target, - is_terminal_stage=(i == last_executed_idx), - weight_on_target=weight_on_target, - ), - "stall": detect_stall(s), - "channeling": detect_channeling(ed), - "curve_adherence": _curve_adherence(s), - } - ) - return { - "stages": stages_out, - "phases": _build_phases(local_analysis), - "weight": { - "actual": wa.get("actual"), - "target": wa.get("target"), - "deviation_pct": wa.get("deviation_percent"), - }, - "total_time_s": ( - local_analysis.get("overall_metrics", {}).get("total_time") - or local_analysis.get("shot_summary", {}).get("total_time") - ), - } diff --git a/apps/server/services/temp_profile_service.py b/apps/server/services/temp_profile_service.py deleted file mode 100644 index 70701e6a..00000000 --- a/apps/server/services/temp_profile_service.py +++ /dev/null @@ -1,471 +0,0 @@ -"""Temporary profile lifecycle management. - -Provides a reusable service that creates short-lived profiles on the -Meticulous machine, loads them for execution, then cleans them up -(purge + delete) once the shot finishes or is aborted. - -The service is generic — pour-over, recipes, or any future feature can -use the same create → load → brew → cleanup lifecycle. - -Temp profiles are identified by well-known names (e.g. -``MeticAI Ratio Pour-Over``) so stale orphans can be detected and -removed on startup. -""" - -import asyncio -import copy -import logging -from dataclasses import dataclass, field -from typing import Any, Dict, Optional - -from services.meticulous_service import ( - async_create_profile, - async_delete_profile, - async_list_profiles, - async_load_profile_by_id, - async_load_profile_from_json, - MachineUnreachableError, -) - -logger = logging.getLogger(__name__) - -# Well-known temporary profile names created by features like pour-over. -# Used by cleanup_stale() to remove orphans on startup. -TEMP_PROFILE_NAMES = frozenset({"MeticAI Ratio Pour-Over"}) - -# Prefixes for temporary profile names (e.g. recipe profiles, override profiles). -TEMP_PROFILE_PREFIXES = frozenset({"MeticAI Recipe: ", "MeticAI Override: "}) - - -def is_temp_profile(name: str) -> bool: - """Return True if *name* belongs to a MeticAI temporary profile.""" - return name in TEMP_PROFILE_NAMES or any( - name.startswith(prefix) for prefix in TEMP_PROFILE_PREFIXES - ) - - -# Async lock guarding _active state to prevent interleaved mutations -# from concurrent calls to create_and_load / cleanup / force_cleanup. -_lock: asyncio.Lock | None = None - - -def _get_lock() -> asyncio.Lock: - """Lazy-initialise the lock to avoid cross-event-loop issues in tests.""" - global _lock - if _lock is None: - _lock = asyncio.Lock() - return _lock - - -def _reset_lock() -> None: - """Reset the lock (for tests that run in different event loops).""" - global _lock - _lock = None - - -@dataclass -class ActiveTempProfile: - """Metadata about the currently-active temporary profile.""" - - profile_id: str - profile_name: str - original_params: Dict[str, Any] = field(default_factory=dict) - previous_profile_id: Optional[str] = None - previous_profile_name: Optional[str] = None - ephemeral: bool = False - - -# Module-level singleton state -_active: Optional[ActiveTempProfile] = None - - -def _set_active(profile: Optional[ActiveTempProfile]) -> None: - global _active - _active = profile - - -def get_active() -> Optional[Dict[str, Any]]: - """Return the active temp profile metadata, or None.""" - if _active is None: - return None - return { - "profile_id": _active.profile_id, - "profile_name": _active.profile_name, - "original_params": _active.original_params, - } - - -# Variable types recognised by the Meticulous profile format. -VARIABLE_TYPES = frozenset( - { - "pressure", - "flow", - "weight", - "power", - "time", - "piston_position", - "temperature", - } -) - - -def apply_variable_overrides( - profile_data: Dict[str, Any], - overrides: Dict[str, Any], -) -> Dict[str, Any]: - """Apply variable value overrides to a deep-copied profile. - - Only adjustable variables (key does NOT start with ``info_``) can be - overridden. Unknown keys or ``info_`` keys are silently skipped so - callers don't need to pre-filter. - - When overriding synthesised top-level keys (``final_weight``, - ``temperature``) the corresponding top-level profile field is also - updated so that the Meticulous machine receives the correct value - regardless of whether it reads from ``variables`` or the top-level. - - Returns a new profile dict — the original is not mutated. - """ - profile = copy.deepcopy(profile_data) - variables = profile.get("variables") - - # Top-level keys that may be synthesised as variables - TOP_LEVEL_KEYS = {"final_weight", "temperature"} - - if not overrides: - return profile - - applied: dict[str, Any] = {} - - # Apply overrides to the variables array if present - if variables: - adjustable_keys: set[str] = set() - for var in variables: - key = var.get("key", "") - if key and not key.startswith("info_"): - adjustable_keys.add(key) - - for key, value in overrides.items(): - if key not in adjustable_keys: - if key not in TOP_LEVEL_KEYS: - logger.debug("Skipping override for non-adjustable key: %s", key) - continue - for var in variables: - if var.get("key") == key: - var["value"] = value - applied[key] = value - break - - # Always apply top-level key overrides directly on the profile dict - for key in TOP_LEVEL_KEYS: - if key in overrides: - profile[key] = overrides[key] - if key not in applied: - applied[key] = overrides[key] - - if applied: - logger.info("Applied %d variable override(s): %s", len(applied), list(applied)) - return profile - - -async def create_and_load( - profile_json: Dict[str, Any], - params: Optional[Dict[str, Any]] = None, - previous_profile_id: Optional[str] = None, - previous_profile_name: Optional[str] = None, -) -> Dict[str, Any]: - """Create a temporary profile on the machine, load it, and track it. - - The profile name is taken from ``profile_json["name"]`` (or defaults to - ``"Temp Profile"``). Callers should use a well-known temporary profile - name (see ``TEMP_PROFILE_NAMES``) so the profile can be identified for - cleanup. If a temp profile is already active it is force-cleaned first. - - Args: - profile_json: Full profile JSON including the desired temp profile name. - params: Optional dict of original user parameters for bookkeeping. - previous_profile_id: ID of the profile to restore after cleanup. - previous_profile_name: Name of the profile to restore after cleanup. - - Returns: - Dict with ``profile_id`` and ``profile_name`` of the created profile. - - Raises: - MachineUnreachableError: If the machine cannot be reached. - HTTPException: If profile creation or loading fails. - """ - async with _get_lock(): - return await _create_and_load_locked( - profile_json, params, previous_profile_id, previous_profile_name - ) - - -async def _create_and_load_locked( - profile_json: Dict[str, Any], - params: Optional[Dict[str, Any]] = None, - previous_profile_id: Optional[str] = None, - previous_profile_name: Optional[str] = None, -) -> Dict[str, Any]: - """Inner implementation of create_and_load, called under lock.""" - # Force-cleanup any lingering temp profile (without restoring — we're about - # to load a new temp profile, so we inherit the tracked previous profile) - if _active is not None: - logger.warning("Replacing already-active temp profile %s", _active.profile_name) - # Inherit the previous profile from the one being replaced so we can - # still restore the original profile when the new temp finishes. - if previous_profile_id is None: - previous_profile_id = _active.previous_profile_id - previous_profile_name = _active.previous_profile_name - await _force_cleanup_inner(restore=False) - - name = profile_json.get("name", "Temp Profile") - - # Delete any pre-existing profile with the same name to avoid duplicates - try: - profiles = await async_list_profiles() - if profiles and not isinstance(profiles, dict): - for p in profiles: - p_name = getattr(p, "name", None) or "" - if p_name == name: - p_id = getattr(p, "id", None) - if p_id: - await async_delete_profile(p_id) - logger.info( - "Deleted pre-existing profile '%s' (%s)", p_name, p_id - ) - except Exception as exc: - logger.warning("Failed to scan for duplicate profiles: %s", exc) - - # Create on machine - result = await async_create_profile(profile_json) - - # The machine returns the saved profile with its assigned ID. - profile_id = result.get("id") or profile_json.get("id") - if not profile_id: - logger.error("Machine did not return a profile ID after creation") - raise RuntimeError("Machine did not return a profile ID after creation") - - # Load the profile on the machine (makes it the active/selected profile) - await async_load_profile_by_id(profile_id) - - _set_active( - ActiveTempProfile( - profile_id=profile_id, - profile_name=name, - original_params=params or {}, - previous_profile_id=previous_profile_id, - previous_profile_name=previous_profile_name, - ) - ) - - logger.info("Temp profile created and loaded: %s (%s)", name, profile_id) - return {"profile_id": profile_id, "profile_name": name} - - -async def load_ephemeral( - profile_json: Dict[str, Any], - params: Optional[Dict[str, Any]] = None, - previous_profile_id: Optional[str] = None, - previous_profile_name: Optional[str] = None, -) -> Dict[str, Any]: - """Load a profile into the machine's memory without persisting. - - Uses ``POST /api/v1/profile/load`` to make the profile the active - selection for the next shot. Nothing is saved to the machine's - catalogue, so there is nothing to delete during cleanup — only - the previous profile needs to be restored. - - Args: - profile_json: Full profile JSON (will be normalised before sending). - params: Optional bookkeeping dict (stored in active state). - previous_profile_id: ID of the profile to restore after cleanup. - previous_profile_name: Name of the profile to restore after cleanup. - - Returns: - Dict with ``profile_id`` and ``profile_name``. - """ - async with _get_lock(): - # Force-cleanup any lingering temp profile - if _active is not None: - logger.warning( - "Replacing already-active temp profile %s", _active.profile_name - ) - if previous_profile_id is None: - previous_profile_id = _active.previous_profile_id - previous_profile_name = _active.previous_profile_name - await _force_cleanup_inner(restore=False) - - name = profile_json.get("name", "Ephemeral Profile") - profile_id = profile_json.get("id", "ephemeral") - - # Ephemeral load — the profile is NOT saved to the catalogue - await async_load_profile_from_json(profile_json) - - _set_active( - ActiveTempProfile( - profile_id=profile_id, - profile_name=name, - original_params=params or {}, - previous_profile_id=previous_profile_id, - previous_profile_name=previous_profile_name, - ephemeral=True, - ) - ) - - logger.info("Ephemeral profile loaded: %s (%s)", name, profile_id) - return {"profile_id": profile_id, "profile_name": name} - - -async def cleanup() -> Dict[str, str]: - """Run a purge cycle and clean up the active temp profile. - - For ephemeral profiles (loaded via ``load_ephemeral``), no delete is - needed — the profile was never saved to the catalogue. For persisted - temp profiles (created via ``create_and_load``), the profile is deleted. - - In both cases the previously-active profile is restored. - - Returns: - Dict with ``status`` key. - """ - async with _get_lock(): - if _active is None: - return {"status": "no_active_profile"} - - profile_id = _active.profile_id - profile_name = _active.profile_name - previous_profile_name = _active.previous_profile_name - is_ephemeral = _active.ephemeral - _set_active(None) - - # Purge first (flush water) - try: - from api.routes.commands import _do_publish, _get_snapshot - - snapshot = _get_snapshot() - if not snapshot.get("brewing"): - _do_publish("purge") - except Exception as exc: - logger.warning("Purge before cleanup failed (non-fatal): %s", exc) - - # Only delete from catalogue if the profile was persisted - if not is_ephemeral: - try: - await async_delete_profile(profile_id) - logger.info("Temp profile deleted: %s (%s)", profile_name, profile_id) - except Exception as exc: - logger.error("Failed to delete temp profile %s: %s", profile_id, exc) - return {"status": "delete_failed", "error": str(exc)} - else: - logger.info( - "Ephemeral profile cleaned up (no delete needed): %s", profile_name - ) - - # Restore the previously-active profile, or deselect if none tracked - try: - from api.routes.commands import _do_publish - - _do_publish("select_profile", previous_profile_name or "") - if previous_profile_name: - logger.info("Restored previous profile: %s", previous_profile_name) - else: - logger.info("No previous profile — sent deselect after cleanup") - except Exception as exc: - logger.warning("Failed to restore/deselect profile after cleanup: %s", exc) - - return {"status": "cleaned_up", "deleted_profile": profile_name} - - -async def force_cleanup() -> Dict[str, str]: - """Delete the temp profile without purging (for aborted shots). - - Returns: - Dict with ``status`` key. - """ - async with _get_lock(): - return await _force_cleanup_inner(restore=True) - - -async def _force_cleanup_inner(restore: bool = True) -> Dict[str, str]: - """Inner force-cleanup logic, must be called under ``_get_lock()``.""" - global _active - if _active is None: - return {"status": "no_active_profile"} - - profile_id = _active.profile_id - profile_name = _active.profile_name - previous_profile_name = _active.previous_profile_name if restore else None - is_ephemeral = _active.ephemeral - _set_active(None) - - # Only delete from catalogue if the profile was persisted - if not is_ephemeral: - try: - await async_delete_profile(profile_id) - logger.info("Temp profile force-deleted: %s (%s)", profile_name, profile_id) - except Exception as exc: - logger.error("Failed to force-delete temp profile %s: %s", profile_id, exc) - return {"status": "delete_failed", "error": str(exc)} - else: - logger.info( - "Ephemeral profile force-cleaned (no delete needed): %s", profile_name - ) - - # Restore the previously-active profile, or deselect if none tracked - if restore: - try: - from api.routes.commands import _do_publish - - _do_publish("select_profile", previous_profile_name or "") - if previous_profile_name: - logger.info("Restored previous profile: %s", previous_profile_name) - else: - logger.info("No previous profile — sent deselect after force-cleanup") - except Exception as exc: - logger.warning( - "Failed to restore/deselect profile after force-cleanup: %s", exc - ) - - return {"status": "force_cleaned_up", "deleted_profile": profile_name} - - -async def cleanup_stale() -> Dict[str, Any]: - """Scan for orphaned ``[Temp] `` profiles on the machine and delete them. - - Called at startup to remove leftovers from crashed sessions. - - Returns: - Dict with count of deleted profiles and any errors. - """ - deleted = [] - errors = [] - - try: - profiles = await async_list_profiles() - if not profiles or isinstance(profiles, dict): - return {"deleted": 0, "errors": []} - - for profile in profiles: - name = getattr(profile, "name", None) or "" - profile_id = getattr(profile, "id", None) - if is_temp_profile(name) and profile_id: - try: - await async_delete_profile(profile_id) - deleted.append(name) - logger.info("Cleaned stale temp profile: %s (%s)", name, profile_id) - except Exception as exc: - errors.append({"name": name, "error": str(exc)}) - logger.warning( - "Failed to clean stale temp profile %s: %s", name, exc - ) - except MachineUnreachableError: - logger.info("Machine not reachable — skipping stale temp profile cleanup") - return {"deleted": 0, "errors": [], "skipped": "machine_unreachable"} - except Exception as exc: - logger.warning("Failed to scan for stale temp profiles: %s", exc) - return {"deleted": 0, "errors": [str(exc)]} - - if deleted: - logger.info("Cleaned %d stale temp profile(s): %s", len(deleted), deleted) - - return {"deleted": len(deleted), "deleted_names": deleted, "errors": errors} diff --git a/apps/server/services/validation_service.py b/apps/server/services/validation_service.py deleted file mode 100644 index 731ddb49..00000000 --- a/apps/server/services/validation_service.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Profile validation service for server-side OEPF pre-validation. - -Provides lightweight profile validation before uploading to the Meticulous machine. -Uses the ProfileValidator from the MCP server when the JSON schema is available, -and falls back to basic structural validation otherwise. -""" - -import os -import sys -from typing import Any, Dict, List, Optional, Tuple - -from logging_config import get_logger - -logger = get_logger() - -# Try to import the full ProfileValidator from MCP server -_FULL_VALIDATOR_AVAILABLE = False -_ProfileValidator = None - -try: - # Add MCP server paths - try Docker layout first, then local dev - _mcp_paths = [ - "/app/mcp-server/meticulous-mcp/src", # Docker container layout - os.path.join( - os.path.dirname(__file__), "..", "..", "mcp-server", "meticulous-mcp", "src" - ), # Local dev: apps/server/services -> apps/mcp-server - ] - for _mcp_src in _mcp_paths: - if os.path.isdir(_mcp_src) and _mcp_src not in sys.path: - sys.path.insert(0, _mcp_src) - break - - from meticulous_mcp.profile_validator import ( # type: ignore[import-untyped] - ProfileValidator as _PV, - ) - - _ProfileValidator = _PV - _FULL_VALIDATOR_AVAILABLE = True - logger.debug("Full OEPF ProfileValidator loaded from MCP server") -except Exception as e: - logger.debug(f"Full ProfileValidator not available ({e}), using basic validation") - - -# Schema search paths (Docker and local dev) -_SCHEMA_PATHS = [ - "/app/espresso-profile-schema/schema.json", - os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "schema.json"), - os.path.join( - os.path.dirname(__file__), - "..", - "..", - "mcp-server", - "meticulous-mcp", - "espresso-profile-schema", - "schema.json", - ), -] - - -class ValidationResult: - """Result of profile validation.""" - - def __init__(self, is_valid: bool, errors: Optional[List[str]] = None): - self.is_valid = is_valid - self.errors = errors or [] - - def error_summary(self) -> str: - """Return a concise summary of validation errors for LLM retry prompts.""" - if not self.errors: - return "" - lines = [f"{i}. {e}" for i, e in enumerate(self.errors, 1)] - return "\n".join(lines) - - -def _find_schema_path() -> Optional[str]: - """Find the OEPF JSON schema file.""" - for p in _SCHEMA_PATHS: - if os.path.isfile(p): - return p - return None - - -# Singleton validator instance -_validator_instance: Optional[Any] = None -_basic_mode = False - - -def _get_validator(): - """Get or create the validator singleton.""" - global _validator_instance, _basic_mode - - if _validator_instance is not None: - return _validator_instance - - schema_path = _find_schema_path() - - if _FULL_VALIDATOR_AVAILABLE and schema_path: - try: - _validator_instance = _ProfileValidator(schema_path=schema_path) - logger.info( - "OEPF ProfileValidator initialized with schema at %s", schema_path - ) - return _validator_instance - except Exception as e: - logger.warning("Failed to initialize full ProfileValidator: %s", e) - - # Fall back to basic mode - _basic_mode = True - _validator_instance = "basic" - logger.info("Using basic profile validation (no JSON schema available)") - return _validator_instance - - -def _basic_validate(profile: Dict[str, Any]) -> Tuple[bool, List[str]]: - """Basic structural validation when full schema isn't available. - - Checks the most critical rules that the prompt's validation section covers. - """ - errors: List[str] = [] - - if not isinstance(profile, dict): - return False, ["Profile must be a JSON object"] - - # Required top-level fields - if "name" not in profile: - errors.append("Missing required field: 'name'") - if "stages" not in profile or not isinstance(profile.get("stages"), list): - errors.append("Missing or invalid 'stages' array") - if not profile.get("stages"): - errors.append("Profile must have at least one stage") - - for i, stage in enumerate(profile.get("stages", [])): - if not isinstance(stage, dict): - errors.append(f"Stage {i + 1}: must be a JSON object") - continue - - sname = stage.get("name", f"Stage {i + 1}") - stype = stage.get("type") - - # Stage type validation - if stype not in ("power", "flow", "pressure"): - errors.append( - f"Stage '{sname}': type must be 'power', 'flow', or 'pressure', got '{stype}'" - ) - - # Exit triggers required - triggers = stage.get("exit_triggers", []) - if not triggers: - errors.append(f"Stage '{sname}': missing exit_triggers") - else: - trigger_types = {t.get("type") for t in triggers if isinstance(t, dict)} - - # Paradox check - if stype in trigger_types and stype in ("flow", "pressure"): - errors.append( - f"Stage '{sname}': {stype} stage cannot have a {stype} exit trigger (paradox)" - ) - - # Backup trigger check - if len(triggers) == 1 and "time" not in trigger_types: - errors.append( - f"Stage '{sname}': single non-time exit trigger needs a time backup" - ) - - # Cross-type limits check - limits = stage.get("limits", []) - if stype == "flow": - has_pressure_limit = any( - isinstance(lim, dict) and lim.get("type") == "pressure" - for lim in limits - ) - if not has_pressure_limit: - errors.append(f"Stage '{sname}': flow stage must have a pressure limit") - elif stype == "pressure": - has_flow_limit = any( - isinstance(lim, dict) and lim.get("type") == "flow" for lim in limits - ) - if not has_flow_limit: - errors.append(f"Stage '{sname}': pressure stage must have a flow limit") - - # Dynamics validation - dynamics = stage.get("dynamics", {}) - if isinstance(dynamics, dict): - over = dynamics.get("over") - if over and over not in ("time", "weight", "piston_position"): - errors.append( - f"Stage '{sname}': dynamics.over must be 'time', 'weight', or 'piston_position'" - ) - - interp = dynamics.get("interpolation") - if interp and interp not in ("linear", "curve"): - errors.append( - f"Stage '{sname}': interpolation must be 'linear' or 'curve'" - ) - - # Unused adjustable variables check - variables = profile.get("variables", []) - if variables and isinstance(variables, list): - # Build a set of all $key references across all stages (recursive) - used_keys: set = set() - - def _collect_refs(obj: Any) -> None: - if isinstance(obj, str): - if obj.startswith("$"): - used_keys.add(obj[1:]) - elif isinstance(obj, list): - for item in obj: - _collect_refs(item) - elif isinstance(obj, dict): - for val in obj.values(): - _collect_refs(val) - - for stage in profile.get("stages", []): - if isinstance(stage, dict): - _collect_refs(stage) - - for var in variables: - if not isinstance(var, dict): - continue - key = var.get("key") - name = var.get("name", "") - is_info = not var.get("adjustable", True) - if key and not is_info and key not in used_keys: - errors.append( - f"Adjustable variable '{key}' ({name}) is defined but never used " - f"in any stage. Use ${key} in a dynamics point, mark it as info-only " - f"(adjustable: false), or remove it." - ) - - return len(errors) == 0, errors - - -def validate_profile(profile: Dict[str, Any]) -> ValidationResult: - """Validate a profile against the OEPF schema. - - Uses the full MCP ProfileValidator when available, falls back to basic - structural validation otherwise. - - Args: - profile: Profile JSON dictionary to validate. - - Returns: - ValidationResult with is_valid flag and list of error strings. - """ - validator = _get_validator() - - if _basic_mode or validator == "basic": - is_valid, errors = _basic_validate(profile) - else: - try: - from meticulous_mcp.profile_validator import ValidationLevel # type: ignore[import-untyped] - - is_valid, errors = validator.validate(profile, level=ValidationLevel.STRICT) - except Exception as e: - logger.warning("Full validation failed, falling back to basic: %s", e) - is_valid, errors = _basic_validate(profile) - - if not is_valid: - logger.debug( - "Profile validation failed with %d error(s)", - len(errors), - extra={"errors": errors[:5]}, - ) - - return ValidationResult(is_valid=is_valid, errors=errors) - - -def is_schema_available() -> bool: - """Check if the full OEPF schema is available for validation.""" - return _FULL_VALIDATOR_AVAILABLE and _find_schema_path() is not None diff --git a/apps/server/test_ai_providers.py b/apps/server/test_ai_providers.py deleted file mode 100644 index 93d8d0af..00000000 --- a/apps/server/test_ai_providers.py +++ /dev/null @@ -1,395 +0,0 @@ -"""Tests for the OpenAI-compatible AI provider layer (#491 PR3).""" - -import os -from unittest.mock import patch - -import pytest -from PIL import Image - -import services.ai_providers as ai_providers -from services.ai_providers import ( - DEFAULT_PROVIDER, - OpenAICompatModel, - ProviderError, - _contents_to_messages, - get_active_provider_id, - get_provider_api_key, - get_provider_model, - is_gemini_active, - is_provider_available, -) - - -class TestActiveProvider: - @patch.dict(os.environ, {}, clear=True) - def test_defaults_to_gemini(self): - assert get_active_provider_id() == DEFAULT_PROVIDER - assert is_gemini_active() is True - - @patch.dict(os.environ, {"AI_PROVIDER": "openai"}, clear=True) - def test_reads_env(self): - assert get_active_provider_id() == "openai" - assert is_gemini_active() is False - - @patch.dict(os.environ, {"AI_PROVIDER": "not-a-provider"}, clear=True) - def test_unknown_falls_back_to_gemini(self): - assert get_active_provider_id() == DEFAULT_PROVIDER - - @patch.dict(os.environ, {"AI_PROVIDER": "OpenAI"}, clear=True) - def test_case_insensitive(self): - assert get_active_provider_id() == "openai" - - -class TestProviderCredentials: - @patch.dict(os.environ, {"GEMINI_API_KEY": "gkey"}, clear=True) - def test_gemini_uses_gemini_key(self): - assert get_provider_api_key("gemini") == "gkey" - - @patch.dict( - os.environ, - {"AI_PROVIDER": "deepseek", "AI_API_KEY": "dkey"}, - clear=True, - ) - def test_non_gemini_uses_ai_key(self): - assert get_provider_api_key() == "dkey" - assert is_provider_available() is True - - @patch.dict(os.environ, {"AI_PROVIDER": "openai"}, clear=True) - def test_missing_key_unavailable(self): - assert is_provider_available() is False - - @patch.dict( - os.environ, - {"AI_PROVIDER": "openai", "AI_MODEL": "gpt-4o"}, - clear=True, - ) - def test_model_from_env(self): - assert get_provider_model() == "gpt-4o" - - @patch.dict(os.environ, {"AI_PROVIDER": "deepseek"}, clear=True) - def test_model_falls_back_to_default(self): - assert get_provider_model() == "deepseek-chat" - - -class TestContentsTranslation: - def test_string_to_single_user_message(self): - msgs = _contents_to_messages("hello", vision=True) - assert msgs == [{"role": "user", "content": "hello"}] - - def test_nested_list_flattened(self): - msgs = _contents_to_messages([["a", "b"]], vision=True) - assert msgs[0]["content"] == "a\n\nb" - - def test_image_with_vision_provider(self): - img = Image.new("RGB", (2, 2), color="red") - msgs = _contents_to_messages(["describe", img], vision=True) - content = msgs[0]["content"] - assert isinstance(content, list) - assert content[0] == {"type": "text", "text": "describe"} - assert content[1]["type"] == "image_url" - assert content[1]["image_url"]["url"].startswith("data:image/") - - def test_image_dropped_for_text_only_provider(self): - img = Image.new("RGB", (2, 2), color="blue") - msgs = _contents_to_messages(["describe", img], vision=False) - assert msgs == [{"role": "user", "content": "describe"}] - - -class TestOpenAICompatModel: - @patch.dict( - os.environ, - {"AI_PROVIDER": "openai", "AI_API_KEY": "k", "AI_MODEL": "gpt-4o-mini"}, - clear=True, - ) - def test_generate_content_parses_response(self): - class _Resp: - status_code = 200 - - @staticmethod - def json(): - return {"choices": [{"message": {"content": "the answer"}}]} - - class _Client: - def __init__(self, *a, **k): - pass - - def __enter__(self): - return self - - def __exit__(self, *a): - return False - - def post(self, url, headers, json): - assert url.endswith("/chat/completions") - assert headers["Authorization"] == "Bearer k" - assert json["model"] == "gpt-4o-mini" - return _Resp() - - with patch.object(ai_providers.httpx, "Client", _Client): - model = OpenAICompatModel() - resp = model.generate_content("hi") - assert resp.text == "the answer" - - @patch.dict(os.environ, {"AI_PROVIDER": "openai"}, clear=True) - def test_missing_key_raises(self): - model = OpenAICompatModel() - with pytest.raises(ProviderError): - model.generate_content("hi") - - @patch.dict( - os.environ, - {"AI_PROVIDER": "openai", "AI_API_KEY": "k"}, - clear=True, - ) - def test_http_error_status_raises(self): - class _Resp: - status_code = 401 - text = "unauthorized" - - class _Client: - def __init__(self, *a, **k): - pass - - def __enter__(self): - return self - - def __exit__(self, *a): - return False - - def post(self, url, headers, json): - return _Resp() - - with patch.object(ai_providers.httpx, "Client", _Client): - model = OpenAICompatModel() - with pytest.raises(ProviderError): - model.generate_content("hi") - - @patch.dict( - os.environ, - {"AI_PROVIDER": "openrouter", "AI_API_KEY": "k"}, - clear=True, - ) - def test_openrouter_referer_headers(self): - model = OpenAICompatModel() - headers = model._headers() - assert "HTTP-Referer" in headers - assert headers["X-Title"] == "Metic" - - @patch.dict( - os.environ, - {"AI_PROVIDER": "openai", "AI_API_KEY": "k"}, - clear=True, - ) - @pytest.mark.asyncio - async def test_async_generate_content(self): - class _Resp: - status_code = 200 - - @staticmethod - def json(): - return {"choices": [{"message": {"content": "async answer"}}]} - - class _Client: - def __init__(self, *a, **k): - pass - - def __enter__(self): - return self - - def __exit__(self, *a): - return False - - def post(self, url, headers, json): - return _Resp() - - with patch.object(ai_providers.httpx, "Client", _Client): - model = OpenAICompatModel() - resp = await model.async_generate_content("hi") - assert resp.text == "async answer" - - -class TestGeminiServiceDelegation: - @patch.dict( - os.environ, - {"AI_PROVIDER": "openai", "AI_API_KEY": "k", "AI_MODEL": "gpt-4o"}, - clear=True, - ) - def test_get_vision_model_returns_compat_for_non_gemini(self): - import importlib - import services.gemini_service as gemini_service - - importlib.reload(gemini_service) - model = gemini_service.get_vision_model() - assert isinstance(model, OpenAICompatModel) - assert gemini_service.get_model_name() == "gpt-4o" - assert gemini_service.is_ai_available() is True - - -def _b64_png() -> str: - """A 1x1 PNG encoded as base64 (valid for base64.b64decode).""" - import base64 - import io - - buf = io.BytesIO() - Image.new("RGB", (1, 1), (10, 20, 30)).save(buf, format="PNG") - return base64.b64encode(buf.getvalue()).decode("ascii") - - -class _ImgResp: - def __init__(self, status_code, payload=None, text=""): - self.status_code = status_code - self._payload = payload or {} - self.text = text - - def json(self): - return self._payload - - -def _img_client_factory(responses): - """Build a fake httpx.Client yielding queued responses per POST call.""" - calls = {"posts": []} - - class _Client: - def __init__(self, *a, **k): - pass - - def __enter__(self): - return self - - def __exit__(self, *a): - return False - - def post(self, url, headers, json): - calls["posts"].append({"url": url, "model": json.get("model")}) - return responses.pop(0) - - return _Client, calls - - -class TestImageGeneration: - @patch.dict( - os.environ, - {"AI_PROVIDER": "openai", "AI_API_KEY": "k"}, - clear=True, - ) - def test_openai_image_success_returns_bytes(self): - from services.ai_providers import _generate_image_bytes_sync - - b64 = _b64_png() - client, calls = _img_client_factory( - [_ImgResp(200, {"data": [{"b64_json": b64}]})] - ) - with patch.object(ai_providers.httpx, "Client", client): - data = _generate_image_bytes_sync("a cat", "openai") - assert isinstance(data, bytes) and len(data) > 0 - # gpt-image-1 is tried first; no response_format fallback needed. - assert calls["posts"][0]["model"] == "gpt-image-1" - - @patch.dict( - os.environ, - {"AI_PROVIDER": "openai", "AI_API_KEY": "k"}, - clear=True, - ) - def test_falls_back_to_next_model_on_403(self): - from services.ai_providers import _generate_image_bytes_sync - - b64 = _b64_png() - # gpt-image-1 → 403 (org not verified), then dall-e-3 → success. - client, calls = _img_client_factory( - [ - _ImgResp(403, text="org must be verified"), - _ImgResp(200, {"data": [{"b64_json": b64}]}), - ] - ) - with patch.object(ai_providers.httpx, "Client", client): - data = _generate_image_bytes_sync("a cat", "openai") - assert isinstance(data, bytes) and len(data) > 0 - assert [p["model"] for p in calls["posts"]] == ["gpt-image-1", "dall-e-3"] - - @patch.dict( - os.environ, - {"AI_PROVIDER": "openai", "AI_API_KEY": "k"}, - clear=True, - ) - def test_non_recoverable_status_raises_without_fallback(self): - from services.ai_providers import ProviderImageError, _generate_image_bytes_sync - - # 500 is not in the recoverable set → raise immediately, no fallback. - client, calls = _img_client_factory([_ImgResp(500, text="server error")]) - with patch.object(ai_providers.httpx, "Client", client): - with pytest.raises(ProviderImageError): - _generate_image_bytes_sync("a cat", "openai") - assert len(calls["posts"]) == 1 - - @patch.dict(os.environ, {"AI_PROVIDER": "deepseek", "AI_API_KEY": "k"}, clear=True) - def test_unsupported_provider_raises(self): - from services.ai_providers import ProviderError, _generate_image_bytes_sync - - with pytest.raises(ProviderError): - _generate_image_bytes_sync("a cat", "deepseek") - - -class TestListProviderModels: - @patch.dict(os.environ, {"AI_PROVIDER": "openai", "AI_API_KEY": "k"}, clear=True) - async def test_parses_data_envelope(self): - from services.ai_providers import list_provider_models - - class _Resp: - status_code = 200 - - @staticmethod - def raise_for_status(): - return None - - @staticmethod - def json(): - return {"data": [{"id": "gpt-4o"}, {"id": "gpt-4o-mini"}, {"no": "id"}]} - - class _Client: - def __init__(self, *a, **k): - pass - - def __enter__(self): - return self - - def __exit__(self, *a): - return False - - def get(self, url, headers): - assert url.endswith("/models") - return _Resp() - - with patch.object(ai_providers.httpx, "Client", _Client): - models = await list_provider_models("openai") - ids = [m["id"] for m in models] - assert ids == ["gpt-4o", "gpt-4o-mini"] - - @patch.dict(os.environ, {"AI_PROVIDER": "openai", "AI_API_KEY": "k"}, clear=True) - async def test_falls_back_to_default_on_error(self): - from services.ai_providers import list_provider_models - - class _Client: - def __init__(self, *a, **k): - pass - - def __enter__(self): - return self - - def __exit__(self, *a): - return False - - def get(self, url, headers): - raise ai_providers.httpx.HTTPError("boom") - - with patch.object(ai_providers.httpx, "Client", _Client): - models = await list_provider_models("openai") - assert models == [ - {"id": "gpt-4o-mini", "display_name": "gpt-4o-mini", "description": ""} - ] - - @patch.dict(os.environ, {"AI_PROVIDER": "openai"}, clear=True) - async def test_no_api_key_returns_fallback(self): - from services.ai_providers import list_provider_models - - models = await list_provider_models("openai") - assert models[0]["id"] == "gpt-4o-mini" diff --git a/apps/server/test_analyze_shot.py b/apps/server/test_analyze_shot.py deleted file mode 100644 index 281a8283..00000000 --- a/apps/server/test_analyze_shot.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Tests for the tools.analyze_shot CLI runner.""" - -import json -from pathlib import Path - -import pytest - -from tools import analyze_shot - -SAMPLE_SHOT = Path(__file__).parent / "tools" / "samples" / "slayer_at_home.shot.json" - - -def _slayer_profile() -> dict: - """A 'Slayer at Home'-style profile: nested dynamics, aggressive flow - target governed by a pressure limit — should read as effectively pressure.""" - return { - "name": "Slayer at Home", - "temperature": 93, - "final_weight": 36, - "variables": [ - { - "key": "flow_MaxFlowRate", - "name": "flow_MaxFlowRate", - "type": "flow", - "value": 10.8, - }, - { - "key": "pressure_Max", - "name": "Max Pressure", - "type": "pressure", - "value": 6, - }, - ], - "stages": [ - { - "name": "Extraction", - "type": "flow", - "key": "flow_extraction", - "dynamics": { - "points": [[0, "$flow_MaxFlowRate"], [30, "$flow_MaxFlowRate"]], - "over": "time", - "interpolation": "linear", - }, - "exit_triggers": [{"type": "weight", "value": 36, "comparison": ">="}], - "limits": [{"type": "pressure", "value": "$pressure_Max"}], - } - ], - } - - -def _shot_log(with_profile: bool = False) -> dict: - shot: dict = { - "data": [ - { - "time": 0, - "status": "Extraction", - "shot": {"pressure": 0, "flow": 0, "weight": 0}, - }, - { - "time": 5000, - "status": "Extraction", - "shot": {"pressure": 6, "flow": 3.2, "weight": 8}, - }, - { - "time": 30000, - "status": "Extraction", - "shot": {"pressure": 6, "flow": 2.1, "weight": 36}, - }, - ] - } - if with_profile: - shot["profile"] = _slayer_profile() - return shot - - -def test_run_analysis_with_separate_profile(tmp_path): - shot_path = tmp_path / "shot.json" - profile_path = tmp_path / "profile.json" - shot_path.write_text(json.dumps(_shot_log()), encoding="utf-8") - profile_path.write_text(json.dumps(_slayer_profile()), encoding="utf-8") - - analysis = analyze_shot.run_analysis(str(shot_path), str(profile_path)) - - stage = analysis["stage_analyses"][0] - assert stage["profile_max_target"] == 10.8 - fact = next( - s for s in analysis["shot_facts"]["stages"] if s["stage_name"] == "Extraction" - ) - assert fact["control_mode"] == "pressure" - assert fact["mode_overridden"] is True - - -def test_run_analysis_uses_embedded_profile(tmp_path): - shot_path = tmp_path / "shot.json" - shot_path.write_text(json.dumps(_shot_log(with_profile=True)), encoding="utf-8") - - analysis = analyze_shot.run_analysis(str(shot_path)) - - assert analysis["profile_info"]["name"] == "Slayer at Home" - assert analysis["shot_facts"]["stages"][0]["control_mode"] == "pressure" - - -def test_run_analysis_missing_embedded_profile_raises(tmp_path): - shot_path = tmp_path / "shot.json" - shot_path.write_text(json.dumps(_shot_log()), encoding="utf-8") - - with pytest.raises(ValueError, match="no embedded 'profile'"): - analyze_shot.run_analysis(str(shot_path)) - - -def test_format_report_includes_stage_facts(tmp_path): - shot_path = tmp_path / "shot.json" - shot_path.write_text(json.dumps(_shot_log(with_profile=True)), encoding="utf-8") - analysis = analyze_shot.run_analysis(str(shot_path)) - - report = analyze_shot.format_report(analysis) - - assert "Slayer at Home" in report - assert "Extraction" in report - assert "control mode: pressure" in report - - -def test_main_json_output(tmp_path, capsys): - shot_path = tmp_path / "shot.json" - shot_path.write_text(json.dumps(_shot_log(with_profile=True)), encoding="utf-8") - - exit_code = analyze_shot.main([str(shot_path), "--json"]) - - assert exit_code == 0 - out = capsys.readouterr().out - parsed = json.loads(out) - assert parsed["profile_info"]["name"] == "Slayer at Home" - - -def test_main_reports_error_for_missing_file(tmp_path, capsys): - exit_code = analyze_shot.main([str(tmp_path / "nope.json")]) - - assert exit_code == 1 - assert "error:" in capsys.readouterr().err - - -def test_real_slayer_export_resolves_extraction_as_pressure(): - """The bundled real 'Slayer at Home' export uses nested dynamics with an - aggressive flow target and a pressure limit. Its Extraction stage must - resolve as effectively pressure-controlled (#423).""" - analysis = analyze_shot.run_analysis(str(SAMPLE_SHOT)) - - assert analysis["profile_info"]["name"] == "Slayer at Home" - extraction = next( - s for s in analysis["shot_facts"]["stages"] if s["stage_name"] == "Extraction" - ) - assert extraction["control_mode"] == "pressure" - assert extraction["declared_mode"] == "flow" - assert extraction["mode_overridden"] is True - # The flow target ($flow_MaxFlowRate = 10.8) must resolve from nested points. - stage_analysis = next( - s for s in analysis["stage_analyses"] if s["stage_name"] == "Extraction" - ) - assert stage_analysis["profile_max_target"] == 10.8 diff --git a/apps/server/test_integration_machine.py b/apps/server/test_integration_machine.py deleted file mode 100644 index 29d44cfc..00000000 --- a/apps/server/test_integration_machine.py +++ /dev/null @@ -1,560 +0,0 @@ -""" -Integration tests for MeticAI with real Meticulous machine. - -These tests require a real Meticulous machine to be accessible on the network. -They are excluded from CI and should only be run locally. - -Usage: - export METICULOUS_IP=192.168.x.x - export TEST_INTEGRATION=true - cd apps/server && pytest test_integration_machine.py -v - -Environment Variables: - METICULOUS_IP: IP address of the Meticulous machine (required) - TEST_INTEGRATION: Set to "true" to enable integration tests - MQTT_HOST: MQTT broker host (default: 127.0.0.1) - MQTT_PORT: MQTT broker port (default: 1883) -""" - -import asyncio -import pytest -import httpx -import time -import json - -# Import integration test fixtures - - -# ============================================================================ -# CONNECTION TESTS -# ============================================================================ - - -@pytest.mark.integration -class TestMachineConnection: - """Test basic connectivity to the Meticulous machine.""" - - async def test_machine_reachable(self, meticulous_base_url): - """Verify the machine responds to HTTP requests.""" - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get(f"{meticulous_base_url}/api/v1/settings") - assert response.status_code == 200 - data = response.json() - assert isinstance(data, dict) - assert len(data) > 0 - - async def test_websocket_connection(self, meticulous_base_url): - """Test WebSocket connection to machine (Socket.IO).""" - # The Meticulous machine uses Socket.IO for real-time updates - # We test the HTTP handshake portion - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get( - f"{meticulous_base_url}/socket.io/", - params={"EIO": "4", "transport": "polling"}, - ) - # Socket.IO handshake should return 200 with session info - assert response.status_code == 200 - - async def test_api_client_initialization(self, integration_api): - """Verify the API client initializes correctly.""" - assert integration_api is not None - assert hasattr(integration_api, "base_url") - assert integration_api.base_url.startswith("http") - - async def test_connection_recovery(self, meticulous_base_url, helpers): - """Test that connection can be re-established after interruption.""" - async with httpx.AsyncClient(timeout=10.0) as client: - # First connection - response1 = await client.get(f"{meticulous_base_url}/api/v1/settings") - assert response1.status_code == 200 - - # Small delay - await asyncio.sleep(0.5) - - # Second connection (simulates recovery) - response2 = await client.get(f"{meticulous_base_url}/api/v1/settings") - assert response2.status_code == 200 - - -@pytest.mark.integration -class TestMQTTConnection: - """Test MQTT broker connectivity.""" - - def test_mqtt_broker_reachable(self, mqtt_host, mqtt_port, helpers): - """Verify MQTT broker accepts connections.""" - connected = helpers.wait_for_connection(mqtt_host, mqtt_port, timeout=5.0) - if not connected: - pytest.skip("MQTT broker not reachable - may not be running locally") - assert connected - - def test_mqtt_subscription(self, mqtt_host, mqtt_port): - """Test subscribing to MQTT topics.""" - try: - import paho.mqtt.client as mqtt - except ImportError: - pytest.skip("paho-mqtt not installed") - - received_messages = [] - asyncio.Event() - - def on_connect(client, userdata, flags, rc): - if rc == 0: - client.subscribe("meticulous/#") - - def on_message(client, userdata, msg): - received_messages.append( - { - "topic": msg.topic, - "payload": msg.payload.decode("utf-8", errors="ignore"), - } - ) - - client = mqtt.Client() - client.on_connect = on_connect - client.on_message = on_message - - try: - client.connect(mqtt_host, mqtt_port, 60) - client.loop_start() - - # Wait a moment for potential messages - time.sleep(2.0) - - client.loop_stop() - client.disconnect() - - # Test passes if we connected (messages may or may not be present) - assert True - except Exception as e: - pytest.skip(f"MQTT connection failed: {e}") - - -# ============================================================================ -# API TESTS (Real Data) -# ============================================================================ - - -@pytest.mark.integration -class TestProfileAPI: - """Test profile CRUD operations against real machine.""" - - async def test_list_profiles(self, wait_for_machine): - """Verify we can list profiles from the machine.""" - from services.meticulous_service import async_list_profiles - - profiles = await async_list_profiles() - assert isinstance(profiles, list) - # Machine should have at least some default profiles - if len(profiles) > 0: - profile = profiles[0] - assert "id" in profile or hasattr(profile, "id") - - async def test_profile_schema_validation(self, wait_for_machine): - """Test profile data matches expected schema.""" - from services.meticulous_service import async_list_profiles - - profiles = await async_list_profiles() - if not profiles: - pytest.skip("No profiles on machine") - - profile = profiles[0] - # Convert to dict if needed - if hasattr(profile, "__dict__"): - vars(profile) - else: - dict(profile) if hasattr(profile, "keys") else {} - - # Basic schema checks - profile should have key fields - # Note: actual field names depend on meticulous API response format - assert profile is not None - - async def test_fetch_shot_history(self, meticulous_base_url): - """Test retrieving shot history from machine.""" - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.get(f"{meticulous_base_url}/api/v1/history/list") - - if response.status_code == 404: - pytest.skip("History endpoint not available") - - assert response.status_code == 200 - history = response.json() - assert isinstance(history, (list, dict)) - - -@pytest.mark.integration -class TestLastShotAPI: - """Test last shot retrieval.""" - - async def test_last_shot_endpoint(self, meticulous_base_url): - """Test fetching last shot data.""" - async with httpx.AsyncClient(timeout=30.0) as client: - # Try the history endpoint first - response = await client.get(f"{meticulous_base_url}/api/v1/history/last") - - if response.status_code == 404: - # Try alternative endpoint - response = await client.get( - f"{meticulous_base_url}/api/v1/history/list" - ) - if response.status_code == 404: - pytest.skip("No shot history endpoints available") - - # If we got a response, validate structure - if response.status_code == 200: - data = response.json() - assert data is not None - - -# ============================================================================ -# TELEMETRY TESTS -# ============================================================================ - - -@pytest.mark.integration -class TestTelemetry: - """Test real-time telemetry data reception via Socket.IO. - - The Meticulous machine streams sensor data (weight, temperature, pressure) - through Socket.IO 'status' events, not REST endpoints. - """ - - async def test_weight_data_available(self, integration_api): - """Verify weight sensor data is accessible via Socket.IO.""" - received = {"data": None} - - def on_status(data): - if received["data"] is None: - received["data"] = data - - integration_api.sio.on("status", on_status) - try: - if not integration_api.sio.connected: - integration_api.connect_to_socket(retries=2) - - # Wait for a status event (up to 5 seconds) - for _ in range(50): - if received["data"] is not None: - break - await asyncio.sleep(0.1) - - assert received["data"] is not None, "No status event received from machine" - - data = received["data"] - sensors = ( - data.get("sensors", {}) - if isinstance(data, dict) - else vars(getattr(data, "sensors", {})) - ) - assert "w" in sensors, f"Weight field 'w' not in sensors: {sensors.keys()}" - assert isinstance(sensors["w"], (int, float)) - print(f"Weight: {sensors['w']}g") - finally: - integration_api.sio.on("status", None) - - async def test_temperature_data_available(self, integration_api): - """Verify temperature sensor data is accessible via Socket.IO.""" - received = {"data": None} - - def on_status(data): - if received["data"] is None: - received["data"] = data - - integration_api.sio.on("status", on_status) - try: - if not integration_api.sio.connected: - integration_api.connect_to_socket(retries=2) - - for _ in range(50): - if received["data"] is not None: - break - await asyncio.sleep(0.1) - - assert received["data"] is not None, "No status event received from machine" - - data = received["data"] - sensors = ( - data.get("sensors", {}) - if isinstance(data, dict) - else vars(getattr(data, "sensors", {})) - ) - # 't' = temperature, 'g' = group temperature - assert "t" in sensors, ( - f"Temperature field 't' not in sensors: {sensors.keys()}" - ) - assert isinstance(sensors["t"], (int, float)) - print(f"Temperature: {sensors['t']}°C, Group: {sensors.get('g', 'N/A')}°C") - finally: - integration_api.sio.on("status", None) - - async def test_pressure_data_available(self, integration_api): - """Verify pressure sensor data is accessible via Socket.IO.""" - received = {"data": None} - - def on_status(data): - if received["data"] is None: - received["data"] = data - - integration_api.sio.on("status", on_status) - try: - if not integration_api.sio.connected: - integration_api.connect_to_socket(retries=2) - - for _ in range(50): - if received["data"] is not None: - break - await asyncio.sleep(0.1) - - assert received["data"] is not None, "No status event received from machine" - - data = received["data"] - sensors = ( - data.get("sensors", {}) - if isinstance(data, dict) - else vars(getattr(data, "sensors", {})) - ) - assert "p" in sensors, ( - f"Pressure field 'p' not in sensors: {sensors.keys()}" - ) - assert isinstance(sensors["p"], (int, float)) - print(f"Pressure: {sensors['p']} bar") - finally: - integration_api.sio.on("status", None) - - async def test_telemetry_polling_latency(self, integration_api): - """Test telemetry event frequency from Socket.IO.""" - timestamps = [] - - def on_status(data): - timestamps.append(time.time()) - - integration_api.sio.on("status", on_status) - try: - if not integration_api.sio.connected: - integration_api.connect_to_socket(retries=2) - - # Collect events for 3 seconds - await asyncio.sleep(3.0) - - assert len(timestamps) >= 2, ( - f"Only received {len(timestamps)} status events in 3s" - ) - - # Calculate inter-event intervals - intervals = [ - (timestamps[i] - timestamps[i - 1]) * 1000 - for i in range(1, len(timestamps)) - ] - avg_interval = sum(intervals) / len(intervals) - - print(f"Received {len(timestamps)} status events in 3s") - print(f"Average interval: {avg_interval:.1f}ms") - - # Events should arrive at least every 2 seconds - assert avg_interval < 2000 - finally: - integration_api.sio.on("status", None) - - -# ============================================================================ -# COMMAND TESTS -# ============================================================================ - - -@pytest.mark.integration -class TestMachineCommands: - """Test machine command execution. - - CAUTION: These tests send actual commands to the machine. - Only safe commands (tare, brightness) are tested by default. - """ - - async def test_tare_command(self, wait_for_machine, meticulous_base_url): - """Test tare (zero scale) command.""" - async with httpx.AsyncClient(timeout=10.0) as client: - # Verify machine is reachable via settings endpoint - response = await client.get(f"{meticulous_base_url}/api/v1/settings") - assert response.status_code == 200 - - # Send tare command via the SDK's action endpoint - tare_response = await client.get( - f"{meticulous_base_url}/api/v1/action/tare" - ) - - # Command should be accepted (200) or rejected if machine is busy (400) - assert tare_response.status_code in (200, 400), ( - f"Unexpected tare response: {tare_response.status_code}" - ) - print(f"Tare response: {tare_response.status_code}") - - @pytest.mark.skip(reason="Preheat command may not be safe to run automatically") - async def test_preheat_command(self, wait_for_machine): - """Test preheat command - SKIPPED by default for safety.""" - pass - - async def test_brightness_command(self, meticulous_base_url): - """Test brightness setting via the settings endpoint.""" - async with httpx.AsyncClient(timeout=10.0) as client: - # Get current settings first - get_response = await client.get(f"{meticulous_base_url}/api/v1/settings") - - if get_response.status_code == 404: - pytest.skip("Settings endpoint not available") - - assert get_response.status_code == 200 - settings = get_response.json() - print(f"Settings keys: {list(settings.keys())}") - # Settings endpoint exists and returns data - assert isinstance(settings, dict) - - -# ============================================================================ -# POUR-OVER MODE TESTS -# ============================================================================ - - -@pytest.mark.integration -class TestPourOverMode: - """Test pour-over specific functionality.""" - - async def test_scale_weight_polling(self, integration_api): - """Verify continuous scale weight readings via Socket.IO.""" - weights = [] - - def on_status(data): - sensors = ( - data.get("sensors", {}) - if isinstance(data, dict) - else vars(getattr(data, "sensors", {})) - ) - weight = sensors.get("w", 0) - weights.append(weight) - - integration_api.sio.on("status", on_status) - try: - if not integration_api.sio.connected: - integration_api.connect_to_socket(retries=2) - - # Collect weight readings for 2 seconds - await asyncio.sleep(2.0) - - assert len(weights) >= 2, ( - f"Only received {len(weights)} weight readings in 2s" - ) - - # Weights should be numeric - for w in weights: - assert isinstance(w, (int, float)), f"Weight {w} is not numeric" - - print( - f"Collected {len(weights)} weight readings, range: {min(weights):.1f}g - {max(weights):.1f}g" - ) - finally: - integration_api.sio.on("status", None) - - async def test_flow_rate_calculation(self, integration_api): - """Test that flow rate can be calculated from weight changes.""" - samples = [] - - def on_status(data): - sensors = ( - data.get("sensors", {}) - if isinstance(data, dict) - else vars(getattr(data, "sensors", {})) - ) - weight = sensors.get("w", 0) - samples.append({"time": time.time(), "weight": weight}) - - integration_api.sio.on("status", on_status) - try: - if not integration_api.sio.connected: - integration_api.connect_to_socket(retries=2) - - # Collect samples for 2 seconds - await asyncio.sleep(2.0) - finally: - integration_api.sio.on("status", None) - - assert len(samples) >= 2, f"Need at least 2 samples, got {len(samples)}" - - # Calculate flow rates between samples - flow_rates = [] - for i in range(1, len(samples)): - dt = samples[i]["time"] - samples[i - 1]["time"] - dw = samples[i]["weight"] - samples[i - 1]["weight"] - if dt > 0: - flow_rates.append(dw / dt) - - print(f"Calculated {len(flow_rates)} flow rates from {len(samples)} samples") - assert len(flow_rates) > 0 - - -# ============================================================================ -# INTEGRATION SMOKE TESTS -# ============================================================================ - - -@pytest.mark.integration -class TestIntegrationSmoke: - """Quick smoke tests to verify basic integration.""" - - async def test_full_workflow_profiles(self, wait_for_machine): - """Test complete profile listing workflow.""" - from services.meticulous_service import ( - async_list_profiles, - invalidate_profile_list_cache, - ) - - # Clear cache - invalidate_profile_list_cache() - - # List profiles (fresh) - profiles = await async_list_profiles() - assert isinstance(profiles, list) - - # List again (should use cache) - profiles2 = await async_list_profiles() - assert profiles2 is profiles # Same object from cache - - async def test_machine_state_complete(self, meticulous_base_url, integration_api): - """Test that machine settings and Socket.IO state are available.""" - # Verify REST settings endpoint - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get(f"{meticulous_base_url}/api/v1/settings") - assert response.status_code == 200 - - settings = response.json() - print(f"\nMachine settings fields: {list(settings.keys())}") - assert isinstance(settings, dict) - assert len(settings) > 0 - - # Verify Socket.IO status event delivers full state - received = {"data": None} - - def on_status(data): - if received["data"] is None: - received["data"] = data - - integration_api.sio.on("status", on_status) - try: - if not integration_api.sio.connected: - integration_api.connect_to_socket(retries=2) - - for _ in range(50): - if received["data"] is not None: - break - await asyncio.sleep(0.1) - - assert received["data"] is not None, "No status event received" - - data = received["data"] - state_fields = ( - list(data.keys()) - if isinstance(data, dict) - else [k for k in vars(data).keys() if not k.startswith("_")] - ) - print(f"Socket.IO status fields: {state_fields}") - print( - f"Socket.IO status sample: {json.dumps(data if isinstance(data, dict) else vars(data), indent=2, default=str)[:500]}" - ) - finally: - integration_api.sio.on("status", None) diff --git a/apps/server/test_logging.py b/apps/server/test_logging.py deleted file mode 100644 index afaeffd8..00000000 --- a/apps/server/test_logging.py +++ /dev/null @@ -1,419 +0,0 @@ -""" -Tests for the logging system in Coffee Relay. - -Tests cover: -- Logging configuration and initialization -- Log file creation and rotation -- JSON log formatting -- Request tracking with correlation IDs -- Log retrieval endpoint -- Error logging with stack traces -""" - -import pytest -import json -import logging -from pathlib import Path -from unittest.mock import Mock, patch -from fastapi.testclient import TestClient -import tempfile -import os -import sys - -# Import modules -sys.path.insert(0, os.path.dirname(__file__)) -from logging_config import ( - setup_logging, - JSONFormatter, - HumanReadableFormatter, - get_logger, -) -from main import app - - -@pytest.fixture -def temp_log_dir(): - """Create a temporary directory for log files.""" - with tempfile.TemporaryDirectory() as tmpdir: - yield tmpdir - - -@pytest.fixture -def client(): - """Create a test client for the FastAPI app.""" - return TestClient(app) - - -class TestLoggingConfiguration: - """Tests for logging configuration setup.""" - - def test_setup_logging_creates_directory(self, temp_log_dir): - """Test that setup_logging creates the log directory if it doesn't exist.""" - log_dir = Path(temp_log_dir) / "new_logs" - assert not log_dir.exists() - - logger = setup_logging(log_dir=str(log_dir)) - - assert log_dir.exists() - assert logger is not None - - def test_setup_logging_creates_log_files(self, temp_log_dir): - """Test that setup_logging creates the expected log files.""" - logger = setup_logging(log_dir=temp_log_dir) - - # Write a test log entry - logger.info("Test log entry") - logger.error("Test error entry") - - # Check that log files were created - all_logs = Path(temp_log_dir) / "meticai-server.log" - error_logs = Path(temp_log_dir) / "meticai-server-errors.log" - - assert all_logs.exists() - assert error_logs.exists() - - def test_setup_logging_configures_handlers(self, temp_log_dir): - """Test that setup_logging configures the correct handlers.""" - logger = setup_logging(log_dir=temp_log_dir) - - # Should have 3 handlers: console, all logs, error logs - assert len(logger.handlers) == 3 - - # Check handler types - handler_types = [type(h).__name__ for h in logger.handlers] - assert "StreamHandler" in handler_types - assert handler_types.count("RotatingFileHandler") == 2 - - def test_setup_logging_log_level(self, temp_log_dir): - """Test that setup_logging sets the correct log level.""" - logger = setup_logging(log_dir=temp_log_dir, log_level="DEBUG") - assert logger.level == logging.DEBUG - - logger = setup_logging(log_dir=temp_log_dir, log_level="ERROR") - assert logger.level == logging.ERROR - - def test_get_logger_returns_configured_logger(self, temp_log_dir): - """Test that get_logger returns the configured logger.""" - setup_logging(log_dir=temp_log_dir) - logger = get_logger() - - assert logger.name == "meticai-server" - assert len(logger.handlers) > 0 - - -class TestJSONFormatter: - """Tests for JSON log formatting.""" - - def test_json_formatter_basic_fields(self): - """Test that JSONFormatter includes basic required fields.""" - formatter = JSONFormatter() - record = logging.LogRecord( - name="test", - level=logging.INFO, - pathname="test.py", - lineno=42, - msg="Test message", - args=(), - exc_info=None, - ) - - formatted = formatter.format(record) - log_data = json.loads(formatted) - - assert "timestamp" in log_data - assert log_data["level"] == "INFO" - assert log_data["logger"] == "test" - assert log_data["message"] == "Test message" - assert log_data["line"] == 42 - - def test_json_formatter_with_exception(self): - """Test that JSONFormatter includes exception information.""" - formatter = JSONFormatter() - - try: - raise ValueError("Test error") - except ValueError: - record = logging.LogRecord( - name="test", - level=logging.ERROR, - pathname="test.py", - lineno=42, - msg="Error occurred", - args=(), - exc_info=sys.exc_info(), - ) - - formatted = formatter.format(record) - log_data = json.loads(formatted) - - assert "exception" in log_data - assert log_data["exception"]["type"] == "ValueError" - assert "Test error" in log_data["exception"]["message"] - assert "traceback" in log_data["exception"] - - def test_json_formatter_with_extra_fields(self): - """Test that JSONFormatter includes extra context fields.""" - formatter = JSONFormatter() - record = logging.LogRecord( - name="test", - level=logging.INFO, - pathname="test.py", - lineno=42, - msg="Test message", - args=(), - exc_info=None, - ) - - # Add extra fields - record.request_id = "test-123" - record.endpoint = "/test" - record.user_agent = "Mozilla/5.0" - record.duration_ms = 150 - - formatted = formatter.format(record) - log_data = json.loads(formatted) - - assert log_data["request_id"] == "test-123" - assert log_data["endpoint"] == "/test" - assert log_data["user_agent"] == "Mozilla/5.0" - assert log_data["duration_ms"] == 150 - - -class TestHumanReadableFormatter: - """Tests for human-readable log formatting.""" - - def test_human_readable_formatter_format(self): - """Test that HumanReadableFormatter produces readable output.""" - formatter = HumanReadableFormatter() - record = logging.LogRecord( - name="test", - level=logging.INFO, - pathname="test.py", - lineno=42, - msg="Test message", - args=(), - exc_info=None, - ) - - formatted = formatter.format(record) - - # Should contain key components - assert "test" in formatted - assert "INFO" in formatted - assert "Test message" in formatted - - -class TestLogRetrieval: - """Tests for the /api/logs endpoint.""" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_get_logs_endpoint_exists(self, client): - """Test that /api/logs endpoint is accessible.""" - # Create a temporary log directory - with tempfile.TemporaryDirectory() as tmpdir: - # Mock the log directory path - with patch("main.Path") as mock_path_class: - # Setup mock to return temp dir path - def path_side_effect(arg): - if arg == "/app/logs": - return Path(tmpdir) - return Path(arg) - - mock_path_class.side_effect = path_side_effect - - response = client.get("/api/logs") - assert response.status_code == 200 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_get_logs_returns_json_structure(self, client, temp_log_dir): - """Test that /api/logs returns expected JSON structure.""" - # Create a test log file - log_file = Path(temp_log_dir) / "meticai-server.log" - log_file.parent.mkdir(parents=True, exist_ok=True) - - test_log_entry = { - "timestamp": "2024-01-01T00:00:00Z", - "level": "INFO", - "message": "Test log entry", - } - - with open(log_file, "w") as f: - f.write(json.dumps(test_log_entry) + "\n") - - with patch("main.Path") as mock_path: - mock_path.return_value = Path(temp_log_dir) - - response = client.get("/api/logs") - - assert response.status_code == 200 - data = response.json() - - assert "logs" in data - assert "total_lines" in data - assert "log_file" in data - assert isinstance(data["logs"], list) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_get_logs_filters_by_level(self, client, temp_log_dir): - """Test that /api/logs can filter logs by level.""" - log_file = Path(temp_log_dir) / "meticai-server.log" - log_file.parent.mkdir(parents=True, exist_ok=True) - - # Write multiple log entries with different levels - with open(log_file, "w") as f: - f.write(json.dumps({"level": "INFO", "message": "Info message"}) + "\n") - f.write(json.dumps({"level": "ERROR", "message": "Error message"}) + "\n") - f.write(json.dumps({"level": "DEBUG", "message": "Debug message"}) + "\n") - - with patch("main.Path") as mock_path: - mock_path.return_value = Path(temp_log_dir) - - response = client.get("/api/logs?level=ERROR") - - assert response.status_code == 200 - data = response.json() - - # Should only return ERROR level logs - for log in data["logs"]: - assert log["level"] == "ERROR" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_get_logs_limits_lines(self, client, temp_log_dir): - """Test that /api/logs respects the lines parameter.""" - log_file = Path(temp_log_dir) / "meticai-server.log" - log_file.parent.mkdir(parents=True, exist_ok=True) - - # Write many log entries - with open(log_file, "w") as f: - for i in range(200): - f.write(json.dumps({"level": "INFO", "message": f"Message {i}"}) + "\n") - - with patch("main.Path") as mock_path: - mock_path.return_value = Path(temp_log_dir) - - response = client.get("/api/logs?lines=50") - - assert response.status_code == 200 - data = response.json() - - # Should return at most 50 entries - assert len(data["logs"]) <= 50 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_get_logs_error_type(self, client, temp_log_dir): - """Test that /api/logs can retrieve error logs specifically.""" - error_log_file = Path(temp_log_dir) / "meticai-server-errors.log" - error_log_file.parent.mkdir(parents=True, exist_ok=True) - - with open(error_log_file, "w") as f: - f.write(json.dumps({"level": "ERROR", "message": "Error message"}) + "\n") - - with patch("main.Path") as mock_path: - mock_path.return_value = Path(temp_log_dir) - - response = client.get("/api/logs?log_type=errors") - - assert response.status_code == 200 - data = response.json() - assert "meticai-server-errors.log" in data["log_file"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.system.Path") - def test_get_logs_missing_file(self, mock_path, client): - """Test that /api/logs handles missing log file gracefully.""" - mock_log_file = Mock() - mock_log_file.exists.return_value = False - mock_path.return_value.__truediv__.return_value = mock_log_file - - response = client.get("/api/logs") - - assert response.status_code == 200 - data = response.json() - assert data["total_lines"] == 0 - assert "message" in data - - -class TestRequestLogging: - """Tests for request logging middleware.""" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.get_vision_model") - def test_request_includes_correlation_id( - self, mock_vision_model, client, temp_log_dir - ): - """Test that requests include a correlation ID for tracking.""" - # Setup mock - mock_response = Mock() - mock_response.text = "Test coffee" - mock_vision_model.return_value.generate_content.return_value = mock_response - - # Create a simple image - from PIL import Image - from io import BytesIO - - img = Image.new("RGB", (100, 100), color="red") - img_bytes = BytesIO() - img.save(img_bytes, format="PNG") - img_bytes.seek(0) - - # Make request - response = client.post( - "/analyze_coffee", files={"file": ("test.png", img_bytes, "image/png")} - ) - - assert response.status_code == 200 - # The middleware should have added request_id to the request state - # This is verified by checking that the endpoint was called successfully - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_openapi_includes_logs_endpoint(self, client): - """Test that /api/logs endpoint is in OpenAPI schema.""" - response = client.get("/openapi.json") - assert response.status_code == 200 - - openapi_data = response.json() - assert "/api/logs" in openapi_data["paths"] - assert "get" in openapi_data["paths"]["/api/logs"] - - -class TestLogRotation: - """Tests for log rotation functionality.""" - - def test_rotating_handler_configured(self, temp_log_dir): - """Test that rotating file handlers are properly configured.""" - max_bytes = 1024 # 1 KB for testing - backup_count = 2 - - logger = setup_logging( - log_dir=temp_log_dir, max_bytes=max_bytes, backup_count=backup_count - ) - - # Find rotating handlers - rotating_handlers = [ - h for h in logger.handlers if type(h).__name__ == "RotatingFileHandler" - ] - - assert len(rotating_handlers) == 2 - - for handler in rotating_handlers: - assert handler.maxBytes == max_bytes - assert handler.backupCount == backup_count - - def test_log_rotation_creates_backup(self, temp_log_dir): - """Test that log rotation creates backup files when size limit is reached.""" - max_bytes = 500 # Small size for testing - - logger = setup_logging( - log_dir=temp_log_dir, max_bytes=max_bytes, backup_count=2 - ) - - # Write enough data to trigger rotation - large_message = "x" * 200 - for i in range(10): - logger.info(large_message, extra={"iteration": i}) - - # Check for backup files - log_files = list(Path(temp_log_dir).glob("meticai-server.log*")) - - # Should have main log file and at least one backup - assert len(log_files) >= 1 diff --git a/apps/server/test_main.py b/apps/server/test_main.py deleted file mode 100644 index 2401d352..00000000 --- a/apps/server/test_main.py +++ /dev/null @@ -1,18339 +0,0 @@ -""" -Comprehensive tests for the Coffee Relay FastAPI application. - -Tests cover: -- /analyze_coffee endpoint functionality -- /analyze_and_profile endpoint functionality (consolidated endpoint) -- Error handling and edge cases -- Integration with Gemini AI (mocked) -""" - -import pytest -from fastapi.testclient import TestClient -from fastapi import HTTPException -from unittest.mock import Mock, patch, MagicMock, mock_open, AsyncMock -from io import BytesIO -import base64 -from PIL import Image -from pathlib import Path -import os -import subprocess -import json -from types import SimpleNamespace -import asyncio -import requests -import httpx - - -# Import the app and services -import sys - -sys.path.insert(0, os.path.dirname(__file__)) -from main import app -import main # Import main module for lifespan and remaining functions - -# Import service modules -import services.gemini_service -import services.meticulous_service -import services.analysis_service -import services.scheduling_state -import utils.file_utils -import utils.sanitization -import config -from services.history_service import save_to_history -from services.cache_service import _get_cached_image - - -@pytest.fixture -def client(): - """Create a test client for the FastAPI app.""" - return TestClient(app) - - -@pytest.fixture -def sample_image(): - """Create a sample image for testing.""" - # Create a simple test image - img = Image.new("RGB", (100, 100), color="red") - img_bytes = BytesIO() - img.save(img_bytes, format="PNG") - img_bytes.seek(0) - return img_bytes - - -class TestAnalyzeCoffeeEndpoint: - """Tests for the /analyze_coffee endpoint.""" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_coffee_success(self, mock_vision_model, client, sample_image): - """Test successful coffee bag analysis.""" - # Mock the Gemini response - mock_response = Mock() - mock_response.text = ( - "Ethiopian Yirgacheffe, Light Roast, Floral and Citrus Notes" - ) - mock_vision_model.return_value.generate_content.return_value = mock_response - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=mock_response - ) - - # Send request - response = client.post( - "/analyze_coffee", files={"file": ("test.png", sample_image, "image/png")} - ) - - # Assertions - assert response.status_code == 200 - assert "analysis" in response.json() - assert "Ethiopian" in response.json()["analysis"] - mock_vision_model.return_value.async_generate_content.assert_called_once() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_coffee_with_whitespace( - self, mock_vision_model, client, sample_image - ): - """Test that response text is properly stripped of whitespace.""" - # Mock response with extra whitespace - mock_response = Mock() - mock_response.text = " Colombian Supremo, Medium Roast \n" - mock_vision_model.return_value.generate_content.return_value = mock_response - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=mock_response - ) - - response = client.post( - "/analyze_coffee", files={"file": ("test.png", sample_image, "image/png")} - ) - - assert response.status_code == 200 - assert response.json()["analysis"] == "Colombian Supremo, Medium Roast" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_coffee_api_error(self, mock_vision_model, client, sample_image): - """Test error handling when Gemini API fails.""" - # Mock an API error - mock_vision_model.return_value.generate_content.side_effect = Exception( - "API Error: Rate limit exceeded" - ) - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=Exception("API Error: Rate limit exceeded") - ) - - response = client.post( - "/analyze_coffee", files={"file": ("test.png", sample_image, "image/png")} - ) - - assert response.status_code == 200 - assert "error" in response.json() - assert "API Error" in response.json()["error"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_analyze_coffee_invalid_image(self, client): - """Test handling of invalid image data.""" - # Send invalid image data - invalid_data = BytesIO(b"not an image") - - response = client.post( - "/analyze_coffee", files={"file": ("test.txt", invalid_data, "text/plain")} - ) - - assert response.status_code == 200 - assert "error" in response.json() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_analyze_coffee_missing_file(self, client): - """Test error when no file is provided.""" - response = client.post("/analyze_coffee") - - assert response.status_code == 422 # Unprocessable Entity - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_coffee_different_image_formats(self, mock_vision_model, client): - """Test analysis with different image formats (JPEG, PNG, etc.).""" - mock_response = Mock() - mock_response.text = "Test coffee analysis" - mock_vision_model.return_value.generate_content.return_value = mock_response - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=mock_response - ) - - for format_type in ["PNG", "JPEG"]: - img = Image.new("RGB", (100, 100), color="blue") - img_bytes = BytesIO() - img.save(img_bytes, format=format_type) - img_bytes.seek(0) - - response = client.post( - "/analyze_coffee", - files={ - "file": ( - f"test.{format_type.lower()}", - img_bytes, - f"image/{format_type.lower()}", - ) - }, - ) - - assert response.status_code == 200 - assert "analysis" in response.json() - - -@pytest.mark.usefixtures("mock_validate_profile") -class TestAnalyzeAndProfileEndpoint: - """Tests for the /analyze_and_profile endpoint (consolidated endpoint).""" - - PROFILE_REPLY = ( - "**Profile Created:** Test Profile\n\n" - "PROFILE JSON:\n" - "```json\n" - '{"name":"Test Profile","stages":[],"variables":[]}\n' - "```\n" - ) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.save_to_history") - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_and_profile_with_image_only( - self, - mock_vision_model, - mock_create_profile, - mock_save_history, - client, - sample_image, - ): - """Test profile creation with only an image (no user preferences).""" - # Mock history saving - mock_save_history.return_value = {"id": "test-123"} - mock_create_profile.return_value = {"id": "machine-123"} - - # Mock vision model with two calls: image analysis then profile generation - analysis_response = Mock() - analysis_response.text = "Ethiopian Yirgacheffe, Light Roast, Floral Notes" - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=[analysis_response, profile_response] - ) - - response = client.post( - "/analyze_and_profile", - files={"file": ("test.png", sample_image, "image/png")}, - ) - - assert response.status_code == 200 - assert response.json()["status"] == "success" - assert ( - response.json()["analysis"] - == "Ethiopian Yirgacheffe, Light Roast, Floral Notes" - ) - assert "Profile Created" in response.json()["reply"] - - # Verify vision model was called - assert mock_vision_model.return_value.async_generate_content.call_count == 2 - mock_create_profile.assert_awaited_once() - - # Verify the final prompt contains the analysis - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args_list[-1][0][ - 0 - ] - ) - prompt = prompt_payload[0] - assert "Ethiopian Yirgacheffe, Light Roast, Floral Notes" in prompt - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.save_to_history") - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_and_profile_with_prefs_only( - self, mock_vision_model, mock_create_profile, mock_save_history, client - ): - """Test profile creation with only user preferences (no image).""" - # Mock history saving - mock_save_history.return_value = {"id": "test-456"} - mock_create_profile.return_value = {"id": "machine-456"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - response = client.post( - "/analyze_and_profile", data={"user_prefs": "Strong and intense espresso"} - ) - - assert response.status_code == 200 - assert response.json()["status"] == "success" - assert response.json()["analysis"] is None # No image, so no analysis - assert "Profile Created" in response.json()["reply"] - - # Verify model prompt includes user preferences - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args[0][0] - ) - prompt = prompt_payload[0] - assert "Strong and intense espresso" in prompt - mock_create_profile.assert_awaited_once() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.save_to_history") - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_and_profile_with_both( - self, - mock_vision_model, - mock_create_profile, - mock_save_history, - client, - sample_image, - ): - """Test profile creation with both image and user preferences.""" - # Mock history saving - mock_save_history.return_value = {"id": "test-789"} - mock_create_profile.return_value = {"id": "machine-789"} - - # Mock two calls: image analysis then profile generation - analysis_response = Mock() - analysis_response.text = "Colombian Supremo, Medium Roast, Nutty" - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=[analysis_response, profile_response] - ) - - response = client.post( - "/analyze_and_profile", - files={"file": ("test.png", sample_image, "image/png")}, - data={"user_prefs": "Quick extraction"}, - ) - - assert response.status_code == 200 - assert response.json()["status"] == "success" - assert response.json()["analysis"] == "Colombian Supremo, Medium Roast, Nutty" - - # Verify prompt was built with both coffee analysis and user preferences - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args_list[-1][0][ - 0 - ] - ) - prompt = prompt_payload[0] - assert "Colombian Supremo, Medium Roast, Nutty" in prompt - assert "Quick extraction" in prompt - mock_create_profile.assert_awaited_once() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_analyze_and_profile_missing_both(self, client): - """Test error when neither image nor preferences are provided.""" - response = client.post("/analyze_and_profile") - - assert response.status_code == 400 - assert "at least one" in response.json()["detail"].lower() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_analyze_and_profile_missing_both_api_prefix(self, client): - """Test /api-prefixed route parity for analyze_and_profile endpoint.""" - response = client.post("/api/analyze_and_profile") - - assert response.status_code == 400 - assert "At least one of" in response.json()["detail"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_and_profile_profile_creation_error( - self, mock_vision_model, mock_create_profile, client, sample_image - ): - """Test error handling when machine profile creation fails.""" - # Mock the Gemini vision response - analysis_response = Mock() - analysis_response.text = "Test Coffee" - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=[analysis_response, profile_response] - ) - - # Mock machine API failure - mock_create_profile.return_value = {"error": "Docker container not found"} - - response = client.post( - "/analyze_and_profile", - files={"file": ("test.png", sample_image, "image/png")}, - ) - - assert response.status_code == 200 - assert response.json()["status"] == "error" - assert response.json()["analysis"] == "Test Coffee" - assert "reply" in response.json() - assert "Docker container not found" in response.json()["message"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_and_profile_exception(self, mock_vision_model, client): - """Test handling of unexpected exceptions.""" - # Mock an exception in model generation - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=Exception("Unexpected error occurred") - ) - - response = client.post("/analyze_and_profile", data={"user_prefs": "Default"}) - - assert response.status_code == 500 - assert "Unexpected error" in response.json()["detail"]["message"] - - @patch("api.routes.coffee.get_vision_model") - def test_analyze_and_profile_timeout(self, mock_vision_model, client): - """Test handling of SDK timeout.""" - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=asyncio.TimeoutError() - ) - - response = client.post("/analyze_and_profile", data={"user_prefs": "Default"}) - - assert response.status_code == 504 - assert "timed out" in response.json()["detail"]["message"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_and_profile_image_processing_error( - self, mock_vision_model, client - ): - """Test error when image processing fails.""" - # Mock an exception in vision model - mock_vision_model.return_value.generate_content.side_effect = Exception( - "Vision API error" - ) - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=Exception("Vision API error") - ) - - # Send invalid image data - invalid_data = BytesIO(b"not an image") - - response = client.post( - "/analyze_and_profile", - files={"file": ("test.txt", invalid_data, "text/plain")}, - ) - - assert response.status_code == 500 - assert "error" in response.json()["detail"]["status"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_and_profile_llm_silent_failure(self, mock_vision_model, client): - """Test that missing profile JSON in model output is treated as failure.""" - profile_response = Mock() - profile_response.text = ( - "I've hit a roadblock trying to create this profile. " - "The validation system returned conflicting errors that I couldn't resolve. " - "Could you try again with slightly different parameters?" - ) - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - response = client.post( - "/analyze_and_profile", data={"user_prefs": "Berry, Florals, Medium Body"} - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "error" - assert "validation errors" in data["message"] - assert "reply" in data # The LLM's failure explanation is still included - assert "hit a roadblock" in data["reply"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_and_profile_llm_silent_failure_empty_output( - self, mock_vision_model, client - ): - """Test detection of empty LLM output as a failure.""" - profile_response = Mock() - profile_response.text = "" - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - response = client.post("/analyze_and_profile", data={"user_prefs": "Default"}) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "error" - assert "validation errors" in data["message"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_and_profile_llm_silent_failure_includes_history( - self, mock_vision_model, client - ): - """Test failed attempts without profile JSON are still saved to history.""" - profile_response = Mock() - profile_response.text = "I couldn't create the profile due to errors." - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - response = client.post("/analyze_and_profile", data={"user_prefs": "Default"}) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "error" - assert "history_id" in data # Failed attempts are still saved to history - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.save_to_history") - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_and_profile_various_preferences( - self, - mock_vision_model, - mock_create_profile, - mock_save_history, - client, - sample_image, - ): - """Test profile creation with different user preferences.""" - # Mock history saving - mock_save_history.return_value = {"id": "test-multi"} - mock_create_profile.return_value = {"id": "machine-multi"} - - analysis_response = Mock() - analysis_response.text = "Test Coffee" - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=[analysis_response, profile_response] * 4 - ) - - preferences = [ - "Strong and intense", - "Mild and smooth", - "Default settings", - "Quick extraction", - ] - - for pref in preferences: - response = client.post( - "/analyze_and_profile", - files={"file": ("test.png", sample_image, "image/png")}, - data={"user_prefs": pref}, - ) - - assert response.status_code == 200 - assert response.json()["status"] == "success" - assert mock_create_profile.await_count == 4 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_and_profile_sdk_create_called( - self, mock_vision_model, mock_create_profile, client, sample_image - ): - """Test SDK path calls machine create API instead of subprocess.""" - mock_create_profile.return_value = {"id": "machine-sdk"} - analysis_response = Mock() - analysis_response.text = "Test Coffee" - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=[analysis_response, profile_response] - ) - - response = client.post( - "/analyze_and_profile", - files={"file": ("test.png", sample_image, "image/png")}, - ) - - assert response.status_code == 200 - assert response.json()["status"] == "success" - mock_create_profile.assert_awaited_once() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.save_to_history") - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_and_profile_special_characters( - self, mock_vision_model, mock_create_profile, mock_save_history, client - ): - """Test handling of special characters in input.""" - # Mock history saving - mock_save_history.return_value = {"id": "test-special"} - mock_create_profile.return_value = {"id": "machine-special"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - response = client.post( - "/analyze_and_profile", - data={"user_prefs": "Extra-strong & 'special' \"roast\""}, - ) - - assert response.status_code == 200 - assert response.json()["status"] == "success" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_analyze_and_profile_returns_409_when_locked(self, client): - """Test that a concurrent request returns 409 when generation is in progress.""" - from api.routes import coffee as coffee_module - - # Simulate the lock being held by another request - with patch.object( - coffee_module._profile_generation_lock, "locked", return_value=True - ): - response = client.post( - "/analyze_and_profile", data={"user_prefs": "Some espresso"} - ) - - assert response.status_code == 409 - body = response.json() - assert body["status"] == "busy" - assert "already being generated" in body["message"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_analyze_and_profile_409_with_api_prefix(self, client): - """Test that the /api/ prefixed route also returns 409 when locked.""" - from api.routes import coffee as coffee_module - - with patch.object( - coffee_module._profile_generation_lock, "locked", return_value=True - ): - response = client.post( - "/api/analyze_and_profile", data={"user_prefs": "Some espresso"} - ) - - assert response.status_code == 409 - assert response.json()["status"] == "busy" - - -class TestHealthAndStartup: - """Tests for application health and startup.""" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_app_imports_successfully(self): - """Test that the app can be imported without errors.""" - from main import app - - assert app is not None - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_fastapi_app_initialization(self, client): - """Test that FastAPI app initializes correctly.""" - # Try to access the OpenAPI schema - response = client.get("/openapi.json") - assert response.status_code == 200 - - # Verify our endpoints are registered - openapi_data = response.json() - assert "/analyze_coffee" in openapi_data["paths"] - assert "/analyze_and_profile" in openapi_data["paths"] - - -class TestEdgeCases: - """Tests for edge cases and boundary conditions.""" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_coffee_large_image(self, mock_vision_model, client): - """Test handling of large images.""" - mock_response = Mock() - mock_response.text = "Analysis result" - mock_vision_model.return_value.generate_content.return_value = mock_response - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=mock_response - ) - - # Create a larger image - img = Image.new("RGB", (4000, 3000), color="green") - img_bytes = BytesIO() - img.save(img_bytes, format="PNG") - img_bytes.seek(0) - - response = client.post( - "/analyze_coffee", files={"file": ("large.png", img_bytes, "image/png")} - ) - - assert response.status_code == 200 - assert "analysis" in response.json() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.get_vision_model") - def test_analyze_coffee_very_long_response( - self, mock_vision_model, client, sample_image - ): - """Test handling of very long AI responses.""" - mock_response = Mock() - mock_response.text = "A" * 10000 # Very long response - mock_vision_model.return_value.generate_content.return_value = mock_response - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=mock_response - ) - - response = client.post( - "/analyze_coffee", files={"file": ("test.png", sample_image, "image/png")} - ) - - assert response.status_code == 200 - assert len(response.json()["analysis"]) == 10000 - - -@pytest.mark.usefixtures("mock_validate_profile") -class TestEnhancedBaristaPersona: - """Tests for enhanced barista persona and profile creation features.""" - - PROFILE_REPLY = ( - "**Profile Created:** Test Profile\n\n" - "PROFILE JSON:\n" - "```json\n" - '{"name":"Test Profile","stages":[],"variables":[]}\n' - "```\n" - ) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_prompt_includes_modern_barista_persona( - self, mock_vision_model, mock_create_profile, client, sample_image - ): - """Test that the prompt includes the modern experimental barista persona.""" - mock_create_profile.return_value = {"id": "persona-1"} - analysis_response = Mock() - analysis_response.text = "Ethiopian Coffee, Light Roast" - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=[analysis_response, profile_response] - ) - - client.post( - "/analyze_and_profile", - files={"file": ("test.png", sample_image, "image/png")}, - ) - - # Verify the prompt contains persona elements - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args_list[-1][0][ - 0 - ] - ) - prompt = prompt_payload[0] - - assert "PERSONA:" in prompt - assert "modern, experimental barista" in prompt - assert "espresso profiling" in prompt - assert "creative" in prompt or "puns" in prompt - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_prompt_includes_complex_profile_support( - self, mock_vision_model, mock_create_profile, client - ): - """Test that the prompt includes instructions for complex profile creation.""" - mock_create_profile.return_value = {"id": "persona-2"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - client.post("/analyze_and_profile", data={"user_prefs": "Test preferences"}) - - # Verify the prompt includes complex profile guidelines - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args[0][0] - ) - prompt = prompt_payload[0] - - assert "PROFILE CREATION GUIDELINES:" in prompt - assert "multi-stage extraction" in prompt - assert "pre-infusion" in prompt - assert "blooming" in prompt - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_prompt_includes_naming_convention( - self, mock_vision_model, mock_create_profile, client - ): - """Test that the prompt includes witty naming convention instructions.""" - mock_create_profile.return_value = {"id": "persona-3"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - client.post("/analyze_and_profile", data={"user_prefs": "Strong espresso"}) - - # Verify the prompt includes naming guidelines - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args[0][0] - ) - prompt = prompt_payload[0] - - assert "NAMING CONVENTION:" in prompt - assert "witty" in prompt or "pun" in prompt - assert "Examples:" in prompt - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_prompt_includes_user_summary_instructions( - self, mock_vision_model, mock_create_profile, client - ): - """Test that the prompt includes instructions for post-creation user summary.""" - mock_create_profile.return_value = {"id": "persona-4"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - client.post("/analyze_and_profile", data={"user_prefs": "Test"}) - - # Verify the prompt includes user summary requirements - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args[0][0] - ) - prompt = prompt_payload[0] - - assert "user summary" in prompt - assert "Profile Name" in prompt or "Description" in prompt - assert "Preparation" in prompt - assert "Design Rationale" in prompt or "Why This Works" in prompt - assert "Special Requirements" in prompt or "Special Notes" in prompt - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_prompt_includes_output_format( - self, mock_vision_model, mock_create_profile, client - ): - """Test that the prompt includes the output format template.""" - mock_create_profile.return_value = {"id": "persona-5"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - client.post("/analyze_and_profile", data={"user_prefs": "Test"}) - - # Verify the prompt includes output format - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args[0][0] - ) - prompt = prompt_payload[0] - - assert "OUTPUT FORMAT (use this exact format):" in prompt - assert "Profile Created:" in prompt - assert "Description:" in prompt - assert "Preparation:" in prompt - assert "Why This Works:" in prompt - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_prompt_includes_validation_rules( - self, mock_vision_model, mock_create_profile, client - ): - """Test that the prompt includes validation rules to prevent MCP rejections.""" - mock_create_profile.return_value = {"id": "persona-6"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - client.post("/analyze_and_profile", data={"user_prefs": "Test"}) - - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args[0][0] - ) - prompt = prompt_payload[0] - - # Verify validation rules are present (distilled mode is the default) - assert "VALIDATION RULES" in prompt - assert "PARADOX" in prompt - assert "BACKUP TRIGGER" in prompt - assert "CROSS-TYPE LIMITS" in prompt - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_prompt_includes_error_recovery( - self, mock_vision_model, mock_create_profile, client - ): - """Test that the prompt includes error recovery instructions.""" - mock_create_profile.return_value = {"id": "persona-7"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - client.post("/analyze_and_profile", data={"user_prefs": "Test"}) - - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args[0][0] - ) - prompt = prompt_payload[0] - - assert "ERROR RECOVERY" in prompt - assert "Fix ALL errors in a SINGLE retry" in prompt - assert "NEVER give up" in prompt - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_distilled_mode_is_default( - self, mock_vision_model, mock_create_profile, client - ): - """Test that distilled (compact) prompt mode is the default.""" - mock_create_profile.return_value = {"id": "persona-d1"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - client.post("/analyze_and_profile", data={"user_prefs": "Test"}) - - prompt = mock_vision_model.return_value.async_generate_content.call_args[0][0][ - 0 - ] - - # Distilled mode uses compact versions - assert "PROFILING QUICK REFERENCE" in prompt - assert "OEPF FORMAT SUMMARY" in prompt - # Should NOT contain the full verbose sections - assert "Understanding Puck Dynamics" not in prompt - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_detailed_knowledge_mode( - self, mock_vision_model, mock_create_profile, client - ): - """Test that detailed_knowledge=true includes full profiling knowledge.""" - mock_create_profile.return_value = {"id": "persona-d2"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - client.post( - "/analyze_and_profile", - data={"user_prefs": "Test", "detailed_knowledge": "true"}, - ) - - prompt = mock_vision_model.return_value.async_generate_content.call_args[0][0][ - 0 - ] - - # Full mode uses verbose versions - assert "ESPRESSO PROFILING GUIDE" in prompt - assert "Understanding Puck Dynamics" in prompt - assert "BACKUP EXIT TRIGGERS" in prompt - # Should NOT contain the compact versions - assert "PROFILING QUICK REFERENCE" not in prompt - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_enhanced_prompt_with_both_inputs( - self, mock_vision_model, mock_create_profile, client, sample_image - ): - """Test enhanced prompt when both image and preferences are provided.""" - mock_create_profile.return_value = {"id": "persona-8"} - analysis_response = Mock() - analysis_response.text = "Kenyan AA, Medium Roast, Berry notes" - profile_response = Mock() - profile_response.text = ( - "Profile Created: The Berry Express\n\n" - "PROFILE JSON:\n```json\n" - '{"name":"The Berry Express","stages":[],"variables":[]}\n' - "```\n" - ) - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=[analysis_response, profile_response] - ) - - response = client.post( - "/analyze_and_profile", - files={"file": ("test.png", sample_image, "image/png")}, - data={"user_prefs": "Highlight berry notes"}, - ) - - assert response.status_code == 200 - - # Verify the prompt includes all elements for both inputs - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args_list[-1][0][ - 0 - ] - ) - prompt = prompt_payload[0] - - # Should have coffee analysis - assert "Kenyan AA, Medium Roast, Berry notes" in prompt - # Should have user preferences - assert "Highlight berry notes" in prompt - # Should have all enhancement features - assert "PERSONA:" in prompt - assert "PROFILE CREATION GUIDELINES:" in prompt - assert "NAMING CONVENTION:" in prompt - assert "OUTPUT FORMAT (use this exact format):" in prompt - - -class TestValidationRetry: - """Tests for profile validation and retry logic (#229).""" - - VALID_PROFILE_REPLY = ( - "**Profile Created:** Retry Test\n\n" - "PROFILE JSON:\n" - "```json\n" - '{"name":"Retry Test","stages":[{"name":"Pre-infusion",' - '"type":"flow","dynamics":{"points":[{"value":2}],"over":"time"},' - '"exit_triggers":[{"type":"time","value":10}],' - '"limits":[{"type":"pressure","value":3}]}],"variables":[]}\n' - "```\n" - ) - - INVALID_PROFILE_REPLY = ( - "**Profile Created:** Bad Profile\n\n" - "PROFILE JSON:\n" - "```json\n" - '{"name":"Bad Profile","stages":[]}\n' - "```\n" - ) - - NO_JSON_REPLY = ( - "I had trouble creating the profile. Let me try a different approach next time." - ) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.save_to_history") - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.validate_profile") - @patch("api.routes.coffee.get_vision_model") - def test_validation_passes_first_try( - self, - mock_vision_model, - mock_validate, - mock_create_profile, - mock_save_history, - client, - ): - """Test that validation passing on first attempt skips retry.""" - mock_save_history.return_value = {"id": "test-v1"} - mock_create_profile.return_value = {"id": "machine-v1"} - - valid_result = Mock() - valid_result.is_valid = True - valid_result.errors = [] - mock_validate.return_value = valid_result - - profile_response = Mock() - profile_response.text = self.VALID_PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - response = client.post( - "/analyze_and_profile", data={"user_prefs": "Fruity and bright"} - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - # Vision model called once for generation, no retries - assert mock_vision_model.return_value.async_generate_content.call_count == 1 - mock_validate.assert_called_once() - mock_create_profile.assert_awaited_once() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.save_to_history") - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.validate_profile") - @patch("api.routes.coffee.get_vision_model") - def test_validation_fails_then_succeeds_on_retry( - self, - mock_vision_model, - mock_validate, - mock_create_profile, - mock_save_history, - client, - ): - """Test that a validation failure triggers retry and succeeds.""" - mock_save_history.return_value = {"id": "test-v2"} - mock_create_profile.return_value = {"id": "machine-v2"} - - # First validation fails, second passes - invalid_result = Mock() - invalid_result.is_valid = False - invalid_result.errors = [ - "Stage 'Pre-infusion': pressure stage must have a flow limit" - ] - invalid_result.error_summary.return_value = ( - "1. Stage 'Pre-infusion': pressure stage must have a flow limit" - ) - - valid_result = Mock() - valid_result.is_valid = True - valid_result.errors = [] - - mock_validate.side_effect = [invalid_result, valid_result] - - # First call: original generation, second call: fix response - gen_response = Mock() - gen_response.text = self.VALID_PROFILE_REPLY - fix_response = Mock() - fix_response.text = self.VALID_PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=[gen_response, fix_response] - ) - - response = client.post( - "/analyze_and_profile", data={"user_prefs": "Clean and balanced"} - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - # Generation + fix retry = 2 calls - assert mock_vision_model.return_value.async_generate_content.call_count == 2 - assert mock_validate.call_count == 2 - mock_create_profile.assert_awaited_once() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.save_to_history") - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.validate_profile") - @patch("api.routes.coffee.get_vision_model") - def test_validation_exhausts_retries( - self, - mock_vision_model, - mock_validate, - mock_create_profile, - mock_save_history, - client, - ): - """Test that exhausting retries still proceeds with best-effort profile.""" - mock_save_history.return_value = {"id": "test-v3"} - mock_create_profile.return_value = {"id": "machine-v3"} - - # All validation attempts fail - invalid_result = Mock() - invalid_result.is_valid = False - invalid_result.errors = ["Error that persists"] - invalid_result.error_summary.return_value = "1. Error that persists" - mock_validate.return_value = invalid_result - - gen_response = Mock() - gen_response.text = self.VALID_PROFILE_REPLY - fix_response = Mock() - fix_response.text = self.VALID_PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=[gen_response, fix_response, fix_response] - ) - - response = client.post( - "/analyze_and_profile", data={"user_prefs": "Rich and bold"} - ) - - assert response.status_code == 200 - data = response.json() - # Should still proceed with best-effort profile upload - assert data["status"] == "success" - # Generation + 2 fix retries = 3 calls - assert mock_vision_model.return_value.async_generate_content.call_count == 3 - # 3 validations: initial + 2 retries - assert mock_validate.call_count == 3 - mock_create_profile.assert_awaited_once() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.save_to_history") - @patch("api.routes.coffee.validate_profile") - @patch("api.routes.coffee.get_vision_model") - def test_no_json_retries_then_fails( - self, mock_vision_model, mock_validate, mock_save_history, client - ): - """Test that missing JSON triggers retries and eventually fails.""" - mock_save_history.return_value = {"id": "test-v4"} - - # All responses lack JSON - no_json = Mock() - no_json.text = self.NO_JSON_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=no_json - ) - - response = client.post( - "/analyze_and_profile", data={"user_prefs": "Something impossible"} - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "error" - assert "validation errors" in data["message"] - # Initial generation + 2 retries = 3 calls - assert mock_vision_model.return_value.async_generate_content.call_count == 3 - # validate_profile should never be called since no JSON was extracted - mock_validate.assert_not_called() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.save_to_history") - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.validate_profile") - @patch("api.routes.coffee.get_vision_model") - def test_no_json_then_json_on_retry( - self, - mock_vision_model, - mock_validate, - mock_create_profile, - mock_save_history, - client, - ): - """Test that missing JSON on first try but found on retry succeeds.""" - mock_save_history.return_value = {"id": "test-v5"} - mock_create_profile.return_value = {"id": "machine-v5"} - - valid_result = Mock() - valid_result.is_valid = True - valid_result.errors = [] - mock_validate.return_value = valid_result - - no_json = Mock() - no_json.text = self.NO_JSON_REPLY - with_json = Mock() - with_json.text = self.VALID_PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=[no_json, with_json] - ) - - response = client.post( - "/analyze_and_profile", data={"user_prefs": "Light and delicate"} - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - # 2 calls: initial (no JSON) + retry (with JSON) - assert mock_vision_model.return_value.async_generate_content.call_count == 2 - mock_validate.assert_called_once() - mock_create_profile.assert_awaited_once() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.save_to_history") - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.validate_profile") - @patch("api.routes.coffee.get_vision_model") - def test_response_includes_generation_id( - self, - mock_vision_model, - mock_validate, - mock_create_profile, - mock_save_history, - client, - ): - """Test that the response includes a generation_id for SSE tracking.""" - mock_save_history.return_value = {"id": "test-v6"} - mock_create_profile.return_value = {"id": "machine-v6"} - - valid_result = Mock() - valid_result.is_valid = True - valid_result.errors = [] - mock_validate.return_value = valid_result - - profile_response = Mock() - profile_response.text = self.VALID_PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - response = client.post( - "/analyze_and_profile", data={"user_prefs": "Bright acidity"} - ) - - assert response.status_code == 200 - data = response.json() - assert "generation_id" in data - assert len(data["generation_id"]) == 8 - - -class TestUnicodeEscapeInReSubFix: - """Test that re.sub replacement with Unicode JSON doesn't raise PatternError. - - Regression test for a bug where json.dumps output containing \\uXXXX - sequences caused re.PatternError('bad escape \\u') when used directly - as the replacement string in re.sub(). - """ - - # Profile with Unicode characters that produce \uXXXX in json.dumps - UNICODE_PROFILE_REPLY = ( - "**Profile Created:** Café Résumé\n\n" - "PROFILE JSON:\n" - "```json\n" - '{"name":"Caf\\u00e9 R\\u00e9sum\\u00e9","stages":[{"name":"Pre-infusion",' - '"type":"flow","dynamics":{"points":[{"value":2}],"over":"time"},' - '"exit_triggers":[{"type":"time","value":10}],' - '"limits":[{"type":"pressure","value":3}]}],"variables":[]}\n' - "```\n" - ) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.save_to_history") - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.validate_profile") - @patch("api.routes.coffee.get_vision_model") - def test_unicode_in_fixed_json_does_not_raise( - self, - mock_vision_model, - mock_validate, - mock_create_profile, - mock_save_history, - client, - ): - """Validation fix path replaces JSON with unicode chars without error.""" - mock_save_history.return_value = {"id": "test-unicode"} - mock_create_profile.return_value = {"id": "machine-unicode"} - - # First validation fails, second passes — forces the re.sub code path - invalid_result = Mock() - invalid_result.is_valid = False - invalid_result.errors = ["Stage missing limit"] - invalid_result.error_summary.return_value = "1. Stage missing limit" - - valid_result = Mock() - valid_result.is_valid = True - valid_result.errors = [] - - mock_validate.side_effect = [invalid_result, valid_result] - - gen_response = Mock() - gen_response.text = self.UNICODE_PROFILE_REPLY - fix_response = Mock() - fix_response.text = self.UNICODE_PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=[gen_response, fix_response] - ) - - response = client.post( - "/analyze_and_profile", data={"user_prefs": "Test unicode handling"} - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - # The re.sub path was exercised (2 model calls = generation + fix) - assert mock_vision_model.return_value.async_generate_content.call_count == 2 - - -@pytest.mark.usefixtures("mock_validate_profile") -class TestAdvancedCustomization: - """Tests for advanced_customization parameter functionality.""" - - PROFILE_REPLY = ( - "**Profile Created:** Test Profile\n\n" - "PROFILE JSON:\n" - "```json\n" - '{"name":"Test Profile","stages":[],"variables":[]}\n' - "```\n" - ) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_advanced_customization_parameter_parsed( - self, mock_vision_model, mock_create_profile, client - ): - """Test that advanced_customization parameter is correctly parsed.""" - mock_create_profile.return_value = {"id": "advanced-1"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - advanced_params = "Temperature: 93°C, Dose: 18g, Max Pressure: 9 bar" - - response = client.post( - "/analyze_and_profile", - data={ - "user_prefs": "Create a balanced profile", - "advanced_customization": advanced_params, - }, - ) - - assert response.status_code == 200 - - # Verify the parameter was parsed and included in the prompt - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args[0][0] - ) - prompt = prompt_payload[0] - assert advanced_params in prompt - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_prompt_includes_advanced_customization_when_provided( - self, mock_vision_model, mock_create_profile, client - ): - """Test that prompt includes advanced customization section when provided.""" - mock_create_profile.return_value = {"id": "advanced-2"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - advanced_params = "Temperature: 93°C, Dose: 18g, Max Pressure: 9 bar" - - response = client.post( - "/analyze_and_profile", - data={ - "user_prefs": "Create a balanced profile", - "advanced_customization": advanced_params, - }, - ) - - assert response.status_code == 200 - - # Verify the prompt includes advanced customization section - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args[0][0] - ) - prompt = prompt_payload[0] - - # Check for section header - assert ( - "⚠️ MANDATORY EQUIPMENT & EXTRACTION PARAMETERS (MUST BE USED EXACTLY):" - in prompt - ) - # Check for the actual parameters - assert advanced_params in prompt - # Check for CRITICAL instruction - assert ( - "CRITICAL: You MUST configure the profile to use these EXACT values." - in prompt - ) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_prompt_omits_advanced_customization_when_not_provided( - self, mock_vision_model, mock_create_profile, client - ): - """Test that prompt omits advanced customization section when not provided.""" - mock_create_profile.return_value = {"id": "advanced-3"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - response = client.post( - "/analyze_and_profile", data={"user_prefs": "Create a balanced profile"} - ) - - assert response.status_code == 200 - - # Verify the prompt does NOT include advanced customization section - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args[0][0] - ) - prompt = prompt_payload[0] - - assert ( - "⚠️ MANDATORY EQUIPMENT & EXTRACTION PARAMETERS (MUST BE USED EXACTLY):" - not in prompt - ) - assert ( - "CRITICAL: You MUST configure the profile to use these EXACT values." - not in prompt - ) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_advanced_customization_section_formatting( - self, mock_vision_model, mock_create_profile, client - ): - """Test that advanced customization section has correct formatting.""" - mock_create_profile.return_value = {"id": "advanced-4"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - advanced_params = "Temperature: 93°C\nDose: 18g\nBasket: 18g VST" - - response = client.post( - "/analyze_and_profile", - data={"user_prefs": "Test", "advanced_customization": advanced_params}, - ) - - assert response.status_code == 200 - - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args[0][0] - ) - prompt = prompt_payload[0] - - # Verify all formatting elements are present - assert ( - "⚠️ MANDATORY EQUIPMENT & EXTRACTION PARAMETERS (MUST BE USED EXACTLY):" - in prompt - ) - assert "Temperature: 93°C" in prompt - assert "Dose: 18g" in prompt - assert "Basket: 18g VST" in prompt - assert ( - "CRITICAL: You MUST configure the profile to use these EXACT values." - in prompt - ) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_advanced_customization_mandatory_instructions_included( - self, mock_vision_model, mock_create_profile, client - ): - """Test that MANDATORY instructions are included in the advanced customization section.""" - mock_create_profile.return_value = {"id": "advanced-5"} - profile_response = Mock() - profile_response.text = self.PROFILE_REPLY - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - response = client.post( - "/analyze_and_profile", - data={ - "user_prefs": "Test", - "advanced_customization": "Temperature: 93°C, Dose: 18g", - }, - ) - - assert response.status_code == 200 - - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args[0][0] - ) - prompt = prompt_payload[0] - - # Verify all mandatory instruction bullets are present - assert ( - "• If a temperature is specified, set the profile temperature to that EXACT value" - in prompt - ) - assert ( - "• If a dose is specified, the profile MUST be designed for that EXACT dose" - in prompt - ) - assert ( - "• If max pressure/flow is specified, NO stage should exceed those limits" - in prompt - ) - assert ( - "• If basket size/type is specified, account for it in your dose and extraction design" - in prompt - ) - assert ( - "• If bottom filter is specified, mention it in preparation notes" in prompt - ) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_advanced_customization_with_image( - self, mock_vision_model, mock_create_profile, client, sample_image - ): - """Test advanced_customization with image input.""" - mock_create_profile.return_value = {"id": "advanced-6"} - analysis_response = Mock() - analysis_response.text = "Ethiopian Yirgacheffe, Light Roast, Floral notes" - profile_response = Mock() - profile_response.text = ( - "Profile Created: Floral Fantasy\n\n" - "PROFILE JSON:\n```json\n" - '{"name":"Floral Fantasy","stages":[],"variables":[]}\n' - "```\n" - ) - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=[analysis_response, profile_response] - ) - - advanced_params = "Temperature: 94°C, Dose: 20g" - - response = client.post( - "/analyze_and_profile", - files={"file": ("test.png", sample_image, "image/png")}, - data={"advanced_customization": advanced_params}, - ) - - assert response.status_code == 200 - - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args_list[-1][0][ - 0 - ] - ) - prompt = prompt_payload[0] - - # Should have coffee analysis - assert "Ethiopian Yirgacheffe, Light Roast, Floral notes" in prompt - # Should have advanced customization - assert ( - "⚠️ MANDATORY EQUIPMENT & EXTRACTION PARAMETERS (MUST BE USED EXACTLY):" - in prompt - ) - assert advanced_params in prompt - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_advanced_customization_with_user_prefs( - self, mock_vision_model, mock_create_profile, client - ): - """Test advanced_customization with user preferences.""" - mock_create_profile.return_value = {"id": "advanced-7"} - profile_response = Mock() - profile_response.text = ( - "Profile Created: Precise Pour\n\n" - "PROFILE JSON:\n```json\n" - '{"name":"Precise Pour","stages":[],"variables":[]}\n' - "```\n" - ) - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - advanced_params = "Max Flow: 4 ml/s, Bottom Filter: IMS Superfine" - user_prefs = "Emphasize clarity and sweetness" - - response = client.post( - "/analyze_and_profile", - data={"user_prefs": user_prefs, "advanced_customization": advanced_params}, - ) - - assert response.status_code == 200 - - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args[0][0] - ) - prompt = prompt_payload[0] - - # Should have user preferences - assert user_prefs in prompt - # Should have advanced customization - assert ( - "⚠️ MANDATORY EQUIPMENT & EXTRACTION PARAMETERS (MUST BE USED EXACTLY):" - in prompt - ) - assert advanced_params in prompt - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_advanced_customization_with_image_and_user_prefs( - self, mock_vision_model, mock_create_profile, client, sample_image - ): - """Test advanced_customization with both image and user preferences.""" - mock_create_profile.return_value = {"id": "advanced-8"} - analysis_response = Mock() - analysis_response.text = "Kenyan AA, Medium Roast, Berry notes" - profile_response = Mock() - profile_response.text = ( - "Profile Created: Berry Bomb\n\n" - "PROFILE JSON:\n```json\n" - '{"name":"Berry Bomb","stages":[],"variables":[]}\n' - "```\n" - ) - mock_vision_model.return_value.async_generate_content = AsyncMock( - side_effect=[analysis_response, profile_response] - ) - - advanced_params = "Temperature: 92°C, Dose: 18g, Max Pressure: 8 bar" - user_prefs = "Highlight berry notes with gentle extraction" - - response = client.post( - "/analyze_and_profile", - files={"file": ("test.png", sample_image, "image/png")}, - data={"user_prefs": user_prefs, "advanced_customization": advanced_params}, - ) - - assert response.status_code == 200 - - prompt_payload = ( - mock_vision_model.return_value.async_generate_content.call_args_list[-1][0][ - 0 - ] - ) - prompt = prompt_payload[0] - - # Should have coffee analysis - assert "Kenyan AA, Medium Roast, Berry notes" in prompt - # Should have user preferences - assert user_prefs in prompt - # Should have advanced customization - assert ( - "⚠️ MANDATORY EQUIPMENT & EXTRACTION PARAMETERS (MUST BE USED EXACTLY):" - in prompt - ) - assert advanced_params in prompt - # Should have all mandatory instructions - assert ( - "CRITICAL: You MUST configure the profile to use these EXACT values." - in prompt - ) - - -class TestCORS: - """Tests for CORS middleware configuration.""" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.get_vision_model") - def test_cors_headers_on_analyze_coffee( - self, mock_vision_model, client, sample_image - ): - """Test that CORS headers are present on /analyze_coffee responses.""" - mock_response = Mock() - mock_response.text = "Test coffee" - mock_vision_model.return_value.generate_content.return_value = mock_response - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=mock_response - ) - - response = client.post( - "/analyze_coffee", - files={"file": ("test.png", sample_image, "image/png")}, - headers={"Origin": "http://localhost:3000"}, - ) - - assert response.status_code == 200 - # Check for CORS headers - assert "access-control-allow-origin" in response.headers - assert response.headers["access-control-allow-origin"] == "*" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.coffee.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.coffee.get_vision_model") - def test_cors_headers_on_analyze_and_profile( - self, mock_vision_model, mock_create_profile, client - ): - """Test that CORS headers are present on /analyze_and_profile responses.""" - mock_create_profile.return_value = {"id": "cors-1"} - profile_response = Mock() - profile_response.text = ( - "**Profile Created:** Test Profile\n\n" - "PROFILE JSON:\n```json\n" - '{"name":"Test Profile","stages":[],"variables":[]}\n' - "```\n" - ) - mock_vision_model.return_value.async_generate_content = AsyncMock( - return_value=profile_response - ) - - response = client.post( - "/analyze_and_profile", - data={"user_prefs": "Test"}, - headers={"Origin": "http://localhost:3000"}, - ) - - assert response.status_code == 200 - # Check for CORS headers - assert "access-control-allow-origin" in response.headers - assert response.headers["access-control-allow-origin"] == "*" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_cors_preflight_request(self, client): - """Test CORS preflight OPTIONS request.""" - response = client.options( - "/analyze_coffee", - headers={ - "Origin": "http://localhost:3000", - "Access-Control-Request-Method": "POST", - "Access-Control-Request-Headers": "content-type", - }, - ) - - # Preflight requests should return 200 - assert response.status_code == 200 - # Check CORS headers on preflight response - assert "access-control-allow-origin" in response.headers - assert "access-control-allow-methods" in response.headers - assert "access-control-allow-headers" in response.headers - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_cors_allows_credentials(self, client): - """Test that CORS does not send credentials header with wildcard origin. - - Browsers reject allow_origins='*' combined with allow_credentials=True - per the CORS spec, so credentials must be disabled when using wildcard. - """ - response = client.options( - "/analyze_and_profile", - headers={ - "Origin": "http://localhost:3000", - "Access-Control-Request-Method": "POST", - }, - ) - - assert response.status_code == 200 - # With allow_origins=["*"], credentials must not be sent - assert response.headers.get("access-control-allow-credentials") != "true" - - -class TestStatusEndpoint: - """Tests for the /api/status endpoint (GitHub Releases API).""" - - @pytest.fixture(autouse=True) - def clear_update_cache(self): - """Clear the update cache between tests.""" - import api.routes.system as sys_module - - sys_module._update_cache = None - sys_module._update_cache_time = None - yield - sys_module._update_cache = None - sys_module._update_cache_time = None - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.system._get_running_version", return_value="2.0.0") - @patch("httpx.AsyncClient") - def test_status_endpoint_exists(self, mock_client_cls, mock_version, client): - """Test that /api/status endpoint exists and is accessible.""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = [ - {"tag_name": "v2.0.0", "html_url": "", "prerelease": False} - ] - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.get("/api/status") - assert response.status_code == 200 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.system._get_running_version", return_value="2.0.0") - @patch("httpx.AsyncClient") - def test_status_returns_json_structure(self, mock_client_cls, mock_version, client): - """Test that /api/status returns expected JSON structure.""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = [ - {"tag_name": "v2.0.0", "html_url": "", "prerelease": False} - ] - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.get("/api/status") - assert response.status_code == 200 - data = response.json() - assert "update_available" in data - assert isinstance(data["update_available"], bool) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.system._get_running_version", return_value="1.0.0") - @patch("httpx.AsyncClient") - def test_status_detects_updates_available( - self, mock_client_cls, mock_version, client - ): - """Test that /api/status correctly identifies when updates are available.""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = [ - {"tag_name": "v2.0.0", "html_url": "", "prerelease": False} - ] - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.get("/api/status") - assert response.status_code == 200 - data = response.json() - assert data["update_available"] is True - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.system._get_running_version", return_value="2.0.0") - @patch("httpx.AsyncClient") - def test_status_no_updates(self, mock_client_cls, mock_version, client): - """Test /api/status when already on latest version.""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = [ - {"tag_name": "v2.0.0", "html_url": "", "prerelease": False} - ] - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.get("/api/status") - assert response.status_code == 200 - data = response.json() - assert data["update_available"] is False - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.system._get_running_version", return_value="2.0.0") - @patch("httpx.AsyncClient") - def test_status_handles_github_error(self, mock_client_cls, mock_version, client): - """Test that /api/status handles GitHub API errors gracefully.""" - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(side_effect=Exception("Connection refused")) - mock_client_cls.return_value = mock_client - - response = client.get("/api/status") - assert response.status_code == 200 - data = response.json() - assert data["update_available"] is False - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.system._get_running_version", return_value="2.0.0") - @patch("httpx.AsyncClient") - def test_status_endpoint_cors_enabled(self, mock_client_cls, mock_version, client): - """Test that /api/status endpoint has CORS enabled for web app.""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = [ - {"tag_name": "v2.0.0", "html_url": "", "prerelease": False} - ] - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.get( - "/api/status", headers={"Origin": "http://localhost:3550"} - ) - - assert response.status_code == 200 - assert "access-control-allow-origin" in response.headers - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_status_in_openapi_schema(self, client): - """Test that /api/status endpoint is registered in OpenAPI schema.""" - response = client.get("/openapi.json") - assert response.status_code == 200 - - openapi_data = response.json() - assert "/api/status" in openapi_data["paths"] - assert "get" in openapi_data["paths"]["/api/status"] - - -class TestTriggerUpdateEndpoint: - """Tests for the /api/trigger-update endpoint (Watchtower-based).""" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("httpx.AsyncClient") - def test_trigger_update_watchtower_success(self, mock_client_cls, client): - """Test successful update trigger via Watchtower HTTP API.""" - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.post = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.post("/api/trigger-update") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert "watchtower" in data["message"].lower() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("httpx.AsyncClient") - def test_trigger_update_watchtower_401(self, mock_client_cls, client): - """Test update trigger when Watchtower returns 401 (no token).""" - mock_response = AsyncMock() - mock_response.status_code = 401 - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.post = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.post("/api/trigger-update") - - assert response.status_code == 503 - data = response.json() - assert data["detail"]["status"] == "error" - assert "rejected" in data["detail"]["message"].lower() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("httpx.AsyncClient") - def test_trigger_update_no_watchtower(self, mock_client_cls, client): - """Test update trigger when Watchtower is not available.""" - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.post = AsyncMock(side_effect=Exception("Connection refused")) - mock_client_cls.return_value = mock_client - - response = client.post("/api/trigger-update") - - assert response.status_code == 503 - data = response.json() - assert "detail" in data - assert "watchtower" in data["detail"]["error"].lower() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("httpx.AsyncClient") - def test_trigger_update_cors_enabled(self, mock_client_cls, client): - """Test that /api/trigger-update has CORS enabled.""" - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.post = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.post( - "/api/trigger-update", headers={"Origin": "http://localhost:3550"} - ) - - assert response.status_code == 200 - assert "access-control-allow-origin" in response.headers - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_trigger_update_in_openapi_schema(self, client): - """Test that /api/trigger-update endpoint is registered in OpenAPI schema.""" - response = client.get("/openapi.json") - assert response.status_code == 200 - - openapi_data = response.json() - assert "/api/trigger-update" in openapi_data["paths"] - assert "post" in openapi_data["paths"]["/api/trigger-update"] - - -class TestUpdateMethodEndpoint: - """Tests for the /api/update-method endpoint.""" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.system._probe_watchtower_api", new_callable=AsyncMock) - def test_update_method_watchtower_reachable(self, mock_probe, client): - """Returns watchtower mode when API is reachable via any probe endpoint.""" - mock_probe.return_value = { - "reachable": True, - "can_trigger": False, - "endpoint": "http://watchtower:8080/v1/update", - "status_code": 401, - "error": None, - } - - response = client.get("/api/update-method") - - assert response.status_code == 200 - data = response.json() - assert data["method"] == "watchtower" - assert data["watchtower_running"] is True - assert data["can_trigger_update"] is False - assert data["watchtower_endpoint"] == "http://watchtower:8080/v1/update" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("subprocess.run") - @patch("api.routes.system._probe_watchtower_api", new_callable=AsyncMock) - def test_update_method_reports_container_error( - self, mock_probe, mock_subprocess_run, client - ): - """Surfaces watchtower container startup failures in response diagnostics.""" - mock_probe.return_value = { - "reachable": False, - "can_trigger": False, - "endpoint": None, - "status_code": None, - "error": "http://localhost:8088/v1/update: connection refused", - } - - mock_result = Mock() - mock_result.returncode = 0 - mock_result.stdout = "created|failed to bind host port 127.0.0.1:8088/tcp: address already in use" - mock_subprocess_run.return_value = mock_result - - response = client.get("/api/update-method") - - assert response.status_code == 200 - data = response.json() - assert data["method"] == "manual" - assert data["watchtower_running"] is False - assert data["can_trigger_update"] is False - assert "address already in use" in data["watchtower_error"] - - @patch.dict( - os.environ, - {"GEMINI_API_KEY": "test_api_key", "WATCHTOWER_TOKEN": "test-token-123"}, - ) - @patch("httpx.AsyncClient") - def test_update_method_sends_auth_header_when_token_set( - self, mock_client_cls, client - ): - """Sends Authorization header when WATCHTOWER_TOKEN env var is set.""" - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.get("/api/update-method") - - assert response.status_code == 200 - data = response.json() - assert data["method"] == "watchtower" - assert data["watchtower_running"] is True - assert data["can_trigger_update"] is True - - # Verify the auth header was sent - call_kwargs = mock_client.get.call_args - assert call_kwargs is not None - assert ( - call_kwargs.kwargs.get("headers", {}).get("Authorization") - == "Bearer test-token-123" - ) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}, clear=False) - @patch("httpx.AsyncClient") - def test_update_method_no_auth_header_without_token(self, mock_client_cls, client): - """Does not send Authorization header when WATCHTOWER_TOKEN is unset.""" - # Ensure WATCHTOWER_TOKEN is not in environment - import os as _os - - _os.environ.pop("WATCHTOWER_TOKEN", None) - - mock_response = AsyncMock() - mock_response.status_code = 401 - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.get("/api/update-method") - - assert response.status_code == 200 - data = response.json() - assert data["method"] == "watchtower" - assert data["watchtower_running"] is True - assert data["can_trigger_update"] is False - - # Verify no auth header was sent - call_kwargs = mock_client.get.call_args - assert call_kwargs is not None - assert call_kwargs.kwargs.get("headers", {}) == {} - - @patch.dict( - os.environ, - {"GEMINI_API_KEY": "test_api_key", "WATCHTOWER_TOKEN": "trigger-token"}, - ) - @patch("httpx.AsyncClient") - def test_trigger_update_sends_auth_header(self, mock_client_cls, client): - """Trigger-update POST sends Authorization header when token is set.""" - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.post = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.post("/api/trigger-update") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - - # Verify the auth header was sent on POST - call_kwargs = mock_client.post.call_args - assert call_kwargs is not None - assert ( - call_kwargs.kwargs.get("headers", {}).get("Authorization") - == "Bearer trigger-token" - ) - - -class TestHistoryAPI: - """Tests for the profile history API endpoints.""" - - @pytest.fixture - def mock_history_file(self, tmp_path): - """Create a temporary history file.""" - history_file = tmp_path / "profile_history.json" - history_file.write_text("[]") - return history_file - - @pytest.fixture - def sample_history_entry(self): - """Create a sample history entry.""" - return { - "id": "test-entry-123", - "created_at": "2026-01-18T10:00:00+00:00", - "profile_name": "Ethiopian Sunrise", - "coffee_analysis": "Light roast with floral notes", - "user_preferences": "Light Body with Florals", - "reply": "Profile Created: Ethiopian Sunrise\n\nDescription: A bright and floral profile...", - "profile_json": {"name": "Ethiopian Sunrise", "stages": []}, - "image_preview": None, - } - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.load_history") - def test_get_history_empty(self, mock_load, client): - """Test getting history when it's empty.""" - mock_load.return_value = [] - - response = client.get("/api/history") - - assert response.status_code == 200 - data = response.json() - assert data["entries"] == [] - assert data["total"] == 0 - assert data["limit"] == 50 - assert data["offset"] == 0 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.load_history") - def test_get_history_with_entries(self, mock_load, client, sample_history_entry): - """Test getting history with existing entries.""" - mock_load.return_value = [sample_history_entry] - - response = client.get("/api/history") - - assert response.status_code == 200 - data = response.json() - assert len(data["entries"]) == 1 - assert data["entries"][0]["profile_name"] == "Ethiopian Sunrise" - assert data["total"] == 1 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.load_history") - def test_get_history_pagination(self, mock_load, client, sample_history_entry): - """Test history pagination with limit and offset.""" - # Create multiple entries - entries = [] - for i in range(10): - entry = sample_history_entry.copy() - entry["id"] = f"entry-{i}" - entry["profile_name"] = f"Profile {i}" - entries.append(entry) - mock_load.return_value = entries - - # Test with custom limit and offset - response = client.get("/api/history?limit=3&offset=2") - - assert response.status_code == 200 - data = response.json() - assert len(data["entries"]) == 3 - assert data["total"] == 10 - assert data["limit"] == 3 - assert data["offset"] == 2 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.load_history") - def test_get_history_removes_image_preview( - self, mock_load, client, sample_history_entry - ): - """Test that image_preview is removed from list view.""" - entry = sample_history_entry.copy() - entry["image_preview"] = "base64-thumbnail-data" - mock_load.return_value = [entry] - - response = client.get("/api/history") - - assert response.status_code == 200 - data = response.json() - # image_preview should be None in list view - assert data["entries"][0]["image_preview"] is None - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.load_history") - def test_get_history_entry_by_id(self, mock_load, client, sample_history_entry): - """Test getting a specific history entry by ID.""" - mock_load.return_value = [sample_history_entry] - - response = client.get(f"/api/history/{sample_history_entry['id']}") - - assert response.status_code == 200 - data = response.json() - assert data["id"] == sample_history_entry["id"] - assert data["profile_name"] == "Ethiopian Sunrise" - assert data["profile_json"] is not None - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.load_history") - def test_get_history_entry_not_found(self, mock_load, client): - """Test 404 when history entry doesn't exist.""" - mock_load.return_value = [] - - response = client.get("/api/history/non-existent-id") - - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.save_history") - @patch("api.routes.history.load_history") - def test_delete_history_entry( - self, mock_load, mock_save, client, sample_history_entry - ): - """Test deleting a specific history entry.""" - mock_load.return_value = [sample_history_entry] - - response = client.delete(f"/api/history/{sample_history_entry['id']}") - - assert response.status_code == 200 - assert response.json()["status"] == "success" - # Verify save was called with empty list - mock_save.assert_called_once_with([]) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.load_history") - def test_delete_history_entry_not_found(self, mock_load, client): - """Test 404 when deleting non-existent entry.""" - mock_load.return_value = [] - - response = client.delete("/api/history/non-existent-id") - - assert response.status_code == 404 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.save_history") - @patch("api.routes.history.load_history") - def test_clear_history(self, mock_load, mock_save, client, sample_history_entry): - """Test clearing all history.""" - mock_load.return_value = [sample_history_entry] - - response = client.delete("/api/history") - - assert response.status_code == 200 - assert response.json()["status"] == "success" - assert "cleared" in response.json()["message"].lower() - mock_save.assert_called_once_with([]) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.load_history") - def test_get_profile_json(self, mock_load, client, sample_history_entry): - """Test getting profile JSON for download.""" - mock_load.return_value = [sample_history_entry] - - response = client.get(f"/api/history/{sample_history_entry['id']}/json") - - assert response.status_code == 200 - assert response.json()["name"] == "Ethiopian Sunrise" - assert "content-disposition" in response.headers - assert "ethiopian-sunrise.json" in response.headers["content-disposition"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.load_history") - def test_get_profile_json_not_available( - self, mock_load, client, sample_history_entry - ): - """Test 404 when profile JSON is not available.""" - entry = sample_history_entry.copy() - entry["profile_json"] = None - mock_load.return_value = [entry] - - response = client.get(f"/api/history/{sample_history_entry['id']}/json") - - assert response.status_code == 404 - assert "not available" in response.json()["detail"].lower() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.load_history") - def test_get_profile_json_entry_not_found(self, mock_load, client): - """Test 404 when entry doesn't exist for JSON download.""" - mock_load.return_value = [] - - response = client.get("/api/history/non-existent-id/json") - - assert response.status_code == 404 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.save_history") - @patch("api.routes.history.load_history") - def test_migrate_history_cleans_markdown_artifacts( - self, mock_load, mock_save, client - ): - """Test that migration successfully cleans profile names with markdown artifacts.""" - # Create entries with various markdown artifacts - history_with_artifacts = [ - { - "id": "entry-1", - "profile_name": "**Bold Profile**", - "created_at": "2026-01-01T10:00:00+00:00", - }, - { - "id": "entry-2", - "profile_name": "*Italic Profile*", - "created_at": "2026-01-01T10:00:00+00:00", - }, - { - "id": "entry-3", - "profile_name": "**Bold Only**", - "created_at": "2026-01-01T10:00:00+00:00", - }, - { - "id": "entry-4", - "profile_name": "** Spaces After Marker**", # Tests handling spaces after opening marker - "created_at": "2026-01-01T10:00:00+00:00", - }, - { - "id": "entry-5", - "profile_name": "Clean Profile", # No artifacts - "created_at": "2026-01-01T10:00:00+00:00", - }, - ] - mock_load.return_value = history_with_artifacts - - response = client.post("/api/history/migrate") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["fixed_count"] == 4 # 4 entries had artifacts - - # Verify save was called with cleaned names - mock_save.assert_called_once() - saved_history = mock_save.call_args[0][0] - assert saved_history[0]["profile_name"] == "Bold Profile" - assert saved_history[1]["profile_name"] == "Italic Profile" - assert saved_history[2]["profile_name"] == "Bold Only" - assert saved_history[3]["profile_name"] == "Spaces After Marker" - assert saved_history[4]["profile_name"] == "Clean Profile" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.save_history") - @patch("api.routes.history.load_history") - def test_migrate_history_returns_correct_count(self, mock_load, mock_save, client): - """Test that migration returns correct count of fixed entries.""" - history_with_some_artifacts = [ - { - "id": "1", - "profile_name": "**Fixed 1**", - "created_at": "2026-01-01T10:00:00+00:00", - }, - { - "id": "2", - "profile_name": "Clean", - "created_at": "2026-01-01T10:00:00+00:00", - }, - { - "id": "3", - "profile_name": "**Fixed 2**", - "created_at": "2026-01-01T10:00:00+00:00", - }, - { - "id": "4", - "profile_name": "Also Clean", - "created_at": "2026-01-01T10:00:00+00:00", - }, - ] - mock_load.return_value = history_with_some_artifacts - - response = client.post("/api/history/migrate") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["fixed_count"] == 2 - assert "Fixed 2 profile names" in data["message"] - - # Verify save was called since there were fixes - mock_save.assert_called_once() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.save_history") - @patch("api.routes.history.load_history") - def test_migrate_history_handles_empty_history(self, mock_load, mock_save, client): - """Test that migration handles empty history gracefully.""" - mock_load.return_value = [] - - response = client.post("/api/history/migrate") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["fixed_count"] == 0 - assert "Fixed 0 profile names" in data["message"] - - # Verify save was NOT called since there were no fixes - mock_save.assert_not_called() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.save_history") - @patch("api.routes.history.load_history") - def test_migrate_history_handles_missing_profile_name( - self, mock_load, mock_save, client - ): - """Test that migration handles entries without profile_name field.""" - history_with_missing_field = [ - { - "id": "entry-1", - "profile_name": "**Has Name**", - "created_at": "2026-01-01T10:00:00+00:00", - }, - { - "id": "entry-2", - # Missing profile_name field - "created_at": "2026-01-01T10:00:00+00:00", - }, - { - "id": "entry-3", - "profile_name": "**Another Name**", - "created_at": "2026-01-01T10:00:00+00:00", - }, - ] - mock_load.return_value = history_with_missing_field - - response = client.post("/api/history/migrate") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - # Should fix the 2 entries with profile_name, ignore the one without - assert data["fixed_count"] == 2 - - mock_save.assert_called_once() - saved_history = mock_save.call_args[0][0] - assert saved_history[0]["profile_name"] == "Has Name" - # Entry without profile_name field should remain without it (not modified) - assert "profile_name" not in saved_history[1] - assert saved_history[2]["profile_name"] == "Another Name" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.load_history") - def test_migrate_history_handles_errors_gracefully(self, mock_load, client): - """Test that migration handles errors gracefully.""" - # Simulate an error in _load_history - mock_load.side_effect = Exception("Database connection failed") - - response = client.post("/api/history/migrate") - - assert response.status_code == 500 - data = response.json() - assert data["detail"]["status"] == "error" - assert "Failed to migrate history" in data["detail"]["message"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.save_history") - @patch("api.routes.history.load_history") - def test_migrate_history_no_changes_needed(self, mock_load, mock_save, client): - """Test migration when all profile names are already clean.""" - clean_history = [ - { - "id": "1", - "profile_name": "Clean Profile 1", - "created_at": "2026-01-01T10:00:00+00:00", - }, - { - "id": "2", - "profile_name": "Clean Profile 2", - "created_at": "2026-01-01T10:00:00+00:00", - }, - { - "id": "3", - "profile_name": "Clean Profile 3", - "created_at": "2026-01-01T10:00:00+00:00", - }, - ] - mock_load.return_value = clean_history - - response = client.post("/api/history/migrate") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["fixed_count"] == 0 - - # Verify save was NOT called since no changes were made - mock_save.assert_not_called() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.history.save_history") - @patch("api.routes.history.load_history") - def test_migrate_history_save_error(self, mock_load, mock_save, client): - """Test migration handles save errors gracefully.""" - history_with_artifacts = [ - { - "id": "1", - "profile_name": "**Profile**", - "created_at": "2026-01-01T10:00:00+00:00", - } - ] - mock_load.return_value = history_with_artifacts - mock_save.side_effect = Exception("Disk full") - - response = client.post("/api/history/migrate") - - assert response.status_code == 500 - data = response.json() - assert data["detail"]["status"] == "error" - - -class TestHistoryHelperFunctions: - """Tests for history helper functions.""" - - def test_extract_profile_json_from_code_block(self): - """Test extracting profile JSON from markdown code block.""" - from services.history_service import _extract_profile_json - - reply = """Profile Created: Test Profile - -Description: A test profile - -```json -{"name": "Test Profile", "stages": [{"name": "stage1"}]} -``` -""" - result = _extract_profile_json(reply) - - assert result is not None - assert result["name"] == "Test Profile" - assert len(result["stages"]) == 1 - - def test_extract_profile_json_no_json(self): - """Test extracting when no JSON is present.""" - from services.history_service import _extract_profile_json - - reply = "Profile Created: Test Profile\n\nNo JSON here" - result = _extract_profile_json(reply) - - assert result is None - - def test_extract_profile_json_invalid_json(self): - """Test extracting when JSON is invalid.""" - from services.history_service import _extract_profile_json - - reply = """Profile Created: Test Profile - -```json -{not valid json} -``` -""" - result = _extract_profile_json(reply) - - assert result is None - - def test_extract_profile_name(self): - """Test extracting profile name from reply.""" - from services.history_service import _extract_profile_name - - reply = "Profile Created: Ethiopian Sunrise\n\nDescription: ..." - result = _extract_profile_name(reply) - - assert result == "Ethiopian Sunrise" - - def test_extract_profile_name_with_bold_format(self): - """Test extracting profile name when label uses **bold** format.""" - from services.history_service import _extract_profile_name - - reply = "**Profile Created:** Berry Blast Bloom\n\nDescription: ..." - result = _extract_profile_name(reply) - - assert result == "Berry Blast Bloom" - - def test_extract_profile_name_cleans_leading_asterisks(self): - """Test that leading ** are cleaned from profile name.""" - from services.history_service import _extract_profile_name - - # This simulates the case where regex captures ** as part of the name - reply = "**Profile Created:** ** Berry Blast Bloom\n\nDescription: ..." - result = _extract_profile_name(reply) - - assert result == "Berry Blast Bloom" - - def test_clean_profile_name(self): - """Test cleaning markdown from profile names.""" - from utils.sanitization import clean_profile_name - - assert clean_profile_name("** Berry Blast Bloom") == "Berry Blast Bloom" - assert clean_profile_name("Berry Blast Bloom **") == "Berry Blast Bloom" - assert clean_profile_name("**Berry** Bloom") == "Berry Bloom" - assert clean_profile_name("* test *") == "test" - assert clean_profile_name("Normal Name") == "Normal Name" - - def test_extract_profile_name_not_found(self): - """Test default name when pattern not found.""" - from services.history_service import _extract_profile_name - - reply = "Some reply without profile name" - result = _extract_profile_name(reply) - - assert result == "Untitled Profile" - - def test_extract_profile_name_case_insensitive(self): - """Test that extraction is case-insensitive.""" - from services.history_service import _extract_profile_name - - reply = "profile created: lowercase Name\n\nDescription: ..." - result = _extract_profile_name(reply) - - assert result == "lowercase Name" - - @patch("services.history_service.save_history") - @patch("services.history_service.load_history") - def test_save_to_history(self, mock_load, mock_save): - """Test saving a profile to history.""" - from services.history_service import save_to_history - - mock_load.return_value = [] - - reply = """Profile Created: Test Profile - -Description: A test profile - -```json -{"name": "Test Profile", "stages": []} -``` -""" - entry = save_to_history( - coffee_analysis="Test analysis", user_prefs="Light Body", reply=reply - ) - - assert entry["profile_name"] == "Test Profile" - assert entry["coffee_analysis"] == "Test analysis" - assert entry["user_preferences"] == "Light Body" - assert entry["profile_json"] is not None - assert "id" in entry - assert "created_at" in entry - - # Verify save was called with the new entry - mock_save.assert_called_once() - saved_history = mock_save.call_args[0][0] - assert len(saved_history) == 1 - assert saved_history[0]["id"] == entry["id"] - - @patch("services.history_service.save_history") - @patch("services.history_service.load_history") - def test_save_to_history_limits_entries(self, mock_load, mock_save): - """Test that history is limited to 100 entries.""" - from services.history_service import save_to_history - - # Create 100 existing entries - existing_entries = [{"id": f"entry-{i}"} for i in range(100)] - mock_load.return_value = existing_entries - - save_to_history( - coffee_analysis=None, - user_prefs="Test", - reply="Profile Created: New Profile", - ) - - # Verify save was called - mock_save.assert_called_once() - saved_history = mock_save.call_args[0][0] - - # Should still be 100 entries (oldest removed) - assert len(saved_history) == 100 - # New entry should be first - assert saved_history[0]["profile_name"] == "New Profile" - - @patch("services.history_service.save_history") - @patch("services.history_service.load_history") - def test_save_to_history_new_entry_first(self, mock_load, mock_save): - """Test that new entries are added at the beginning.""" - from services.history_service import save_to_history - - existing = [{"id": "old-entry", "profile_name": "Old Profile"}] - mock_load.return_value = existing - - save_to_history( - coffee_analysis=None, user_prefs=None, reply="Profile Created: New Profile" - ) - - mock_save.assert_called_once() - saved_history = mock_save.call_args[0][0] - - assert len(saved_history) == 2 - assert saved_history[0]["profile_name"] == "New Profile" - assert saved_history[1]["id"] == "old-entry" - - -class TestSecurityFeatures: - """Tests for security features added to prevent vulnerabilities.""" - - # Test constants - TEST_SIZE_EXCESS = 1000 # Bytes to exceed limits in tests - - def testsanitize_profile_name_for_filename(self): - """Test that profile names are properly sanitized for filenames.""" - from utils.sanitization import sanitize_profile_name_for_filename - - # Test path traversal attempts - assert ".." not in sanitize_profile_name_for_filename("../../etc/passwd") - assert "/" not in sanitize_profile_name_for_filename("path/to/file") - assert "\\" not in sanitize_profile_name_for_filename("path\\to\\file") - - # Test special characters are removed/replaced - result = sanitize_profile_name_for_filename("Profile: Test & Name!") - assert ":" not in result - assert "&" not in result - assert "!" not in result - - # Test spaces are replaced with underscores - assert sanitize_profile_name_for_filename("My Profile") == "my_profile" - - # Test length limiting - long_name = "a" * 300 - result = sanitize_profile_name_for_filename(long_name) - assert len(result) <= 200 - - def test_get_cached_image_prevents_path_traversal(self): - """Test that _get_cached_image prevents path traversal attacks.""" - from pathlib import Path - import tempfile - import shutil - - # Create a temporary test cache directory - temp_dir = tempfile.mkdtemp() - cache_dir = Path(temp_dir) / "test_cache" - cache_dir.mkdir(parents=True, exist_ok=True) - - # Patch IMAGE_CACHE_DIR to use our temp directory - with patch("services.cache_service.IMAGE_CACHE_DIR", cache_dir): - # Attempt path traversal - should return None safely - result = _get_cached_image("../../etc/passwd") - assert result is None - - # Also test other path traversal attempts - result = _get_cached_image("../../../root") - assert result is None - - # Clean up - shutil.rmtree(temp_dir, ignore_errors=True) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.meticulous_service.get_meticulous_api") - def test_upload_profile_image_validates_size(self, mock_api, client): - """Test that image upload validates file size.""" - from config import MAX_UPLOAD_SIZE - - # Create a large image (simulate exceeding limit) - large_image = BytesIO(b"x" * (MAX_UPLOAD_SIZE + self.TEST_SIZE_EXCESS)) - - response = client.post( - "/api/profile/test-profile/image", - files={"file": ("test.png", large_image, "image/png")}, - ) - - assert response.status_code == 413 # Payload Too Large - assert "too large" in response.json()["detail"].lower() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.meticulous_service.get_meticulous_api") - def test_upload_profile_image_validates_content_type( - self, mock_api, client, sample_image - ): - """Test that image upload validates content type.""" - response = client.post( - "/api/profile/test-profile/image", - files={"file": ("test.txt", sample_image, "text/plain")}, - ) - - assert response.status_code == 400 - assert "must be an image" in response.json()["detail"].lower() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.meticulous_service.get_meticulous_api") - def test_apply_profile_image_validates_base64_size(self, mock_api, client): - """Test that apply-image endpoint validates decoded size.""" - import base64 - from config import MAX_UPLOAD_SIZE - - # Create oversized base64 data - large_data = b"x" * (MAX_UPLOAD_SIZE + self.TEST_SIZE_EXCESS) - b64_data = base64.b64encode(large_data).decode("utf-8") - data_uri = f"data:image/png;base64,{b64_data}" - - response = client.post( - "/api/profile/test-profile/apply-image", json={"image_data": data_uri} - ) - - assert response.status_code == 413 # Payload Too Large - assert "too large" in response.json()["detail"].lower() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_apply_profile_image_validates_format(self, client): - """Test that apply-image endpoint validates image format.""" - # Test with invalid data URI format - response = client.post( - "/api/profile/test-profile/apply-image", - json={"image_data": "not-a-data-uri"}, - ) - - assert response.status_code == 400 - assert "invalid" in response.json()["detail"].lower() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_apply_profile_image_validates_png_format(self, client): - """Test that apply-image validates it's actually PNG data.""" - import base64 - - # Create invalid PNG data (just random bytes) - fake_data = b"not-a-valid-png-image" - b64_data = base64.b64encode(fake_data).decode("utf-8") - data_uri = f"data:image/png;base64,{b64_data}" - - response = client.post( - "/api/profile/test-profile/apply-image", json={"image_data": data_uri} - ) - - assert response.status_code == 400 - assert "invalid image" in response.json()["detail"].lower() - - -class TestHelperFunctions: - """Tests for helper functions that process data.""" - - def test_safe_float_with_valid_input(self): - """Test _safe_float with valid float.""" - from services.analysis_service import _safe_float - - assert _safe_float(3.14) == 3.14 - assert _safe_float("5.5") == 5.5 - assert _safe_float(10) == 10.0 - - def test_safe_float_with_invalid_input(self): - """Test _safe_float with invalid input uses default.""" - from services.analysis_service import _safe_float - - assert _safe_float("invalid", default=0.0) == 0.0 - assert _safe_float(None, default=1.5) == 1.5 - assert _safe_float({}, default=2.0) == 2.0 - - def testsanitize_profile_name_for_filename_basic(self): - """Test basic filename sanitization.""" - from utils.sanitization import sanitize_profile_name_for_filename - - # Normal name - assert sanitize_profile_name_for_filename("My Profile") == "my_profile" - - # With special characters - result = sanitize_profile_name_for_filename("Profile: Test!") - assert ":" not in result - assert "!" not in result - - def test_sanitize_profile_name_path_traversal(self): - """Test path traversal prevention in filename sanitization.""" - from utils.sanitization import sanitize_profile_name_for_filename - - # Path traversal attempts - assert ".." not in sanitize_profile_name_for_filename("../../../etc/passwd") - assert "/" not in sanitize_profile_name_for_filename("path/to/file") - assert "\\" not in sanitize_profile_name_for_filename("path\\to\\file") - - def test_extract_profile_name_from_reply(self): - """Test extraction of profile name from LLM reply.""" - from services.history_service import _extract_profile_name - - # Standard format - reply = "Profile Created: My Amazing Profile\nSome description..." - assert _extract_profile_name(reply) == "My Amazing Profile" - - # With extra whitespace - reply2 = "Profile Created: Spaced Name \nMore text" - assert _extract_profile_name(reply2).strip() == "Spaced Name" - - def test_extract_profile_name_not_found(self): - """Test profile name extraction when not found.""" - from services.history_service import _extract_profile_name - - reply = "This doesn't contain the profile marker" - assert _extract_profile_name(reply) == "Untitled Profile" - - -class TestCacheManagement: - """Tests for caching helper functions.""" - - @patch("services.cache_service.IMAGE_CACHE_DIR", Path("/tmp/test_image_cache")) - def test_ensure_image_cache_dir(self): - """Test that cache directory is created.""" - from services.cache_service import _ensure_image_cache_dir - import shutil - - # Clean up if exists - if Path("/tmp/test_image_cache").exists(): - shutil.rmtree("/tmp/test_image_cache") - - _ensure_image_cache_dir() - - assert Path("/tmp/test_image_cache").exists() - - # Clean up - shutil.rmtree("/tmp/test_image_cache", ignore_errors=True) - - @patch("services.cache_service.SHOT_CACHE_FILE", Path("/tmp/test_shot_cache.json")) - @patch("services.cache_service._load_shot_cache") - @patch("services.cache_service._save_shot_cache") - def test_set_cached_shots(self, mock_save, mock_load): - """Test setting cached shot data.""" - from services.cache_service import _set_cached_shots - - mock_load.return_value = {} - - test_data = {"shot1": {"time": 30}} - _set_cached_shots("test-profile", test_data, limit=50) - - # Verify save was called - mock_save.assert_called_once() - saved_cache = mock_save.call_args[0][0] - assert "test-profile" in saved_cache - - @patch("services.cache_service.SHOT_CACHE_FILE", Path("/tmp/test_shot_cache.json")) - @patch("services.cache_service._load_shot_cache") - def test_get_cached_shots_hit(self, mock_load): - """Test getting cached shots when cache exists.""" - from services.cache_service import _get_cached_shots - import time - - # Mock recent cache - mock_load.return_value = { - "test-profile": { - "data": {"shot1": {"time": 30}}, - "cached_at": time.time() - 100, # 100 seconds ago - "limit": 50, - } - } - - data, is_stale, age = _get_cached_shots("test-profile", limit=50) - - assert data is not None - assert data is not None - assert "shot1" in data - assert not is_stale - - @patch("services.cache_service.SHOT_CACHE_FILE", Path("/tmp/test_shot_cache.json")) - @patch("services.cache_service._load_shot_cache") - def test_get_cached_shots_miss(self, mock_load): - """Test getting cached shots when cache doesn't exist.""" - from services.cache_service import _get_cached_shots - - mock_load.return_value = {} - - data, is_stale, age = _get_cached_shots("nonexistent-profile", limit=50) - - assert data is None - assert age is None - - -class TestShotAnalysisHelpers: - """Tests for shot analysis helper functions.""" - - def test_format_dynamics_description_basic(self): - """Test formatting of stage dynamics description.""" - from services.analysis_service import _format_dynamics_description - - stage = { - "type": "pressure", - "dynamics_points": [[0, 2.0], [10, 2.5]], - "dynamics_over": "time", - } - - desc = _format_dynamics_description(stage) - - assert isinstance(desc, str) - assert isinstance(desc, str) and len(desc) > 0 - - def test_dynamics_targets_handle_nested_format(self): - """Nested dynamics (dynamics.points) must resolve like flat dynamics_points. - - Regression: 'Slayer at Home' uses nested dynamics, so a flat-only reader - returned None and mis-detected the effective control mode (#423). - """ - from services.analysis_service import ( - _max_dynamics_target, - _mean_dynamics_target, - _format_dynamics_description, - ) - - variables = [{"key": "flow_MaxFlowRate", "type": "flow", "value": 10.8}] - stage = { - "type": "flow", - "dynamics": { - "points": [[0, "$flow_MaxFlowRate"], [30, "$flow_MaxFlowRate"]], - "over": "time", - }, - } - assert _max_dynamics_target(stage, variables) == 10.8 - assert _mean_dynamics_target(stage, variables) == 10.8 - assert "10.8" in _format_dynamics_description(stage, variables) - - def test_effective_mode_pressure_for_nested_aggressive_flow(self): - """A nested aggressive-flow stage with a pressure limit is pressure-governed.""" - from services.shot_facts import effective_control_mode - from services.analysis_service import ( - _max_dynamics_target, - _mean_dynamics_target, - _format_limits, - ) - - variables = [ - {"key": "flow_MaxFlowRate", "type": "flow", "value": 10.8}, - {"key": "pressure_Max Pressure", "type": "pressure", "value": 6}, - ] - profile_stage = { - "type": "flow", - "dynamics": {"points": [[0, "$flow_MaxFlowRate"], [30, "$flow_MaxFlowRate"]]}, - "limits": [{"type": "pressure", "value": "$pressure_Max Pressure"}], - } - stage = { - "type": "flow", - "stage_type": "flow", - "profile_max_target": _max_dynamics_target(profile_stage, variables), - "profile_target_value": _mean_dynamics_target(profile_stage, variables), - "limits": _format_limits(profile_stage["limits"], variables), - } - assert effective_control_mode(stage) == "pressure" - - def test_compute_stage_stats_basic(self): - """Test computing statistics for stage telemetry.""" - from services.analysis_service import _compute_stage_stats - - entries = [ - {"time": 0, "shot": {"pressure": 8.0, "flow": 2.0}}, - {"time": 1000, "shot": {"pressure": 9.0, "flow": 2.5}}, - {"time": 2000, "shot": {"pressure": 8.5, "flow": 2.2}}, - ] - - stats = _compute_stage_stats(entries) - - assert "avg_pressure" in stats - assert stats["avg_pressure"] > 8.0 - assert stats["avg_pressure"] < 9.0 - - def test_compute_stage_stats_ignores_early_flow(self): - """Flow stats exclude the first 3.5 s to avoid retraction rush false positives.""" - from services.analysis_service import _compute_stage_stats - - entries = [ - # Early entries (< 3.5s) — huge flow spike from retraction - {"time": 500, "shot": {"pressure": 1.0, "flow": 12.0}}, - {"time": 1500, "shot": {"pressure": 2.0, "flow": 8.0}}, - {"time": 3000, "shot": {"pressure": 3.0, "flow": 6.0}}, - # After 3.5s — normal flow - {"time": 4000, "shot": {"pressure": 5.0, "flow": 2.5}}, - {"time": 6000, "shot": {"pressure": 6.0, "flow": 3.0}}, - ] - - stats = _compute_stage_stats(entries) - - # max_flow should come from the filtered set (>= 3.5s), i.e. 3.0 - assert stats["max_flow"] == 3.0 - # avg_flow should only average the two filtered entries - assert abs(stats["avg_flow"] - 2.75) < 0.01 - # start_flow and end_flow still use all entries - assert stats["start_flow"] == 12.0 - assert stats["end_flow"] == 3.0 - - def test_compute_stage_stats_all_early_falls_back(self): - """If all entries are within the first 3.5 s, flow stats use all data.""" - from services.analysis_service import _compute_stage_stats - - entries = [ - {"time": 500, "shot": {"pressure": 1.0, "flow": 10.0}}, - {"time": 2000, "shot": {"pressure": 2.0, "flow": 5.0}}, - ] - - stats = _compute_stage_stats(entries) - # No filtered data — falls back to all flows - assert stats["max_flow"] == 10.0 - - def test_format_dynamics_description_flow_type(self): - """Test with flow type.""" - from services.analysis_service import _format_dynamics_description - - stage = { - "type": "flow", - "dynamics_points": [[0, 2.0], [10, 4.0]], - "dynamics_over": "time", - } - result = _format_dynamics_description(stage) - assert "ml/s" in result - - def test_safe_float_with_string(self): - """Test _safe_float with string input.""" - from services.analysis_service import _safe_float - - assert _safe_float("42.5") == 42.5 - assert _safe_float("invalid") == 0.0 - assert _safe_float("", 5.0) == 5.0 - assert _safe_float(None, 10.0) == 10.0 - - def test_resolve_variable_basic(self): - """Test variable resolution.""" - from services.analysis_service import _resolve_variable - - variables = [ - {"key": "dose", "name": "Dose", "value": 18.0}, - {"key": "ratio", "name": "Ratio", "value": 2.0}, - ] - - value, name = _resolve_variable("$dose", variables) - assert value == 18.0 - assert name == "Dose" - - def test_resolve_variable_not_found(self): - """Test variable resolution when variable not found.""" - from services.analysis_service import _resolve_variable - - variables = [] - value, name = _resolve_variable("$unknown", variables) - assert value == "$unknown" - assert name == "unknown" - - def test_resolve_variable_not_variable(self): - """Test resolution of non-variable value.""" - from services.analysis_service import _resolve_variable - - value, name = _resolve_variable(42.0, []) - assert value == 42.0 - assert name is None - - def test_format_exit_triggers_basic(self): - """Test exit trigger formatting.""" - from services.analysis_service import _format_exit_triggers - - triggers = [ - {"type": "time", "value": 25, "comparison": ">="}, - {"type": "weight", "value": 36, "comparison": ">="}, - ] - - result = _format_exit_triggers(triggers) - assert len(result) == 2 - assert result[0]["type"] == "time" - assert "25" in result[0]["description"] # Contains 25 - assert "s" in result[0]["description"] # Has seconds unit - - def test_format_exit_triggers_new_types(self): - """Test exit trigger formatting for flow_dose_correlation and pressure_rise.""" - from services.analysis_service import _format_exit_triggers - - triggers = [ - {"type": "flow_dose_correlation", "value": 2.0, "comparison": ">="}, - {"type": "pressure_rise", "value": 2.0, "comparison": ">="}, - ] - - result = _format_exit_triggers(triggers) - assert len(result) == 2 - assert result[0]["type"] == "flow_dose_correlation" - assert "×dose" in result[0]["description"] - assert result[1]["type"] == "pressure_rise" - assert "bar" in result[1]["description"] - - def test_profiling_knowledge_contains_native_preinfusion_rules(self): - """PROFILING_KNOWLEDGE expresses #420 pre-infusion intent via native triggers.""" - from services.gemini_service import PROFILING_KNOWLEDGE - - # #420 saturation rules must steer toward NATIVE machine triggers - # (weight ~2x dose + pressure rise), not app-only pseudo triggers. - assert "Saturation Rules (#420)" in PROFILING_KNOWLEDGE - assert "2 × dose" in PROFILING_KNOWLEDGE - assert "native" in PROFILING_KNOWLEDGE.lower() - # The old app-monitored pseudo-trigger guidance must be gone. - assert "flow_dose_correlation" not in PROFILING_KNOWLEDGE - assert "app-monitored" not in PROFILING_KNOWLEDGE.lower() - - def test_format_limits_basic(self): - """Test limits formatting.""" - from services.analysis_service import _format_limits - - limits = [ - {"type": "pressure", "value": 10, "comparison": "<="}, - {"type": "flow", "value": 4, "comparison": "<="}, - ] - - result = _format_limits(limits) - assert len(result) == 2 - assert "10" in result[0]["description"] # Contains 10 - assert "bar" in result[0]["description"] # Has bar unit - - def test_extract_shot_stage_data(self): - """Test extracting stage data from shot.""" - from services.analysis_service import _extract_shot_stage_data - - shot_data = { - "data": [ - { - "time": 0, - "shot": {"weight": 0, "pressure": 2.0, "flow": 0.5}, - "status": "during", - "stage_name": "Bloom", - }, - { - "time": 5000, - "shot": {"weight": 2.0, "pressure": 2.0, "flow": 0.5}, - "status": "during", - "stage_name": "Bloom", - }, - { - "time": 10000, - "shot": {"weight": 10.0, "pressure": 9.0, "flow": 2.5}, - "status": "during", - "stage_name": "Main", - }, - { - "time": 25000, - "shot": {"weight": 36.0, "pressure": 9.0, "flow": 2.5}, - "status": "during", - "stage_name": "Main", - }, - ] - } - - result = _extract_shot_stage_data(shot_data) - # Function returns dict of stages - assert isinstance(result, dict) - - def test_prepare_profile_for_llm(self): - """Test preparing shot summary data for LLM.""" - from services.analysis_service import _prepare_shot_summary_for_llm - - shot_data = { - "time": 1705320000, - "data": [ - {"time": 0, "shot": {"weight": 0, "pressure": 0, "flow": 0}}, - {"time": 25000, "shot": {"weight": 36.0, "pressure": 9.0, "flow": 2.5}}, - ], - } - profile_data = { - "name": "Test", - "temperature": 93.0, - "final_weight": 36.0, - "variables": [{"key": "dose", "value": 18.0}], - "stages": [{"name": "Main", "type": "pressure"}], - } - local_analysis = { - "overall_metrics": {"total_time": 25.0}, - "weight_analysis": {"final_weight": 36.0}, - "preinfusion_summary": {}, - "stage_analyses": [], - } - - result = _prepare_shot_summary_for_llm(shot_data, profile_data, local_analysis) - assert "shot_summary" in result - assert "stages" in result - assert "graph_samples" in result - - def test_compute_stage_stats_includes_start_end_pressure(self): - """Test that stage stats include start/end pressure and flow values.""" - from services.analysis_service import _compute_stage_stats - - entries = [ - {"time": 0, "shot": {"pressure": 2.0, "flow": 0.5, "weight": 0}}, - {"time": 1000, "shot": {"pressure": 5.0, "flow": 1.0, "weight": 5}}, - {"time": 2000, "shot": {"pressure": 8.0, "flow": 2.0, "weight": 10}}, - {"time": 3000, "shot": {"pressure": 6.0, "flow": 1.5, "weight": 15}}, - ] - - stats = _compute_stage_stats(entries) - - # New fields should be present - assert "start_pressure" in stats - assert "end_pressure" in stats - assert "start_flow" in stats - assert "end_flow" in stats - - # Values should match first and last entries - assert stats["start_pressure"] == 2.0 - assert stats["end_pressure"] == 6.0 - assert stats["start_flow"] == 0.5 - assert stats["end_flow"] == 1.5 - - def test_determine_exit_trigger_hit_with_less_than_comparison(self): - """Test that <= comparisons use min/end values instead of max.""" - from services.analysis_service import _determine_exit_trigger_hit - - # Stage data where pressure declined from 9 to 3 bar - stage_data = { - "duration": 15.0, - "end_weight": 36.0, - "max_pressure": 9.0, - "min_pressure": 3.0, - "end_pressure": 3.0, - "max_flow": 2.5, - "min_flow": 1.0, - "end_flow": 1.0, - } - - # Exit trigger: pressure <= 4 bar (should trigger because end_pressure is 3) - exit_triggers = [{"type": "pressure", "value": 4.0, "comparison": "<="}] - - result = _determine_exit_trigger_hit(stage_data, exit_triggers) - - # Should trigger because end_pressure (3.0) <= 4.0 - assert result["triggered"] is not None - assert result["triggered"]["type"] == "pressure" - # The actual value should be end_pressure, not max_pressure - assert result["triggered"]["actual"] == 3.0 - - def test_determine_exit_trigger_hit_with_greater_than_comparison(self): - """Test that >= comparisons still use max values.""" - from services.analysis_service import _determine_exit_trigger_hit - - stage_data = { - "duration": 5.0, - "end_weight": 10.0, - "max_pressure": 9.0, - "min_pressure": 2.0, - "end_pressure": 8.0, - "max_flow": 3.0, - "min_flow": 0.5, - "end_flow": 2.5, - } - - # Exit trigger: pressure >= 8.5 bar (should trigger because max_pressure is 9) - exit_triggers = [{"type": "pressure", "value": 8.5, "comparison": ">="}] - - result = _determine_exit_trigger_hit(stage_data, exit_triggers) - - # Should trigger because max_pressure (9.0) >= 8.5 - assert result["triggered"] is not None - assert result["triggered"]["actual"] == 9.0 - - def test_determine_exit_trigger_hit_flow_less_than(self): - """Test flow exit trigger with <= comparison.""" - from services.analysis_service import _determine_exit_trigger_hit - - stage_data = { - "duration": 20.0, - "end_weight": 40.0, - "max_pressure": 6.0, - "min_pressure": 6.0, - "end_pressure": 6.0, - "max_flow": 4.0, - "min_flow": 1.5, - "end_flow": 1.5, - } - - # Exit trigger: flow <= 2.0 ml/s - exit_triggers = [{"type": "flow", "value": 2.0, "comparison": "<="}] - - result = _determine_exit_trigger_hit(stage_data, exit_triggers) - - # Should trigger because end_flow (1.5) <= 2.0 - assert result["triggered"] is not None - assert result["triggered"]["type"] == "flow" - assert result["triggered"]["actual"] == 1.5 - - def test_determine_exit_trigger_hit_zero_pressure(self): - """Test that zero pressure is treated as a legitimate value, not missing data.""" - from services.analysis_service import _determine_exit_trigger_hit - - # Stage data where pressure dropped to zero (e.g., pressure release phase) - stage_data = { - "duration": 10.0, - "end_weight": 30.0, - "max_pressure": 5.0, - "min_pressure": 0.0, - "end_pressure": 0.0, # Legitimate zero at end - "max_flow": 2.0, - "min_flow": 0.5, - "end_flow": 0.5, - } - - # Exit trigger: pressure <= 1.0 bar (should trigger because end_pressure is 0) - exit_triggers = [{"type": "pressure", "value": 1.0, "comparison": "<="}] - - result = _determine_exit_trigger_hit(stage_data, exit_triggers) - - # Should trigger because end_pressure (0.0) <= 1.0 - assert result["triggered"] is not None - assert result["triggered"]["type"] == "pressure" - # The actual value should be end_pressure (0.0), not min_pressure - assert result["triggered"]["actual"] == 0.0 - - def test_determine_exit_trigger_hit_zero_flow(self): - """Test that zero flow is treated as a legitimate value, not missing data.""" - from services.analysis_service import _determine_exit_trigger_hit - - # Stage data where flow dropped to zero - stage_data = { - "duration": 12.0, - "end_weight": 35.0, - "max_pressure": 6.0, - "min_pressure": 5.0, - "end_pressure": 5.5, - "max_flow": 3.0, - "min_flow": 0.0, - "end_flow": 0.0, # Legitimate zero at end - } - - # Exit trigger: flow <= 0.5 ml/s (should trigger because end_flow is 0) - exit_triggers = [{"type": "flow", "value": 0.5, "comparison": "<="}] - - result = _determine_exit_trigger_hit(stage_data, exit_triggers) - - # Should trigger because end_flow (0.0) <= 0.5 - assert result["triggered"] is not None - assert result["triggered"]["type"] == "flow" - # The actual value should be end_flow (0.0), not min_flow - assert result["triggered"]["actual"] == 0.0 - - def test_generate_execution_description_rising_pressure(self): - """Test execution description for rising pressure.""" - from services.analysis_service import _generate_execution_description - - desc = _generate_execution_description( - stage_type="pressure", - duration=5.0, - start_pressure=2.0, - end_pressure=9.0, - max_pressure=9.0, - start_flow=0.5, - end_flow=2.0, - max_flow=2.5, - weight_gain=8.0, - ) - - assert "rose" in desc.lower() or "increased" in desc.lower() - assert "2.0" in desc - assert "9.0" in desc - - def test_generate_execution_description_declining_pressure(self): - """Test execution description for declining pressure.""" - from services.analysis_service import _generate_execution_description - - desc = _generate_execution_description( - stage_type="pressure", - duration=10.0, - start_pressure=9.0, - end_pressure=6.0, - max_pressure=9.0, - start_flow=2.0, - end_flow=2.0, - max_flow=2.5, - weight_gain=15.0, - ) - - assert ( - "declined" in desc.lower() - or "decreased" in desc.lower() - or "dropped" in desc.lower() - ) - assert "9.0" in desc - assert "6.0" in desc - - def test_generate_execution_description_steady_pressure(self): - """Test execution description for steady pressure.""" - from services.analysis_service import _generate_execution_description - - desc = _generate_execution_description( - stage_type="pressure", - duration=15.0, - start_pressure=6.0, - end_pressure=6.1, # Very small change - max_pressure=6.2, - start_flow=2.0, - end_flow=2.0, - max_flow=2.1, - weight_gain=20.0, - ) - - # Should describe as "held" or "steady" since delta is < 0.5 - assert "held" in desc.lower() or "steady" in desc.lower() - - def test_generate_profile_target_curves_basic(self): - """Test generating profile target curves for chart overlay.""" - from services.analysis_service import _generate_profile_target_curves - - profile_data = { - "stages": [ - { - "name": "Bloom", - "type": "pressure", - "dynamics_points": [[0, 2.0]], # Constant 2 bar - "dynamics_over": "time", - }, - { - "name": "Main", - "type": "pressure", - "dynamics_points": [[0, 9.0]], # Constant 9 bar - "dynamics_over": "time", - }, - ], - "variables": [], - } - - shot_stage_times = {"Bloom": (0.0, 5.0), "Main": (5.0, 25.0)} - - shot_data = { - "data": [ - {"time": 0, "shot": {"weight": 0, "pressure": 2.0}, "status": "Bloom"}, - { - "time": 5000, - "shot": {"weight": 2.0, "pressure": 2.0}, - "status": "Bloom", - }, - { - "time": 6000, - "shot": {"weight": 5.0, "pressure": 9.0}, - "status": "Main", - }, - { - "time": 25000, - "shot": {"weight": 36.0, "pressure": 9.0}, - "status": "Main", - }, - ] - } - - curves = _generate_profile_target_curves( - profile_data, shot_stage_times, shot_data - ) - - assert isinstance(curves, list) - assert len(curves) > 0 - - # Should have target_pressure values - pressure_points = [c for c in curves if "target_pressure" in c] - assert len(pressure_points) > 0 - - def test_generate_profile_target_curves_ramp(self): - """Test generating target curves for a pressure ramp.""" - from services.analysis_service import _generate_profile_target_curves - - profile_data = { - "stages": [ - { - "name": "Ramp Up", - "type": "pressure", - "dynamics_points": [ - [0, 2.0], - [5, 9.0], - ], # Ramp from 2 to 9 bar over 5s - "dynamics_over": "time", - } - ], - "variables": [], - } - - shot_stage_times = {"Ramp Up": (0.0, 5.0)} - - shot_data = { - "data": [ - { - "time": 0, - "shot": {"weight": 0, "pressure": 2.0}, - "status": "Ramp Up", - }, - { - "time": 5000, - "shot": {"weight": 5.0, "pressure": 9.0}, - "status": "Ramp Up", - }, - ] - } - - curves = _generate_profile_target_curves( - profile_data, shot_stage_times, shot_data - ) - - # Should have at least 2 points (start and end) - pressure_points = [c for c in curves if "target_pressure" in c] - assert len(pressure_points) >= 2 - - # First should be 2 bar, last should be 9 bar - assert pressure_points[0]["target_pressure"] == 2.0 - assert pressure_points[-1]["target_pressure"] == 9.0 - - def test_short_ramp_not_compressed_to_instant(self): - """Regression: a short ramp inside a longer stage keeps its real duration. - - A 2s ramp from 3->9 bar in a stage that actually ran 30s must occupy its - true 2 seconds and then hold at 9 bar — it must NOT be stretched across - the whole stage, nor (when a trailing hold point exists) compressed until - the ramp looks instantaneous. See issue #483. - """ - from services.analysis_service import _generate_profile_target_curves - - profile_data = { - "stages": [ - { - "name": "Ramp", - "type": "pressure", - # Ramp 3->9 over 2s, then hold at 9 (trailing hold point at 30s). - "dynamics_points": [[0, 3.0], [2, 9.0], [30, 9.0]], - "dynamics_over": "time", - } - ], - "variables": [], - } - - # The stage actually ran 30s in the shot. - shot_stage_times = {"Ramp": (0.0, 30.0)} - shot_data = { - "data": [ - {"time": 0, "shot": {"weight": 0, "pressure": 3.0}, "status": "Ramp"}, - { - "time": 30000, - "shot": {"weight": 36.0, "pressure": 9.0}, - "status": "Ramp", - }, - ] - } - - curves = _generate_profile_target_curves( - profile_data, shot_stage_times, shot_data - ) - pressure_points = [c for c in curves if "target_pressure" in c] - - # The 3 -> 9 transition must complete at ~2s, not stretched to 30s and not - # collapsed to t=0 (instant). - nine_bar_times = [ - p["time"] for p in pressure_points if p["target_pressure"] == 9.0 - ] - assert nine_bar_times, "expected the curve to reach 9 bar" - first_nine = min(nine_bar_times) - assert first_nine == pytest.approx(2.0, abs=0.2), ( - f"ramp should reach 9 bar at ~2s, got {first_nine}s" - ) - # And the final target should still be held at 9 bar at stage end. - assert pressure_points[-1]["target_pressure"] == 9.0 - assert pressure_points[-1]["time"] == pytest.approx(30.0, abs=0.2) - - def test_short_ramp_estimated_curves_not_compressed(self): - """Regression (#483): estimated curves also preserve short-ramp duration. - - Without shot data, a 2s ramp in a stage whose estimated duration is longer - must still render the ramp over 2 seconds (then hold), instead of being - rescaled to fill the estimated duration. - """ - from services.analysis_service import generate_estimated_target_curves - - profile_data = { - "stages": [ - { - "name": "Ramp", - "type": "pressure", - "dynamics_points": [[0, 3.0], [2, 9.0]], - "dynamics_over": "time", - # No time trigger -> estimated duration falls back to 10s. - "exit_triggers": [{"type": "weight", "value": 36}], - } - ], - "variables": [], - } - - curves = generate_estimated_target_curves(profile_data) - pressure_points = [c for c in curves if "target_pressure" in c] - - nine_bar_times = [ - p["time"] for p in pressure_points if p["target_pressure"] == 9.0 - ] - assert nine_bar_times - assert min(nine_bar_times) == pytest.approx(2.0, abs=0.2) - # Final value held at 9 bar to the (estimated) stage end. - assert pressure_points[-1]["target_pressure"] == 9.0 - assert pressure_points[-1]["time"] > 2.0 - - def test_generate_profile_target_curves_flow_stage(self): - """Test generating target curves for flow-based stage.""" - from services.analysis_service import _generate_profile_target_curves - - profile_data = { - "stages": [ - { - "name": "Flow Stage", - "type": "flow", - "dynamics_points": [[0, 2.5]], - "dynamics_over": "time", - } - ], - "variables": [], - } - - shot_stage_times = {"Flow Stage": (0.0, 20.0)} - - shot_data = { - "data": [ - {"time": 0, "shot": {"weight": 0, "flow": 2.5}, "status": "Flow Stage"}, - { - "time": 20000, - "shot": {"weight": 36.0, "flow": 2.5}, - "status": "Flow Stage", - }, - ] - } - - curves = _generate_profile_target_curves( - profile_data, shot_stage_times, shot_data - ) - - # Should have target_flow values - flow_points = [c for c in curves if "target_flow" in c] - assert len(flow_points) > 0 - assert flow_points[0]["target_flow"] == 2.5 - - def test_generate_profile_target_curves_weight_based(self): - """Test generating target curves for weight-based dynamics.""" - from services.analysis_service import _generate_profile_target_curves - - profile_data = { - "stages": [ - { - "name": "Ramp", - "type": "flow", - "dynamics_points": [ - [0, 2.0], - [20, 3.0], - [40, 2.5], - ], # Flow changes by weight - "dynamics_over": "weight", - } - ], - "variables": [], - } - - shot_stage_times = {"Ramp": (0.0, 30.0)} - - # Shot data with weight progression - shot_data = { - "data": [ - {"time": 0, "shot": {"weight": 0, "flow": 2.0}, "status": "Ramp"}, - {"time": 10000, "shot": {"weight": 10, "flow": 2.3}, "status": "Ramp"}, - {"time": 20000, "shot": {"weight": 20, "flow": 2.8}, "status": "Ramp"}, - {"time": 25000, "shot": {"weight": 30, "flow": 2.7}, "status": "Ramp"}, - {"time": 30000, "shot": {"weight": 40, "flow": 2.5}, "status": "Ramp"}, - ] - } - - curves = _generate_profile_target_curves( - profile_data, shot_stage_times, shot_data - ) - - # Should have target_flow values - flow_points = [c for c in curves if "target_flow" in c] - assert len(flow_points) == 3 # Three dynamics points - - # Verify values are correct - assert flow_points[0]["target_flow"] == 2.0 # At 0g - assert flow_points[1]["target_flow"] == 3.0 # At 20g - assert flow_points[2]["target_flow"] == 2.5 # At 40g - - # Verify times are mapped correctly (roughly) - # 0g should be at time 0 - assert flow_points[0]["time"] == 0.0 - # 20g should be around 20s (from shot data) - assert abs(flow_points[1]["time"] - 20.0) < 1.0 - # 40g should be at 30s (from shot data) - assert abs(flow_points[2]["time"] - 30.0) < 1.0 - - def test_generate_profile_target_curves_nested_dynamics_format(self): - """Test generating target curves when dynamics is nested (dynamics.points format from embedded profile).""" - from services.analysis_service import _generate_profile_target_curves - - # Profile with nested dynamics format (as embedded in shot data from machine) - profile_data = { - "stages": [ - { - "name": "Ultra Slow Preinfusion", - "type": "flow", - "dynamics": { - "points": [[0, 1.3]], - "over": "time", - "interpolation": "linear", - }, - }, - { - "name": "Pressure Ramp Up", - "type": "pressure", - "dynamics": { - "points": [[0, 2.1], [2, 8.4], [40.9, 3.7]], - "over": "time", - "interpolation": "curve", - }, - }, - ], - "variables": [], - } - - shot_stage_times = { - "Ultra Slow Preinfusion": (0.0, 19.0), - "Pressure Ramp Up": (19.0, 45.0), - } - - shot_data = { - "data": [ - { - "time": 0, - "shot": {"weight": 0, "flow": 1.3}, - "status": "Ultra Slow Preinfusion", - }, - { - "time": 19000, - "shot": {"weight": 5.0, "flow": 1.3}, - "status": "Ultra Slow Preinfusion", - }, - { - "time": 20000, - "shot": {"weight": 6.0, "pressure": 2.5}, - "status": "Pressure Ramp Up", - }, - { - "time": 45000, - "shot": {"weight": 36.0, "pressure": 4.0}, - "status": "Pressure Ramp Up", - }, - ] - } - - curves = _generate_profile_target_curves( - profile_data, shot_stage_times, shot_data - ) - - assert isinstance(curves, list) - assert len(curves) > 0 - - # Should have both flow and pressure targets - flow_points = [c for c in curves if "target_flow" in c] - pressure_points = [c for c in curves if "target_pressure" in c] - - assert len(flow_points) >= 2 # At least start and end of flow stage - assert len(pressure_points) >= 3 # Three dynamics points in pressure stage - - # Verify flow targets - assert flow_points[0]["target_flow"] == 1.3 - - # Verify pressure targets (should have the ramp values) - pressure_values = [p["target_pressure"] for p in pressure_points] - assert 2.1 in pressure_values - assert 8.4 in pressure_values - # The dynamics curve's final point is at t=40.9s, but the stage only ran - # 26s (19s -> 45s) before exiting, so the curve is clipped at the stage - # boundary rather than rescaled to reach the final 3.7 value. The boundary - # value is the linear interpolation of 8.4 -> 3.7 at 26s into the stage. - assert 3.7 not in pressure_values - boundary_value = pressure_points[-1]["target_pressure"] - assert 3.7 < boundary_value < 8.4 - - def test_generate_profile_target_curves_handles_both_formats(self): - """Test that target curve generation handles both flat and nested dynamics formats.""" - from services.analysis_service import _generate_profile_target_curves - - # Mix of both formats in same profile - profile_data = { - "stages": [ - { - # Flat format (dynamics_points) - "name": "Stage1", - "type": "pressure", - "dynamics_points": [[0, 3.0]], - "dynamics_over": "time", - }, - { - # Nested format (dynamics.points) - "name": "Stage2", - "type": "flow", - "dynamics": {"points": [[0, 2.0], [5, 3.0]], "over": "time"}, - }, - ], - "variables": [], - } - - shot_stage_times = {"Stage1": (0.0, 10.0), "Stage2": (10.0, 20.0)} - - shot_data = { - "data": [ - {"time": 0, "shot": {"weight": 0}, "status": "Stage1"}, - {"time": 10000, "shot": {"weight": 5}, "status": "Stage1"}, - {"time": 11000, "shot": {"weight": 6}, "status": "Stage2"}, - {"time": 20000, "shot": {"weight": 15}, "status": "Stage2"}, - ] - } - - curves = _generate_profile_target_curves( - profile_data, shot_stage_times, shot_data - ) - - # Should have targets from both stages - pressure_points = [c for c in curves if "target_pressure" in c] - flow_points = [c for c in curves if "target_flow" in c] - - assert len(pressure_points) >= 1 # From Stage1 - assert len(flow_points) >= 2 # From Stage2 - assert pressure_points[0]["target_pressure"] == 3.0 - assert 2.0 in [p["target_flow"] for p in flow_points] - assert 3.0 in [p["target_flow"] for p in flow_points] - - def test_interpolate_weight_to_time_with_edge_cases(self): - """Test weight-to-time interpolation helper function including edge cases.""" - from services.analysis_service import _interpolate_weight_to_time - - # Create sample weight-time pairs (weight, time) - weight_time_pairs = [(0, 0.0), (10, 5.0), (20, 12.0), (40, 30.0)] - - # Test exact match points - assert _interpolate_weight_to_time(0, weight_time_pairs) == 0.0 - assert _interpolate_weight_to_time(10, weight_time_pairs) == 5.0 - assert _interpolate_weight_to_time(20, weight_time_pairs) == 12.0 - assert _interpolate_weight_to_time(40, weight_time_pairs) == 30.0 - - # Test interpolation between points - # Weight 5 is halfway between 0 and 10, so time should be halfway between 0 and 5 = 2.5 - result = _interpolate_weight_to_time(5, weight_time_pairs) - assert abs(result - 2.5) < 0.01 - - # Weight 15 is halfway between 10 and 20, so time should be halfway between 5 and 12 = 8.5 - result = _interpolate_weight_to_time(15, weight_time_pairs) - assert abs(result - 8.5) < 0.01 - - # Weight 30 is halfway between 20 and 40, so time should be halfway between 12 and 30 = 21 - result = _interpolate_weight_to_time(30, weight_time_pairs) - assert abs(result - 21.0) < 0.01 - - # Test edge case: weight before first point - result = _interpolate_weight_to_time(-5, weight_time_pairs) - assert result == 0.0 # Should use first time - - # Test edge case: weight after last point - result = _interpolate_weight_to_time(50, weight_time_pairs) - assert result == 30.0 # Should use last time - - # Test edge case: empty list - result = _interpolate_weight_to_time(10, []) - assert result is None - - def test_local_analysis_includes_profile_target_curves(self): - """Test that local analysis returns profile target curves.""" - from services.analysis_service import _perform_local_shot_analysis - - shot_data = { - "data": [ - { - "time": 0, - "shot": {"weight": 0, "pressure": 2.0, "flow": 0.5}, - "status": "Bloom", - }, - { - "time": 5000, - "shot": {"weight": 2.0, "pressure": 2.0, "flow": 0.5}, - "status": "Bloom", - }, - { - "time": 6000, - "shot": {"weight": 5.0, "pressure": 9.0, "flow": 2.5}, - "status": "Main", - }, - { - "time": 25000, - "shot": {"weight": 36.0, "pressure": 9.0, "flow": 2.5}, - "status": "Main", - }, - ] - } - - profile_data = { - "name": "Test Profile", - "final_weight": 36.0, - "stages": [ - { - "name": "Bloom", - "key": "bloom", - "type": "pressure", - "dynamics_points": [[0, 2.0]], - "dynamics_over": "time", - "exit_triggers": [{"type": "time", "value": 5, "comparison": ">="}], - }, - { - "name": "Main", - "key": "main", - "type": "pressure", - "dynamics_points": [[0, 9.0]], - "dynamics_over": "time", - "exit_triggers": [ - {"type": "weight", "value": 36, "comparison": ">="} - ], - }, - ], - "variables": [], - } - - result = _perform_local_shot_analysis(shot_data, profile_data) - - # Should include profile_target_curves - assert "profile_target_curves" in result - assert isinstance(result["profile_target_curves"], list) - - def test_stage_execution_data_includes_description(self): - """Test that stage execution data includes a description.""" - from services.analysis_service import _analyze_stage_execution - - profile_stage = { - "name": "Main Extraction", - "key": "main", - "type": "pressure", - "dynamics_points": [[0, 9.0]], - "dynamics_over": "time", - "exit_triggers": [{"type": "weight", "value": 36, "comparison": ">="}], - } - - shot_stage_data = { - "duration": 20.0, - "start_weight": 5.0, - "end_weight": 36.0, - "start_pressure": 2.0, - "end_pressure": 9.0, - "avg_pressure": 8.5, - "max_pressure": 9.0, - "min_pressure": 2.0, - "start_flow": 0.5, - "end_flow": 2.5, - "avg_flow": 2.0, - "max_flow": 2.5, - "min_flow": 0.5, - } - - result = _analyze_stage_execution(profile_stage, shot_stage_data, 25.0) - - # Execution data should include description - assert result["execution_data"] is not None - assert "description" in result["execution_data"] - assert isinstance(result["execution_data"]["description"], str) - assert len(result["execution_data"]["description"]) > 0 - - def test_flow_stage_assessment_uses_end_flow_not_max_flow(self): - """Test that flow stage assessment uses end_flow instead of max_flow. - - The initial peak flow from piston retraction should be ignored. - Assessment should use end_flow which reflects the stabilized value. - """ - from services.analysis_service import _analyze_stage_execution - - # Flow stage with target flow of 1.3 ml/s - profile_stage = { - "name": "Ultra Slow Preinfusion", - "key": "preinfusion", - "type": "flow", - "dynamics_points": [[0, 1.3]], # Target 1.3 ml/s - "dynamics_over": "time", - "exit_triggers": [{"type": "time", "value": 20, "comparison": ">="}], - } - - # Shot stage data where: - # - max_flow is 4.8 (initial peak from piston) - # - end_flow is 1.5 (stabilized flow, close to target) - shot_stage_data = { - "duration": 18.0, # Stage ended early (before 20s trigger) - "start_weight": 0.0, - "end_weight": 5.0, - "start_pressure": 0.0, - "end_pressure": 0.5, - "avg_pressure": 0.3, - "max_pressure": 0.6, - "min_pressure": 0.0, - "start_flow": 4.8, # Initial peak - "end_flow": 1.5, # Stabilized flow, close to target 1.3 - "avg_flow": 2.0, - "max_flow": 4.8, # Peak from initial rush - "min_flow": 0.5, - } - - result = _analyze_stage_execution(profile_stage, shot_stage_data, 40.0) - - # Should have an assessment - assert result["assessment"] is not None - - # The message should reference end_flow (1.5), NOT max_flow (4.8) - message = result["assessment"]["message"] - assert "1.5" in message or "1.5" in message.replace(" ", "") # end_flow value - assert "4.8" not in message # max_flow should NOT be used - - # Status should be 'incomplete' (ended early) but goal reached - # because end_flow 1.5 >= 1.3 * 0.95 (target within 5%) - assert result["assessment"]["status"] == "incomplete" - assert "reached" in message.lower() - - def test_pressure_stage_assessment_uses_max_pressure(self): - """Test that pressure stage assessment correctly uses max_pressure.""" - from services.analysis_service import _analyze_stage_execution - - # Pressure stage with target 9 bar - profile_stage = { - "name": "Main Extraction", - "key": "main", - "type": "pressure", - "dynamics_points": [[0, 9.0]], # Target 9 bar - "dynamics_over": "time", - "exit_triggers": [{"type": "time", "value": 30, "comparison": ">="}], - } - - # Shot stage where we hit target pressure - shot_stage_data = { - "duration": 25.0, # Stage ended early - "start_weight": 5.0, - "end_weight": 30.0, - "start_pressure": 2.0, - "end_pressure": 8.5, - "avg_pressure": 8.0, - "max_pressure": 9.2, # Hit target - "min_pressure": 2.0, - "start_flow": 0.5, - "end_flow": 2.5, - "avg_flow": 2.0, - "max_flow": 3.0, - "min_flow": 0.5, - } - - result = _analyze_stage_execution(profile_stage, shot_stage_data, 50.0) - - # Should have an assessment - assert result["assessment"] is not None - - # The message should reference max_pressure (9.2) - message = result["assessment"]["message"] - assert "9.2" in message # max_pressure value - - # Status should be 'incomplete' but goal reached - assert result["assessment"]["status"] == "incomplete" - assert "reached" in message.lower() - - -class TestEstimatedTargetCurves: - """Tests for generate_estimated_target_curves (live-view goal overlays).""" - - def test_single_point_pressure_stage(self): - """Single-point constant pressure stage generates start+end points.""" - from services.analysis_service import generate_estimated_target_curves - - profile = { - "stages": [ - { - "name": "Pre-Infusion", - "type": "pressure", - "dynamics_points": [[0, 3]], - "exit_triggers": [{"type": "time", "value": 10}], - } - ], - "variables": [], - } - curves = generate_estimated_target_curves(profile) - assert len(curves) == 2 - assert curves[0]["target_pressure"] == 3.0 - assert curves[0]["time"] == 0.0 - assert curves[1]["time"] == 10.0 - assert curves[1]["stage_name"] == "Pre-Infusion" - - def test_multi_stage_accumulates_time(self): - """Multiple stages stack up durations correctly.""" - from services.analysis_service import generate_estimated_target_curves - - profile = { - "stages": [ - { - "name": "S1", - "type": "pressure", - "dynamics_points": [[0, 2]], - "exit_triggers": [{"type": "time", "value": 5}], - }, - { - "name": "S2", - "type": "flow", - "dynamics_points": [[0, 4]], - "exit_triggers": [{"type": "time", "value": 8}], - }, - ], - "variables": [], - } - curves = generate_estimated_target_curves(profile) - # S1: 0-5s, S2: 5-13s - flow_pts = [c for c in curves if "target_flow" in c] - assert flow_pts[0]["time"] == 5.0 - assert flow_pts[1]["time"] == 13.0 - - def test_variable_resolution(self): - """Variables in dynamics_points are resolved.""" - from services.analysis_service import generate_estimated_target_curves - - profile = { - "stages": [ - { - "name": "Main", - "type": "pressure", - "dynamics_points": [[0, "$my_pressure"]], - "exit_triggers": [{"type": "time", "value": 10}], - } - ], - "variables": [{"key": "my_pressure", "value": 9}], - } - curves = generate_estimated_target_curves(profile) - assert curves[0]["target_pressure"] == 9.0 - - def test_multi_point_dynamics(self): - """Multi-point dynamics produces multiple curve points.""" - from services.analysis_service import generate_estimated_target_curves - - profile = { - "stages": [ - { - "name": "Ramp", - "type": "pressure", - "dynamics_points": [[0, 2], [5, 6], [10, 9]], - "exit_triggers": [{"type": "time", "value": 20}], - } - ], - "variables": [], - } - curves = generate_estimated_target_curves(profile) - # Dynamics define a ramp over 10s; the stage's time exit is 20s, so the - # ramp keeps its real 10s duration and then holds at 9 bar until 20s. - # It must NOT be rescaled to span the full 20s (issue #483). - assert len(curves) == 4 - assert curves[0]["time"] == 0.0 - assert curves[0]["target_pressure"] == 2.0 - assert curves[1]["time"] == 5.0 - assert curves[1]["target_pressure"] == 6.0 - assert curves[2]["time"] == 10.0 - assert curves[2]["target_pressure"] == 9.0 - # Final value held flat to the stage end. - assert curves[3]["time"] == 20.0 - assert curves[3]["target_pressure"] == 9.0 - - def test_no_time_trigger_uses_default(self): - """Stage without a time exit trigger uses the 10s default.""" - from services.analysis_service import generate_estimated_target_curves - - profile = { - "stages": [ - { - "name": "WtStage", - "type": "flow", - "dynamics_points": [[0, 3]], - "exit_triggers": [{"type": "weight", "value": 30}], - } - ], - "variables": [], - } - curves = generate_estimated_target_curves(profile) - assert curves[1]["time"] == 10.0 # default duration - - def test_empty_profile_returns_empty(self): - """Profile with no stages returns empty list.""" - from services.analysis_service import generate_estimated_target_curves - - assert generate_estimated_target_curves({"stages": []}) == [] - - -class TestBasicEndpoints: - """Tests for basic utility endpoints.""" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_parse_gemini_error(self): - """Test Gemini error parsing.""" - from services.gemini_service import parse_gemini_error - - error_text = """Some error occurred - Error details here - Quota exceeded message""" - - result = parse_gemini_error(error_text) - - assert isinstance(result, str) - assert len(result) > 0 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.history_service.ensure_history_file") - @patch("api.routes.system.Path") - def test_load_history_with_valid_file(self, mock_path, mock_ensure): - """Test loading history from valid file.""" - from services.history_service import load_history as _load_history - - # Mock file operations - mock_file = Mock() - mock_file.exists.return_value = True - mock_file.read_text.return_value = json.dumps( - [{"id": "123", "profile_name": "Test"}] - ) - mock_path.return_value = mock_file - - history = _load_history() - - assert isinstance(history, list) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.history_service.ensure_history_file") - @patch("builtins.open") - def test_load_history_with_missing_file(self, mock_open_func, mock_ensure): - """Test loading history when file doesn't exist.""" - from services.history_service import load_history as _load_history - - # Mock file not found - mock_open_func.side_effect = FileNotFoundError("File not found") - - history = _load_history() - - assert history == [] - - -class TestProcessImageForProfile: - """Tests for image processing function.""" - - def test_process_image_for_profile_valid_png(self): - """Test processing a valid PNG image.""" - from api.routes.profiles import process_image_for_profile - from PIL import Image - import io - - # Create a test image - img = Image.new("RGB", (1024, 768), color="red") - img_bytes = io.BytesIO() - img.save(img_bytes, format="PNG") - img_data = img_bytes.getvalue() - - data_uri, png_bytes = process_image_for_profile(img_data, "image/png") - - assert data_uri.startswith("data:image/png;base64,") - assert len(png_bytes) > 0 - - # Verify it's 512x512 - result_img = Image.open(io.BytesIO(png_bytes)) - assert result_img.size == (512, 512) - - def test_process_image_for_profile_jpeg(self): - """Test processing a JPEG image.""" - from api.routes.profiles import process_image_for_profile - from PIL import Image - import io - - # Create a test JPEG image - img = Image.new("RGB", (800, 600), color="blue") - img_bytes = io.BytesIO() - img.save(img_bytes, format="JPEG") - img_data = img_bytes.getvalue() - - data_uri, png_bytes = process_image_for_profile(img_data, "image/jpeg") - - assert data_uri.startswith("data:image/png;base64,") - - # Verify conversion and resize - result_img = Image.open(io.BytesIO(png_bytes)) - assert result_img.format == "PNG" - assert result_img.size == (512, 512) - - -class TestCheckUpdatesEndpoint: - """Tests for the /api/check-updates endpoint (GitHub Releases API).""" - - @pytest.fixture(autouse=True) - def clear_update_cache(self): - """Clear the update cache between tests.""" - import api.routes.system as sys_module - - sys_module._update_cache = None - sys_module._update_cache_time = None - yield - sys_module._update_cache = None - sys_module._update_cache_time = None - - @patch("api.routes.system._get_running_version", return_value="2.0.0") - @patch("httpx.AsyncClient") - def test_check_updates_success_with_updates( - self, mock_client_cls, mock_version, client - ): - """Test successful update check with updates available.""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = [ - { - "tag_name": "v2.1.0", - "html_url": "https://github.com/hessius/MeticAI/releases/tag/v2.1.0", - "prerelease": False, - } - ] - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.post("/api/check-updates") - - assert response.status_code == 200 - data = response.json() - assert data["update_available"] is True - assert data["current_version"] == "2.0.0" - assert data["latest_version"] == "2.1.0" - assert data["fresh_check"] is True - - @patch("api.routes.system._get_running_version", return_value="2.0.0") - @patch("httpx.AsyncClient") - def test_check_updates_no_updates(self, mock_client_cls, mock_version, client): - """Test update check when already on latest version.""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = [ - { - "tag_name": "v2.0.0", - "html_url": "https://github.com/hessius/MeticAI/releases/tag/v2.0.0", - "prerelease": False, - } - ] - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.post("/api/check-updates") - - assert response.status_code == 200 - data = response.json() - assert data["update_available"] is False - - @patch("api.routes.system._get_running_version", return_value="2.0.0") - @patch("httpx.AsyncClient") - def test_check_updates_github_unreachable( - self, mock_client_cls, mock_version, client - ): - """Test update check when GitHub API is unreachable.""" - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(side_effect=Exception("Connection refused")) - mock_client_cls.return_value = mock_client - - response = client.post("/api/check-updates") - - assert response.status_code == 200 - data = response.json() - assert data["update_available"] is False - assert data["current_version"] == "2.0.0" - - @patch("api.routes.system._get_running_version", return_value="2.0.0") - @patch("httpx.AsyncClient") - def test_check_updates_fresh_check(self, mock_client_cls, mock_version, client): - """Test that check-updates returns fresh_check=True.""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = [ - { - "tag_name": "v2.0.0", - "html_url": "https://github.com/hessius/MeticAI/releases/tag/v2.0.0", - "prerelease": False, - } - ] - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - - response = client.post("/api/check-updates") - - assert response.status_code == 200 - data = response.json() - assert data.get("fresh_check") is True - assert "last_check" in data - - -class TestMachineProfilesEndpoint: - """Tests for the /api/machine/profiles endpoint.""" - - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - @patch("api.routes.profiles.load_history") - def test_list_profiles_success( - self, mock_load_history, mock_list_profiles, mock_get_profile, client - ): - """Test successful profile listing from machine.""" - # Mock list_profiles result - return simple objects - mock_profile1 = type("Profile", (), {})() - mock_profile1.id = "profile-1" - mock_profile1.name = "Espresso Classic" - mock_profile1.error = None - - mock_profile2 = type("Profile", (), {})() - mock_profile2.id = "profile-2" - mock_profile2.name = "Light Roast" - mock_profile2.error = None - - mock_list_profiles.return_value = [mock_profile1, mock_profile2] - - # Mock get_profile results - return simple objects with all required attributes - full_profile1 = type("FullProfile", (), {})() - full_profile1.id = "profile-1" - full_profile1.name = "Espresso Classic" - full_profile1.author = "Barista Joe" - full_profile1.temperature = 93.0 - full_profile1.final_weight = 36.0 - full_profile1.error = None - - full_profile2 = type("FullProfile", (), {})() - full_profile2.id = "profile-2" - full_profile2.name = "Light Roast" - full_profile2.author = "Barista Jane" - full_profile2.temperature = 91.0 - full_profile2.final_weight = 40.0 - full_profile2.error = None - - mock_get_profile.side_effect = [full_profile1, full_profile2] - - # Mock history via load_history - mock_load_history.return_value = [ - {"profile_name": "Espresso Classic", "reply": "Great profile"} - ] - - response = client.get("/api/machine/profiles") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["total"] == 2 - assert len(data["profiles"]) == 2 - - # Check first profile has history - profile1 = next(p for p in data["profiles"] if p["name"] == "Espresso Classic") - assert profile1["in_history"] is True - assert profile1["has_description"] is True - assert "derived_tags" in profile1 - - # Check second profile has no history - profile2 = next(p for p in data["profiles"] if p["name"] == "Light Roast") - assert profile2["in_history"] is False - assert "derived_tags" in profile2 - - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_list_profiles_api_error(self, mock_list_profiles, client): - """Test error handling when machine API fails.""" - # Mock API error - mock_result = MagicMock() - mock_result.error = "Connection timeout" - mock_list_profiles.return_value = mock_result - - response = client.get("/api/machine/profiles") - - assert response.status_code == 502 - assert "Machine API error" in response.json()["detail"] - - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - @patch("api.routes.profiles.load_history", return_value=[]) - def test_list_profiles_empty(self, mock_load_history, mock_list_profiles, client): - """Test listing when no profiles exist.""" - mock_list_profiles.return_value = [] - - response = client.get("/api/machine/profiles") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["total"] == 0 - assert len(data["profiles"]) == 0 - - @patch("api.routes.profiles.invalidate_profile_list_cache") - @patch("api.routes.profiles.async_session_post", new_callable=AsyncMock) - def test_reorder_profiles_success( - self, mock_session_post, mock_invalidate, client - ): - """Reordering forwards the new ID list to the machine settings endpoint.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_session_post.return_value = mock_response - - order = ["profile-b", "profile-a", "profile-c"] - response = client.post("/api/machine/profiles/order", json={"order": order}) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["order"] == order - mock_session_post.assert_awaited_once_with( - "/api/v1/settings", {"profile_order": order} - ) - mock_invalidate.assert_called_once() - - @patch("api.routes.profiles.async_session_post", new_callable=AsyncMock) - def test_reorder_profiles_rejects_empty_list(self, mock_session_post, client): - """An empty order list is a client error and never reaches the machine.""" - response = client.post("/api/machine/profiles/order", json={"order": []}) - - assert response.status_code == 400 - mock_session_post.assert_not_called() - - @patch("api.routes.profiles.async_session_post", new_callable=AsyncMock) - def test_reorder_profiles_rejects_non_string_ids(self, mock_session_post, client): - """Order entries must be non-empty strings.""" - response = client.post( - "/api/machine/profiles/order", json={"order": ["ok", 42, ""]} - ) - - assert response.status_code == 400 - mock_session_post.assert_not_called() - - @patch("api.routes.profiles.invalidate_profile_list_cache") - @patch("api.routes.profiles.async_session_post", new_callable=AsyncMock) - def test_reorder_profiles_machine_unreachable( - self, mock_session_post, mock_invalidate, client - ): - """A MachineUnreachableError surfaces as a 503.""" - from services.meticulous_service import MachineUnreachableError - - mock_session_post.side_effect = MachineUnreachableError(ConnectionError("offline")) - - response = client.post( - "/api/machine/profiles/order", json={"order": ["a", "b"]} - ) - - assert response.status_code == 503 - - @patch("api.routes.profiles.invalidate_profile_list_cache") - @patch("api.routes.profiles.async_session_post", new_callable=AsyncMock) - def test_reorder_profiles_machine_rejects( - self, mock_session_post, mock_invalidate, client - ): - """A non-2xx machine response surfaces as a 502.""" - mock_response = MagicMock() - mock_response.status_code = 400 - mock_session_post.return_value = mock_response - - response = client.post( - "/api/machine/profiles/order", json={"order": ["a", "b"]} - ) - - assert response.status_code == 502 - - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - @patch("api.routes.profiles.load_history", return_value=[]) - def test_list_profiles_partial_failure( - self, mock_load_history, mock_list_profiles, mock_get_profile, client - ): - """Test listing continues when individual profile fetch fails.""" - mock_profile1 = type("Profile", (), {})() - mock_profile1.id = "profile-1" - mock_profile1.name = "Good Profile" - mock_profile1.error = None - mock_profile1.author = "Barista" - mock_profile1.temperature = 93 - mock_profile1.final_weight = 40 - - mock_profile2 = type("Profile", (), {})() - mock_profile2.id = "profile-2" - mock_profile2.name = "Bad Profile" - mock_profile2.error = None - mock_profile2.author = None - mock_profile2.temperature = None - mock_profile2.final_weight = None - - mock_list_profiles.return_value = [mock_profile1, mock_profile2] - - full_profile1 = type("FullProfile", (), {})() - full_profile1.id = "profile-1" - full_profile1.name = "Good Profile" - full_profile1.author = "Barista" - full_profile1.error = None - - # Second profile fetch fails - should fall back to partial data - mock_get_profile.side_effect = [full_profile1, Exception("Network error")] - - response = client.get("/api/machine/profiles") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - # Now includes both profiles (using partial data for failed fetch) - assert data["total"] == 2 - assert len(data["profiles"]) == 2 - assert data["profiles"][0]["name"] == "Good Profile" - assert data["profiles"][1]["name"] == "Bad Profile" # Partial data fallback - - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - @patch("api.routes.profiles.load_history") - def test_list_profiles_history_dict_format( - self, mock_load_history, mock_list_profiles, mock_get_profile, client - ): - """Test handling of legacy history format (dict with entries key).""" - mock_profile = type("Profile", (), {})() - mock_profile.id = "profile-1" - mock_profile.name = "Test Profile" - mock_profile.error = None - - mock_list_profiles.return_value = [mock_profile] - - full_profile = type("FullProfile", (), {})() - full_profile.id = "profile-1" - full_profile.name = "Test Profile" - full_profile.author = "Barista" - full_profile.error = None - - mock_get_profile.return_value = full_profile - - # Legacy format: dict with entries key - mock_load_history.return_value = { - "entries": [{"profile_name": "Test Profile", "reply": "Description here"}] - } - - response = client.get("/api/machine/profiles") - - assert response.status_code == 200 - data = response.json() - assert data["profiles"][0]["in_history"] is True - - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - @patch("api.routes.profiles.load_history", return_value=[]) - def test_list_profiles_includes_derived_tags( - self, mock_load_history, mock_list_profiles, mock_get_profile, client - ): - """Test that profile list includes derived structural tags.""" - # Create a profile with pressure stages (triggers Pressure-controlled + Pre-infusion tags) - mock_profile = type("Profile", (), {})() - mock_profile.id = "profile-1" - mock_profile.name = "Classic Espresso" - mock_profile.error = None - - # Build full profile with stages as objects (getattr-compatible) - dynamics = type( - "Dynamics", - (), - {"points": [[0, 2.0], [5, 4.0]], "over": "time", "interpolation": "linear"}, - )() - stage1 = type( - "Stage", - (), - { - "name": "Preinfusion", - "type": "pressure", - "dynamics": dynamics, - "limits": [], - "exit_triggers": [], - }, - )() - dynamics2 = type( - "Dynamics", - (), - { - "points": [[0, 9.0], [25, 8.5]], - "over": "time", - "interpolation": "linear", - }, - )() - stage2 = type( - "Stage", - (), - { - "name": "Extraction", - "type": "pressure", - "dynamics": dynamics2, - "limits": [], - "exit_triggers": [], - }, - )() - - full_profile = type("FullProfile", (), {})() - full_profile.id = "profile-1" - full_profile.name = "Classic Espresso" - full_profile.author = "Barista" - full_profile.temperature = 92.0 - full_profile.final_weight = 36.0 - full_profile.stages = [stage1, stage2] - full_profile.variables = None - full_profile.display = None - full_profile.error = None - - mock_list_profiles.return_value = [mock_profile] - mock_get_profile.return_value = full_profile - - response = client.get("/api/machine/profiles") - - assert response.status_code == 200 - data = response.json() - profile = data["profiles"][0] - assert "derived_tags" in profile - assert isinstance(profile["derived_tags"], list) - assert "Pressure-controlled" in profile["derived_tags"] - assert "Pre-infusion" in profile["derived_tags"] - assert "High temp (91\u201393\u00b0C)" in profile["derived_tags"] - assert "Normale (36\u201344g)" in profile["derived_tags"] - assert "Standard pressure (8\u20139 bar)" in profile["derived_tags"] - - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - @patch("api.routes.profiles.load_history", return_value=[]) - def test_list_profiles_derived_tags_empty_without_stages( - self, mock_load_history, mock_list_profiles, mock_get_profile, client - ): - """Test that profiles without stages still get derived_tags (possibly just temperature).""" - mock_profile = type("Profile", (), {})() - mock_profile.id = "profile-1" - mock_profile.name = "Simple" - mock_profile.error = None - - full_profile = type("FullProfile", (), {})() - full_profile.id = "profile-1" - full_profile.name = "Simple" - full_profile.author = None - full_profile.temperature = 88.0 - full_profile.final_weight = None - full_profile.error = None - - mock_list_profiles.return_value = [mock_profile] - mock_get_profile.return_value = full_profile - - response = client.get("/api/machine/profiles") - - assert response.status_code == 200 - profile = response.json()["profiles"][0] - assert "derived_tags" in profile - # Should at least have temperature tag - assert "Medium temp (88\u201390\u00b0C)" in profile["derived_tags"] - - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - @patch("api.routes.profiles.load_history", return_value=[]) - def test_derived_tags_weight_range( - self, mock_load_history, mock_list_profiles, mock_get_profile, client - ): - """Test that derived tags include weight range labels.""" - mock_profile = type( - "Profile", (), {"id": "p1", "name": "Ristretto", "error": None} - )() - dynamics = type( - "Dynamics", - (), - {"points": [[0, 9.0]], "over": "time", "interpolation": "linear"}, - )() - stage = type( - "Stage", - (), - { - "name": "Main", - "type": "pressure", - "dynamics": dynamics, - "limits": [], - "exit_triggers": [], - }, - )() - full = type( - "Full", - (), - { - "id": "p1", - "name": "Ristretto", - "author": None, - "temperature": 93.0, - "final_weight": 25.0, - "stages": [stage], - "variables": None, - "display": None, - "error": None, - }, - )() - mock_list_profiles.return_value = [mock_profile] - mock_get_profile.return_value = full - response = client.get("/api/machine/profiles") - tags = response.json()["profiles"][0]["derived_tags"] - assert "Ristretto (\u226435g)" in tags - - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - @patch("api.routes.profiles.load_history", return_value=[]) - def test_derived_tags_adaptive_detection( - self, mock_load_history, mock_list_profiles, mock_get_profile, client - ): - """Test that profiles with $variable references get Adaptive tag.""" - mock_profile = type( - "Profile", (), {"id": "p1", "name": "Adaptive", "error": None} - )() - dynamics = type( - "Dynamics", - (), - {"points": [[0, "$pressure"]], "over": "time", "interpolation": "linear"}, - )() - stage = type( - "Stage", - (), - { - "name": "Main", - "type": "pressure", - "dynamics": dynamics, - "limits": [], - "exit_triggers": [], - }, - )() - full = type( - "Full", - (), - { - "id": "p1", - "name": "Adaptive Profile", - "author": None, - "temperature": 93.0, - "final_weight": 40.0, - "stages": [stage], - "variables": None, - "display": None, - "error": None, - }, - )() - mock_list_profiles.return_value = [mock_profile] - mock_get_profile.return_value = full - response = client.get("/api/machine/profiles") - tags = response.json()["profiles"][0]["derived_tags"] - assert "Adaptive" in tags - - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - @patch("api.routes.profiles.load_history", return_value=[]) - def test_derived_tags_structural_bloom( - self, mock_load_history, mock_list_profiles, mock_get_profile, client - ): - """Test content-based bloom detection from zero-flow stage with time exit.""" - mock_profile = type( - "Profile", (), {"id": "p1", "name": "TestProfile", "error": None} - )() - bloom_dyn = type( - "Dynamics", - (), - {"points": [[0, 0.0], [5, 0.0]], "over": "time", "interpolation": "linear"}, - )() - time_exit = type("Exit", (), {"type": "time", "value": 30})() - bloom_stage = type( - "Stage", - (), - { - "name": "Phase1", - "type": "flow", - "dynamics": bloom_dyn, - "limits": [], - "exit_triggers": [time_exit], - }, - )() - main_dyn = type( - "Dynamics", - (), - {"points": [[0, 3.0]], "over": "time", "interpolation": "linear"}, - )() - main_stage = type( - "Stage", - (), - { - "name": "Phase2", - "type": "flow", - "dynamics": main_dyn, - "limits": [], - "exit_triggers": [], - }, - )() - full = type( - "Full", - (), - { - "id": "p1", - "name": "TestProfile", - "author": None, - "temperature": 90.0, - "final_weight": 40.0, - "stages": [bloom_stage, main_stage], - "variables": None, - "display": None, - "error": None, - }, - )() - mock_list_profiles.return_value = [mock_profile] - mock_get_profile.return_value = full - response = client.get("/api/machine/profiles") - tags = response.json()["profiles"][0]["derived_tags"] - assert "Bloom" in tags - - -class TestTargetCurvesOverride: - """Target-curves endpoint reflects active temporary variable overrides.""" - - def _make_profile(self): - full = type("FullProfile", (), {})() - full.id = "profile-1" - full.name = "Override Profile" - full.error = None - # Single-point pressure stage whose value references a variable, so an - # override of that variable changes the estimated target curve. - full.stages = [ - { - "name": "Hold", - "type": "pressure", - "dynamics_points": [[0, "$pressure_var"]], - "dynamics_over": "time", - "exit_triggers": [{"type": "time", "value": 10}], - } - ] - full.variables = [ - {"key": "pressure_var", "type": "pressure", "value": 6.0} - ] - return full - - @patch("api.routes.profiles.get_active") - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_curves_use_saved_value_without_active_override( - self, mock_list, mock_get, mock_active, client - ): - mock_profile = type("Profile", (), {})() - mock_profile.id = "profile-1" - mock_profile.name = "Override Profile" - mock_profile.error = None - mock_list.return_value = [mock_profile] - mock_get.return_value = self._make_profile() - mock_active.return_value = None - - response = client.get("/api/profile/Override Profile/target-curves") - assert response.status_code == 200 - curves = response.json()["target_curves"] - pressures = {c["target_pressure"] for c in curves if "target_pressure" in c} - assert pressures == {6.0} - - @patch("api.routes.profiles.get_active") - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_curves_reflect_active_override( - self, mock_list, mock_get, mock_active, client - ): - mock_profile = type("Profile", (), {})() - mock_profile.id = "profile-1" - mock_profile.name = "Override Profile" - mock_profile.error = None - mock_list.return_value = [mock_profile] - mock_get.return_value = self._make_profile() - mock_active.return_value = { - "profile_id": "profile-1", - "profile_name": "Override Profile", - "original_params": {"overrides": {"pressure_var": 9.0}}, - } - - response = client.get("/api/profile/Override Profile/target-curves") - assert response.status_code == 200 - curves = response.json()["target_curves"] - pressures = {c["target_pressure"] for c in curves if "target_pressure" in c} - assert pressures == {9.0} - - @patch("api.routes.profiles.get_active") - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_curves_ignore_override_for_other_profile( - self, mock_list, mock_get, mock_active, client - ): - mock_profile = type("Profile", (), {})() - mock_profile.id = "profile-1" - mock_profile.name = "Override Profile" - mock_profile.error = None - mock_list.return_value = [mock_profile] - mock_get.return_value = self._make_profile() - # Active override is for a different profile name -> ignored. - mock_active.return_value = { - "profile_id": "other", - "profile_name": "Some Other Profile", - "original_params": {"overrides": {"pressure_var": 9.0}}, - } - - response = client.get("/api/profile/Override Profile/target-curves") - assert response.status_code == 200 - curves = response.json()["target_curves"] - pressures = {c["target_pressure"] for c in curves if "target_pressure" in c} - assert pressures == {6.0} - - -class TestMachineProfileJsonEndpoint: - """Tests for the /api/machine/profile/{profile_id}/json endpoint.""" - - @patch("api.routes.profiles.fetch_machine_profile_dict", new_callable=AsyncMock) - def test_get_profile_json_success(self, mock_fetch, client): - """Test successful profile JSON retrieval.""" - machine_json = { - "id": "profile-123", - "name": "Test Profile", - "author": "Barista Joe", - "temperature": 93.0, - "final_weight": 36.0, - "stages": [{"name": "preinfusion"}], - "variables": [{"key": "value"}], - } - mock_fetch.return_value = machine_json - - response = client.get("/api/machine/profile/profile-123/json") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["profile"]["id"] == "profile-123" - assert data["profile"]["name"] == "Test Profile" - assert data["profile"]["author"] == "Barista Joe" - assert data["profile"]["temperature"] == 93.0 - - @patch("api.routes.profiles.fetch_machine_profile_dict", new_callable=AsyncMock) - def test_get_profile_json_api_error(self, mock_fetch, client): - """Test error handling when machine API fails.""" - mock_fetch.side_effect = Exception("Profile not found") - - response = client.get("/api/machine/profile/invalid-id/json") - - assert response.status_code == 502 - assert "Machine API error" in response.json()["detail"] - - @patch("api.routes.profiles.fetch_machine_profile_dict", new_callable=AsyncMock) - def test_get_profile_json_nested_objects(self, mock_fetch, client): - """Test handling of nested objects in profile.""" - machine_json = { - "id": "profile-456", - "name": "Complex Profile", - "display": {"nested_key": "nested_value"}, - } - mock_fetch.return_value = machine_json - - response = client.get("/api/machine/profile/profile-456/json") - - assert response.status_code == 200 - data = response.json() - assert "display" in data["profile"] - - @patch("api.routes.profiles.fetch_machine_profile_dict", new_callable=AsyncMock) - def test_get_profile_json_list_of_objects(self, mock_fetch, client): - """Test handling of list of objects in profile.""" - machine_json = { - "id": "profile-789", - "name": "Multi-Stage Profile", - "stages": [ - {"name": "preinfusion", "duration": 5}, - {"name": "extraction", "duration": 25}, - ], - } - mock_fetch.return_value = machine_json - - response = client.get("/api/machine/profile/profile-789/json") - - assert response.status_code == 200 - data = response.json() - assert len(data["profile"]["stages"]) == 2 - assert data["profile"]["stages"][0]["name"] == "preinfusion" - - @patch("api.routes.profiles.fetch_machine_profile_dict", new_callable=AsyncMock) - def test_get_profile_json_exception(self, mock_fetch, client): - """Test handling of unexpected exceptions.""" - mock_fetch.side_effect = Exception("Unexpected error") - - response = client.get("/api/machine/profile/error-id/json") - - assert response.status_code == 502 - - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - def test_get_profile_json_merges_synthesized_variables( - self, mock_get_profile, client - ): - """Test that top-level final_weight/temperature are merged with explicit variables.""" - mock_profile = type("Profile", (), {})() - mock_profile.id = "profile-merge" - mock_profile.name = "Yirgacheffe You Going?" - mock_profile.author = "Metic" - mock_profile.temperature = 94.0 - mock_profile.final_weight = 38.0 - mock_profile.error = None - # Explicit variables that do NOT include final_weight or temperature - mock_profile.variables = [ - { - "key": "pressure", - "name": "Brew Pressure", - "type": "pressure", - "value": 9.0, - }, - ] - - mock_get_profile.return_value = mock_profile - - response = client.get("/api/machine/profile/profile-merge") - - assert response.status_code == 200 - data = response.json() - variables = data["variables"] - keys = [v["key"] for v in variables] - assert "pressure" in keys, "Explicit variable should be preserved" - assert "final_weight" in keys, "Synthesized final_weight should be merged" - assert "temperature" in keys, "Synthesized temperature should be merged" - assert len(variables) == 3 - - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - def test_get_profile_json_no_duplicate_variables(self, mock_get_profile, client): - """Test that explicit variables are not duplicated by synthesis.""" - mock_profile = type("Profile", (), {})() - mock_profile.id = "profile-dedup" - mock_profile.name = "Dedup Profile" - mock_profile.temperature = 94.0 - mock_profile.final_weight = 38.0 - mock_profile.error = None - # Explicit variables already include temperature - mock_profile.variables = [ - { - "key": "temperature", - "name": "Brew Temp", - "type": "temperature", - "value": 92.0, - }, - ] - - mock_get_profile.return_value = mock_profile - - response = client.get("/api/machine/profile/profile-dedup") - - assert response.status_code == 200 - data = response.json() - variables = data["variables"] - temp_vars = [v for v in variables if v["key"] == "temperature"] - assert len(temp_vars) == 1, "Should not duplicate existing temperature variable" - assert temp_vars[0]["value"] == 92.0, ( - "Should keep explicit value, not synthesized" - ) - fw_vars = [v for v in variables if v["key"] == "final_weight"] - assert len(fw_vars) == 1, "Should still add missing final_weight" - - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - def test_get_profile_json_synthesizes_when_no_variables( - self, mock_get_profile, client - ): - """Test that variables are synthesized when profile has none.""" - mock_profile = type("Profile", (), {})() - mock_profile.id = "profile-synth" - mock_profile.name = "Bare Profile" - mock_profile.temperature = 93.0 - mock_profile.final_weight = 36.0 - mock_profile.error = None - - mock_get_profile.return_value = mock_profile - - response = client.get("/api/machine/profile/profile-synth") - - assert response.status_code == 200 - data = response.json() - variables = data["variables"] - assert len(variables) == 2 - keys = {v["key"] for v in variables} - assert keys == {"final_weight", "temperature"} - - -class TestProfileImportEndpoint: - """Tests for the /api/profile/import endpoint.""" - - @patch("api.routes.profiles.save_history") - @patch("api.routes.profiles.load_history", return_value=[]) - @patch("api.routes.profiles._generate_profile_description", new_callable=AsyncMock) - def test_import_profile_success( - self, mock_generate_desc, mock_load_history, mock_save_history, client - ): - """Test successful profile import with description generation.""" - mock_generate_desc.return_value = ( - "Great espresso profile with balanced extraction" - ) - - profile_json = { - "name": "Imported Espresso", - "author": "Coffee Master", - "temperature": 93.0, - "stages": [{"name": "extraction"}], - } - - response = client.post( - "/api/profile/import", - json={ - "profile": profile_json, - "generate_description": True, - "source": "file", - }, - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["profile_name"] == "Imported Espresso" - assert data["has_description"] is True - assert "entry_id" in data - mock_save_history.assert_called_once() - - @patch("api.routes.profiles.save_history") - @patch("api.routes.profiles.load_history", return_value=[]) - @patch("api.routes.profiles._generate_profile_description", new_callable=AsyncMock) - def test_import_profile_without_description( - self, mock_generate_desc, mock_load_history, mock_save_history, client - ): - """Test profile import without generating description.""" - # Should not be called when generate_description=False - mock_generate_desc.return_value = "Should not use this" - - profile_json = {"name": "Quick Import", "temperature": 92.0} - - response = client.post( - "/api/profile/import", - json={ - "profile": profile_json, - "generate_description": False, - "source": "machine", - }, - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - mock_generate_desc.assert_not_called() - mock_save_history.assert_called_once() - - @patch("api.routes.profiles.load_history") - def test_import_profile_already_exists(self, mock_load_history, client): - """Test importing a profile that already exists in history.""" - profile_json = {"name": "Existing Profile", "temperature": 93.0} - - mock_load_history.return_value = [ - { - "id": "existing-123", - "profile_name": "Existing Profile", - "reply": "Already here", - } - ] - - response = client.post("/api/profile/import", json={"profile": profile_json}) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "exists" - assert "already exists" in data["message"] - assert data["entry_id"] == "existing-123" - - def test_import_profile_missing_json(self, client): - """Test error when no profile JSON is provided.""" - response = client.post( - "/api/profile/import", json={"generate_description": True} - ) - - assert response.status_code == 400 - assert "No profile JSON provided" in response.json()["detail"] - - @patch("api.routes.profiles.save_history") - @patch("api.routes.profiles.load_history", return_value=[]) - @patch("api.routes.profiles._generate_profile_description", new_callable=AsyncMock) - def test_import_profile_description_generation_fails( - self, mock_generate_desc, mock_load_history, mock_save_history, client - ): - """Test import falls back to static description when AI generation fails.""" - mock_generate_desc.side_effect = Exception("AI service unavailable") - - profile_json = {"name": "Fallback Profile", "temperature": 91.0} - - response = client.post( - "/api/profile/import", - json={ - "profile": profile_json, - "generate_description": True, - "source": "file", - }, - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - # Should have a static fallback description (not empty) - assert data["has_description"] is True - mock_save_history.assert_called_once() - - @patch("api.routes.profiles.save_history") - @patch("api.routes.profiles.load_history") - def test_import_profile_legacy_history_format( - self, mock_load_history, mock_save_history, client - ): - """Test import with legacy history format (dict with entries).""" - profile_json = {"name": "New Profile", "temperature": 94.0} - - # Legacy format - mock_load_history.return_value = {"entries": [{"profile_name": "Old Profile"}]} - - response = client.post( - "/api/profile/import", - json={"profile": profile_json, "generate_description": False}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - - def test_import_profile_empty_request(self, client): - """Test error when request body is empty.""" - response = client.post("/api/profile/import", json={}) - - assert response.status_code == 400 - - -class TestShotsByProfileEndpoint: - """Tests for the /api/shots/by-profile/{profile_name} endpoint.""" - - @pytest.fixture(autouse=True) - def clear_shot_index(self): - """Reset the persistent shot profile index so tests use mocked data.""" - import services.cache_service as _cs - - _cs._shot_index = None - if _cs.SHOT_INDEX_FILE.exists(): - _cs.SHOT_INDEX_FILE.unlink() - yield - _cs._shot_index = None - if _cs.SHOT_INDEX_FILE.exists(): - _cs.SHOT_INDEX_FILE.unlink() - - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - @patch("api.routes.shots._get_cached_shots") - @patch("api.routes.shots._set_cached_shots") - def test_get_shots_by_profile_success( - self, - mock_set_cache, - mock_get_cache, - mock_fetch_shot, - mock_get_dates, - mock_get_files, - client, - ): - """Test successful retrieval of shots for a profile.""" - # No cache initially - mock_get_cache.return_value = (None, False, None) - - # Mock dates - date1 = MagicMock() - date1.name = "2024-01-15" - mock_get_dates.return_value = [date1] - - # Mock files - file1 = MagicMock() - file1.name = "shot_001.json" - file2 = MagicMock() - file2.name = "shot_002.json" - mock_get_files.return_value = [file1, file2] - - # Mock shot data - shot_data_1 = { - "profile_name": "Espresso Classic", - "time": 1705320000, - "data": [{"time": 25000, "shot": {"weight": 36.5}}], - } - - shot_data_2 = { - "profile": {"name": "Espresso Classic"}, - "time": 1705320100, - "data": [{"time": 28000, "shot": {"weight": 38.0}}], - } - - mock_fetch_shot.side_effect = [shot_data_1, shot_data_2] - - response = client.get("/api/shots/by-profile/Espresso%20Classic?limit=10") - - assert response.status_code == 200 - data = response.json() - assert data["profile_name"] == "Espresso Classic" - assert data["count"] == 2 - assert len(data["shots"]) == 2 - # Sorted newest-first by timestamp - assert data["shots"][0]["final_weight"] == 38.0 - assert data["shots"][1]["final_weight"] == 36.5 - - @patch("api.routes.shots._get_cached_shots") - def test_get_shots_by_profile_from_cache(self, mock_get_cache, client): - """Test returning cached shots when available.""" - cached_data = { - "profile_name": "Cached Profile", - "shots": [{"date": "2024-01-15", "filename": "shot_001.json"}], - "count": 1, - "limit": 10, - } - - mock_get_cache.return_value = (cached_data, False, 1705320000.0) - - response = client.get("/api/shots/by-profile/Cached%20Profile") - - assert response.status_code == 200 - data = response.json() - assert data["profile_name"] == "Cached Profile" - assert data["count"] == 1 - assert "cached_at" in data - assert data["is_stale"] is False - - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - @patch("api.routes.shots._get_cached_shots") - def test_get_shots_by_profile_api_error( - self, mock_get_cache, mock_get_dates, client - ): - """Test error handling when machine API fails.""" - mock_get_cache.return_value = (None, False, None) - - mock_result = MagicMock() - mock_result.error = "Connection timeout" - mock_get_dates.return_value = mock_result - - response = client.get("/api/shots/by-profile/Test%20Profile") - - assert response.status_code == 502 - - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - @patch("api.routes.shots._get_cached_shots") - @patch("api.routes.shots._set_cached_shots") - def test_get_shots_by_profile_no_matches( - self, - mock_set_cache, - mock_get_cache, - mock_fetch_shot, - mock_get_dates, - mock_get_files, - client, - ): - """Test when no shots match the profile.""" - mock_get_cache.return_value = (None, False, None) - - date1 = type("Date", (), {})() - date1.name = "2024-01-15" - date1.error = None - mock_get_dates.return_value = [date1] - - file1 = type("File", (), {})() - file1.name = "shot_001.json" - file1.error = None - mock_get_files.return_value = [file1] - - # Shot with different profile name - shot_data = { - "profile_name": "Different Profile", - "time": 1705320000, - "data": [], - } - mock_fetch_shot.return_value = shot_data - - response = client.get("/api/shots/by-profile/Nonexistent%20Profile") - - assert response.status_code == 200 - data = response.json() - assert data["count"] == 0 - assert len(data["shots"]) == 0 - - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - @patch("api.routes.shots._get_cached_shots") - @patch("api.routes.shots._set_cached_shots") - def test_get_shots_by_profile_with_limit( - self, - mock_set_cache, - mock_get_cache, - mock_fetch_shot, - mock_get_dates, - mock_get_files, - client, - ): - """Test limit parameter works correctly.""" - mock_get_cache.return_value = (None, False, None) - - date1 = type("Date", (), {})() - date1.name = "2024-01-15" - date1.error = None - mock_get_dates.return_value = [date1] - - # Create file objects properly - files = [] - for i in range(5): - f = type("File", (), {})() - f.name = f"shot_{i:03d}.json" - f.error = None - files.append(f) - mock_get_files.return_value = files - - # All shots match - def create_shot_data(i): - return { - "profile_name": "Test Profile", - "time": 1705320000 + i, - "data": [{"time": 25000, "shot": {"weight": 36.0 + i}}], - } - - mock_fetch_shot.side_effect = [create_shot_data(i) for i in range(5)] - - response = client.get("/api/shots/by-profile/Test%20Profile?limit=2") - - assert response.status_code == 200 - data = response.json() - assert data["count"] == 2 - assert data["limit"] == 2 - - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - @patch("api.routes.shots._get_cached_shots") - def test_get_shots_by_profile_include_data( - self, mock_get_cache, mock_fetch_shot, mock_get_dates, mock_get_files, client - ): - """Test including full shot data in response.""" - mock_get_cache.return_value = (None, False, None) - - date1 = MagicMock() - date1.name = "2024-01-15" - mock_get_dates.return_value = [date1] - - file1 = MagicMock() - file1.name = "shot_001.json" - mock_get_files.return_value = [file1] - - shot_data = { - "profile_name": "Full Data Profile", - "time": 1705320000, - "data": [ - {"time": 10000, "shot": {"weight": 10.0, "pressure": 9.0}}, - {"time": 25000, "shot": {"weight": 36.0, "pressure": 9.0}}, - ], - } - mock_fetch_shot.return_value = shot_data - - response = client.get( - "/api/shots/by-profile/Full%20Data%20Profile?include_data=true" - ) - - assert response.status_code == 200 - data = response.json() - assert data["count"] == 1 - assert "data" in data["shots"][0] - assert len(data["shots"][0]["data"]["data"]) == 2 - - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - @patch("api.routes.shots._get_cached_shots") - @patch("api.routes.shots._set_cached_shots") - def test_get_shots_by_profile_case_insensitive( - self, - mock_set_cache, - mock_get_cache, - mock_fetch_shot, - mock_get_dates, - mock_get_files, - client, - ): - """Test profile name matching is case-insensitive.""" - mock_get_cache.return_value = (None, False, None) - - date1 = type("Date", (), {})() - date1.name = "2024-01-15" - date1.error = None - mock_get_dates.return_value = [date1] - - file1 = type("File", (), {})() - file1.name = "shot_001.json" - file1.error = None - mock_get_files.return_value = [file1] - - shot_data = { - "profile_name": "ESPRESSO CLASSIC", - "time": 1705320000, - "data": [{"time": 25000, "shot": {"weight": 36.0}}], - } - mock_fetch_shot.return_value = shot_data - - response = client.get("/api/shots/by-profile/espresso%20classic") - - assert response.status_code == 200 - data = response.json() - assert data["count"] == 1 - - @patch("api.routes.shots._get_cached_shots") - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - @patch("api.routes.shots._set_cached_shots") - def test_get_shots_by_profile_force_refresh( - self, mock_set_cache, mock_get_dates, mock_get_cache, client - ): - """Test force_refresh parameter bypasses cache.""" - # Cache exists but should be ignored - cached_data = {"profile_name": "Cached", "shots": [], "count": 0} - mock_get_cache.return_value = (cached_data, False, 1705320000.0) - - mock_get_dates.return_value = [] - - response = client.get("/api/shots/by-profile/Test?force_refresh=true") - - assert response.status_code == 200 - # Should hit API, not cache - mock_get_dates.assert_called_once() - - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - @patch("api.routes.shots._get_cached_shots") - @patch("api.routes.shots._set_cached_shots") - def test_get_shots_by_profile_partial_shot_errors( - self, - mock_set_cache, - mock_get_cache, - mock_fetch_shot, - mock_get_dates, - mock_get_files, - client, - ): - """Test continues when individual shot fetch fails.""" - mock_get_cache.return_value = (None, False, None) - - date1 = type("Date", (), {})() - date1.name = "2024-01-15" - date1.error = None - mock_get_dates.return_value = [date1] - - file1 = type("File", (), {})() - file1.name = "good.json" - file1.error = None - file2 = type("File", (), {})() - file2.name = "bad.json" - file2.error = None - mock_get_files.return_value = [file1, file2] - - good_shot = { - "profile_name": "Test", - "time": 1705320000, - "data": [{"time": 25000, "shot": {"weight": 36.0}}], - } - - # First succeeds, second fails - mock_fetch_shot.side_effect = [good_shot, Exception("Corrupted file")] - - response = client.get("/api/shots/by-profile/Test") - - assert response.status_code == 200 - data = response.json() - assert data["count"] == 1 # Only the good shot - - -class TestImageProxyEndpoint: - """Tests for the /api/profile/{profile_name}/image-proxy endpoint.""" - - @patch("api.routes.profiles._get_cached_image") - def test_image_proxy_from_cache(self, mock_get_cache, client): - """Test returning cached image.""" - mock_get_cache.return_value = b"fake_png_data" - - response = client.get("/api/profile/Test%20Profile/image-proxy") - - assert response.status_code == 200 - assert response.headers["content-type"] == "image/png" - assert response.content == b"fake_png_data" - - @patch("api.routes.profiles._get_cached_image") - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_image_proxy_profile_not_found_returns_placeholder( - self, mock_list_profiles, mock_get_cache, client - ): - """Test placeholder SVG returned when profile not found.""" - mock_get_cache.return_value = None - - mock_list_profiles.return_value = [] - - response = client.get("/api/profile/Nonexistent/image-proxy") - - # Returns placeholder SVG instead of 404 - assert response.status_code == 200 - assert response.headers["content-type"] == "image/svg+xml" - assert b" 0 # Should use gravimetric_flow - - def test_format_dynamics_over_weight(self): - """Test dynamics description over weight.""" - from services.analysis_service import _format_dynamics_description - - stage = { - "type": "flow", - "dynamics_points": [[0, 2.0], [40, 3.0]], - "dynamics_over": "weight", - } - result = _format_dynamics_description(stage) - assert "g" in result # Should use weight unit - - def test_format_dynamics_with_variable_references(self): - """Test dynamics description with $variable references resolves correctly.""" - from services.analysis_service import _format_dynamics_description - - variables = [ - { - "key": "bloom_pressure", - "name": "Bloom Pressure", - "type": "pressure", - "value": 3.5, - }, - { - "key": "peak_pressure", - "name": "Peak Pressure", - "type": "pressure", - "value": 9.0, - }, - ] - - # Single point with variable - stage = { - "type": "pressure", - "dynamics_points": [[0, "$bloom_pressure"]], - } - result = _format_dynamics_description(stage, variables) - assert "3.5" in result - assert "$" not in result - - # Two points with variables - should produce ramp description - stage_ramp = { - "type": "pressure", - "dynamics_points": [[0, "$bloom_pressure"], [5, "$peak_pressure"]], - } - result = _format_dynamics_description(stage_ramp, variables) - assert "3.5" in result - assert "9.0" in result - assert "$" not in result - assert "ramp up" in result - - def test_analyze_stage_execution_with_variable_dynamics(self): - """Test that stage analysis handles $variable references in dynamics_points without crashing.""" - from services.analysis_service import _analyze_stage_execution - - variables = [ - { - "key": "decline_pressure", - "name": "Decline Pressure", - "type": "pressure", - "value": 6.0, - }, - ] - - profile_stage = { - "name": "Sweet Decline", - "key": "sweet_decline", - "type": "pressure", - "dynamics_points": [[0, 9.0], [10, "$decline_pressure"]], - "exit_triggers": [{"type": "weight", "value": 40, "comparison": ">="}], - "limits": [], - } - - shot_stage_data = { - "duration": 8.5, - "start_weight": 20.0, - "end_weight": 35.0, - "start_pressure": 8.8, - "end_pressure": 6.2, - "avg_pressure": 7.5, - "max_pressure": 8.9, - "min_pressure": 6.1, - "start_flow": 2.0, - "end_flow": 1.8, - "avg_flow": 1.9, - "max_flow": 2.1, - } - - # This should NOT raise TypeError: can't multiply sequence by non-int of type 'float' - result = _analyze_stage_execution( - profile_stage, shot_stage_data, 30.0, variables - ) - - assert result["executed"] is True - assert result["stage_name"] == "Sweet Decline" - # The target value should have been resolved from "$decline_pressure" to 6.0 - assert result["profile_target"] is not None - assert "$" not in result["profile_target"] - - -class TestDataDirectoryConfiguration: - """Tests for DATA_DIR configuration and TEST_MODE.""" - - def test_data_dir_uses_temp_in_test_mode(self): - """Test that DATA_DIR uses temp directory when TEST_MODE is true.""" - from config import DATA_DIR, TEST_MODE - import tempfile - - # Verify TEST_MODE is enabled (set by conftest.py) - assert TEST_MODE is True - - # Verify DATA_DIR uses temp directory - temp_base = Path(tempfile.gettempdir()) - assert str(DATA_DIR).startswith(str(temp_base)) - - def test_data_dir_exists_in_test_mode(self): - """Test that DATA_DIR is created in test mode.""" - from config import DATA_DIR - - # DATA_DIR should be created during import - assert DATA_DIR.exists() - assert DATA_DIR.is_dir() - - def test_all_data_files_use_data_dir(self): - """Test that all data file paths use DATA_DIR.""" - from config import DATA_DIR - from services.settings_service import SETTINGS_FILE - from services.history_service import HISTORY_FILE - from services.cache_service import ( - LLM_CACHE_FILE, - SHOT_CACHE_FILE, - IMAGE_CACHE_DIR, - ) - - # All paths should be under DATA_DIR - assert SETTINGS_FILE.parent == DATA_DIR - assert HISTORY_FILE.parent == DATA_DIR - assert LLM_CACHE_FILE.parent == DATA_DIR - assert SHOT_CACHE_FILE.parent == DATA_DIR - assert IMAGE_CACHE_DIR.parent == DATA_DIR - - -class TestImagePromptErrorHandling: - """Tests for image prompt generation error handling.""" - - @patch("services.meticulous_service.get_meticulous_api") - def test_generate_image_with_invalid_prompt_result_none(self, mock_get_api, client): - """Test image generation when prompt builder returns None.""" - # Mock API to return profile exists - mock_api = MagicMock() - mock_get_api.return_value = mock_api - - partial_profile = MagicMock() - partial_profile.name = "TestProfile" - partial_profile.id = "p-123" - mock_api.list_profiles.return_value = [partial_profile] - - # Mock prompt builder to return None - with patch( - "prompt_builder.build_image_prompt_with_metadata", return_value=None - ): - response = client.post( - "/api/profile/TestProfile/generate-image", params={"preview": "true"} - ) - - # Should return 500 error - assert response.status_code == 500 - assert ( - "Failed to build image generation prompt" in response.json()["detail"] - ) - - @patch("services.meticulous_service.get_meticulous_api") - def test_generate_image_with_invalid_prompt_result_not_dict( - self, mock_get_api, client - ): - """Test image generation when prompt builder returns non-dict.""" - # Mock API to return profile exists - mock_api = MagicMock() - mock_get_api.return_value = mock_api - - partial_profile = MagicMock() - partial_profile.name = "TestProfile" - partial_profile.id = "p-123" - mock_api.list_profiles.return_value = [partial_profile] - - # Mock prompt builder to return a string instead of dict - with patch( - "prompt_builder.build_image_prompt_with_metadata", return_value="invalid" - ): - response = client.post( - "/api/profile/TestProfile/generate-image", params={"preview": "true"} - ) - - # Should return 500 error - assert response.status_code == 500 - assert ( - "Failed to build image generation prompt" in response.json()["detail"] - ) - - @patch("services.meticulous_service.get_meticulous_api") - def test_generate_image_with_valid_prompt_result(self, mock_get_api, client): - """Test image generation with valid prompt result doesn't fail at validation.""" - # Mock API to return profile exists - mock_api = MagicMock() - mock_get_api.return_value = mock_api - - partial_profile = MagicMock() - partial_profile.name = "TestProfile" - partial_profile.id = "p-123" - mock_api.list_profiles.return_value = [partial_profile] - - # Mock valid prompt result - valid_prompt = { - "prompt": "A beautiful coffee image", - "metadata": {"influences_found": 2, "selected_colors": ["brown", "cream"]}, - } - - with patch( - "prompt_builder.build_image_prompt_with_metadata", return_value=valid_prompt - ): - response = client.post( - "/api/profile/TestProfile/generate-image", params={"preview": "true"} - ) - - # Should not fail with prompt validation error - # May fail later for other reasons (gemini CLI, etc.) - if response.status_code == 500: - error_detail = str(response.json().get("detail", "")) - assert "Failed to build image generation prompt" not in error_detail - - -class TestDataFileManagement: - """Tests for data file ensure functions and management.""" - - def testensure_settings_file_creates_file(self): - """Test that ensure_settings_file creates settings file.""" - from services.settings_service import ensure_settings_file, SETTINGS_FILE - - # Delete file if it exists - if SETTINGS_FILE.exists(): - SETTINGS_FILE.unlink() - - # Call ensure function - ensure_settings_file() - - # File should now exist - assert SETTINGS_FILE.exists() - - # Should contain valid JSON - with open(SETTINGS_FILE) as f: - settings = json.load(f) - assert isinstance(settings, dict) - assert "geminiApiKey" in settings - - def test_ensure_history_file_creates_file(self): - """Test that _ensure_history_file creates history file.""" - from services.history_service import ensure_history_file, HISTORY_FILE - - # Delete file if it exists - if HISTORY_FILE.exists(): - HISTORY_FILE.unlink() - - # Call ensure function - ensure_history_file() - - # File should now exist - assert HISTORY_FILE.exists() - - # Should contain empty array - with open(HISTORY_FILE) as f: - history = json.load(f) - assert isinstance(history, list) - assert len(history) == 0 - - def test_ensure_llm_cache_file_creates_file(self): - """Test that _ensure_llm_cache_file creates cache file.""" - from services.cache_service import _ensure_llm_cache_file, LLM_CACHE_FILE - - # Delete file if it exists - if LLM_CACHE_FILE.exists(): - LLM_CACHE_FILE.unlink() - - # Call ensure function - _ensure_llm_cache_file() - - # File should now exist - assert LLM_CACHE_FILE.exists() - - # Should contain empty dict - with open(LLM_CACHE_FILE) as f: - cache = json.load(f) - assert isinstance(cache, dict) - assert len(cache) == 0 - - def test_ensure_shot_cache_file_creates_file(self): - """Test that _ensure_shot_cache_file creates cache file.""" - from services.cache_service import _ensure_shot_cache_file, SHOT_CACHE_FILE - - # Delete file if it exists - if SHOT_CACHE_FILE.exists(): - SHOT_CACHE_FILE.unlink() - - # Call ensure function - _ensure_shot_cache_file() - - # File should now exist - assert SHOT_CACHE_FILE.exists() - - # Should contain empty dict - with open(SHOT_CACHE_FILE) as f: - cache = json.load(f) - assert isinstance(cache, dict) - - def test_ensure_image_cache_dir_creates_directory(self): - """Test that _ensure_image_cache_dir creates directory.""" - from services.cache_service import _ensure_image_cache_dir, IMAGE_CACHE_DIR - import shutil - - # Delete directory if it exists - if IMAGE_CACHE_DIR.exists(): - shutil.rmtree(IMAGE_CACHE_DIR) - - # Call ensure function - _ensure_image_cache_dir() - - # Directory should now exist - assert IMAGE_CACHE_DIR.exists() - assert IMAGE_CACHE_DIR.is_dir() - - def test_save_and_load_history(self): - """Test saving and loading history.""" - from services.history_service import ( - save_history as _save_history, - load_history as _load_history, - ) - - test_history = [ - {"id": "123", "profile_name": "Test", "created_at": "2024-01-01"} - ] - - _save_history(test_history) - loaded = _load_history() - - assert len(loaded) == 1 - assert loaded[0]["id"] == "123" - assert loaded[0]["profile_name"] == "Test" - - def test_load_history_with_valid_file(self): - """Test loading history with existing valid file.""" - from services.history_service import load_history as _load_history, HISTORY_FILE - - # Create a valid history file with proper v2 schema fields - test_data = [ - { - "id": "test123", - "profile_name": "TestProfile", - "reply": "**Profile Created:** TestProfile\n", - } - ] - with open(HISTORY_FILE, "w") as f: - json.dump(test_data, f) - - history = _load_history() - assert len(history) == 1 - assert history[0]["id"] == "test123" - assert history[0]["profile_name"] == "TestProfile" - - def test_load_history_filters_malformed_entries(self): - """Test that load_history drops entries without profile_name or reply.""" - from services.history_service import load_history as _load_history, HISTORY_FILE - - # Mix of valid and invalid entries - test_data = [ - {"id": "valid1", "profile_name": "Good Entry", "reply": "some reply"}, - {"id": "bad1", "name": "TestProfile"}, # missing profile_name AND reply - { - "id": "valid2", - "reply": "**Profile Created:** Another\n", - }, # has reply, no profile_name - {"id": "bad2"}, # missing everything - ] - with open(HISTORY_FILE, "w") as f: - json.dump(test_data, f) - - history = _load_history() - assert len(history) == 2 - assert history[0]["id"] == "valid1" - assert history[1]["id"] == "valid2" - - -class TestCacheManagementFunctions: - """Tests for cache management helper functions.""" - - def test_llm_cache_save_and_load(self): - """Test LLM cache save and load operations.""" - from services.cache_service import ( - save_llm_analysis_to_cache, - get_cached_llm_analysis, - ) - - # Save an analysis - save_llm_analysis_to_cache( - "TestProfile", "2024-01-15", "shot.json", "Test analysis result" - ) - - # Load it back - result = get_cached_llm_analysis("TestProfile", "2024-01-15", "shot.json") - - assert result == "Test analysis result" - - def test_llm_cache_miss(self): - """Test LLM cache miss returns None.""" - from services.cache_service import get_cached_llm_analysis - - # Try to get non-existent cache entry - result = get_cached_llm_analysis("NonExistent", "2024-01-15", "missing.json") - - assert result is None - - def test_shot_cache_operations(self): - """Test shot cache set and get operations.""" - from services.cache_service import _set_cached_shots, _get_cached_shots - - test_data = {"shots": [{"id": 1, "weight": 36.0}]} - - # Set cache - _set_cached_shots("TestProfile", test_data, limit=100) - - # Get cache - result, is_stale, cached_at = _get_cached_shots("TestProfile", limit=100) - - assert result is not None - assert "shots" in result - assert isinstance(is_stale, bool) - - def test_shot_cache_miss(self): - """Test shot cache miss returns None.""" - from services.cache_service import _get_cached_shots - - result, is_stale, cached_at = _get_cached_shots("NonExistentProfile", limit=100) - - assert result is None - - -class TestSettingsManagement: - """Tests for settings management functions.""" - - def test_settings_load(self): - """Test loading settings from file.""" - from services.settings_service import SETTINGS_FILE, ensure_settings_file - - # Ensure file exists - ensure_settings_file() - - # Load and verify structure - with open(SETTINGS_FILE) as f: - settings = json.load(f) - - assert isinstance(settings, dict) - assert "geminiApiKey" in settings - assert "meticulousIp" in settings - assert "serverIp" in settings - assert "authorName" in settings - - def test_settings_update(self): - """Test updating settings.""" - from services.settings_service import SETTINGS_FILE, ensure_settings_file - - ensure_settings_file() - - # Update settings - new_settings = { - "geminiApiKey": "test_key", - "meticulousIp": "192.168.1.1", - "serverIp": "192.168.1.2", - "authorName": "Test Author", - } - - with open(SETTINGS_FILE, "w") as f: - json.dump(new_settings, f) - - # Read back and verify - with open(SETTINGS_FILE) as f: - settings = json.load(f) - - assert settings["geminiApiKey"] == "test_key" - assert settings["authorName"] == "Test Author" - - -class TestSanitizationHelpers: - """Tests for profile-name sanitization helpers.""" - - def test_sanitize_profile_name(self): - """Test profile name sanitization for filenames.""" - from utils.sanitization import sanitize_profile_name_for_filename - - # Test various special characters (converts to lowercase) - assert sanitize_profile_name_for_filename("Test/Profile") == "test_profile" - assert sanitize_profile_name_for_filename("Test\\Profile") == "test_profile" - assert sanitize_profile_name_for_filename("Test:Profile") == "test_profile" - assert sanitize_profile_name_for_filename("Normal_Name") == "normal_name" - assert sanitize_profile_name_for_filename("Test Profile") == "test_profile" - - -class TestHealthEndpoint: - """Tests for the /api/health endpoint.""" - - def test_health_endpoint_returns_200(self, client): - """Test health endpoint returns 200 OK.""" - response = client.get("/api/health") - assert response.status_code == 200 - - def test_health_endpoint_returns_status_ok(self, client): - """Test health endpoint returns correct JSON body.""" - response = client.get("/api/health") - data = response.json() - assert data == {"status": "ok"} - - def test_health_endpoint_is_fast(self, client): - """Test health endpoint responds quickly (no heavy logic).""" - import time - - start = time.time() - response = client.get("/api/health") - elapsed = time.time() - start - assert response.status_code == 200 - assert elapsed < 1.0 # Should be near-instant - - -class TestVersionEndpoint: - """Tests for the /api/version endpoint.""" - - def test_version_endpoint_basic_structure(self, client): - """Test basic version endpoint returns correct structure.""" - response = client.get("/api/version") - assert response.status_code == 200 - - data = response.json() - assert "version" in data - assert "repo_url" in data - # Should always have a repo URL (at minimum the default) - assert isinstance(data["repo_url"], str) - assert len(data["repo_url"]) > 0 - - def test_version_endpoint_returns_default_fallback(self, client): - """Test that version endpoint returns a valid URL.""" - response = client.get("/api/version") - assert response.status_code == 200 - - data = response.json() - # Should be a GitHub URL - assert "github.com" in data["repo_url"] - assert "Metic" in data["repo_url"] - - def test_version_endpoint_handles_errors_gracefully(self, client): - """Test that version endpoint doesn't crash even if files are missing.""" - # Even if all files are missing, endpoint should work - response = client.get("/api/version") - assert response.status_code == 200 - - data = response.json() - # Should have all required keys even on error - assert "version" in data - assert "repo_url" in data - - # Verify history can be retrieved and has expected structure - response2 = client.get("/api/history") - data = response2.json() - # Should have minimal or no history in entries - assert isinstance(data.get("entries", []), list) - - -class TestVersionEndpointDetailed: - """Detailed tests for the /api/version endpoint.""" - - def test_version_endpoint_exists(self, client): - """Test that /api/version endpoint exists and is accessible.""" - response = client.get("/api/version") - assert response.status_code == 200 - - def test_version_returns_expected_json_structure(self, client): - """Test that /api/version returns the expected JSON structure with all required keys.""" - response = client.get("/api/version") - assert response.status_code == 200 - - data = response.json() - # Check all required keys are present (unified version) - assert "version" in data - assert "repo_url" in data - - # Check that values are strings - assert isinstance(data["version"], str) - assert isinstance(data["repo_url"], str) - - # Check that repo URL is the expected value - assert data["repo_url"] == "https://github.com/hessius/MeticAI" - - @patch("api.routes.system.Path") - def test_version_with_existing_version_files(self, mock_path, client): - """Test that /api/version correctly reads VERSION file when it exists.""" - # Due to complexity of mocking Path internals, just verify endpoint works - response = client.get("/api/version") - assert response.status_code == 200 - data = response.json() - assert "version" in data - assert "repo_url" in data - - def test_version_with_missing_version_files(self, client): - """Test that /api/version defaults to 'unknown' when VERSION file doesn't exist.""" - response = client.get("/api/version") - assert response.status_code == 200 - - data = response.json() - assert "version" in data - assert "repo_url" in data - assert data["repo_url"] == "https://github.com/hessius/MeticAI" - assert isinstance(data["version"], str) - - @patch("api.routes.system.Path") - def test_version_handles_file_read_errors(self, mock_path, client): - """Test that /api/version handles file read errors gracefully.""" - mock_file = Mock() - mock_file.exists.return_value = True - mock_file.read_text.side_effect = Exception("File read error") - mock_path.return_value.__truediv__ = Mock(return_value=mock_file) - - response = client.get("/api/version") - assert response.status_code == 200 - - data = response.json() - assert "version" in data - assert "repo_url" in data - - @patch("api.routes.system.Path") - def test_version_parses_version_file(self, mock_path, client): - """Test that /api/version correctly reads the VERSION file.""" - # Due to complexity of mocking Path internals, just verify endpoint works - response = client.get("/api/version") - - # Endpoint should work even with complex mocking - assert response.status_code == 200 - data = response.json() - assert "version" in data - - def test_version_endpoint_cors_enabled(self, client): - """Test that /api/version endpoint has CORS enabled for web app.""" - response = client.get( - "/api/version", headers={"Origin": "http://localhost:3550"} - ) - - assert response.status_code == 200 - assert "access-control-allow-origin" in response.headers - - def test_version_in_openapi_schema(self, client): - """Test that /api/version endpoint is registered in OpenAPI schema.""" - response = client.get("/openapi.json") - assert response.status_code == 200 - - openapi_data = response.json() - assert "/api/version" in openapi_data["paths"] - assert "get" in openapi_data["paths"]["/api/version"] - - -class TestNetworkIpEndpoint: - """Tests for the /api/network-ip endpoint.""" - - def test_network_ip_returns_200(self, client): - """Test that /api/network-ip responds with 200.""" - response = client.get("/api/network-ip") - assert response.status_code == 200 - - def test_network_ip_returns_ip_field(self, client): - """Test that /api/network-ip returns an 'ip' field.""" - response = client.get("/api/network-ip") - data = response.json() - assert "ip" in data - assert isinstance(data["ip"], str) - - @patch("api.routes.system.socket") - def test_network_ip_uses_udp_socket(self, mock_socket_mod, client): - """Test that the endpoint tries the UDP socket trick first.""" - mock_sock = Mock() - mock_sock.getsockname.return_value = ("192.168.1.42", 0) - mock_sock.__enter__ = Mock(return_value=mock_sock) - mock_sock.__exit__ = Mock(return_value=False) - mock_socket_mod.socket.return_value = mock_sock - mock_socket_mod.AF_INET = 2 - mock_socket_mod.SOCK_DGRAM = 2 - - response = client.get("/api/network-ip") - assert response.status_code == 200 - data = response.json() - assert data["ip"] == "192.168.1.42" - - @patch("api.routes.system.socket") - def test_network_ip_fallback_to_hostname(self, mock_socket_mod, client): - """Test fallback to hostname resolution when UDP fails.""" - mock_sock = Mock() - mock_sock.__enter__ = Mock(return_value=mock_sock) - mock_sock.__exit__ = Mock(return_value=False) - mock_sock.connect.side_effect = OSError("No network") - mock_socket_mod.socket.return_value = mock_sock - mock_socket_mod.AF_INET = 2 - mock_socket_mod.SOCK_DGRAM = 2 - mock_socket_mod.gethostname.return_value = "myhost" - mock_socket_mod.gethostbyname.return_value = "10.0.0.5" - - response = client.get("/api/network-ip") - assert response.status_code == 200 - data = response.json() - assert data["ip"] == "10.0.0.5" - - -class TestRunShotEndpoints: - """Tests for the Run Shot / Machine control endpoints.""" - - @patch("api.routes.scheduling.async_get_last_profile", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_get_settings", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_session_get", new_callable=AsyncMock) - def test_machine_status_endpoint( - self, mock_session_get, mock_get_settings, mock_get_last_profile, client - ): - """Test GET /api/machine/status endpoint.""" - # Mock the async wrapper responses - mock_status_response = MagicMock() - mock_status_response.status_code = 200 - mock_status_response.json.return_value = {"state": "idle"} - mock_session_get.return_value = mock_status_response - mock_get_settings.return_value = MagicMock(auto_preheat=0) - mock_get_last_profile.return_value = MagicMock( - profile=MagicMock(id="test-123", name="Test Profile") - ) - - response = client.get("/api/machine/status") - - # Should return status info - assert response.status_code == 200 - data = response.json() - assert "machine_status" in data or "status" in data or "scheduled_shots" in data - - @patch("api.routes.scheduling.async_get_last_profile", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_get_settings", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_session_get", new_callable=AsyncMock) - def test_machine_status_no_connection( - self, mock_session_get, mock_get_settings, mock_get_last_profile, client - ): - """Test machine status when machine is not reachable.""" - # Simulate connection error when trying to fetch status - mock_session_get.side_effect = requests.exceptions.ConnectionError( - "Connection refused" - ) - mock_get_settings.return_value = MagicMock(auto_preheat=0) - mock_get_last_profile.return_value = MagicMock(profile=None) - - response = client.get("/api/machine/status") - - # Should handle gracefully and return status with error info - assert response.status_code == 200 - data = response.json() - assert "machine_status" in data - # Connection error should be captured in the status - assert "error" in data["machine_status"] or "state" in data["machine_status"] - - @patch("api.routes.scheduling.async_get_last_profile", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_get_settings", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_session_get", new_callable=AsyncMock) - def test_machine_status_api_unavailable( - self, mock_session_get, mock_get_settings, mock_get_last_profile, client - ): - """Test machine status when API is not available.""" - mock_session_get.side_effect = AttributeError( - "'NoneType' object has no attribute 'base_url'" - ) - mock_get_settings.side_effect = AttributeError( - "'NoneType' object has no attribute 'get_settings'" - ) - mock_get_last_profile.side_effect = AttributeError( - "'NoneType' object has no attribute 'get_last_profile'" - ) - - response = client.get("/api/machine/status") - - # Should handle gracefully - assert response.status_code in [200, 503] - - @patch("api.routes.scheduling.async_execute_action", new_callable=AsyncMock) - @patch("api.routes.scheduling.get_meticulous_api") - def test_preheat_endpoint_success(self, mock_get_api, mock_execute_action, client): - """Test POST /api/machine/preheat endpoint.""" - mock_get_api.return_value = MagicMock() # Not None = connected - # Ensure the mock response doesn't have an error attribute - mock_result = MagicMock(spec=[]) # Empty spec means no 'error' attribute - mock_execute_action.return_value = mock_result - - response = client.post("/api/machine/preheat") - - assert response.status_code == 200 - data = response.json() - assert "message" in data - assert "preheat" in data["message"].lower() or "Preheat" in data["message"] - - @patch("api.routes.scheduling.async_load_profile_by_id", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_execute_action", new_callable=AsyncMock) - @patch("api.routes.scheduling.get_meticulous_api") - def test_preheat_with_profile_preselection( - self, mock_get_api, mock_execute_action, mock_load, client - ): - """Test POST /api/machine/preheat with profile_id pre-selects the profile.""" - mock_get_api.return_value = MagicMock() - mock_result = MagicMock(spec=[]) - mock_execute_action.return_value = mock_result - mock_load.return_value = MagicMock(spec=[]) - - response = client.post( - "/api/machine/preheat", - json={"profile_id": "my-profile-123"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["profile_preselected"] is True - mock_load.assert_called_once_with("my-profile-123") - - @patch("api.routes.scheduling.async_execute_action", new_callable=AsyncMock) - @patch("api.routes.scheduling.get_meticulous_api") - def test_preheat_connection_error(self, mock_get_api, mock_execute_action, client): - """Test preheat when connection fails.""" - mock_get_api.return_value = MagicMock() # Not None = connected - # Simulate a connection error when trying to execute action - mock_execute_action.side_effect = Exception("Connection refused") - - response = client.post("/api/machine/preheat") - - # Connection error should result in 500 (internal error handling the request) - assert response.status_code == 500 - data = response.json() - assert "detail" in data - # Connection errors are caught by the general exception handler - assert response.status_code == 500 - - @patch("api.routes.scheduling.get_meticulous_api") - def test_preheat_no_connection(self, mock_get_api, client): - """Test preheat when machine not connected.""" - mock_get_api.return_value = None - - response = client.post("/api/machine/preheat") - - assert response.status_code == 503 - - @patch("api.routes.scheduling.async_execute_action", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_load_profile_by_id", new_callable=AsyncMock) - @patch("api.routes.scheduling.get_meticulous_api") - def test_run_profile_success( - self, mock_get_api, mock_load_profile, mock_execute_action, client - ): - """Test POST /api/machine/run-profile/{profile_id} endpoint.""" - mock_get_api.return_value = MagicMock() # Not None = connected - # Create mock results without 'error' attribute - mock_load_result = MagicMock(spec=["id", "name"]) - mock_load_result.id = "test-123" - mock_load_result.name = "Test" - mock_load_profile.return_value = mock_load_result - - mock_action_result = MagicMock(spec=["status", "action"]) - mock_action_result.status = "ok" - mock_action_result.action = "start" - mock_execute_action.return_value = mock_action_result - - response = client.post("/api/machine/run-profile/test-123") - - assert response.status_code == 200 - data = response.json() - assert "message" in data - - @patch("api.routes.scheduling.async_load_profile_by_id", new_callable=AsyncMock) - @patch("api.routes.scheduling.get_meticulous_api") - def test_run_profile_not_found(self, mock_get_api, mock_load_profile, client): - """Test running a profile that doesn't exist.""" - mock_get_api.return_value = MagicMock() # Not None = connected - # Create a mock result with error attribute - mock_result = MagicMock() - mock_result.error = "Profile not found" - mock_load_profile.return_value = mock_result - - response = client.post("/api/machine/run-profile/nonexistent") - - assert response.status_code == 502 - - @patch("api.routes.scheduling.async_load_profile_by_id", new_callable=AsyncMock) - @patch("api.routes.scheduling.get_meticulous_api") - def test_run_profile_connection_error( - self, mock_get_api, mock_load_profile, client - ): - """Test run profile when connection fails.""" - mock_get_api.return_value = MagicMock() # Not None = connected - # Simulate a connection error when trying to load the profile - mock_load_profile.side_effect = Exception("Connection refused") - - response = client.post("/api/machine/run-profile/test-123") - - # Connection error should result in 500 (internal error handling the request) - assert response.status_code == 500 - data = response.json() - assert "detail" in data - # Connection errors are caught by the general exception handler - assert response.status_code == 500 - - @patch("api.routes.scheduling.get_meticulous_api") - def test_run_profile_no_connection(self, mock_get_api, client): - """Test run profile when machine not connected.""" - mock_get_api.return_value = None - - response = client.post("/api/machine/run-profile/test-123") - - assert response.status_code == 503 - - def test_schedule_shot_success(self, client): - """Test POST /api/machine/schedule-shot endpoint.""" - from datetime import datetime, timedelta - - scheduled_time = (datetime.now() + timedelta(hours=1)).isoformat() - - response = client.post( - "/api/machine/schedule-shot", - json={ - "profile_id": "test-123", - "scheduled_time": scheduled_time, - "preheat": False, - }, - ) - - assert response.status_code == 200 - data = response.json() - assert "scheduled_shot" in data - assert data["scheduled_shot"]["profile_id"] == "test-123" - - def test_schedule_shot_with_preheat(self, client): - """Test scheduling a shot with preheat enabled.""" - from datetime import datetime, timedelta - - scheduled_time = (datetime.now() + timedelta(hours=1)).isoformat() - - response = client.post( - "/api/machine/schedule-shot", - json={ - "profile_id": "test-456", - "scheduled_time": scheduled_time, - "preheat": True, - }, - ) - - assert response.status_code == 200 - data = response.json() - assert data["scheduled_shot"]["preheat"] - - def test_schedule_shot_preheat_only(self, client): - """Test scheduling preheat only without a profile.""" - from datetime import datetime, timedelta - - scheduled_time = (datetime.now() + timedelta(hours=1)).isoformat() - - response = client.post( - "/api/machine/schedule-shot", - json={ - "profile_id": None, - "scheduled_time": scheduled_time, - "preheat": True, - }, - ) - - assert response.status_code == 200 - data = response.json() - assert data["scheduled_shot"]["profile_id"] is None - assert data["scheduled_shot"]["preheat"] - - def test_schedule_shot_invalid_no_profile_no_preheat(self, client): - """Test that scheduling without profile and without preheat fails.""" - from datetime import datetime, timedelta - - scheduled_time = (datetime.now() + timedelta(hours=1)).isoformat() - - response = client.post( - "/api/machine/schedule-shot", - json={ - "profile_id": None, - "scheduled_time": scheduled_time, - "preheat": False, - }, - ) - - assert response.status_code == 400 - - def test_get_scheduled_shots(self, client): - """Test GET /api/machine/scheduled-shots endpoint.""" - response = client.get("/api/machine/scheduled-shots") - - assert response.status_code == 200 - data = response.json() - assert "scheduled_shots" in data - assert isinstance(data["scheduled_shots"], list) - - def test_cancel_scheduled_shot(self, client): - """Test DELETE /api/machine/schedule-shot/{schedule_id}.""" - from datetime import datetime, timedelta - - # First create a scheduled shot - scheduled_time = (datetime.now() + timedelta(hours=1)).isoformat() - create_response = client.post( - "/api/machine/schedule-shot", - json={ - "profile_id": "test-cancel", - "scheduled_time": scheduled_time, - "preheat": False, - }, - ) - assert create_response.status_code == 200 - schedule_id = create_response.json()["scheduled_shot"]["id"] - - # Now cancel it - response = client.delete(f"/api/machine/schedule-shot/{schedule_id}") - - assert response.status_code == 200 - data = response.json() - assert "message" in data - - def test_cancel_nonexistent_scheduled_shot(self, client): - """Test canceling a scheduled shot that doesn't exist.""" - response = client.delete("/api/machine/schedule-shot/nonexistent-id-123") - - assert response.status_code == 404 - - -class TestScheduledShotsPersistence: - """Tests for scheduled shots persistence functionality.""" - - def test_persistence_save_and_load(self, tmp_path): - """Test saving and loading scheduled shots from disk.""" - import asyncio - from services.scheduling_state import ScheduledShotsPersistence - - # Create persistence instance with temp file - persistence_file = tmp_path / "test_scheduled_shots.json" - persistence = ScheduledShotsPersistence(str(persistence_file)) - - # Create test data - test_shots = { - "shot-1": { - "id": "shot-1", - "profile_id": "test-profile", - "scheduled_time": "2026-02-01T18:00:00Z", - "preheat": True, - "status": "scheduled", - "created_at": "2026-02-01T17:00:00Z", - }, - "shot-2": { - "id": "shot-2", - "profile_id": "test-profile-2", - "scheduled_time": "2026-02-01T19:00:00Z", - "preheat": False, - "status": "preheating", - "created_at": "2026-02-01T17:30:00Z", - }, - } - - # Save shots - asyncio.run(persistence.save(test_shots)) - - # Verify file was created - assert persistence_file.exists() - - # Load shots - loaded_shots = asyncio.run(persistence.load()) - - # Verify loaded data matches - assert len(loaded_shots) == 2 - assert "shot-1" in loaded_shots - assert "shot-2" in loaded_shots - assert loaded_shots["shot-1"]["profile_id"] == "test-profile" - assert loaded_shots["shot-2"]["preheat"] is False - - def test_persistence_filters_inactive_shots(self, tmp_path): - """Test that only active (scheduled/preheating) shots are persisted.""" - import asyncio - from services.scheduling_state import ScheduledShotsPersistence - - persistence_file = tmp_path / "test_scheduled_shots.json" - persistence = ScheduledShotsPersistence(str(persistence_file)) - - # Create test data with mixed statuses - test_shots = { - "shot-1": {"id": "shot-1", "status": "scheduled"}, - "shot-2": {"id": "shot-2", "status": "preheating"}, - "shot-3": {"id": "shot-3", "status": "completed"}, - "shot-4": {"id": "shot-4", "status": "cancelled"}, - "shot-5": {"id": "shot-5", "status": "failed"}, - } - - # Save shots - asyncio.run(persistence.save(test_shots)) - - # Load and verify only active shots were saved - loaded_shots = asyncio.run(persistence.load()) - - assert len(loaded_shots) == 2 - assert "shot-1" in loaded_shots - assert "shot-2" in loaded_shots - assert "shot-3" not in loaded_shots - assert "shot-4" not in loaded_shots - assert "shot-5" not in loaded_shots - - def test_persistence_handles_missing_file(self, tmp_path): - """Test that loading from non-existent file returns empty dict.""" - import asyncio - from services.scheduling_state import ScheduledShotsPersistence - - persistence_file = tmp_path / "nonexistent.json" - persistence = ScheduledShotsPersistence(str(persistence_file)) - - # Load from non-existent file - loaded_shots = asyncio.run(persistence.load()) - - assert loaded_shots == {} - - def test_persistence_handles_corrupt_file(self, tmp_path): - """Test that corrupt JSON file is handled gracefully.""" - import asyncio - from services.scheduling_state import ScheduledShotsPersistence - - persistence_file = tmp_path / "corrupt.json" - - # Create a corrupt JSON file - with open(persistence_file, "w") as f: - f.write("{invalid json content") - - persistence = ScheduledShotsPersistence(str(persistence_file)) - - # Load should return empty dict and backup corrupt file - loaded_shots = asyncio.run(persistence.load()) - - assert loaded_shots == {} - # Corrupt file should be backed up - assert (tmp_path / "corrupt.corrupt").exists() - - def test_persistence_clear(self, tmp_path): - """Test clearing persisted scheduled shots.""" - import asyncio - from services.scheduling_state import ScheduledShotsPersistence - - persistence_file = tmp_path / "test_scheduled_shots.json" - persistence = ScheduledShotsPersistence(str(persistence_file)) - - # Create and save test data - test_shots = {"shot-1": {"id": "shot-1", "status": "scheduled"}} - asyncio.run(persistence.save(test_shots)) - - assert persistence_file.exists() - - # Clear persistence - asyncio.run(persistence.clear()) - - assert not persistence_file.exists() - - def test_persistence_atomic_write(self, tmp_path): - """Test that writes are atomic (use temp file + rename).""" - import asyncio - from services.scheduling_state import ScheduledShotsPersistence - - persistence_file = tmp_path / "test_scheduled_shots.json" - persistence = ScheduledShotsPersistence(str(persistence_file)) - - # Save some data - test_shots = {"shot-1": {"id": "shot-1", "status": "scheduled"}} - asyncio.run(persistence.save(test_shots)) - - # Verify no temp file remains after save - assert not (tmp_path / "test_scheduled_shots.tmp").exists() - - # Verify final file exists - assert persistence_file.exists() - - def test_scheduled_shot_persists_on_creation(self, client): - """Test that creating a scheduled shot persists it to disk.""" - from datetime import datetime, timedelta - import time - - # Schedule a shot - scheduled_time = (datetime.now() + timedelta(hours=2)).isoformat() - response = client.post( - "/api/machine/schedule-shot", - json={ - "profile_id": "test-persist", - "scheduled_time": scheduled_time, - "preheat": True, - }, - ) - - assert response.status_code == 200 - schedule_id = response.json()["schedule_id"] - - # Give persistence a moment to complete - time.sleep(0.1) - - # Check that persistence file was created/updated - # Note: We can't easily check the file content in the test environment - # but we verified the save is called in the endpoint - assert schedule_id is not None - - def test_scheduled_shot_persists_on_cancellation(self, client): - """Test that cancelling a scheduled shot persists the status change.""" - from datetime import datetime, timedelta - import time - - # Create a scheduled shot - scheduled_time = (datetime.now() + timedelta(hours=2)).isoformat() - create_response = client.post( - "/api/machine/schedule-shot", - json={ - "profile_id": "test-cancel-persist", - "scheduled_time": scheduled_time, - "preheat": False, - }, - ) - - assert create_response.status_code == 200 - schedule_id = create_response.json()["schedule_id"] - - # Cancel it - cancel_response = client.delete(f"/api/machine/schedule-shot/{schedule_id}") - assert cancel_response.status_code == 200 - - # Give persistence a moment to complete - time.sleep(0.1) - - # Verify cancellation was successful - assert cancel_response.json()["status"] == "success" - - -class TestUtilityFunctions: - """Test utility functions for better coverage.""" - - def test_deep_convert_to_dict_with_none(self): - """Test deep_convert_to_dict with None.""" - result = utils.file_utils.deep_convert_to_dict(None) - assert result is None - - def test_deep_convert_to_dict_with_primitives(self): - """Test deep_convert_to_dict with primitive types.""" - assert utils.file_utils.deep_convert_to_dict("string") == "string" - assert utils.file_utils.deep_convert_to_dict(42) == 42 - assert utils.file_utils.deep_convert_to_dict(3.14) == 3.14 - assert utils.file_utils.deep_convert_to_dict(True) is True - - def test_deep_convert_to_dict_with_dict(self): - """Test deep_convert_to_dict with nested dict.""" - data = {"a": 1, "b": {"c": 2}} - result = utils.file_utils.deep_convert_to_dict(data) - assert result == {"a": 1, "b": {"c": 2}} - - def test_deep_convert_to_dict_with_list(self): - """Test deep_convert_to_dict with list and tuple.""" - assert utils.file_utils.deep_convert_to_dict([1, 2, 3]) == [1, 2, 3] - assert utils.file_utils.deep_convert_to_dict((1, 2, 3)) == [1, 2, 3] - - def test_deep_convert_to_dict_with_object(self): - """Test deep_convert_to_dict with object having __dict__.""" - - class TestObj: - def __init__(self): - self.public = "value" - self._private = "hidden" - - obj = TestObj() - result = utils.file_utils.deep_convert_to_dict(obj) - assert result == {"public": "value"} - assert "_private" not in result - - def test_deep_convert_to_dict_with_unconvertible_type(self): - """Test deep_convert_to_dict with type that can be stringified.""" - import datetime - - dt = datetime.datetime(2024, 1, 1, 12, 0, 0) - result = utils.file_utils.deep_convert_to_dict(dt) - assert isinstance(result, str) - assert "2024" in result - - def test_deep_convert_to_dict_with_exception_during_str(self): - """Test deep_convert_to_dict with object that fails str().""" - - class BadStr: - def __str__(self): - raise ValueError("Cannot convert") - - result = utils.file_utils.deep_convert_to_dict(BadStr()) - # Object has __dict__, so it returns empty dict - assert result == {} - - def test_atomic_write_json_success(self, tmp_path): - """Test atomic_write_json successfully writes file.""" - filepath = tmp_path / "test.json" - data = {"key": "value", "number": 42} - - utils.file_utils.atomic_write_json(filepath, data) - - assert filepath.exists() - with open(filepath) as f: - loaded = json.load(f) - assert loaded == data - - def test_atomic_write_json_with_invalid_path(self, tmp_path): - """Test atomic_write_json handles invalid path errors.""" - # Try to write to a path that requires the parent to be a file - file_path = tmp_path / "file.txt" - file_path.write_text("content") - - # Try to write to a "subdirectory" of the file (invalid) - invalid_path = file_path / "subdir" / "test.json" - - data = {"key": "value"} - - with pytest.raises((OSError, FileNotFoundError, AttributeError)): - utils.file_utils.atomic_write_json(invalid_path, data) - - def test_atomic_write_json_cleanup_on_failure(self, tmp_path, monkeypatch): - """Test atomic_write_json cleans up temp file on failure.""" - filepath = tmp_path / "test.json" - data = {"key": "value"} - - # Mock os.replace to raise an exception (atomic_write_json uses os.replace) - def failing_replace(src, dst): - raise OSError("Simulated replace failure") - - monkeypatch.setattr(os, "replace", failing_replace) - - with pytest.raises(OSError): - utils.file_utils.atomic_write_json(filepath, data) - - # Original file should not exist - assert not filepath.exists() - - # No temp files should remain - temp_files = list(tmp_path.glob(".test.json.*.tmp")) - assert len(temp_files) == 0 - - -class TestStartupAndLifespan: - """Test startup and lifespan management.""" - - @pytest.mark.asyncio - async def test_check_for_updates_task_script_not_found(self, monkeypatch): - """Test check_for_updates_task when script doesn't exist.""" - - # Mock Path.exists to return False - def mock_exists(self): - return False - - monkeypatch.setattr(Path, "exists", mock_exists) - - # Should complete without error - await main.check_for_updates_task() - - @pytest.mark.asyncio - async def test_check_for_updates_task_success(self, monkeypatch, tmp_path): - """Test check_for_updates_task with successful execution.""" - script_path = tmp_path / "update.sh" - script_path.write_text("#!/bin/bash\necho 'Update check'\nexit 0\n") - script_path.chmod(0o755) - - # Mock the script path - monkeypatch.setattr( - main, "Path", lambda x: script_path if x == "/app/update.sh" else Path(x) - ) - - # Mock subprocess.run - async def mock_run(*args, **kwargs): - import subprocess - - return subprocess.CompletedProcess( - args=args[0], returncode=0, stdout="Check complete", stderr="" - ) - - import asyncio - - async def mock_to_thread(func, *args, **kwargs): - # Simulate subprocess.run result - import subprocess - - return subprocess.CompletedProcess( - args=["bash", str(script_path), "--check-only"], - returncode=0, - stdout="Check complete", - stderr="", - ) - - monkeypatch.setattr(asyncio, "to_thread", mock_to_thread) - - # Should complete successfully - await main.check_for_updates_task() - - @pytest.mark.asyncio - async def test_check_for_updates_task_non_zero_exit(self, monkeypatch): - """Test check_for_updates_task with non-zero exit code.""" - # Mock Path.exists to return True - monkeypatch.setattr(Path, "exists", lambda self: True) - - # Mock subprocess to return non-zero exit - import asyncio - - async def mock_to_thread(func, *args, **kwargs): - import subprocess - - return subprocess.CompletedProcess( - args=args, returncode=1, stdout="", stderr="Error occurred" - ) - - monkeypatch.setattr(asyncio, "to_thread", mock_to_thread) - - # Should log warning but not raise - await main.check_for_updates_task() - - @pytest.mark.asyncio - async def test_check_for_updates_task_timeout(self, monkeypatch): - """Test check_for_updates_task with timeout.""" - # Mock Path.exists to return True - monkeypatch.setattr(Path, "exists", lambda self: True) - - # Mock subprocess to raise TimeoutExpired - import asyncio - import subprocess - - async def mock_to_thread(func, *args, **kwargs): - raise subprocess.TimeoutExpired(cmd=args, timeout=120) - - monkeypatch.setattr(asyncio, "to_thread", mock_to_thread) - - # Should log error but not raise - await main.check_for_updates_task() - - @pytest.mark.asyncio - async def test_check_for_updates_task_generic_exception(self, monkeypatch): - """Test check_for_updates_task with generic exception.""" - # Mock Path.exists to return True - monkeypatch.setattr(Path, "exists", lambda self: True) - - # Mock subprocess to raise exception - import asyncio - - async def mock_to_thread(func, *args, **kwargs): - raise RuntimeError("Unexpected error") - - monkeypatch.setattr(asyncio, "to_thread", mock_to_thread) - - # Should log error but not raise - await main.check_for_updates_task() - - -class TestParseGeminiErrorExtended: - """Extended tests for parse_gemini_error function.""" - - def test_parse_gemini_error_quota_exhausted(self): - """Test quota exhausted error.""" - error = "Error: Quota exhausted for the day" - result = services.gemini_service.parse_gemini_error(error) - assert "quota" in result.lower() - assert "tomorrow" in result.lower() - - def test_parse_gemini_error_rate_limit(self): - """Test rate limit error.""" - error = "Error: Rate limit exceeded - too many requests" - result = services.gemini_service.parse_gemini_error(error) - assert "rate limit" in result.lower() - assert "wait" in result.lower() - - def test_parse_gemini_error_api_key(self): - """Test API key error.""" - error = "Error: Invalid API key provided" - result = services.gemini_service.parse_gemini_error(error) - assert "api" in result.lower() or "authentication" in result.lower() - - def test_parse_gemini_error_network(self): - """Test network error.""" - error = "Error: Network timeout connecting to API" - result = services.gemini_service.parse_gemini_error(error) - assert "network" in result.lower() - - def test_parse_gemini_error_mcp_connection(self): - """Test MCP connection error.""" - error = "MCP error: Connection refused to meticulous machine" - result = services.gemini_service.parse_gemini_error(error) - assert "connect" in result.lower() or "meticulous" in result.lower() - - def test_parse_gemini_error_safety_filter(self): - """Test content safety filter error.""" - error = "Error: Content blocked by safety filters" - result = services.gemini_service.parse_gemini_error(error) - assert "safety" in result.lower() or "blocked" in result.lower() - - def test_parse_gemini_error_extract_clean_message(self): - """Test extracting clean error message from verbose output.""" - error = "Stack trace...\nError: Profile validation failed - invalid temperature\nmore details..." - result = services.gemini_service.parse_gemini_error(error) - assert "validation" in result.lower() - - def test_parse_gemini_error_truncate_long_message(self): - """Test truncating very long error messages.""" - error = "x" * 300 - result = services.gemini_service.parse_gemini_error(error) - assert len(result) < 300 - - def test_parse_gemini_error_generic_fallback(self): - """Test generic fallback for unknown errors.""" - error = "Something unexpected happened" - result = services.gemini_service.parse_gemini_error(error) - assert "failed" in result.lower() - - def test_parse_gemini_error_empty_string(self): - """Test empty error string.""" - result = services.gemini_service.parse_gemini_error("") - assert "unexpectedly" in result.lower() - - def test_parse_gemini_error_auth_method_missing(self): - """Test Gemini CLI auth method / API key not set error.""" - error = ( - "YOLO mode is enabled. All tool calls will be automatically approved.\n" - "Please set an Auth method in your /root/.gemini/settings.json or " - "specify one of the following environment variables before running: " - "GEMINI_API_KEY, GOOGLE_GENAI_USE_VERTEXAI" - ) - result = services.gemini_service.parse_gemini_error(error) - assert "api key" in result.lower() or "settings" in result.lower() - # Must NOT leak raw YOLO noise into the user message - assert "yolo" not in result.lower() - - def test_parse_gemini_error_api_key_underscore(self): - """Test that GEMINI_API_KEY (with underscores) is caught as auth error.""" - error = "Error: GEMINI_API_KEY environment variable is not set" - result = services.gemini_service.parse_gemini_error(error) - assert "api key" in result.lower() or "configured" in result.lower() - - def test_parse_gemini_error_model_no_longer_available(self): - """Test deprecated / unavailable model error (404 NOT_FOUND).""" - error = ( - "404 NOT_FOUND. {'error': {'code': 404, 'message': " - "'This model models/gemini-2.0-flash is no longer available to new users.', " - "'status': 'NOT_FOUND'}}" - ) - result = services.gemini_service.parse_gemini_error(error) - assert "no longer available" in result.lower() - assert "update" in result.lower() - - def test_parse_gemini_error_model_deprecated(self): - """Test generic deprecated model message.""" - error = "Error: The model gemini-1.5-pro has been deprecated" - result = services.gemini_service.parse_gemini_error(error) - assert "no longer available" in result.lower() - - def test_parse_gemini_error_model_not_found(self): - """Test 404 NOT_FOUND with model reference.""" - error = "404 NOT_FOUND: model 'models/gemini-old' not found" - result = services.gemini_service.parse_gemini_error(error) - assert "no longer available" in result.lower() - - -class TestGetModelName: - """Tests for get_model_name() env var resolution.""" - - def test_default_model_when_env_unset(self, monkeypatch): - """Returns default model when GEMINI_MODEL is not set.""" - monkeypatch.delenv("GEMINI_MODEL", raising=False) - from services.gemini_service import get_model_name - - assert get_model_name() == "gemini-2.5-flash" - - def test_custom_model_from_env(self, monkeypatch): - """Returns custom model when GEMINI_MODEL is set.""" - monkeypatch.setenv("GEMINI_MODEL", "gemini-2.5-pro") - from services.gemini_service import get_model_name - - assert get_model_name() == "gemini-2.5-pro" - - def test_empty_env_falls_back_to_default(self, monkeypatch): - """Falls back to default when GEMINI_MODEL is empty string.""" - monkeypatch.setenv("GEMINI_MODEL", "") - from services.gemini_service import get_model_name - - assert get_model_name() == "gemini-2.5-flash" - - def test_whitespace_env_falls_back_to_default(self, monkeypatch): - """Falls back to default when GEMINI_MODEL is whitespace.""" - monkeypatch.setenv("GEMINI_MODEL", " ") - from services.gemini_service import get_model_name - - assert get_model_name() == "gemini-2.5-flash" - - -class TestSettingsHydration: - """Test that stored settings are hydrated into os.environ at startup.""" - - @pytest.mark.asyncio - async def test_lifespan_hydrates_gemini_api_key(self, monkeypatch, tmp_path): - """Lifespan hydrates GEMINI_API_KEY from settings.json when env is empty.""" - import json - from main import lifespan, app - - # Write a fake settings.json - settings_file = tmp_path / "settings.json" - settings_file.write_text(json.dumps({"geminiApiKey": "sk-test-key-123"})) - - # Patch settings service to use our tmp file - monkeypatch.setattr("services.settings_service.SETTINGS_FILE", settings_file) - # Clear cache - import services.settings_service as ss - - monkeypatch.setattr(ss, "_settings_cache", None) - - # Make sure env var is empty - monkeypatch.delenv("GEMINI_API_KEY", raising=False) - - # Run the lifespan context manager - async with lifespan(app): - result = os.environ.get("GEMINI_API_KEY") - - assert result == "sk-test-key-123" - - @pytest.mark.asyncio - async def test_lifespan_does_not_overwrite_env_var(self, monkeypatch, tmp_path): - """Lifespan should NOT overwrite an env var that's already set.""" - import json - from main import lifespan, app - - settings_file = tmp_path / "settings.json" - settings_file.write_text(json.dumps({"geminiApiKey": "stored-key"})) - - monkeypatch.setattr("services.settings_service.SETTINGS_FILE", settings_file) - import services.settings_service as ss - - monkeypatch.setattr(ss, "_settings_cache", None) - - # Set the env var to a different value - monkeypatch.setenv("GEMINI_API_KEY", "env-provided-key") - - async with lifespan(app): - result = os.environ.get("GEMINI_API_KEY") - - # Should keep the env-provided key, not overwrite with stored - assert result == "env-provided-key" - - -class TestGetVisionModel: - """Test vision model / Gemini client initialization.""" - - def test_get_vision_model_missing_api_key(self, monkeypatch): - """Test get_vision_model raises error when API key is missing.""" - # Clear the cached client - services.gemini_service._gemini_client = None - - # Remove API key - monkeypatch.delenv("GEMINI_API_KEY", raising=False) - - with pytest.raises(ValueError) as exc_info: - services.gemini_service.get_vision_model() - - assert "GEMINI_API_KEY" in str(exc_info.value) - - # Restore for other tests - monkeypatch.setenv("GEMINI_API_KEY", "test_key") - - def test_get_gemini_client_lazy_initialization(self, monkeypatch): - """Test Gemini client is lazily initialized.""" - # Clear the cached client - services.gemini_service._gemini_client = None - - monkeypatch.setenv("GEMINI_API_KEY", "test_key_123") - - # Track Client constructor calls - client_calls = [] - mock_client_instance = MagicMock() - - def mock_client_constructor(**kwargs): - client_calls.append(kwargs) - return mock_client_instance - - with patch( - "services.gemini_service.genai.Client", side_effect=mock_client_constructor - ): - # First call should initialize - client1 = services.gemini_service.get_gemini_client() - assert len(client_calls) == 1 - assert client_calls[0]["api_key"] == "test_key_123" - - # Second call should reuse cached client - client2 = services.gemini_service.get_gemini_client() - assert len(client_calls) == 1 # Not called again - assert client1 is client2 - - # Cleanup - services.gemini_service._gemini_client = None - - def test_get_vision_model_returns_wrapper(self, monkeypatch): - """Test get_vision_model returns a wrapper with generate_content.""" - services.gemini_service._gemini_client = None - monkeypatch.setenv("GEMINI_API_KEY", "test_key") - - mock_client_instance = MagicMock() - with patch( - "services.gemini_service.genai.Client", return_value=mock_client_instance - ): - model = services.gemini_service.get_vision_model() - assert hasattr(model, "generate_content") - assert hasattr(model, "async_generate_content") - - # Call generate_content and verify it delegates to client - model.generate_content("test prompt") - mock_client_instance.models.generate_content.assert_called_once() - - # Cleanup - services.gemini_service._gemini_client = None - - -class TestGetMeticulousAPI: - """Test get_meticulous_api function.""" - - def test_get_meticulous_api_lazy_init(self, monkeypatch): - """Test get_meticulous_api lazy initialization.""" - # Clear cached API - services.meticulous_service._meticulous_api = None - - monkeypatch.setenv("METICULOUS_IP", "192.168.1.100") - - # Mock the Api class - class MockApi: - def __init__(self, base_url): - self.base_url = base_url - - from unittest.mock import MagicMock - - mock_module = MagicMock() - mock_module.Api = MockApi - - import sys - - sys.modules["meticulous"] = mock_module - sys.modules["meticulous.api"] = mock_module - - api1 = services.meticulous_service.get_meticulous_api() - assert api1 is not None - assert api1.base_url == "http://192.168.1.100" - - # Second call should return cached instance - api2 = services.meticulous_service.get_meticulous_api() - assert api1 is api2 - - # Cleanup - services.meticulous_service._meticulous_api = None - - def test_get_meticulous_api_adds_http_prefix(self, monkeypatch): - """Test get_meticulous_api adds http:// prefix when missing.""" - services.meticulous_service._meticulous_api = None - - monkeypatch.setenv("METICULOUS_IP", "espresso.local") - - class MockApi: - def __init__(self, base_url): - self.base_url = base_url - - from unittest.mock import MagicMock - - mock_module = MagicMock() - mock_module.Api = MockApi - - import sys - - sys.modules["meticulous"] = mock_module - sys.modules["meticulous.api"] = mock_module - - api = services.meticulous_service.get_meticulous_api() - assert api.base_url == "http://espresso.local" - - services.meticulous_service._meticulous_api = None - - def test_get_meticulous_api_reinitializes_when_target_changes(self, monkeypatch): - """Test cached client is reinitialized when METICULOUS_IP changes.""" - services.meticulous_service._meticulous_api = None - - class MockApi: - def __init__(self, base_url): - self.base_url = base_url - - from unittest.mock import MagicMock - - mock_module = MagicMock() - mock_module.Api = MockApi - - import sys - - sys.modules["meticulous"] = mock_module - sys.modules["meticulous.api"] = mock_module - - monkeypatch.setenv("METICULOUS_IP", "192.168.1.100") - api1 = services.meticulous_service.get_meticulous_api() - assert api1.base_url == "http://192.168.1.100" - - monkeypatch.setenv("METICULOUS_IP", "192.168.1.101") - api2 = services.meticulous_service.get_meticulous_api() - assert api2.base_url == "http://192.168.1.101" - assert api1 is not api2 - - services.meticulous_service._meticulous_api = None - - -class TestSettingsEndpoints: - """Test settings management endpoints.""" - - def test_get_settings_with_api_key_set(self, client, monkeypatch): - """Test get_settings masks API key.""" - monkeypatch.setenv("GEMINI_API_KEY", "sk-test-api-key-12345") - monkeypatch.setenv("METICULOUS_IP", "192.168.1.100") - monkeypatch.setenv("PI_IP", "192.168.1.200") - - response = client.get("/api/settings") - assert response.status_code == 200 - - data = response.json() - assert data["geminiApiKeyConfigured"] is True - assert data["geminiApiKeyMasked"] is True - assert "*" in data["geminiApiKey"] - assert "sk-test" not in data["geminiApiKey"] # Should be masked - assert data["meticulousIp"] == "192.168.1.100" - assert data["serverIp"] == "192.168.1.200" - - def test_get_settings_without_api_key(self, client, monkeypatch): - """Test get_settings when API key is not set.""" - monkeypatch.delenv("GEMINI_API_KEY", raising=False) - - response = client.get("/api/settings") - assert response.status_code == 200 - - data = response.json() - assert data["geminiApiKeyConfigured"] is False - - def test_get_settings_with_stored_api_key_masks_and_marks_configured( - self, client, monkeypatch - ): - """Stored API key should be masked and marked configured when env key is absent.""" - monkeypatch.delenv("GEMINI_API_KEY", raising=False) - - import api.routes.system as system_module - - def mock_load_settings(): - return { - "geminiApiKey": "stored-key-1234567890", - "meticulousIp": "", - "serverIp": "", - "authorName": "", - "mqttEnabled": True, - "tailscaleEnabled": False, - "tailscaleAuthKey": "", - } - - monkeypatch.setattr(system_module, "load_settings", mock_load_settings) - - response = client.get("/api/settings") - assert response.status_code == 200 - - data = response.json() - assert data["geminiApiKeyConfigured"] is True - assert data["geminiApiKeyMasked"] is True - assert data["geminiApiKey"] - assert "stored-key" not in data["geminiApiKey"] - - def test_get_settings_never_leaks_raw_ai_provider_key(self, client, monkeypatch): - """The raw BYO provider key (aiApiKey) must never be returned (#491).""" - monkeypatch.delenv("GEMINI_API_KEY", raising=False) - - import api.routes.system as system_module - import services.ai_providers as ai_providers_module - - def mock_load_settings(): - return { - "aiProvider": "openai", - "aiApiKey": "sk-secret-openai-key-1234567890", - "meticulousIp": "", - "serverIp": "", - "authorName": "", - } - - monkeypatch.setattr(system_module, "load_settings", mock_load_settings) - monkeypatch.setattr( - ai_providers_module, "get_active_provider_id", lambda: "openai" - ) - monkeypatch.setattr( - ai_providers_module, - "get_provider_api_key", - lambda _provider=None: "sk-secret-openai-key-1234567890", - ) - - response = client.get("/api/settings") - assert response.status_code == 200 - - data = response.json() - assert "aiApiKey" not in data - assert "sk-secret-openai-key" not in json.dumps(data) - # Masked representation is still present and configured. - assert data["geminiApiKeyConfigured"] is True - assert data["geminiApiKeyMasked"] is True - - def test_get_settings_error_handling(self, client, monkeypatch): - """Test get_settings handles errors.""" - import api.routes.system as system_module - - # Mock load_settings to raise an exception - def mock_load_settings(): - raise ValueError("Settings file corrupted") - - monkeypatch.setattr(system_module, "load_settings", mock_load_settings) - - response = client.get("/api/settings") - assert response.status_code == 500 - assert "detail" in response.json() - assert "error" in response.json()["detail"] - - -class TestRestartEndpoint: - """Test system restart endpoint (SIGTERM to PID 1).""" - - @patch("api.routes.system.os.kill") - def test_restart_success(self, mock_kill, client, tmp_path, monkeypatch): - """Test successful restart triggers SIGTERM to PID 1.""" - response = client.post("/api/restart") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert "restart" in data["message"].lower() - - def test_restart_returns_success_response(self, client): - """Test that restart endpoint returns success even without PID 1 access.""" - response = client.post("/api/restart") - - # Should succeed — the actual kill is deferred in a background task - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - - -class TestLogsEndpoint: - """Test logs retrieval endpoint.""" - - def test_get_logs_file_not_found(self, client, tmp_path, monkeypatch): - """Test get_logs when log file doesn't exist.""" - monkeypatch.setenv("LOG_DIR", str(tmp_path)) - - response = client.get("/api/logs") - - # Should return empty logs or handle gracefully - assert response.status_code in [200, 404] - if response.status_code == 200: - data = response.json() - # May return logs if the logging system was already initialized - assert "logs" in data - - -class TestSaveSettingsEndpoint: - """Test save settings endpoint.""" - - def test_save_settings_empty_body(self, client): - """Test save_settings with empty request body.""" - response = client.post("/api/settings", json={}) - - # Should handle gracefully - assert response.status_code in [200, 400, 500] - - def test_save_gemini_model_persists_and_returns( - self, client, monkeypatch, tmp_path - ): - """POST geminiModel then GET and verify it is returned.""" - import services.settings_service as settings_svc - - settings_file = tmp_path / "settings.json" - settings_file.write_text("{}") - monkeypatch.setattr(settings_svc, "SETTINGS_FILE", settings_file) - monkeypatch.delenv("GEMINI_MODEL", raising=False) - - resp = client.post("/api/settings", json={"geminiModel": "gemini-2.5-pro"}) - assert resp.status_code == 200 - - resp = client.get("/api/settings") - assert resp.status_code == 200 - assert resp.json()["geminiModel"] == "gemini-2.5-pro" - - def test_save_gemini_model_empty_falls_back_to_default( - self, client, monkeypatch, tmp_path - ): - """POST empty geminiModel falls back to the default model.""" - import services.settings_service as settings_svc - - settings_file = tmp_path / "settings.json" - settings_file.write_text("{}") - monkeypatch.setattr(settings_svc, "SETTINGS_FILE", settings_file) - monkeypatch.delenv("GEMINI_MODEL", raising=False) - - resp = client.post("/api/settings", json={"geminiModel": " "}) - assert resp.status_code == 200 - - resp = client.get("/api/settings") - assert resp.status_code == 200 - assert resp.json()["geminiModel"] == "gemini-2.5-flash" - - def test_get_settings_includes_gemini_model(self, client, monkeypatch): - """GET /api/settings always includes geminiModel.""" - monkeypatch.delenv("GEMINI_MODEL", raising=False) - - resp = client.get("/api/settings") - assert resp.status_code == 200 - assert "geminiModel" in resp.json() - - -class TestDecompressShotData: - """Test shot data decompression.""" - - def test_decompress_shot_data_success(self): - """Test successful decompression.""" - import zstandard - import json - - # Create test data - original_data = {"test": "data", "shot_id": 123} - json_str = json.dumps(original_data) - - # Compress it - compressor = zstandard.ZstdCompressor() - compressed = compressor.compress(json_str.encode("utf-8")) - - # Decompress using the function - result = services.meticulous_service.decompress_shot_data(compressed) - - assert result == original_data - - -class TestLLMAnalysisCacheFunctions: - """Test LLM analysis cache functions.""" - - def test_llm_cache_file_creation(self, tmp_path, monkeypatch): - """Test LLM cache file management.""" - cache_file = tmp_path / "llm_cache.json" - cache_file.write_text("{}") - - monkeypatch.setattr(config, "DATA_DIR", tmp_path) - - # Verify cache file exists - assert cache_file.exists() - - # Verify it's valid JSON - data = json.loads(cache_file.read_text()) - assert isinstance(data, dict) - - -class TestAdditionalCoveragePaths: - """Additional tests to reach 75% coverage target.""" - - def testsanitize_profile_name_for_filename(self): - """Test sanitize_profile_name_for_filename with various inputs.""" - # Test with special characters - result = utils.sanitization.sanitize_profile_name_for_filename( - "Test/Profile\\Name:123" - ) - assert "/" not in result and "\\" not in result - - # Test with spaces - result = utils.sanitization.sanitize_profile_name_for_filename( - "My Cool Profile" - ) - assert isinstance(result, str) - - def test_safe_float_with_various_types(self): - """Test safe_float helper function.""" - # These might not exist, so wrap in try/except - try: - assert services.analysis_service._safe_float("3.14") == 3.14 - assert services.analysis_service._safe_float(42) == 42.0 - assert services.analysis_service._safe_float(None) == 0.0 - except AttributeError: - pass # Function doesn't exist - - @pytest.mark.asyncio - async def test_version_info_retrieval(self, client): - """Test version info endpoint comprehensively.""" - response = client.get("/api/version") - assert response.status_code == 200 - - data = response.json() - assert "meticai" in data or "version" in str(data).lower() - - def test_data_directory_configuration_paths(self, monkeypatch): - """Test data directory path resolution.""" - # Test that DATA_DIR is set - assert config.DATA_DIR is not None - assert isinstance(config.DATA_DIR, Path) - - -# ============================================================================== -# Tests for Recurring Schedules Feature (developed today) -# ============================================================================== - - -class TestRecurringSchedulesPersistence: - """Tests for RecurringSchedulesPersistence class.""" - - @pytest.fixture - def temp_persistence_file(self, tmp_path): - """Create a temporary persistence file.""" - return str(tmp_path / "recurring_schedules.json") - - @pytest.mark.asyncio - async def test_persistence_save_and_load(self, temp_persistence_file): - """Test saving and loading recurring schedules.""" - persistence = services.scheduling_state.RecurringSchedulesPersistence( - temp_persistence_file - ) - - test_schedules = { - "schedule-1": { - "name": "Morning Preheat", - "time": "07:00", - "recurrence_type": "weekdays", - "profile_id": None, - "preheat": True, - "enabled": True, - }, - "schedule-2": { - "name": "Weekend Coffee", - "time": "09:00", - "recurrence_type": "weekends", - "profile_id": "profile-123", - "preheat": True, - "enabled": True, - }, - } - - # Save schedules - await persistence.save(test_schedules) - - # Load them back - loaded = await persistence.load() - - assert len(loaded) == 2 - assert "schedule-1" in loaded - assert loaded["schedule-1"]["name"] == "Morning Preheat" - assert loaded["schedule-2"]["recurrence_type"] == "weekends" - - @pytest.mark.asyncio - async def test_persistence_saves_all_schedules_including_disabled( - self, temp_persistence_file - ): - """Test that both enabled and disabled schedules are persisted.""" - persistence = services.scheduling_state.RecurringSchedulesPersistence( - temp_persistence_file - ) - - test_schedules = { - "enabled-schedule": {"name": "Enabled", "time": "07:00", "enabled": True}, - "disabled-schedule": { - "name": "Disabled", - "time": "08:00", - "enabled": False, - }, - } - - await persistence.save(test_schedules) - loaded = await persistence.load() - - # Both schedules should be saved (disabled schedules survive restarts) - assert len(loaded) == 2 - assert "enabled-schedule" in loaded - assert "disabled-schedule" in loaded - assert loaded["disabled-schedule"]["enabled"] is False - - @pytest.mark.asyncio - async def test_persistence_load_nonexistent_file(self, tmp_path): - """Test loading when file doesn't exist.""" - persistence = services.scheduling_state.RecurringSchedulesPersistence( - str(tmp_path / "nonexistent.json") - ) - - loaded = await persistence.load() - - assert loaded == {} - - @pytest.mark.asyncio - async def test_persistence_load_invalid_json(self, temp_persistence_file): - """Test loading when file contains invalid JSON.""" - # Write invalid JSON - with open(temp_persistence_file, "w") as f: - f.write("not valid json {{{") - - persistence = services.scheduling_state.RecurringSchedulesPersistence( - temp_persistence_file - ) - loaded = await persistence.load() - - assert loaded == {} - - @pytest.mark.asyncio - async def test_persistence_load_non_dict_json(self, temp_persistence_file): - """Test loading when file contains non-dict JSON.""" - # Write a list instead of dict - with open(temp_persistence_file, "w") as f: - f.write('["item1", "item2"]') - - persistence = services.scheduling_state.RecurringSchedulesPersistence( - temp_persistence_file - ) - loaded = await persistence.load() - - assert loaded == {} - - @pytest.mark.asyncio - async def test_persistence_creates_directory(self, tmp_path): - """Test that persistence creates parent directory if needed.""" - nested_path = tmp_path / "nested" / "dir" / "schedules.json" - persistence = services.scheduling_state.RecurringSchedulesPersistence( - str(nested_path) - ) - - await persistence.save({"test": {"enabled": True}}) - - assert nested_path.exists() - - -class TestGetNextOccurrence: - """Tests for _get_next_occurrence function.""" - - def test_daily_schedule(self): - """Test daily recurrence calculation.""" - schedule = {"time": "07:00", "recurrence_type": "daily"} - - result = services.scheduling_state.get_next_occurrence(schedule) - - assert result is not None - assert result.hour == 7 - assert result.minute == 0 - - def test_weekdays_schedule_on_weekday(self): - """Test weekdays recurrence returns next weekday.""" - schedule = {"time": "08:30", "recurrence_type": "weekdays"} - - result = services.scheduling_state.get_next_occurrence(schedule) - - assert result is not None - assert result.weekday() < 5 # Monday-Friday - assert result.hour == 8 - assert result.minute == 30 - - def test_weekends_schedule(self): - """Test weekends recurrence returns Saturday or Sunday.""" - schedule = {"time": "10:00", "recurrence_type": "weekends"} - - result = services.scheduling_state.get_next_occurrence(schedule) - - assert result is not None - assert result.weekday() >= 5 # Saturday or Sunday - assert result.hour == 10 - - def test_specific_days_schedule(self): - """Test specific days recurrence.""" - schedule = { - "time": "09:00", - "recurrence_type": "specific_days", - "days_of_week": [0, 2, 4], # Monday, Wednesday, Friday - } - - result = services.scheduling_state.get_next_occurrence(schedule) - - assert result is not None - assert result.weekday() in [0, 2, 4] - - def test_interval_schedule_first_run(self): - """Test interval recurrence with no previous run.""" - schedule = {"time": "06:00", "recurrence_type": "interval", "interval_days": 3} - - result = services.scheduling_state.get_next_occurrence(schedule) - - assert result is not None - assert result.hour == 6 - - def test_interval_schedule_with_last_run(self): - """Test interval recurrence with previous run.""" - from datetime import datetime, timezone, timedelta - - last_run = datetime.now(timezone.utc) - timedelta(days=1) - - schedule = { - "time": "06:00", - "recurrence_type": "interval", - "interval_days": 3, - "last_run": last_run.isoformat(), - } - - result = services.scheduling_state.get_next_occurrence(schedule) - - assert result is not None - # Should be approximately 2 more days from now - assert result > datetime.now(timezone.utc) - - def test_invalid_time_format(self): - """Test with invalid time format.""" - schedule = {"time": "invalid", "recurrence_type": "daily"} - - result = services.scheduling_state.get_next_occurrence(schedule) - - assert result is None - - def test_missing_time(self): - """Test with missing time uses default.""" - schedule = {"recurrence_type": "daily"} - - result = services.scheduling_state.get_next_occurrence(schedule) - - # Should use default 07:00 - assert result is not None - assert result.hour == 7 - - -class TestRecurringScheduleEndpoints: - """Tests for recurring schedule API endpoints.""" - - @pytest.fixture(autouse=True) - def clear_schedules(self): - """Clear recurring schedules before each test.""" - import api.routes.scheduling as scheduling_module - - scheduling_module._recurring_schedules.clear() - yield - scheduling_module._recurring_schedules.clear() - - def test_list_recurring_schedules_empty(self, client): - """Test listing when no schedules exist.""" - response = client.get("/api/machine/recurring-schedules") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["recurring_schedules"] == [] - - def test_create_recurring_schedule_daily(self, client): - """Test creating a daily recurring schedule.""" - response = client.post( - "/api/machine/recurring-schedules", - json={ - "name": "Morning Warmup", - "time": "07:00", - "recurrence_type": "daily", - "preheat": True, - }, - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert "schedule_id" in data - assert data["schedule"]["name"] == "Morning Warmup" - assert data["schedule"]["time"] == "07:00" - assert data["next_occurrence"] is not None - - def test_create_recurring_schedule_weekdays(self, client): - """Test creating a weekdays-only schedule.""" - response = client.post( - "/api/machine/recurring-schedules", - json={ - "name": "Workday Coffee", - "time": "06:30", - "recurrence_type": "weekdays", - "profile_id": "test-profile", - "preheat": True, - }, - ) - - assert response.status_code == 200 - data = response.json() - assert data["schedule"]["recurrence_type"] == "weekdays" - - def test_create_recurring_schedule_specific_days(self, client): - """Test creating a specific days schedule.""" - response = client.post( - "/api/machine/recurring-schedules", - json={ - "name": "MWF Coffee", - "time": "08:00", - "recurrence_type": "specific_days", - "days_of_week": [0, 2, 4], # Mon, Wed, Fri - "preheat": True, - }, - ) - - assert response.status_code == 200 - data = response.json() - assert data["schedule"]["days_of_week"] == [0, 2, 4] - - def test_create_recurring_schedule_interval(self, client): - """Test creating an interval-based schedule.""" - response = client.post( - "/api/machine/recurring-schedules", - json={ - "name": "Every 3 Days", - "time": "09:00", - "recurrence_type": "interval", - "interval_days": 3, - "preheat": True, - }, - ) - - assert response.status_code == 200 - data = response.json() - assert data["schedule"]["interval_days"] == 3 - - def test_create_recurring_schedule_missing_time(self, client): - """Test creating schedule without required time field.""" - response = client.post( - "/api/machine/recurring-schedules", - json={"name": "No Time", "recurrence_type": "daily", "preheat": True}, - ) - - assert response.status_code == 400 - assert "time is required" in response.json()["detail"] - - def test_create_recurring_schedule_invalid_time(self, client): - """Test creating schedule with invalid time format.""" - response = client.post( - "/api/machine/recurring-schedules", - json={ - "name": "Bad Time", - "time": "25:99", - "recurrence_type": "daily", - "preheat": True, - }, - ) - - assert response.status_code == 400 - assert "Invalid time" in response.json()["detail"] - - def test_create_recurring_schedule_invalid_recurrence_type(self, client): - """Test creating schedule with invalid recurrence type.""" - response = client.post( - "/api/machine/recurring-schedules", - json={ - "name": "Bad Type", - "time": "07:00", - "recurrence_type": "invalid_type", - "preheat": True, - }, - ) - - assert response.status_code == 400 - assert "recurrence_type must be one of" in response.json()["detail"] - - def test_create_recurring_schedule_specific_days_empty(self, client): - """Test creating specific_days schedule with empty days list.""" - response = client.post( - "/api/machine/recurring-schedules", - json={ - "name": "Empty Days", - "time": "07:00", - "recurrence_type": "specific_days", - "days_of_week": [], - "preheat": True, - }, - ) - - assert response.status_code == 400 - assert "days_of_week cannot be empty" in response.json()["detail"] - - def test_create_recurring_schedule_no_action(self, client): - """Test creating schedule with neither profile nor preheat.""" - response = client.post( - "/api/machine/recurring-schedules", - json={ - "name": "No Action", - "time": "07:00", - "recurrence_type": "daily", - "profile_id": None, - "preheat": False, - }, - ) - - assert response.status_code == 400 - assert ( - "Either profile_id or preheat must be provided" in response.json()["detail"] - ) - - def test_list_recurring_schedules_with_data(self, client): - """Test listing after creating schedules.""" - # Create a schedule - client.post( - "/api/machine/recurring-schedules", - json={ - "name": "Test Schedule", - "time": "07:00", - "recurrence_type": "daily", - "preheat": True, - }, - ) - - # List schedules - response = client.get("/api/machine/recurring-schedules") - - assert response.status_code == 200 - data = response.json() - assert len(data["recurring_schedules"]) == 1 - assert data["recurring_schedules"][0]["name"] == "Test Schedule" - assert "next_occurrence" in data["recurring_schedules"][0] - - def test_update_recurring_schedule(self, client): - """Test updating an existing recurring schedule.""" - # Create a schedule - create_response = client.post( - "/api/machine/recurring-schedules", - json={ - "name": "Original Name", - "time": "07:00", - "recurrence_type": "daily", - "preheat": True, - }, - ) - schedule_id = create_response.json()["schedule_id"] - - # Update it - response = client.put( - f"/api/machine/recurring-schedules/{schedule_id}", - json={"name": "Updated Name", "time": "08:00", "enabled": False}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["schedule"]["name"] == "Updated Name" - assert data["schedule"]["time"] == "08:00" - assert data["schedule"]["enabled"] is False - - def test_update_recurring_schedule_not_found(self, client): - """Test updating non-existent schedule.""" - response = client.put( - "/api/machine/recurring-schedules/nonexistent-id", json={"name": "New Name"} - ) - - assert response.status_code == 404 - - def test_update_recurring_schedule_invalid_time(self, client): - """Test updating schedule with invalid time.""" - # Create a schedule - create_response = client.post( - "/api/machine/recurring-schedules", - json={ - "name": "Test", - "time": "07:00", - "recurrence_type": "daily", - "preheat": True, - }, - ) - schedule_id = create_response.json()["schedule_id"] - - # Try to update with invalid time - response = client.put( - f"/api/machine/recurring-schedules/{schedule_id}", json={"time": "invalid"} - ) - - assert response.status_code == 400 - - def test_delete_recurring_schedule(self, client): - """Test deleting a recurring schedule.""" - # Create a schedule - create_response = client.post( - "/api/machine/recurring-schedules", - json={ - "name": "To Delete", - "time": "07:00", - "recurrence_type": "daily", - "preheat": True, - }, - ) - schedule_id = create_response.json()["schedule_id"] - - # Delete it - response = client.delete(f"/api/machine/recurring-schedules/{schedule_id}") - - assert response.status_code == 200 - assert response.json()["status"] == "success" - - # Verify it's gone - list_response = client.get("/api/machine/recurring-schedules") - assert len(list_response.json()["recurring_schedules"]) == 0 - - def test_delete_recurring_schedule_not_found(self, client): - """Test deleting non-existent schedule.""" - response = client.delete("/api/machine/recurring-schedules/nonexistent-id") - - assert response.status_code == 404 - - -class TestScheduledShotRestoration: - """Tests for scheduled shot restoration with preheating status.""" - - @pytest.fixture(autouse=True) - def clear_shots(self): - """Clear scheduled shots before each test.""" - main._scheduled_shots.clear() - main._scheduled_tasks.clear() - yield - main._scheduled_shots.clear() - main._scheduled_tasks.clear() - - def test_scheduled_shot_preheating_status(self, client): - """Test that preheating status is properly tracked.""" - # This tests the scheduled shot data structure supports preheating status - from datetime import datetime, timezone, timedelta - - future_time = datetime.now(timezone.utc) + timedelta(hours=1) - - shot_data = { - "id": "test-shot-1", - "profile_id": "test-profile", - "scheduled_time": future_time.isoformat(), - "preheat": True, - "status": "preheating", # This is the key status we're testing - "created_at": datetime.now(timezone.utc).isoformat(), - } - - main._scheduled_shots["test-shot-1"] = shot_data - - # Verify the shot is stored correctly - assert "test-shot-1" in main._scheduled_shots - assert main._scheduled_shots["test-shot-1"]["status"] == "preheating" - - def test_scheduled_shot_status_transitions(self, client): - """Test that shot status can transition through expected states.""" - from datetime import datetime, timezone, timedelta - - future_time = datetime.now(timezone.utc) + timedelta(hours=1) - - # Create a scheduled shot - shot_data = { - "id": "test-shot-2", - "profile_id": "test-profile", - "scheduled_time": future_time.isoformat(), - "preheat": True, - "status": "scheduled", - "created_at": datetime.now(timezone.utc).isoformat(), - } - - main._scheduled_shots["test-shot-2"] = shot_data - - # Transition to preheating - main._scheduled_shots["test-shot-2"]["status"] = "preheating" - assert main._scheduled_shots["test-shot-2"]["status"] == "preheating" - - # Transition to running - main._scheduled_shots["test-shot-2"]["status"] = "running" - assert main._scheduled_shots["test-shot-2"]["status"] == "running" - - # Transition to completed - main._scheduled_shots["test-shot-2"]["status"] = "completed" - assert main._scheduled_shots["test-shot-2"]["status"] == "completed" - - -class TestRecurringScheduleChecker: - """Tests for recurring schedule checker background task logic.""" - - def test_recurring_shot_id_format(self): - """Test that recurring shot IDs follow expected format.""" - from datetime import datetime, timezone - - schedule_id = "test-schedule-123" - next_time = datetime.now(timezone.utc) - - expected_id = f"recurring-{schedule_id}-{next_time.isoformat()}" - - # Verify format - assert expected_id.startswith("recurring-") - assert schedule_id in expected_id - - def test_schedule_enabled_filtering(self): - """Test that disabled schedules are properly filtered.""" - schedules = { - "enabled": {"name": "Enabled", "enabled": True}, - "disabled": {"name": "Disabled", "enabled": False}, - "default": {"name": "Default"}, # Should default to enabled - } - - enabled_schedules = { - sid: s for sid, s in schedules.items() if s.get("enabled", True) - } - - assert "enabled" in enabled_schedules - assert "default" in enabled_schedules - assert "disabled" not in enabled_schedules - - -class TestSchedulingStateDirect: - """Direct tests for services.scheduling_state module to improve coverage.""" - - @pytest.mark.asyncio - async def test_schedule_persistence_save_and_load(self, tmp_path): - """Test ScheduledShotsPersistence save and load operations.""" - from services.scheduling_state import ScheduledShotsPersistence - - persistence_file = tmp_path / "test_schedules.json" - persistence = ScheduledShotsPersistence(persistence_file) - - # Test save - use status: scheduled to ensure it's saved - test_data = { - "schedule1": {"name": "Test", "time": "07:00", "status": "scheduled"} - } - await persistence.save(test_data) - - # Verify file was created - assert persistence.persistence_file.exists() - - # Test load - loaded_data = await persistence.load() - assert loaded_data == test_data - - @pytest.mark.asyncio - async def test_schedule_persistence_load_nonexistent(self, tmp_path): - """Test loading from nonexistent file returns empty dict.""" - from services.scheduling_state import ScheduledShotsPersistence - - persistence_file = tmp_path / "nonexistent.json" - persistence = ScheduledShotsPersistence(persistence_file) - result = await persistence.load() - assert result == {} - - @pytest.mark.asyncio - async def test_schedule_persistence_save_error_handling(self, tmp_path): - """Test save handles errors gracefully.""" - from services.scheduling_state import ScheduledShotsPersistence - - persistence_file = tmp_path / "test.json" - persistence = ScheduledShotsPersistence(persistence_file) - - # Mock open to raise an error - with patch("builtins.open", side_effect=PermissionError("Permission denied")): - # Should not raise, just log error - await persistence.save({"test": "data", "status": "scheduled"}) - - @pytest.mark.asyncio - async def test_schedule_persistence_load_error_handling(self, tmp_path): - """Test load handles corrupted JSON gracefully.""" - from services.scheduling_state import ScheduledShotsPersistence - - persistence_file = tmp_path / "bad.json" - persistence = ScheduledShotsPersistence(persistence_file) - - # Write invalid JSON - persistence.persistence_file.write_text("not valid json {{{") - - # Should return empty dict, not raise - result = await persistence.load() - assert result == {} - - @pytest.mark.asyncio - async def test_save_scheduled_shots(self): - """Test save_scheduled_shots function.""" - from services import scheduling_state - - with patch.object( - scheduling_state, "_scheduled_shots_persistence" - ) as mock_persistence: - mock_persistence.save = AsyncMock() - scheduling_state._scheduled_shots = {"test": {"id": "test"}} - - await scheduling_state.save_scheduled_shots() - mock_persistence.save.assert_called_once_with({"test": {"id": "test"}}) - - @pytest.mark.asyncio - async def test_load_scheduled_shots(self): - """Test load_scheduled_shots function.""" - from services import scheduling_state - - with patch.object( - scheduling_state, "_scheduled_shots_persistence" - ) as mock_persistence: - mock_persistence.load = AsyncMock(return_value={"loaded": {"id": "loaded"}}) - - result = await scheduling_state.load_scheduled_shots() - assert result == {"loaded": {"id": "loaded"}} - - @pytest.mark.asyncio - async def test_save_recurring_schedules(self): - """Test save_recurring_schedules function.""" - from services import scheduling_state - - with patch.object( - scheduling_state, "_recurring_schedules_persistence" - ) as mock_persistence: - mock_persistence.save = AsyncMock() - scheduling_state._recurring_schedules = {"recurring1": {"name": "Test"}} - - await scheduling_state.save_recurring_schedules() - mock_persistence.save.assert_called_once_with( - {"recurring1": {"name": "Test"}} - ) - - @pytest.mark.asyncio - async def test_load_recurring_schedules_function(self): - """Test load_recurring_schedules function.""" - from services import scheduling_state - - with patch.object( - scheduling_state, "_recurring_schedules_persistence" - ) as mock_persistence: - mock_persistence.load = AsyncMock( - return_value={"schedule1": {"enabled": True}} - ) - - await scheduling_state.load_recurring_schedules() - assert scheduling_state._recurring_schedules == { - "schedule1": {"enabled": True} - } - - def test_get_next_occurrence_daily(self): - """Test get_next_occurrence with daily schedule.""" - from services.scheduling_state import get_next_occurrence - - schedule = {"time": "10:00", "recurrence_type": "daily"} - result = get_next_occurrence(schedule) - - assert result is not None - assert result.hour == 10 - assert result.minute == 0 - - def test_get_next_occurrence_weekdays(self): - """Test get_next_occurrence with weekdays schedule.""" - from services.scheduling_state import get_next_occurrence - - schedule = {"time": "09:00", "recurrence_type": "weekdays"} - result = get_next_occurrence(schedule) - - assert result is not None - # Should be a weekday (Monday-Friday) - assert result.weekday() < 5 - - def test_get_next_occurrence_weekends(self): - """Test get_next_occurrence with weekends schedule.""" - from services.scheduling_state import get_next_occurrence - - schedule = {"time": "11:00", "recurrence_type": "weekends"} - result = get_next_occurrence(schedule) - - assert result is not None - # Should be a weekend (Saturday or Sunday) - assert result.weekday() >= 5 - - def test_get_next_occurrence_interval_first_run(self): - """Test get_next_occurrence with interval schedule, no last_run.""" - from services.scheduling_state import get_next_occurrence - - schedule = {"time": "08:00", "recurrence_type": "interval", "interval_days": 3} - result = get_next_occurrence(schedule) - - assert result is not None - - def test_get_next_occurrence_interval_with_last_run(self): - """Test get_next_occurrence with interval schedule and last_run.""" - from services.scheduling_state import get_next_occurrence - from datetime import datetime, timezone, timedelta - - last_run = (datetime.now(timezone.utc) - timedelta(days=5)).isoformat() - schedule = { - "time": "08:00", - "recurrence_type": "interval", - "interval_days": 3, - "last_run": last_run, - } - result = get_next_occurrence(schedule) - - assert result is not None - - def test_get_next_occurrence_interval_invalid_last_run(self): - """Test get_next_occurrence handles invalid last_run format.""" - from services.scheduling_state import get_next_occurrence - - schedule = { - "time": "08:00", - "recurrence_type": "interval", - "interval_days": 2, - "last_run": "not-a-valid-date", - } - result = get_next_occurrence(schedule) - - # Should return a result despite invalid last_run - assert result is not None - - def test_get_next_occurrence_specific_days(self): - """Test get_next_occurrence with specific_days schedule.""" - from services.scheduling_state import get_next_occurrence - - # All days of week to ensure we find one - schedule = { - "time": "07:00", - "recurrence_type": "specific_days", - "days_of_week": [0, 1, 2, 3, 4, 5, 6], - } - result = get_next_occurrence(schedule) - - assert result is not None - - def test_get_next_occurrence_specific_days_empty(self): - """Test get_next_occurrence with empty days_of_week returns None.""" - from services.scheduling_state import get_next_occurrence - - schedule = { - "time": "07:00", - "recurrence_type": "specific_days", - "days_of_week": [], - } - result = get_next_occurrence(schedule) - - # Should return None since no days match - assert result is None - - def test_get_next_occurrence_invalid_time(self): - """Test get_next_occurrence with invalid time format.""" - from services.scheduling_state import get_next_occurrence - - schedule = {"time": "invalid", "recurrence_type": "daily"} - result = get_next_occurrence(schedule) - - # Should return None on error - assert result is None - - def test_get_next_occurrence_missing_time(self): - """Test get_next_occurrence with missing time uses default.""" - from services.scheduling_state import get_next_occurrence - - schedule = {"recurrence_type": "daily"} - result = get_next_occurrence(schedule) - - # Should use default time (07:00) - assert result is not None - assert result.hour == 7 - - -class TestMeticulousServiceDirect: - """Direct tests for services.meticulous_service module to improve coverage.""" - - @pytest.mark.asyncio - async def test_execute_scheduled_shot_preheat_and_run(self): - """Test execute_scheduled_shot with preheat enabled.""" - from services.meticulous_service import execute_scheduled_shot - - mock_api = MagicMock() - mock_api.load_profile_by_id.return_value = MagicMock(error=None) - mock_api.execute_action = MagicMock() - - scheduled_shots = {"test-shot": {"id": "test-shot", "status": "pending"}} - scheduled_tasks = {} - - with patch( - "services.meticulous_service.get_meticulous_api", return_value=mock_api - ): - # Use very short delay for testing - await execute_scheduled_shot( - schedule_id="test-shot", - shot_delay=0.01, - preheat=True, - profile_id="profile-123", - scheduled_shots_dict=scheduled_shots, - scheduled_tasks_dict=scheduled_tasks, - preheat_duration_minutes=0.0001, # Very short for test - ) - - assert scheduled_shots["test-shot"]["status"] in ["completed", "running"] - - @pytest.mark.asyncio - async def test_execute_scheduled_shot_no_preheat(self): - """Test execute_scheduled_shot without preheat.""" - from services.meticulous_service import execute_scheduled_shot - - mock_api = MagicMock() - mock_api.load_profile_by_id.return_value = MagicMock(error=None) - mock_api.execute_action = MagicMock() - - scheduled_shots = {"test-shot-2": {"id": "test-shot-2", "status": "pending"}} - scheduled_tasks = {} - - with patch( - "services.meticulous_service.get_meticulous_api", return_value=mock_api - ): - await execute_scheduled_shot( - schedule_id="test-shot-2", - shot_delay=0.01, - preheat=False, - profile_id="profile-456", - scheduled_shots_dict=scheduled_shots, - scheduled_tasks_dict=scheduled_tasks, - ) - - assert scheduled_shots["test-shot-2"]["status"] == "completed" - mock_api.load_profile_by_id.assert_called_once_with("profile-456") - - @pytest.mark.asyncio - async def test_execute_scheduled_shot_preheat_only(self): - """Test execute_scheduled_shot with preheat only (no profile).""" - from services.meticulous_service import execute_scheduled_shot - - mock_api = MagicMock() - mock_api.execute_action = MagicMock() - - scheduled_shots = {"preheat-only": {"id": "preheat-only", "status": "pending"}} - scheduled_tasks = {} - - with patch( - "services.meticulous_service.get_meticulous_api", return_value=mock_api - ): - await execute_scheduled_shot( - schedule_id="preheat-only", - shot_delay=0.01, - preheat=True, - profile_id=None, # No profile - scheduled_shots_dict=scheduled_shots, - scheduled_tasks_dict=scheduled_tasks, - preheat_duration_minutes=0.0001, - ) - - assert scheduled_shots["preheat-only"]["status"] == "completed" - # Should not have called load_profile_by_id - mock_api.load_profile_by_id.assert_not_called() - - @pytest.mark.asyncio - async def test_execute_scheduled_shot_profile_load_error(self): - """Test execute_scheduled_shot when profile load fails.""" - from services.meticulous_service import execute_scheduled_shot - - mock_api = MagicMock() - mock_api.load_profile_by_id.return_value = MagicMock(error="Profile not found") - - scheduled_shots = {"fail-shot": {"id": "fail-shot", "status": "pending"}} - scheduled_tasks = {} - - with patch( - "services.meticulous_service.get_meticulous_api", return_value=mock_api - ): - await execute_scheduled_shot( - schedule_id="fail-shot", - shot_delay=0.01, - preheat=False, - profile_id="bad-profile", - scheduled_shots_dict=scheduled_shots, - scheduled_tasks_dict=scheduled_tasks, - ) - - assert scheduled_shots["fail-shot"]["status"] == "failed" - assert "error" in scheduled_shots["fail-shot"] - - @pytest.mark.asyncio - async def test_execute_scheduled_shot_exception(self): - """Test execute_scheduled_shot handles exceptions.""" - from services.meticulous_service import execute_scheduled_shot - - mock_api = MagicMock() - mock_api.load_profile_by_id.side_effect = Exception("Connection failed") - - scheduled_shots = {"exc-shot": {"id": "exc-shot", "status": "pending"}} - scheduled_tasks = {"exc-shot": MagicMock()} - - with patch( - "services.meticulous_service.get_meticulous_api", return_value=mock_api - ): - await execute_scheduled_shot( - schedule_id="exc-shot", - shot_delay=0.01, - preheat=False, - profile_id="profile", - scheduled_shots_dict=scheduled_shots, - scheduled_tasks_dict=scheduled_tasks, - ) - - assert scheduled_shots["exc-shot"]["status"] == "failed" - assert "exc-shot" not in scheduled_tasks # Should be cleaned up - - @pytest.mark.asyncio - async def test_execute_scheduled_shot_cancelled(self): - """Test execute_scheduled_shot handles cancellation.""" - from services.meticulous_service import execute_scheduled_shot - import asyncio - - scheduled_shots = {"cancel-shot": {"id": "cancel-shot", "status": "pending"}} - scheduled_tasks = {} - - with patch("services.meticulous_service.get_meticulous_api") as mock_get_api: - mock_get_api.side_effect = asyncio.CancelledError() - - await execute_scheduled_shot( - schedule_id="cancel-shot", - shot_delay=0.01, - preheat=False, - profile_id="profile", - scheduled_shots_dict=scheduled_shots, - scheduled_tasks_dict=scheduled_tasks, - ) - - assert scheduled_shots["cancel-shot"]["status"] == "cancelled" - - @pytest.mark.asyncio - async def test_fetch_shot_data_compressed(self): - """Test fetch_shot_data with compressed zst file.""" - from services.meticulous_service import fetch_shot_data - import zstandard - - # Create mock compressed data - test_data = {"shot": "data", "pressure": [1, 2, 3]} - cctx = zstandard.ZstdCompressor() - compressed = cctx.compress(json.dumps(test_data).encode("utf-8")) - - mock_api = MagicMock() - mock_api.base_url = "http://test.local" - - mock_response = MagicMock() - mock_response.content = compressed - mock_response.raise_for_status = MagicMock() - - mock_client = MagicMock() - mock_client.get = AsyncMock(return_value=mock_response) - - with patch( - "services.meticulous_service.get_meticulous_api", return_value=mock_api - ): - with patch( - "services.meticulous_service._get_http_client", return_value=mock_client - ): - result = await fetch_shot_data("2024-01-01", "shot.zst") - - assert result == test_data - - @pytest.mark.asyncio - async def test_fetch_shot_data_uncompressed(self): - """Test fetch_shot_data with uncompressed JSON file.""" - from services.meticulous_service import fetch_shot_data - - test_data = {"shot": "data", "weight": [10, 20, 30]} - - mock_api = MagicMock() - mock_api.base_url = "http://test.local" - - mock_response = MagicMock() - mock_response.json.return_value = test_data - mock_response.raise_for_status = MagicMock() - - mock_client = MagicMock() - mock_client.get = AsyncMock(return_value=mock_response) - - with patch( - "services.meticulous_service.get_meticulous_api", return_value=mock_api - ): - with patch( - "services.meticulous_service._get_http_client", return_value=mock_client - ): - result = await fetch_shot_data("2024-01-01", "shot.json") - - assert result == test_data - - def test_get_meticulous_api_initialization(self): - """Test get_meticulous_api lazy initialization.""" - from services import meticulous_service - - # Reset the cached API - meticulous_service._meticulous_api = None - - with patch.dict(os.environ, {"METICULOUS_IP": "192.168.1.100"}): - with patch("meticulous.api.Api") as mock_api_class: - mock_instance = MagicMock() - mock_instance.base_url = "http://192.168.1.100" - mock_api_class.return_value = mock_instance - - api = meticulous_service.get_meticulous_api() - - mock_api_class.assert_called_once_with(base_url="http://192.168.1.100") - - # Second call should reuse cached instance - api2 = meticulous_service.get_meticulous_api() - assert api is api2 - assert mock_api_class.call_count == 1 # Still only called once - - -class TestMainLifespanCoverage: - """Additional tests for main.py lifespan and middleware coverage.""" - - @pytest.mark.asyncio - async def test_lifespan_startup_and_shutdown(self): - """Test the lifespan context manager.""" - from main import lifespan, app - - # Mock the dependencies - with patch( - "main._restore_scheduled_shots", new_callable=AsyncMock - ) as mock_restore: - with patch( - "main._load_recurring_schedules", new_callable=AsyncMock - ) as mock_load: - with patch("main._recurring_schedules", {}): - with patch("main._scheduled_tasks", {}): - async with lifespan(app): - # Inside the lifespan context - mock_restore.assert_called_once() - mock_load.assert_called_once() - - def test_log_requests_middleware_success(self, client): - """Test request logging middleware logs successful requests.""" - with patch("main.logger") as mock_logger: - client.get("/api/status") - - # Should have logged incoming request and completion - assert mock_logger.info.call_count >= 2 - - def test_log_requests_middleware_error(self, client): - """Test request logging middleware handles errors.""" - # Request to non-existent endpoint - response = client.get("/nonexistent/endpoint") - assert response.status_code == 404 - - -class TestShotsDatesEndpoint: - """Tests for the /api/shots/dates endpoint.""" - - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_get_shot_dates_success(self, mock_get_dates, client): - """Test successful retrieval of shot dates.""" - # Mock dates - date1 = MagicMock() - date1.name = "2024-01-15" - date2 = MagicMock() - date2.name = "2024-01-14" - date3 = MagicMock() - date3.name = "2024-01-16" - mock_get_dates.return_value = [date1, date2, date3] - - response = client.get("/api/shots/dates") - - assert response.status_code == 200 - data = response.json() - assert "dates" in data - # Should be sorted in reverse order - assert data["dates"] == ["2024-01-16", "2024-01-15", "2024-01-14"] - - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_get_shot_dates_empty(self, mock_get_dates, client): - """Test retrieval when no dates exist.""" - mock_get_dates.return_value = [] - - response = client.get("/api/shots/dates") - - assert response.status_code == 200 - data = response.json() - assert data["dates"] == [] - - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_get_shot_dates_none_result(self, mock_get_dates, client): - """Test retrieval when API returns None.""" - mock_get_dates.return_value = None - - response = client.get("/api/shots/dates") - - assert response.status_code == 200 - data = response.json() - assert data["dates"] == [] - - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_get_shot_dates_api_error(self, mock_get_dates, client): - """Test error handling when API returns error.""" - mock_result = MagicMock() - mock_result.error = "Connection failed" - mock_get_dates.return_value = mock_result - - response = client.get("/api/shots/dates") - - assert response.status_code == 502 - assert "Machine API error" in response.json()["detail"] - - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_get_shot_dates_exception(self, mock_get_dates, client): - """Test exception handling in shot dates endpoint.""" - mock_get_dates.side_effect = Exception("Network error") - - response = client.get("/api/shots/dates") - - assert response.status_code == 500 - assert "error" in response.json()["detail"] - - -class TestShotDataEndpoint: - """Tests for the /api/shots/data/{date}/{filename} endpoint.""" - - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - def test_get_shot_data_success(self, mock_fetch, client): - """Test successful retrieval of shot data.""" - mock_fetch.return_value = { - "profile_name": "Test Profile", - "data": [{"time": 1000, "weight": 18.5}], - } - - response = client.get("/api/shots/data/2024-01-15/shot_001.json") - - assert response.status_code == 200 - data = response.json() - assert data["date"] == "2024-01-15" - assert data["filename"] == "shot_001.json" - assert data["data"]["profile_name"] == "Test Profile" - - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - def test_get_shot_data_exception(self, mock_fetch, client): - """Test error handling when fetch fails.""" - mock_fetch.side_effect = Exception("File not found") - - response = client.get("/api/shots/data/2024-01-15/nonexistent.json") - - assert response.status_code == 500 - assert "error" in response.json()["detail"] - - def test_get_shot_data_invalid_date(self, client): - """Test path traversal prevention on date param.""" - response = client.get("/api/shots/data/not-a-date/shot.json") - assert response.status_code == 400 - - def test_get_shot_data_invalid_filename(self, client): - """Test path traversal prevention on filename param.""" - response = client.get("/api/shots/data/2024-01-15/..%2F..%2Fetc%2Fpasswd") - assert response.status_code == 400 - - -class TestShotFilesInputValidation: - """Tests for input validation on shot file endpoints.""" - - def test_get_shot_files_invalid_date_format(self, client): - """Test that invalid date format is rejected.""" - response = client.get("/api/shots/files/not-a-date") - assert response.status_code == 400 - - def test_get_shot_files_date_traversal(self, client): - """Test that path traversal in date is rejected.""" - response = client.get("/api/shots/files/20240115") - assert response.status_code == 400 - - -class TestPrepareProfileForLLM: - """Tests for the _prepare_profile_for_llm helper function.""" - - def test_prepare_profile_basic(self): - """Test basic profile preparation.""" - from api.routes.shots import _prepare_profile_for_llm - - profile_data = { - "name": "Test Profile", - "temperature": 93.5, - "final_weight": 36.0, - "variables": ["pressure", "flow"], - "stages": [], - } - - result = _prepare_profile_for_llm(profile_data, "A test profile") - - assert result["name"] == "Test Profile" - assert result["temperature"] == 93.5 - assert result["final_weight"] == 36.0 - assert result["variables"] == ["pressure", "flow"] - assert result["stages"] == [] - - def test_prepare_profile_with_single_dynamics_point(self): - """Test profile with single dynamics point in stage.""" - from api.routes.shots import _prepare_profile_for_llm - - profile_data = { - "name": "Constant Pressure", - "stages": [ - { - "name": "Main", - "type": "pressure", - "exit_triggers": [{"type": "weight", "value": 36}], - "limits": [], - "dynamics_points": [[0, 9.0]], - } - ], - } - - result = _prepare_profile_for_llm(profile_data, None) - - assert len(result["stages"]) == 1 - assert result["stages"][0]["name"] == "Main" - assert "Constant at 9.0" in result["stages"][0]["target"] - - def test_prepare_profile_with_multiple_dynamics_points(self): - """Test profile with ramp dynamics.""" - from api.routes.shots import _prepare_profile_for_llm - - profile_data = { - "name": "Pressure Ramp", - "stages": [ - { - "name": "Ramp", - "type": "pressure", - "dynamics_points": [[0, 3.0], [5, 6.0], [10, 9.0]], - } - ], - } - - result = _prepare_profile_for_llm(profile_data, None) - - assert "3.0" in result["stages"][0]["target"] - assert "9.0" in result["stages"][0]["target"] - - def test_prepare_profile_with_empty_dynamics(self): - """Test profile with no dynamics points.""" - from api.routes.shots import _prepare_profile_for_llm - - profile_data = { - "name": "No Dynamics", - "stages": [{"name": "Wait", "type": "time", "dynamics_points": []}], - } - - result = _prepare_profile_for_llm(profile_data, None) - - assert "target" not in result["stages"][0] - - -class TestProfileImageUpload: - """Tests for profile image upload endpoint.""" - - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - @patch("api.routes.profiles.process_image_for_profile") - @patch("api.routes.profiles._set_cached_image") - def test_upload_profile_image_not_found( - self, mock_set_cache, mock_process, mock_list_profiles, client - ): - """Test uploading image for non-existent profile.""" - mock_list_profiles.return_value = [] - - mock_process.return_value = ("data:image/png;base64,abc123", b"png_bytes") - - # Create a simple test image - from io import BytesIO - from PIL import Image - - img = Image.new("RGB", (100, 100), color="red") - img_bytes = BytesIO() - img.save(img_bytes, format="PNG") - img_bytes.seek(0) - - response = client.post( - "/api/profile/NonExistentProfile/image", - files={"file": ("test.png", img_bytes, "image/png")}, - ) - - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - @patch("api.routes.profiles.process_image_for_profile") - @patch("api.routes.profiles._set_cached_image") - def test_upload_profile_image_api_error( - self, mock_set_cache, mock_process, mock_list_profiles, client - ): - """Test uploading image when API returns error.""" - mock_result = MagicMock() - mock_result.error = "Machine error" - mock_list_profiles.return_value = mock_result - - mock_process.return_value = ("data:image/png;base64,abc123", b"png_bytes") - - from io import BytesIO - from PIL import Image - - img = Image.new("RGB", (100, 100), color="red") - img_bytes = BytesIO() - img.save(img_bytes, format="PNG") - img_bytes.seek(0) - - response = client.post( - "/api/profile/TestProfile/image", - files={"file": ("test.png", img_bytes, "image/png")}, - ) - - assert response.status_code == 502 - assert "Machine API error" in response.json()["detail"] - - -class TestCacheServiceFunctions: - """Tests for cache service functions.""" - - def test_get_cached_llm_analysis_nonexistent(self): - """Test that nonexistent cache entries return None.""" - from services.cache_service import get_cached_llm_analysis - - result = get_cached_llm_analysis("nonexistent", "2024-01-01", "shot.json") - assert result is None - - def test_shot_cache_functions(self): - """Test shot cache get/set functions.""" - from services.cache_service import _get_cached_shots, _set_cached_shots - - # Get from empty cache - needs both profile_name and limit - result, is_stale, cached_at = _get_cached_shots("NonExistent", 10) - assert result is None - - # Set cache - test_data = {"shots": [], "count": 0} - _set_cached_shots("TestCacheProfile", test_data, 10) - - # Get from cache - result, is_stale, cached_at = _get_cached_shots("TestCacheProfile", 10) - assert result == test_data - assert cached_at is not None - - def test_image_cache_functions(self): - """Test image cache get/set functions.""" - from services.cache_service import _get_cached_image, _set_cached_image - - # Get from empty cache - result = _get_cached_image("NonExistentImage") - assert result is None - - # Set cache - test_data = b"fake_image_bytes" - _set_cached_image("TestImageProfile", test_data) - - # Get from cache - result = _get_cached_image("TestImageProfile") - assert result == test_data - - -class TestSchedulingAdditionalPaths: - """Additional tests for scheduling endpoint coverage.""" - - def test_cancel_scheduled_shot_not_found(self, client): - """Test canceling non-existent scheduled shot.""" - response = client.delete("/api/machine/schedule-shot/nonexistent-id") - - assert response.status_code == 404 - - def test_get_scheduled_shots(self, client): - """Test getting scheduled shots endpoint.""" - response = client.get("/api/machine/scheduled-shots") - - assert response.status_code == 200 - data = response.json() - assert "scheduled_shots" in data - - def test_get_recurring_schedules(self, client): - """Test getting recurring schedules endpoint.""" - response = client.get("/api/machine/recurring-schedules") - - assert response.status_code == 200 - data = response.json() - assert "recurring_schedules" in data - - -class TestHistoryEndpointPaths: - """Tests for history endpoint paths.""" - - def test_get_history_list(self, client): - """Test getting history list.""" - response = client.get("/api/history") - - # Should return 200 even if empty - assert response.status_code == 200 - - def test_delete_history_nonexistent(self, client): - """Test deleting non-existent history entry.""" - response = client.delete("/api/history/nonexistent-id-12345") - - # Should return 404 or appropriate error - assert response.status_code in [404, 500] - - -class TestShotFilesEndpoint: - """Tests for the /api/shots/files/{date} endpoint.""" - - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - def test_get_shot_files_success(self, mock_get_files, client): - """Test successful retrieval of shot files.""" - file1 = MagicMock() - file1.name = "shot_001.json" - file2 = MagicMock() - file2.name = "shot_002.json" - mock_get_files.return_value = [file1, file2] - - response = client.get("/api/shots/files/2024-01-15") - - assert response.status_code == 200 - data = response.json() - assert "files" in data - assert len(data["files"]) == 2 - - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - def test_get_shot_files_empty(self, mock_get_files, client): - """Test retrieval when no files exist.""" - mock_get_files.return_value = [] - - response = client.get("/api/shots/files/2024-01-15") - - assert response.status_code == 200 - data = response.json() - assert data["files"] == [] - - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - def test_get_shot_files_api_error(self, mock_get_files, client): - """Test error handling when API returns error.""" - mock_result = MagicMock() - mock_result.error = "Connection failed" - mock_get_files.return_value = mock_result - - response = client.get("/api/shots/files/2024-01-15") - - assert response.status_code == 502 - - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - def test_get_shot_files_exception(self, mock_get_files, client): - """Test exception handling.""" - mock_get_files.side_effect = Exception("Network error") - - response = client.get("/api/shots/files/2024-01-15") - - assert response.status_code == 500 - - -class TestSystemEndpointsCoverage: - """Additional tests for system endpoints coverage.""" - - def test_version_endpoint(self, client): - """Test version endpoint returns version info.""" - response = client.get("/api/version") - - assert response.status_code == 200 - data = response.json() - # Check that it returns version info structure - assert "meticai" in data or "version" in str(data).lower() - - -class TestProfileEndpointsCoverage: - """Additional tests for profile endpoints coverage.""" - - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_get_profile_not_found_returns_status(self, mock_list_profiles, client): - """Test get profile returns graceful response when profile doesn't exist.""" - mock_list_profiles.return_value = [] - - response = client.get("/api/profile/NonExistent") - - # Returns 200 with status: not_found instead of 404 - assert response.status_code == 200 - data = response.json() - assert data["status"] == "not_found" - - -class TestMachineControlEndpoints: - """Tests for machine control endpoints.""" - - @patch("api.routes.scheduling.async_execute_action", new_callable=AsyncMock) - @patch("api.routes.scheduling.get_meticulous_api") - def test_preheat_endpoint(self, mock_get_api, mock_execute_action, client): - """Test preheat endpoint.""" - mock_get_api.return_value = MagicMock() # Not None = connected - - mock_result = MagicMock() - mock_result.error = None - mock_execute_action.return_value = mock_result - - response = client.post("/api/machine/preheat") - - # Should succeed or return appropriate error - assert response.status_code in [200, 400, 500, 502] - - @patch("api.routes.scheduling.async_execute_action", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_load_profile_by_id", new_callable=AsyncMock) - @patch("api.routes.scheduling.get_meticulous_api") - def test_run_profile_endpoint( - self, mock_get_api, mock_load_profile, mock_execute_action, client - ): - """Test running a profile endpoint.""" - mock_get_api.return_value = MagicMock() # Not None = connected - mock_result = MagicMock() - mock_result.error = "Profile not found" - mock_load_profile.return_value = mock_result - - response = client.post("/api/machine/run-profile/nonexistent-id") - - assert response.status_code in [404, 500, 502] - - -class TestProcessImageForProfileFunction: - """Tests for the process_image_for_profile function.""" - - def test_process_rgba_image(self): - """Test processing RGBA image.""" - from api.routes.profiles import process_image_for_profile - from io import BytesIO - from PIL import Image - - # Create RGBA image - img = Image.new("RGBA", (200, 200), color=(255, 0, 0, 128)) - img_bytes = BytesIO() - img.save(img_bytes, format="PNG") - img_data = img_bytes.getvalue() - - data_uri, png_bytes = process_image_for_profile(img_data, "image/png") - - assert data_uri.startswith("data:image/png;base64,") - assert len(png_bytes) > 0 - - def test_process_grayscale_image(self): - """Test processing grayscale image.""" - from api.routes.profiles import process_image_for_profile - from io import BytesIO - from PIL import Image - - # Create grayscale image - img = Image.new("L", (200, 200), color=128) - img_bytes = BytesIO() - img.save(img_bytes, format="PNG") - img_data = img_bytes.getvalue() - - data_uri, png_bytes = process_image_for_profile(img_data, "image/png") - - assert data_uri.startswith("data:image/png;base64,") - - def test_process_wide_image_crops_to_square(self): - """Test that wide images are cropped to square.""" - from api.routes.profiles import process_image_for_profile - from io import BytesIO - from PIL import Image - - # Create wide image - img = Image.new("RGB", (400, 200), color="blue") - img_bytes = BytesIO() - img.save(img_bytes, format="PNG") - img_data = img_bytes.getvalue() - - data_uri, png_bytes = process_image_for_profile(img_data, "image/png") - - # Verify it was processed - assert data_uri.startswith("data:image/png;base64,") - - -class TestUtilsFunctions: - """Tests for utility functions.""" - - def test_atomic_write_json_success(self): - """Test atomic write JSON success.""" - from utils.file_utils import atomic_write_json - from pathlib import Path - import tempfile - - with tempfile.TemporaryDirectory() as tmp_dir: - file_path = Path(tmp_dir) / "test.json" - data = {"key": "value", "number": 42} - - atomic_write_json(file_path, data) - - # Verify file was written - assert file_path.exists() - import json - - loaded = json.loads(file_path.read_text()) - assert loaded == data - - def test_sanitize_profile_name(self): - """Test profile name sanitization.""" - from utils.sanitization import sanitize_profile_name_for_filename - - # Test with special characters - result = sanitize_profile_name_for_filename("Test/Profile:Name") - - # Should not contain filesystem-unsafe characters - assert "/" not in result - assert ":" not in result - - -class TestGeminiServiceCoverage: - """Tests for gemini service coverage.""" - - def test_get_vision_model_exists(self): - """Test get_vision_model function exists.""" - from services.gemini_service import get_vision_model - - # Just verify the function can be called - actual behavior depends on API key - try: - result = get_vision_model() - # Either returns a model or None - assert result is None or result is not None - except Exception: - # May fail without API key, which is expected - pass - - def test_reset_vision_model(self): - """Test reset_vision_model clears the cached client.""" - import services.gemini_service as gs - - # Set the cached client to a sentinel value - gs._gemini_client = "cached_client" - - # Reset it - gs.reset_vision_model() - - # Should be None now - assert gs._gemini_client is None - - -class TestHistoryServiceCoverage: - """Tests for history service coverage.""" - - def test_load_history(self): - """Test loading history entries.""" - from services.history_service import load_history - - history = load_history() - assert isinstance(history, list) - - def test_save_history(self): - """Test saving history.""" - from services.history_service import load_history, save_history - - # Load current history - current = load_history() - - # Save it back (no-op test) - save_history(current) - - # Verify it can be loaded again - reloaded = load_history() - assert isinstance(reloaded, list) - - -class TestSettingsServiceCoverage: - """Tests for settings service coverage.""" - - def test_load_settings(self): - """Test loading settings.""" - from services.settings_service import load_settings - - settings = load_settings() - - # Should return a dict - assert isinstance(settings, dict) - - def test_save_settings(self): - """Test saving settings.""" - from services.settings_service import load_settings, save_settings - - # Load current settings - current = load_settings() - - # Save them back - save_settings(current) - - # Verify they can be loaded again - reloaded = load_settings() - assert isinstance(reloaded, dict) - - def test_get_author_name(self): - """Test getting author name.""" - from services.settings_service import get_author_name - - name = get_author_name() - assert isinstance(name, str) - - -class TestPromptBuilderCoverage: - """Tests for prompt builder coverage.""" - - def test_build_image_prompt(self): - """Test building image prompt.""" - from prompt_builder import build_image_prompt - - prompt = build_image_prompt("Test Profile", "modern", ["coffee", "espresso"]) - assert isinstance(prompt, str) - assert len(prompt) > 0 - - def test_build_image_prompt_with_metadata(self): - """Test building image prompt with metadata.""" - from prompt_builder import build_image_prompt_with_metadata - - result = build_image_prompt_with_metadata("Test Profile", "vintage", ["latte"]) - assert isinstance(result, dict) - assert "prompt" in result - - -class TestMoreProfileEndpoints: - """Additional profile endpoint tests.""" - - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_get_profile_api_error(self, mock_list_profiles, client): - """Test get profile when API returns error.""" - mock_result = MagicMock() - mock_result.error = "Machine error" - mock_list_profiles.return_value = mock_result - - response = client.get("/api/profile/TestProfile") - - assert response.status_code == 502 - - -class TestMoreSchedulingEndpoints: - """Additional scheduling endpoint tests.""" - - def test_schedule_shot_missing_fields(self, client): - """Test schedule shot with missing required fields.""" - response = client.post( - "/api/machine/schedule-shot", - json={}, # Missing required fields - ) - - # Should return validation error - assert response.status_code in [400, 422] - - def test_get_recurring_schedules_returns_list(self, client): - """Test that recurring schedules returns a list.""" - response = client.get("/api/machine/recurring-schedules") - - assert response.status_code == 200 - data = response.json() - assert "recurring_schedules" in data - assert isinstance(data["recurring_schedules"], (list, dict)) - - -class TestMoreCacheFunctions: - """Additional cache function tests.""" - - def test_ensure_llm_cache_file(self): - """Test LLM cache file creation.""" - from services.cache_service import _ensure_llm_cache_file, LLM_CACHE_FILE - - _ensure_llm_cache_file() - - # File should exist after ensuring - assert LLM_CACHE_FILE.exists() or True # May not exist in test env - - def test_get_llm_cache_key(self): - """Test cache key generation.""" - from services.cache_service import _get_llm_cache_key - - key = _get_llm_cache_key("Profile", "2024-01-15", "shot.json") - - assert "Profile" in key - assert "2024-01-15" in key - - -class TestConfigModule: - """Tests for config module.""" - - def test_data_dir_exists(self): - """Test DATA_DIR is defined.""" - from config import DATA_DIR - - assert DATA_DIR is not None - - def test_cache_ttl_values(self): - """Test cache TTL values are positive.""" - from config import LLM_CACHE_TTL_SECONDS, SHOT_CACHE_STALE_SECONDS - - assert LLM_CACHE_TTL_SECONDS > 0 - assert SHOT_CACHE_STALE_SECONDS > 0 - - -class TestMeticulousServiceCoverage: - """Additional meticulous service tests.""" - - def test_get_meticulous_api_import(self): - """Test meticulous API import.""" - from services.meticulous_service import get_meticulous_api - - # Function should exist - assert callable(get_meticulous_api) - - -class TestNormalizeProfileForMachine: - """Tests for _normalize_profile_for_machine (profile upload enrichment).""" - - def _normalize(self, data): - from services.meticulous_service import _normalize_profile_for_machine - - return _normalize_profile_for_machine(data) - - def test_adds_uuid_id(self): - result = self._normalize({"name": "X", "stages": []}) - assert "id" in result and len(result["id"]) == 36 # UUID format - - def test_preserves_valid_uuid_id(self): - """Valid UUID IDs should be preserved.""" - valid_uuid = "49cb0934-8b7c-4bb8-886d-624e5c5bf54f" - result = self._normalize({"name": "X", "id": valid_uuid, "stages": []}) - assert result["id"] == valid_uuid - - def test_replaces_non_uuid_id(self): - """Non-UUID IDs (e.g., slugs) should be replaced with a proper UUID.""" - result = self._normalize({"name": "X", "id": "my-slug-id", "stages": []}) - # Should be replaced with a valid UUID - assert result["id"] != "my-slug-id" - assert len(result["id"]) == 36 # UUID format - - def test_adds_author_and_author_id(self): - result = self._normalize({"name": "X", "stages": []}) - assert result["author"] == "Metic" - assert len(result["author_id"]) == 36 - - def test_preserves_existing_author(self): - result = self._normalize({"name": "X", "author": "User", "stages": []}) - assert result["author"] == "User" - - def test_defaults_temperature_and_weight(self): - result = self._normalize({"name": "X", "stages": []}) - assert result["temperature"] == 90.0 - assert result["final_weight"] == 40.0 - - def test_preserves_explicit_temperature(self): - result = self._normalize({"name": "X", "temperature": 93.5, "stages": []}) - assert result["temperature"] == 93.5 - - def test_variables_defaults_to_empty_list(self): - result = self._normalize({"name": "X", "stages": []}) - assert result["variables"] == [] - - def test_variables_none_becomes_empty_list(self): - result = self._normalize({"name": "X", "variables": None, "stages": []}) - assert result["variables"] == [] - - def test_variables_preserved_if_present(self): - v = [{"name": "P", "key": "p_1", "type": "pressure", "value": 8}] - result = self._normalize({"name": "X", "variables": v, "stages": []}) - assert result["variables"] == v - - def test_previous_authors_defaults_to_empty(self): - result = self._normalize({"name": "X", "stages": []}) - assert result["previous_authors"] == [] - - def test_stage_key_auto_generated(self): - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"points": [[0, 2]], "over": "time"}, - "exit_triggers": [], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["key"] == "flow_0" - - def test_stage_key_preserved(self): - stage = { - "name": "S1", - "key": "my_key", - "type": "flow", - "dynamics": {"points": [], "over": "time"}, - "exit_triggers": [], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["key"] == "my_key" - - def test_stage_limits_defaults_to_empty_list(self): - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"points": [], "over": "time"}, - "exit_triggers": [], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["limits"] == [] - - def test_stage_limits_none_becomes_empty(self): - stage = { - "name": "S1", - "type": "flow", - "limits": None, - "dynamics": {"points": [], "over": "time"}, - "exit_triggers": [], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["limits"] == [] - - def test_dynamics_interpolation_defaults_to_linear(self): - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"points": [], "over": "time"}, - "exit_triggers": [], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["dynamics"]["interpolation"] == "linear" - - def test_dynamics_interpolation_preserved(self): - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"points": [], "over": "time", "interpolation": "curve"}, - "exit_triggers": [], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["dynamics"]["interpolation"] == "curve" - - def test_dynamics_dict_points_coerced_to_lists(self): - """Points like {"value": 2} are coerced to [0, 2].""" - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"points": [{"value": 2}], "over": "time"}, - "exit_triggers": [], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["dynamics"]["points"] == [[0.0, 2]] - - def test_dynamics_list_points_preserved(self): - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"points": [[0, 1.5], [5, 3]], "over": "time"}, - "exit_triggers": [], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["dynamics"]["points"] == [[0, 1.5], [5, 3]] - - def test_exit_trigger_relative_defaults_true_for_time(self): - trigger = {"type": "time", "value": 10} - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"points": [], "over": "time"}, - "exit_triggers": [trigger], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["exit_triggers"][0]["relative"] is True - - def test_exit_trigger_relative_defaults_false_for_pressure(self): - trigger = {"type": "pressure", "value": 3} - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"points": [], "over": "time"}, - "exit_triggers": [trigger], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["exit_triggers"][0]["relative"] is False - - def test_exit_trigger_relative_preserved_if_set(self): - trigger = {"type": "time", "value": 10, "relative": False} - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"points": [], "over": "time"}, - "exit_triggers": [trigger], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["exit_triggers"][0]["relative"] is False - - def test_exit_trigger_comparison_defaults_to_gte(self): - trigger = {"type": "weight", "value": 30} - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"points": [], "over": "time"}, - "exit_triggers": [trigger], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["exit_triggers"][0]["comparison"] == ">=" - - def test_exit_trigger_comparison_preserved(self): - trigger = {"type": "weight", "value": 30, "comparison": "<="} - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"points": [], "over": "time"}, - "exit_triggers": [trigger], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["exit_triggers"][0]["comparison"] == "<=" - - def test_multi_stage_keys_auto_numbered(self): - s1 = { - "name": "A", - "type": "flow", - "dynamics": {"points": [], "over": "time"}, - "exit_triggers": [], - } - s2 = { - "name": "B", - "type": "pressure", - "dynamics": {"points": [], "over": "time"}, - "exit_triggers": [], - } - result = self._normalize({"name": "X", "stages": [s1, s2]}) - assert result["stages"][0]["key"] == "flow_0" - assert result["stages"][1]["key"] == "pressure_1" - - def test_does_not_mutate_original(self): - """The original dict should not be modified.""" - original = {"name": "X", "stages": []} - self._normalize(original) - assert "id" not in original # shallow copy - - def test_dynamics_over_defaults_to_time(self): - """Missing dynamics.over defaults to 'time'.""" - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"points": [[0, 1]]}, - "exit_triggers": [], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["dynamics"]["over"] == "time" - - def test_dynamics_points_defaults_to_empty(self): - """Missing dynamics.points defaults to [].""" - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"over": "time"}, - "exit_triggers": [], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["dynamics"]["points"] == [] - - def test_dynamics_missing_entirely_gets_defaults(self): - """Missing dynamics entirely gets all defaults.""" - stage = {"name": "S1", "type": "flow", "exit_triggers": []} - result = self._normalize({"name": "X", "stages": [stage]}) - dyn = result["stages"][0]["dynamics"] - assert dyn["interpolation"] == "linear" - assert dyn["over"] == "time" - assert dyn["points"] == [] - - def test_exit_triggers_missing_defaults_to_empty(self): - """Missing exit_triggers defaults to [].""" - stage = { - "name": "S1", - "type": "flow", - "dynamics": {"points": [], "over": "time"}, - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["exit_triggers"] == [] - - def test_stage_type_defaults_to_flow(self): - """Missing stage type defaults to 'flow'.""" - stage = { - "name": "S1", - "dynamics": {"points": [], "over": "time"}, - "exit_triggers": [], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["type"] == "flow" - - def test_stage_name_defaults_to_numbered(self): - """Missing stage name gets auto-generated 'Stage N'.""" - stage = { - "type": "flow", - "dynamics": {"points": [], "over": "time"}, - "exit_triggers": [], - } - result = self._normalize({"name": "X", "stages": [stage]}) - assert result["stages"][0]["name"] == "Stage 1" - - def test_display_preserved_with_description(self): - """Existing display.description is preserved.""" - data = { - "name": "X", - "stages": [], - "display": {"description": "A great profile", "accentColor": "#FF0000"}, - } - result = self._normalize(data) - assert result["display"]["description"] == "A great profile" - assert result["display"]["accentColor"] == "#FF0000" - - def test_display_created_if_missing(self): - """display dict is created when absent.""" - result = self._normalize({"name": "X", "stages": []}) - assert isinstance(result["display"], dict) - - def test_display_none_normalised_to_dict(self): - """display=None is normalised to {}.""" - result = self._normalize({"name": "X", "stages": [], "display": None}) - assert isinstance(result["display"], dict) - - def test_display_non_dict_normalised(self): - """Non-dict display value is replaced with {}.""" - result = self._normalize({"name": "X", "stages": [], "display": "bad"}) - assert isinstance(result["display"], dict) - - def test_info_variable_emoji_added_when_missing(self): - """Info variables (key starts with info_) get emoji prefix if missing.""" - v = [{"name": "Dose", "key": "info_dose", "type": "numeric", "value": 18}] - result = self._normalize({"name": "X", "variables": v, "stages": []}) - assert result["variables"][0]["name"] == "ℹ️ Dose" - - def test_info_variable_emoji_preserved(self): - """Info variables with existing emoji are not modified.""" - v = [{"name": "☕ Dose", "key": "info_dose", "type": "numeric", "value": 18}] - result = self._normalize({"name": "X", "variables": v, "stages": []}) - assert result["variables"][0]["name"] == "☕ Dose" - - def test_adjustable_variable_no_emoji_added(self): - """Adjustable variables (no info_ prefix) don't get emoji.""" - v = [ - {"name": "Pressure", "key": "pressure_main", "type": "pressure", "value": 9} - ] - result = self._normalize({"name": "X", "variables": v, "stages": []}) - assert result["variables"][0]["name"] == "Pressure" - - def test_info_variable_empty_name_gets_default(self): - """Info variable with empty name gets default emoji and text.""" - v = [{"name": "", "key": "info_test", "type": "numeric", "value": 0}] - result = self._normalize({"name": "X", "variables": v, "stages": []}) - assert result["variables"][0]["name"] == "ℹ️ Info" - - def test_adjustable_false_variable_gets_emoji(self): - """Variables with adjustable: false (without info_ prefix) also get emoji.""" - v = [ - { - "name": "Dose", - "key": "dose", - "type": "numeric", - "value": 18, - "adjustable": False, - } - ] - result = self._normalize({"name": "X", "variables": v, "stages": []}) - assert result["variables"][0]["name"] == "ℹ️ Dose" - - def test_adjustable_true_variable_no_emoji(self): - """Variables with adjustable: true should NOT get emoji added.""" - v = [ - { - "name": "Dose", - "key": "dose", - "type": "numeric", - "value": 18, - "adjustable": True, - } - ] - result = self._normalize({"name": "X", "variables": v, "stages": []}) - assert result["variables"][0]["name"] == "Dose" - - -# --------------------------------------------------------------------------- -# Bridge / MQTT Control Center -# --------------------------------------------------------------------------- - - -class TestBridgeStatusEndpoint: - """Tests for GET /api/bridge/status.""" - - def test_bridge_status_returns_structure(self, client): - """Test that bridge status returns the expected JSON structure.""" - response = client.get("/api/bridge/status") - assert response.status_code == 200 - data = response.json() - - # Top-level keys - assert "mqtt_enabled" in data - assert "mosquitto" in data - assert "bridge" in data - - # Mosquitto sub-keys - assert "service" in data["mosquitto"] - assert "port_open" in data["mosquitto"] - assert "host" in data["mosquitto"] - assert "port" in data["mosquitto"] - - # Bridge sub-keys - assert "service" in data["bridge"] - - def test_bridge_status_test_mode_values(self, client): - """In TEST_MODE the services report safe defaults.""" - response = client.get("/api/bridge/status") - data = response.json() - - # TEST_MODE → s6 checks return 'unknown', port check returns False - assert data["mosquitto"]["service"] == "unknown" - assert data["mosquitto"]["port_open"] is False - assert data["bridge"]["service"] == "unknown" - - def test_bridge_status_default_host_port(self, client): - """Default host/port should be 127.0.0.1:1883.""" - response = client.get("/api/bridge/status") - data = response.json() - assert data["mosquitto"]["host"] == "127.0.0.1" - assert data["mosquitto"]["port"] == 1883 - - @patch.dict(os.environ, {"MQTT_ENABLED": "false"}) - def test_bridge_status_mqtt_disabled(self, client): - """When MQTT_ENABLED=false the flag is reflected.""" - response = client.get("/api/bridge/status") - data = response.json() - assert data["mqtt_enabled"] is False - - @patch.dict(os.environ, {"MQTT_HOST": "10.0.0.5", "MQTT_PORT": "1884"}) - def test_bridge_status_custom_host_port(self, client): - """Custom MQTT host/port from env vars are returned.""" - response = client.get("/api/bridge/status") - data = response.json() - assert data["mosquitto"]["host"] == "10.0.0.5" - assert data["mosquitto"]["port"] == 1884 - - -class TestBridgeRestartEndpoint: - """Tests for POST /api/bridge/restart.""" - - def test_bridge_restart_success(self, client): - """Restart endpoint returns success in TEST_MODE.""" - response = client.post("/api/bridge/restart") - assert response.status_code == 200 - data = response.json() - assert data["status"] == "restarting" - - @patch("api.routes.bridge.restart_bridge_service", return_value=False) - def test_bridge_restart_failure(self, mock_restart, client): - """When the restart command fails a 500 is returned.""" - response = client.post("/api/bridge/restart") - assert response.status_code == 500 - data = response.json() - assert "detail" in data - - -class TestBridgeServiceFunctions: - """Unit tests for bridge_service helper functions.""" - - def test_is_process_running_test_mode(self): - """_is_process_running returns False in TEST_MODE.""" - from services.bridge_service import _is_process_running - - assert _is_process_running("mosquitto") is False - - def test_check_s6_service_test_mode(self): - """_check_s6_service returns 'unknown' in TEST_MODE.""" - from services.bridge_service import _check_s6_service - - assert _check_s6_service("mosquitto") == "unknown" - assert _check_s6_service("meticulous-bridge") == "unknown" - - def test_check_mqtt_port_test_mode(self): - """_check_mqtt_port returns False in TEST_MODE.""" - from services.bridge_service import _check_mqtt_port - - assert _check_mqtt_port() is False - - def test_get_bridge_status_returns_dict(self): - """get_bridge_status returns a dict with expected keys.""" - from services.bridge_service import get_bridge_status - - status = get_bridge_status() - assert isinstance(status, dict) - assert "mqtt_enabled" in status - assert "mosquitto" in status - assert "bridge" in status - - def test_restart_bridge_service_test_mode(self): - """restart_bridge_service returns True in TEST_MODE.""" - from services.bridge_service import restart_bridge_service - - assert restart_bridge_service() is True - - @patch("services.bridge_service.TEST_MODE", False) - @patch("subprocess.run") - def test_is_process_running_found(self, mock_run): - """_is_process_running returns True when pgrep finds the process.""" - mock_run.return_value = MagicMock(returncode=0) - from services.bridge_service import _is_process_running - - assert _is_process_running("mosquitto") is True - - @patch("services.bridge_service.TEST_MODE", False) - @patch("subprocess.run") - def test_is_process_running_not_found(self, mock_run): - """_is_process_running returns False when pgrep finds nothing.""" - mock_run.return_value = MagicMock(returncode=1) - from services.bridge_service import _is_process_running - - assert _is_process_running("mosquitto") is False - - @patch("services.bridge_service.TEST_MODE", False) - @patch("subprocess.run", side_effect=FileNotFoundError) - def test_is_process_running_no_pgrep(self, mock_run): - """_is_process_running returns False when pgrep binary is missing.""" - from services.bridge_service import _is_process_running - - assert _is_process_running("mosquitto") is False - - @patch("services.bridge_service.TEST_MODE", False) - @patch("subprocess.run") - def test_check_s6_service_running(self, mock_run): - """_check_s6_service returns 'running' when s6-svstat says up.""" - mock_run.return_value = MagicMock( - returncode=0, stdout="up (pid 123) 45 seconds" - ) - from services.bridge_service import _check_s6_service - - assert _check_s6_service("mosquitto") == "running" - - @patch("services.bridge_service.TEST_MODE", False) - @patch("subprocess.run") - def test_check_s6_service_down(self, mock_run): - """_check_s6_service returns 'down' when s6-svstat says down.""" - mock_run.return_value = MagicMock( - returncode=0, stdout="down (signal SIGTERM) 2 seconds" - ) - from services.bridge_service import _check_s6_service - - assert _check_s6_service("mosquitto") == "down" - - @patch("services.bridge_service.TEST_MODE", False) - @patch("subprocess.run") - def test_check_s6_service_unknown_output(self, mock_run): - """_check_s6_service returns 'unknown' for unexpected output.""" - mock_run.return_value = MagicMock(returncode=1, stdout="") - from services.bridge_service import _check_s6_service - - assert _check_s6_service("mosquitto") == "unknown" - - @patch("services.bridge_service.TEST_MODE", False) - @patch("subprocess.run", side_effect=subprocess.TimeoutExpired("s6-svstat", 5)) - def test_check_s6_service_timeout(self, mock_run): - """_check_s6_service returns 'unknown' on timeout.""" - from services.bridge_service import _check_s6_service - - assert _check_s6_service("mosquitto") == "unknown" - - @patch("services.bridge_service.TEST_MODE", False) - @patch("subprocess.run") - def test_restart_bridge_service_success(self, mock_run): - """restart_bridge_service returns True on success.""" - mock_run.return_value = MagicMock(returncode=0) - from services.bridge_service import restart_bridge_service - - assert restart_bridge_service() is True - - @patch("services.bridge_service.TEST_MODE", False) - @patch("subprocess.run") - def test_restart_bridge_service_failure(self, mock_run): - """restart_bridge_service returns False on command failure.""" - mock_run.return_value = MagicMock(returncode=1) - from services.bridge_service import restart_bridge_service - - assert restart_bridge_service() is False - - @patch("services.bridge_service.TEST_MODE", False) - @patch("subprocess.run", side_effect=subprocess.TimeoutExpired("s6-svc", 10)) - def test_restart_bridge_service_timeout(self, mock_run): - """restart_bridge_service returns False on timeout.""" - from services.bridge_service import restart_bridge_service - - assert restart_bridge_service() is False - - -# --------------------------------------------------------------------------- -# Phase 2 — MQTT Service, WebSocket, Settings integration -# --------------------------------------------------------------------------- - - -class TestMQTTServiceCoercion: - """Tests for _coerce_value type conversion.""" - - def test_coerce_float_sensor(self): - from services.mqtt_service import _coerce_value - - assert _coerce_value("pressure", "9.05") == 9.05 - assert _coerce_value("flow_rate", "2.123") == 2.12 - assert _coerce_value("boiler_temperature", "93.5") == 93.5 - - def test_coerce_float_invalid(self): - from services.mqtt_service import _coerce_value - - assert _coerce_value("pressure", "n/a") == "n/a" - - def test_coerce_bool_sensor(self): - from services.mqtt_service import _coerce_value - - assert _coerce_value("brewing", "true") is True - assert _coerce_value("brewing", "false") is False - assert _coerce_value("connected", "True") is True - assert _coerce_value("connected", "0") is False - - def test_coerce_int_sensor(self): - from services.mqtt_service import _coerce_value - - assert _coerce_value("total_shots", "1234") == 1234 - assert _coerce_value("voltage", "230.0") == 230 - - def test_coerce_int_invalid(self): - from services.mqtt_service import _coerce_value - - assert _coerce_value("total_shots", "unknown") == "unknown" - - def test_coerce_string_sensor(self): - from services.mqtt_service import _coerce_value - - assert _coerce_value("state", "Idle") == "Idle" - assert _coerce_value("active_profile", "My Profile") == "My Profile" - - -class TestMQTTSubscriberLifecycle: - """Tests for MQTTSubscriber in TEST_MODE.""" - - def test_subscriber_start_test_mode(self): - """Subscriber skips connection in TEST_MODE.""" - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - import asyncio - - loop = asyncio.new_event_loop() - try: - sub.start(loop) - # Should not create a thread - assert sub._thread is None - finally: - sub.stop() - loop.close() - - def test_subscriber_get_snapshot_empty(self): - """Empty subscriber returns empty dict.""" - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - assert sub.get_snapshot() == {} - - def test_subscriber_ws_tracking(self): - """WebSocket client count tracking.""" - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - assert sub.ws_client_count == 0 - sub.register_ws(1) - sub.register_ws(2) - assert sub.ws_client_count == 2 - sub.unregister_ws(1) - assert sub.ws_client_count == 1 - sub.unregister_ws(999) # no-op - assert sub.ws_client_count == 1 - - def test_get_mqtt_subscriber_singleton(self): - """get_mqtt_subscriber returns the same instance.""" - from services.mqtt_service import get_mqtt_subscriber, reset_mqtt_subscriber - - reset_mqtt_subscriber() - a = get_mqtt_subscriber() - b = get_mqtt_subscriber() - assert a is b - reset_mqtt_subscriber() - - def test_reset_mqtt_subscriber(self): - """reset_mqtt_subscriber clears the singleton.""" - from services.mqtt_service import get_mqtt_subscriber, reset_mqtt_subscriber - - a = get_mqtt_subscriber() - reset_mqtt_subscriber() - b = get_mqtt_subscriber() - assert a is not b - reset_mqtt_subscriber() - - -class TestMQTTSubscriberOnMessage: - """Tests for MQTTSubscriber._on_message parsing.""" - - def _make_msg(self, topic: str, payload: str): - msg = MagicMock() - msg.topic = topic - msg.payload = payload.encode("utf-8") - return msg - - def test_sensor_message(self): - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - msg = self._make_msg("meticulous_espresso/sensor/pressure/state", "9.1") - sub._on_message(None, None, msg) - assert sub.snapshot["pressure"] == 9.1 - - def test_availability_message(self): - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - msg = self._make_msg("meticulous_espresso/availability", "online") - sub._on_message(None, None, msg) - assert sub.snapshot["availability"] == "online" - - def test_health_message_json(self): - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - payload = json.dumps({"uptime_seconds": 100, "api_connected": True}) - msg = self._make_msg("meticulous_espresso/health", payload) - sub._on_message(None, None, msg) - assert sub.snapshot["health"]["uptime_seconds"] == 100 - - def test_health_message_invalid_json(self): - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - msg = self._make_msg("meticulous_espresso/health", "not-json") - sub._on_message(None, None, msg) - assert sub.snapshot["health"] == "not-json" - - def test_bool_sensor_message(self): - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - msg = self._make_msg("meticulous_espresso/sensor/brewing/state", "true") - sub._on_message(None, None, msg) - assert sub.snapshot["brewing"] is True - - def test_non_state_topic_ignored(self): - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - msg = self._make_msg("meticulous_espresso/sensor/pressure/config", "9.1") - sub._on_message(None, None, msg) - assert "pressure" not in sub.snapshot - - -class TestWebSocketEndpoint: - """Tests for the /api/ws/live WebSocket endpoint.""" - - def test_websocket_connect_disconnect(self, client): - """WebSocket can connect and disconnect cleanly.""" - with client.websocket_connect("/api/ws/live") as ws: - # Connection accepted — close immediately by exiting context - ws.close() - - def test_websocket_connect_no_crash(self, client): - """WebSocket endpoint does not error on connect.""" - try: - with client.websocket_connect("/api/ws/live") as ws: - ws.close() - except Exception as exc: - pytest.fail(f"WebSocket connection raised: {exc}") - - -class TestSettingsMQTTEnabled: - """Tests for mqttEnabled in GET/POST /api/settings.""" - - def test_get_settings_includes_mqtt_enabled(self, client): - """GET /api/settings returns mqttEnabled flag.""" - response = client.get("/api/settings") - assert response.status_code == 200 - data = response.json() - assert "mqttEnabled" in data - - def test_get_settings_mqtt_default_true(self, client): - """mqttEnabled defaults to True.""" - response = client.get("/api/settings") - data = response.json() - assert data["mqttEnabled"] is True - - @patch.dict(os.environ, {"MQTT_ENABLED": "false"}) - def test_get_settings_mqtt_env_override(self, client): - """MQTT_ENABLED env var overrides stored setting.""" - response = client.get("/api/settings") - data = response.json() - assert data["mqttEnabled"] is False - - def test_post_settings_mqtt_toggle(self, client): - """POST /api/settings with mqttEnabled saves it.""" - response = client.post("/api/settings", json={"mqttEnabled": False}) - assert response.status_code == 200 - data = response.json() - assert "mqtt_subscriber" in data.get("services_restarted", []) - - def test_post_settings_mqtt_toggle_true(self, client): - """POST /api/settings with mqttEnabled=true restarts bridge.""" - response = client.post("/api/settings", json={"mqttEnabled": True}) - assert response.status_code == 200 - data = response.json() - assert "meticulous-bridge" in data.get("services_restarted", []) - - @patch("api.routes.system.subprocess.run") - @patch("api.routes.system.Path") - def test_post_settings_ip_restarts_bridge(self, mock_path_cls, mock_run, client): - """Changing meticulousIp also restarts the bridge.""" - mock_run.return_value = MagicMock(returncode=0) - # Make .env path mock writable so the endpoint doesn't crash - mock_env = MagicMock() - mock_env.exists.return_value = False - mock_path_cls.return_value = mock_env - response = client.post("/api/settings", json={"meticulousIp": "10.0.0.99"}) - assert response.status_code == 200 - data = response.json() - assert "meticulous-bridge" in data.get("services_restarted", []) - - -# ============================================================================ -# Last-Shot Endpoint Tests -# ============================================================================ - - -class TestLastShotEndpoint: - """Tests for GET /api/last-shot.""" - - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_last_shot_success(self, mock_dates, mock_files, mock_data, client): - """Returns metadata for the most recent shot.""" - d1 = MagicMock() - d1.name = "2026-02-14" - d2 = MagicMock() - d2.name = "2026-02-13" - mock_dates.return_value = [d1, d2] - f1 = MagicMock() - f1.name = "07:30:00.shot.json.zst" - f2 = MagicMock() - f2.name = "06:00:00.shot.json.zst" - mock_files.return_value = [f1, f2] - mock_data.return_value = { - "profile_name": "Berry Blast Bloom", - "time": "2026-02-14T07:30:00Z", - "data": [{"time": 42300, "shot": {"weight": 36.5, "pressure": 9.0}}], - } - response = client.get("/api/last-shot") - assert response.status_code == 200 - data = response.json() - assert data["profile_name"] == "Berry Blast Bloom" - assert data["date"] == "2026-02-14" - assert data["filename"] == "07:30:00.shot.json.zst" - assert data["final_weight"] == 36.5 - assert data["total_time"] == 42.3 - - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_last_shot_no_dates(self, mock_dates, client): - """Returns 404 when no shot dates exist.""" - mock_dates.return_value = [] - response = client.get("/api/last-shot") - assert response.status_code == 404 - - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_last_shot_empty_files(self, mock_dates, mock_files, client): - """Returns 404 when dates exist but no files.""" - d = MagicMock() - d.name = "2026-02-14" - mock_dates.return_value = [d] - mock_files.return_value = [] - response = client.get("/api/last-shot") - assert response.status_code == 404 - - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_last_shot_profile_from_nested_profile( - self, mock_dates, mock_files, mock_data, client - ): - """Falls back to profile.name when profile_name is missing.""" - d = MagicMock() - d.name = "2026-02-14" - mock_dates.return_value = [d] - f = MagicMock() - f.name = "08:00:00.shot.json.zst" - mock_files.return_value = [f] - mock_data.return_value = { - "profile": {"name": "Slow-Mo Blossom"}, - "time": "2026-02-14T08:00:00Z", - "data": [], - } - response = client.get("/api/last-shot") - assert response.status_code == 200 - assert response.json()["profile_name"] == "Slow-Mo Blossom" - assert response.json()["final_weight"] is None - assert response.json()["total_time"] is None - - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_last_shot_api_error(self, mock_dates, client): - """Returns 502 on machine API error.""" - result = MagicMock() - result.error = "connection timeout" - mock_dates.return_value = result - response = client.get("/api/last-shot") - assert response.status_code == 502 - - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_last_shot_exception(self, mock_dates, client): - """Returns 500 on unexpected error.""" - mock_dates.side_effect = RuntimeError("disk full") - response = client.get("/api/last-shot") - assert response.status_code == 500 - - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_last_shot_machine_unreachable_connection_error(self, mock_dates, client): - """Returns 503 when machine is unreachable during history lookup.""" - import requests - - mock_dates.side_effect = requests.exceptions.ConnectionError("dns failed") - - response = client.get("/api/last-shot") - - assert response.status_code == 503 - assert "unreachable" in response.json()["detail"].lower() - - -# ============================================================================ -# Machine Command Endpoint Tests -# ============================================================================ - - -class TestMachineCommandEndpoints: - """Tests for POST /api/machine/command/* endpoints.""" - - def test_command_start_success(self, client): - """Start command succeeds when machine idle.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - "brewing": False, - } - response = client.post("/api/machine/command/start") - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["command"] == "start_shot" - - def test_command_start_rejected_when_brewing(self, client): - """Start command rejected when a shot is already running.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - "brewing": True, - } - response = client.post("/api/machine/command/start") - assert response.status_code == 409 - - def test_command_start_rejected_when_offline(self, client): - """Start command rejected when machine is offline.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "offline", - "connected": False, - } - response = client.post("/api/machine/command/start") - assert response.status_code == 409 - - def test_command_stop_success(self, client): - """Stop command succeeds when brewing.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - "brewing": True, - } - response = client.post("/api/machine/command/stop") - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["command"] == "stop_shot" - - def test_command_stop_rejected_not_brewing(self, client): - """Stop command rejected when not brewing.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - "brewing": False, - } - response = client.post("/api/machine/command/stop") - assert response.status_code == 409 - - def test_command_abort_success(self, client): - """Abort command succeeds when brewing.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - "brewing": True, - } - response = client.post("/api/machine/command/abort") - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["command"] == "abort_shot" - - def test_command_abort_during_preheat(self, client): - """Abort command succeeds during preheat (not brewing).""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - "brewing": False, - } - response = client.post("/api/machine/command/abort") - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["command"] == "abort_shot" - - def test_command_abort_offline(self, client): - """Abort command fails when machine is offline.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "offline", - "connected": False, - "brewing": False, - } - response = client.post("/api/machine/command/abort") - assert response.status_code == 409 - - def test_command_continue(self, client): - """Continue command always succeeds (no precondition).""" - response = client.post("/api/machine/command/continue") - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["command"] == "continue_shot" - - def test_command_preheat_success(self, client): - """Preheat command succeeds when idle.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - "brewing": False, - "state": "Idle", - } - response = client.post("/api/machine/command/preheat") - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["command"] == "preheat" - - def test_command_preheat_cancel_during_preheating(self, client): - """Preheat command succeeds during preheating (toggle off).""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - "brewing": False, - "state": "Preheating", - } - response = client.post("/api/machine/command/preheat") - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["command"] == "preheat" - - def test_command_preheat_rejected_while_brewing(self, client): - """Preheat command rejected while a shot is running.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - "brewing": True, - "state": "Brewing", - } - response = client.post("/api/machine/command/preheat") - assert response.status_code == 409 - - def test_command_preheat_allowed_during_heating(self, client): - """Preheat command succeeds during heating state.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - "brewing": False, - "state": "Heating", - } - response = client.post("/api/machine/command/preheat") - assert response.status_code == 200 - assert response.json()["success"] is True - - def test_command_tare_success(self, client): - """Tare command succeeds when connected.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - } - response = client.post("/api/machine/command/tare") - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["command"] == "tare_scale" - - def test_command_home_plunger(self, client): - """Home plunger command succeeds when idle.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - "brewing": False, - } - response = client.post("/api/machine/command/home-plunger") - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["command"] == "home_plunger" - - def test_command_purge(self, client): - """Purge command succeeds when idle.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - "brewing": False, - } - response = client.post("/api/machine/command/purge") - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["command"] == "purge" - - def test_command_load_profile(self, client): - """Load profile command sends only select_profile.""" - with ( - patch("api.routes.commands.get_mqtt_subscriber") as mock_sub, - patch( - "api.routes.commands._publish_command", return_value=True - ) as mock_pub, - ): - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - } - response = client.post( - "/api/machine/command/load-profile", json={"name": "Berry Blast Bloom"} - ) - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["command"] == "select_profile" - # Must only send select_profile - calls = mock_pub.call_args_list - assert len(calls) == 1 - assert calls[0][0][0] == "meticulous_espresso/command/select_profile" - assert calls[0][0][1] == "Berry Blast Bloom" - - def test_command_brightness(self, client): - """Brightness command sends value (with connectivity check).""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - } - response = client.post( - "/api/machine/command/brightness", json={"value": 75} - ) - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["command"] == "set_brightness" - - def test_command_brightness_validation(self, client): - """Brightness rejects out-of-range values.""" - response = client.post("/api/machine/command/brightness", json={"value": 150}) - assert response.status_code == 422 - - def test_command_sounds(self, client): - """Sounds command toggles sounds (with connectivity check).""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "online", - "connected": True, - } - response = client.post( - "/api/machine/command/sounds", json={"enabled": False} - ) - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["command"] == "enable_sounds" - - def test_command_publish_failure(self, client): - """Returns 503 when MQTT publish fails.""" - with patch("api.routes.commands._publish_command", return_value=False): - response = client.post("/api/machine/command/continue") - assert response.status_code == 503 - - -class TestPublishCommandFunction: - """Tests for the _publish_command helper.""" - - def test_publish_test_mode(self): - """In TEST_MODE, _publish_command returns True without connecting.""" - from api.routes.commands import _publish_command - - assert _publish_command("meticulous_espresso/command/tare_scale") is True - - def test_require_connected_offline(self): - """_require_connected raises 409 for offline machine.""" - from api.routes.commands import _require_connected - - with pytest.raises(HTTPException) as exc_info: - _require_connected({"availability": "offline"}) - assert exc_info.value.status_code == 409 - - def test_require_connected_disconnected(self): - """_require_connected raises 409 for disconnected machine.""" - from api.routes.commands import _require_connected - - with pytest.raises(HTTPException) as exc_info: - _require_connected({"connected": False}) - assert exc_info.value.status_code == 409 - - def test_require_idle_brewing(self): - """_require_idle raises 409 when brewing.""" - from api.routes.commands import _require_idle - - with pytest.raises(HTTPException) as exc_info: - _require_idle( - {"availability": "online", "connected": True, "brewing": True} - ) - assert exc_info.value.status_code == 409 - - def test_require_brewing_not_brewing(self): - """_require_brewing raises 409 when not brewing.""" - from api.routes.commands import _require_brewing - - with pytest.raises(HTTPException) as exc_info: - _require_brewing( - {"availability": "online", "connected": True, "brewing": False} - ) - assert exc_info.value.status_code == 409 - - -class TestCommandConnectivityGaps: - """Tests for connectivity checks added to brightness/sounds + validation.""" - - def test_command_brightness_rejected_when_offline(self, client): - """Brightness returns 409 when machine is offline.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "offline" - } - response = client.post( - "/api/machine/command/brightness", json={"value": 50} - ) - assert response.status_code == 409 - - def test_command_sounds_rejected_when_offline(self, client): - """Sounds returns 409 when machine is offline.""" - with patch("api.routes.commands.get_mqtt_subscriber") as mock_sub: - mock_sub.return_value.get_snapshot.return_value = { - "availability": "offline" - } - response = client.post( - "/api/machine/command/sounds", json={"enabled": True} - ) - assert response.status_code == 409 - - def test_command_load_profile_empty_name_rejected(self, client): - """Load profile rejects an empty profile name.""" - response = client.post("/api/machine/command/load-profile", json={"name": ""}) - assert response.status_code == 422 - - -class TestMQTTSubscriberAdvanced: - """Tests for thread-safety, callbacks, and stop behaviour.""" - - def test_subscriber_stop_graceful(self): - """stop() doesn't crash when client is None.""" - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - sub.stop() # Should not raise - - def test_ws_tracking_thread_safe(self): - """register/unregister from concurrent calls stays consistent.""" - import threading - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - errors = [] - - def register_many(start): - try: - for i in range(start, start + 100): - sub.register_ws(i) - except Exception as e: - errors.append(e) - - def unregister_many(start): - try: - for i in range(start, start + 50): - sub.unregister_ws(i) - except Exception as e: - errors.append(e) - - threads = [ - threading.Thread(target=register_many, args=(0,)), - threading.Thread(target=register_many, args=(100,)), - threading.Thread(target=unregister_many, args=(0,)), - ] - for t in threads: - t.start() - for t in threads: - t.join() - assert not errors - # 200 registered - 50 unregistered = 150 - assert sub.ws_client_count == 150 - - def test_on_connect_success_subscribes(self): - """_on_connect with rc=0 subscribes to 3 topics.""" - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - mock_client = MagicMock() - sub._on_connect(mock_client, None, None, 0) - assert mock_client.subscribe.call_count == 3 - - def test_on_connect_failure_logs(self): - """_on_connect with rc!=0 does not subscribe.""" - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - mock_client = MagicMock() - sub._on_connect(mock_client, None, None, 5) - mock_client.subscribe.assert_not_called() - - def test_on_disconnect_unexpected_no_crash(self): - """_on_disconnect with rc!=0 logs but doesn't crash.""" - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - sub._on_disconnect(MagicMock(), None, 1) # Should not raise - - def test_on_disconnect_clean(self): - """_on_disconnect with rc=0 is a clean disconnect.""" - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - sub._on_disconnect(MagicMock(), None, 0) # Should not raise - - def test_signal_update_no_loop_noop(self): - """_signal_update with no loop set does nothing.""" - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - sub._signal_update() # Should not raise - - def test_get_mqtt_subscriber_same_instance(self): - """get_mqtt_subscriber returns same instance on repeated calls.""" - from services.mqtt_service import get_mqtt_subscriber, reset_mqtt_subscriber - - reset_mqtt_subscriber() - try: - a = get_mqtt_subscriber() - b = get_mqtt_subscriber() - assert a is b - finally: - reset_mqtt_subscriber() - - def test_stop_with_mocked_client(self): - """stop() calls loop_stop and disconnect on the client.""" - from services.mqtt_service import MQTTSubscriber - - sub = MQTTSubscriber() - mock_client = MagicMock() - sub._client = mock_client - sub.stop() - mock_client.loop_stop.assert_called_once_with(force=True) - mock_client.disconnect.assert_called_once() - - -class TestBridgeServiceLogging: - """Tests for bridge_service restart logging paths.""" - - def test_restart_bridge_service_test_mode(self): - """restart_bridge_service returns True in TEST_MODE.""" - from services.bridge_service import restart_bridge_service - - assert restart_bridge_service() is True - - -class TestTailscaleStatusEndpoint: - """Tests for the Tailscale status and configuration endpoints.""" - - def test_tailscale_status_returns_config_fields(self, client): - """GET /api/tailscale-status includes enabled and auth_key_configured.""" - response = client.get("/api/tailscale-status") - assert response.status_code == 200 - data = response.json() - assert "enabled" in data - assert "auth_key_configured" in data - assert "installed" in data - assert "connected" in data - assert "external_url" in data - - def test_tailscale_status_defaults_disabled(self, client): - """Tailscale defaults to disabled when no settings configured.""" - # Ensure clean defaults - from services.settings_service import save_settings, _DEFAULT_SETTINGS - - save_settings(dict(_DEFAULT_SETTINGS)) - - response = client.get("/api/tailscale-status") - assert response.status_code == 200 - data = response.json() - assert data["enabled"] is False - - def test_tailscale_status_reads_enabled_from_settings(self, client): - """Status reflects tailscaleEnabled from settings.json.""" - from services.settings_service import load_settings, save_settings - - settings = load_settings() - settings["tailscaleEnabled"] = True - save_settings(settings) - - response = client.get("/api/tailscale-status") - assert response.status_code == 200 - data = response.json() - assert data["enabled"] is True - - def test_tailscale_status_auth_key_from_settings(self, client): - """auth_key_configured reflects tailscaleAuthKey in settings.""" - from services.settings_service import load_settings, save_settings - - settings = load_settings() - settings["tailscaleAuthKey"] = "tskey-auth-abc123" - save_settings(settings) - - response = client.get("/api/tailscale-status") - assert response.status_code == 200 - data = response.json() - assert data["auth_key_configured"] is True - - def test_tailscale_status_auth_key_from_env(self, client, monkeypatch): - """auth_key_configured checks TAILSCALE_AUTHKEY env var fallback.""" - monkeypatch.setenv("TAILSCALE_AUTHKEY", "tskey-auth-env") - - response = client.get("/api/tailscale-status") - assert response.status_code == 200 - data = response.json() - assert data["auth_key_configured"] is True - - -class TestTailscaleConfigureEndpoint: - """Tests for POST /api/tailscale/configure.""" - - def test_enable_tailscale(self, client): - """Enabling Tailscale saves to settings.""" - response = client.post("/api/tailscale/configure", json={"enabled": True}) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["enabled"] is True - - from services.settings_service import load_settings - - settings = load_settings() - assert settings["tailscaleEnabled"] is True - - def test_disable_tailscale(self, client): - """Disabling Tailscale saves to settings.""" - # First enable - client.post("/api/tailscale/configure", json={"enabled": True}) - # Then disable - response = client.post("/api/tailscale/configure", json={"enabled": False}) - assert response.status_code == 200 - data = response.json() - assert data["enabled"] is False - - from services.settings_service import load_settings - - settings = load_settings() - assert settings["tailscaleEnabled"] is False - - def test_save_auth_key(self, client): - """Saving an auth key updates settings.""" - response = client.post( - "/api/tailscale/configure", json={"authKey": "tskey-auth-test123"} - ) - assert response.status_code == 200 - data = response.json() - assert data["auth_key_configured"] is True - - from services.settings_service import load_settings - - settings = load_settings() - assert settings["tailscaleAuthKey"] == "tskey-auth-test123" - - def test_save_masked_key_ignored(self, client): - """Masked auth key values are not saved.""" - # Save a real key first - client.post("/api/tailscale/configure", json={"authKey": "tskey-auth-real"}) - - # Try to save a masked value - response = client.post( - "/api/tailscale/configure", json={"authKey": "tskey-***masked***"} - ) - assert response.status_code == 200 - - from services.settings_service import load_settings - - settings = load_settings() - assert settings["tailscaleAuthKey"] == "tskey-auth-real" - - def test_clear_auth_key(self, client): - """Empty auth key clears the saved key.""" - # Save a key first - client.post("/api/tailscale/configure", json={"authKey": "tskey-auth-real"}) - # Clear it - response = client.post("/api/tailscale/configure", json={"authKey": ""}) - assert response.status_code == 200 - - from services.settings_service import load_settings - - settings = load_settings() - assert settings["tailscaleAuthKey"] == "" - - def test_no_changes(self, client): - """Empty body returns success with no-changes message.""" - response = client.post("/api/tailscale/configure", json={}) - assert response.status_code == 200 - data = response.json() - assert "No changes" in data["message"] - - def test_enable_signals_restart(self, client): - """Toggling enabled state signals a restart is required.""" - response = client.post("/api/tailscale/configure", json={"enabled": True}) - assert response.status_code == 200 - data = response.json() - assert data["restart_required"] is True - - def test_auth_key_no_restart(self, client): - """Just changing auth key does not signal restart.""" - response = client.post( - "/api/tailscale/configure", json={"authKey": "tskey-auth-new"} - ) - assert response.status_code == 200 - data = response.json() - assert data.get("restart_required") is not True - - -class TestTailscaleSettingsDefaults: - """Test that settings_service includes Tailscale defaults.""" - - def test_default_settings_include_tailscale(self): - """Default settings include tailscaleEnabled and tailscaleAuthKey.""" - from services.settings_service import _DEFAULT_SETTINGS - - assert "tailscaleEnabled" in _DEFAULT_SETTINGS - assert "tailscaleAuthKey" in _DEFAULT_SETTINGS - assert _DEFAULT_SETTINGS["tailscaleEnabled"] is False - assert _DEFAULT_SETTINGS["tailscaleAuthKey"] == "" - - -class TestUpdateS6Env: - """Tests for _update_s6_env helper that writes to s6 container environment.""" - - def test_update_s6_env_writes_file(self, tmp_path): - """_update_s6_env writes the value to the s6 env directory.""" - from api.routes.system import _update_s6_env - - s6_dir = tmp_path / "container_environment" - s6_dir.mkdir() - with patch("api.routes.system.os.path.isdir", return_value=True): - with patch("builtins.open", mock_open()) as m: - with patch( - "api.routes.system.os.path.join", - return_value=str(s6_dir / "METICULOUS_IP"), - ): - _update_s6_env("METICULOUS_IP", "192.168.1.100", "req-1") - m.assert_called_once() - - def test_update_s6_env_no_dir(self): - """_update_s6_env is a no-op when s6 env dir doesn't exist.""" - from api.routes.system import _update_s6_env - - with patch("api.routes.system.os.path.isdir", return_value=False): - # Should not raise - _update_s6_env("METICULOUS_IP", "192.168.1.100", "req-2") - - def test_update_s6_env_permission_error(self, tmp_path): - """_update_s6_env handles write errors gracefully.""" - from api.routes.system import _update_s6_env - - with patch("api.routes.system.os.path.isdir", return_value=True): - with patch("builtins.open", side_effect=PermissionError("read-only")): - with patch( - "api.routes.system.os.path.join", - return_value="/var/run/s6/container_environment/X", - ): - # Should not raise, just log a warning - _update_s6_env("X", "val", "req-3") - - -class TestBridgeResolveMachineIp: - """Tests for the bridge's _resolve_machine_ip function.""" - - @staticmethod - def _get_bridge_module(): - """Import start_bridge from the bridge app directory.""" - import importlib - - bridge_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__) or ".", "..", "bridge") - ) - sys_path_backup = sys.path.copy() - sys.path.insert(0, bridge_dir) - try: - import start_bridge - - importlib.reload(start_bridge) - return start_bridge - finally: - sys.path = sys_path_backup - - def test_resolve_from_env(self): - """Env var takes priority over settings.json.""" - mod = self._get_bridge_module() - with patch.dict(os.environ, {"METICULOUS_IP": "10.0.0.5"}): - assert mod._resolve_machine_ip() == "10.0.0.5" - - def test_resolve_from_settings_json(self, tmp_path): - """Falls back to settings.json when env var is empty.""" - mod = self._get_bridge_module() - settings_file = tmp_path / "settings.json" - settings_file.write_text('{"meticulousIp": "192.168.50.168"}') - with patch.dict(os.environ, {"METICULOUS_IP": "", "DATA_DIR": str(tmp_path)}): - assert mod._resolve_machine_ip() == "192.168.50.168" - - def test_resolve_default(self, tmp_path): - """Falls back to meticulous.local when nothing is configured.""" - mod = self._get_bridge_module() - with patch.dict( - os.environ, {"METICULOUS_IP": "", "DATA_DIR": str(tmp_path)}, clear=False - ): - assert mod._resolve_machine_ip() == "meticulous.local" - - -# ============================================================================== -# Data Loading Hardening Tests (#198) -# ============================================================================== - - -class TestDataLoadingHardening: - """Tests that all data-loading functions handle corrupt/unexpected types gracefully.""" - - # -- settings_service --------------------------------------------------- - - def test_settings_load_non_dict_returns_defaults(self, tmp_path): - """When settings.json contains a list, load_settings returns defaults.""" - from services.settings_service import ( - load_settings, - _DEFAULT_SETTINGS, - ) - import services.settings_service as ss - - old_file = ss.SETTINGS_FILE - old_cache = ss._settings_cache - try: - ss.SETTINGS_FILE = tmp_path / "settings.json" - ss._settings_cache = None - ss.SETTINGS_FILE.write_text("[1, 2, 3]") - - result = load_settings() - assert isinstance(result, dict) - # All default keys must be present - for key in _DEFAULT_SETTINGS: - assert key in result - finally: - ss.SETTINGS_FILE = old_file - ss._settings_cache = old_cache - - def test_settings_load_null_returns_defaults(self, tmp_path): - """When settings.json contains null, load_settings returns defaults.""" - import services.settings_service as ss - - old_file = ss.SETTINGS_FILE - old_cache = ss._settings_cache - try: - ss.SETTINGS_FILE = tmp_path / "settings.json" - ss._settings_cache = None - ss.SETTINGS_FILE.write_text("null") - - result = ss.load_settings() - assert isinstance(result, dict) - assert result.get("mqttEnabled") is True # default value - finally: - ss.SETTINGS_FILE = old_file - ss._settings_cache = old_cache - - def test_settings_load_merges_with_defaults(self, tmp_path): - """Partial settings on disk get missing keys filled from defaults.""" - import services.settings_service as ss - - old_file = ss.SETTINGS_FILE - old_cache = ss._settings_cache - try: - ss.SETTINGS_FILE = tmp_path / "settings.json" - ss._settings_cache = None - ss.SETTINGS_FILE.write_text('{"geminiApiKey": "test-key"}') - - result = ss.load_settings() - assert result["geminiApiKey"] == "test-key" - # Missing keys filled from defaults - assert "meticulousIp" in result - assert result["mqttEnabled"] is True - finally: - ss.SETTINGS_FILE = old_file - ss._settings_cache = old_cache - - # -- cache_service (LLM cache) ------------------------------------------ - - def test_llm_cache_load_non_dict_returns_empty(self, tmp_path): - """When llm cache file contains a list, _load_llm_cache returns {}.""" - import services.cache_service as cs - - old_file = cs.LLM_CACHE_FILE - old_cache = cs._llm_cache - try: - cs.LLM_CACHE_FILE = tmp_path / "llm_analysis_cache.json" - cs._llm_cache = None - cs.LLM_CACHE_FILE.write_text("[1, 2, 3]") - - result = cs._load_llm_cache() - assert isinstance(result, dict) - assert result == {} - finally: - cs.LLM_CACHE_FILE = old_file - cs._llm_cache = old_cache - - def test_llm_cache_load_null_returns_empty(self, tmp_path): - """When llm cache file contains null, _load_llm_cache returns {}.""" - import services.cache_service as cs - - old_file = cs.LLM_CACHE_FILE - old_cache = cs._llm_cache - try: - cs.LLM_CACHE_FILE = tmp_path / "llm_analysis_cache.json" - cs._llm_cache = None - cs.LLM_CACHE_FILE.write_text("null") - - result = cs._load_llm_cache() - assert isinstance(result, dict) - assert result == {} - finally: - cs.LLM_CACHE_FILE = old_file - cs._llm_cache = old_cache - - # -- cache_service (shot cache) ------------------------------------------ - - def test_shot_cache_load_non_dict_returns_empty(self, tmp_path): - """When shot cache file contains a string, _load_shot_cache returns {}.""" - import services.cache_service as cs - - old_file = cs.SHOT_CACHE_FILE - old_cache = cs._shot_cache - try: - cs.SHOT_CACHE_FILE = tmp_path / "shot_cache.json" - cs._shot_cache = None - cs.SHOT_CACHE_FILE.write_text('"just a string"') - - result = cs._load_shot_cache() - assert isinstance(result, dict) - assert result == {} - finally: - cs.SHOT_CACHE_FILE = old_file - cs._shot_cache = old_cache - - # -- history_service ---------------------------------------------------- - - def test_history_load_non_list_returns_empty(self, tmp_path): - """When history file contains a dict, load_history returns [].""" - import services.history_service as hs - - old_file = hs.HISTORY_FILE - old_cache = hs._history_cache - try: - hs.HISTORY_FILE = tmp_path / "profile_history.json" - hs._history_cache = None - hs.HISTORY_FILE.write_text('{"not": "a list"}') - - result = hs.load_history() - assert isinstance(result, list) - assert result == [] - finally: - hs.HISTORY_FILE = old_file - hs._history_cache = old_cache - - def test_history_load_null_returns_empty(self, tmp_path): - """When history file contains null, load_history returns [].""" - import services.history_service as hs - - old_file = hs.HISTORY_FILE - old_cache = hs._history_cache - try: - hs.HISTORY_FILE = tmp_path / "profile_history.json" - hs._history_cache = None - hs.HISTORY_FILE.write_text("null") - - result = hs.load_history() - assert isinstance(result, list) - assert result == [] - finally: - hs.HISTORY_FILE = old_file - hs._history_cache = old_cache - - -# ============================================================================ -# Pour-Over Profile Adaptation Tests -# ============================================================================ - - -class TestAdaptPourOverProfile: - """Tests for services.pour_over_adapter.adapt_pour_over_profile().""" - - def test_adapt_basic_with_bloom(self): - """Adaptation with bloom enabled produces correct profile structure.""" - from services.pour_over_adapter import adapt_pour_over_profile - - profile = adapt_pour_over_profile( - target_weight=250.0, - bloom_enabled=True, - bloom_seconds=45.0, - dose_grams=18.0, - brew_ratio=13.9, - ) - - assert profile["final_weight"] == 250.0 - assert profile["name"] == "MeticAI Ratio Pour-Over" - assert len(profile["stages"]) == 2 - - # Bloom stage - bloom = profile["stages"][0] - assert "Bloom" in bloom["name"] - assert "45s" in bloom["name"] - time_triggers = [t for t in bloom["exit_triggers"] if t["type"] == "time"] - assert len(time_triggers) == 1 - assert time_triggers[0]["value"] == 45.0 - - # Infusion stage - infusion = profile["stages"][1] - assert "Infusion" in infusion["name"] - assert "250g" in infusion["name"] - weight_triggers = [ - t for t in infusion["exit_triggers"] if t["type"] == "weight" - ] - assert len(weight_triggers) == 1 - assert weight_triggers[0]["value"] == 250.0 - - def test_adapt_without_bloom(self): - """Adaptation with bloom disabled removes bloom stage.""" - from services.pour_over_adapter import adapt_pour_over_profile - - profile = adapt_pour_over_profile( - target_weight=300.0, - bloom_enabled=False, - ) - - assert profile["final_weight"] == 300.0 - assert len(profile["stages"]) == 1 - assert profile["name"] == "MeticAI Ratio Pour-Over" - - stage = profile["stages"][0] - assert "Infusion" in stage["name"] - assert "300g" in stage["name"] - weight_triggers = [t for t in stage["exit_triggers"] if t["type"] == "weight"] - assert weight_triggers[0]["value"] == 300.0 - - def test_adapt_unique_id(self): - """Each adaptation generates a unique profile ID.""" - from services.pour_over_adapter import adapt_pour_over_profile - - p1 = adapt_pour_over_profile(target_weight=200.0) - p2 = adapt_pour_over_profile(target_weight=200.0) - assert p1["id"] != p2["id"] - - def test_adapt_short_description(self): - """Short description includes target, dose, and ratio info.""" - from services.pour_over_adapter import adapt_pour_over_profile - - profile = adapt_pour_over_profile( - target_weight=250.0, - dose_grams=18.0, - brew_ratio=13.9, - ) - short_desc = profile.get("display", {}).get("shortDescription", "") - assert "250g" in short_desc - assert "18.0g" in short_desc - assert "13.9" in short_desc - assert len(short_desc) <= 99 - - def test_adapt_short_description_truncated(self): - """Short description is truncated to 99 chars.""" - from services.pour_over_adapter import adapt_pour_over_profile - - profile = adapt_pour_over_profile( - target_weight=250.0, - dose_grams=18.0, - brew_ratio=13.9, - ) - short_desc = profile.get("display", {}).get("shortDescription", "") - assert len(short_desc) <= 99 - - def test_adapt_does_not_mutate_template(self): - """Adaptation does not modify the loaded template.""" - from services.pour_over_adapter import adapt_pour_over_profile - - adapt_pour_over_profile(target_weight=100.0, bloom_enabled=False) - p2 = adapt_pour_over_profile( - target_weight=500.0, bloom_enabled=True, bloom_seconds=60.0 - ) - - # p2 should still have 2 stages (bloom not removed from template) - assert len(p2["stages"]) == 2 - assert p2["final_weight"] == 500.0 - - def test_adapt_default_bloom_seconds(self): - """Default bloom seconds is 30.""" - from services.pour_over_adapter import adapt_pour_over_profile - - profile = adapt_pour_over_profile(target_weight=200.0, bloom_enabled=True) - bloom = profile["stages"][0] - time_triggers = [t for t in bloom["exit_triggers"] if t["type"] == "time"] - assert time_triggers[0]["value"] == 30.0 - - def test_adapt_preserves_template_structure(self): - """Adapted profile preserves variables, dynamics, etc.""" - from services.pour_over_adapter import adapt_pour_over_profile - - profile = adapt_pour_over_profile(target_weight=200.0) - assert "variables" in profile - assert profile["temperature"] == 0 - for stage in profile["stages"]: - assert "dynamics" in stage - assert "exit_triggers" in stage - - -# ============================================================================ -# TempProfileService Tests -# ============================================================================ - - -class TestTempProfileService: - """Tests for services.temp_profile_service.""" - - def setup_method(self): - """Reset module state before each test.""" - import services.temp_profile_service as tps - - tps._set_active(None) - - def test_get_active_none(self): - """get_active returns None when no temp profile is active.""" - import services.temp_profile_service as tps - - assert tps.get_active() is None - - @pytest.mark.asyncio - async def test_create_and_load_success(self): - """create_and_load creates a profile, loads it, and tracks it.""" - import services.temp_profile_service as tps - - mock_profile_json = {"name": "MeticAI Ratio Pour-Over", "id": "test-uuid-123"} - - with ( - patch("services.temp_profile_service.async_create_profile") as mock_create, - patch( - "services.temp_profile_service.async_load_profile_by_id" - ) as mock_load, - patch("services.temp_profile_service.async_list_profiles", return_value=[]), - ): - mock_create.return_value = {"id": "machine-uuid-456"} - mock_load.return_value = None - - result = await tps.create_and_load( - mock_profile_json, params={"target_weight": 250} - ) - - assert result["profile_id"] == "machine-uuid-456" - assert result["profile_name"] == "MeticAI Ratio Pour-Over" - mock_create.assert_called_once() - mock_load.assert_called_once_with("machine-uuid-456") - - active = tps.get_active() - assert active is not None - assert active["profile_id"] == "machine-uuid-456" - assert active["original_params"]["target_weight"] == 250 - - @pytest.mark.asyncio - async def test_create_and_load_preserves_name(self): - """create_and_load preserves the profile name from caller.""" - import services.temp_profile_service as tps - - mock_profile = {"name": "MeticAI Ratio Pour-Over", "id": "test-id"} - - with ( - patch("services.temp_profile_service.async_create_profile") as mock_create, - patch("services.temp_profile_service.async_load_profile_by_id"), - patch("services.temp_profile_service.async_list_profiles", return_value=[]), - ): - mock_create.return_value = {"id": "abc"} - await tps.create_and_load(mock_profile) - - created_profile = mock_create.call_args[0][0] - assert created_profile["name"] == "MeticAI Ratio Pour-Over" - - @pytest.mark.asyncio - async def test_create_and_load_replaces_existing(self): - """create_and_load force-cleans existing temp profile first.""" - import services.temp_profile_service as tps - from services.temp_profile_service import ActiveTempProfile - - tps._set_active( - ActiveTempProfile(profile_id="old-id", profile_name="Old Profile") - ) - - with ( - patch("services.temp_profile_service.async_create_profile") as mock_create, - patch("services.temp_profile_service.async_load_profile_by_id"), - patch("services.temp_profile_service.async_delete_profile") as mock_delete, - patch("services.temp_profile_service.async_list_profiles", return_value=[]), - ): - mock_create.return_value = {"id": "new-id"} - await tps.create_and_load({"name": "New", "id": "new-id"}) - - # Should have deleted old active + no pre-existing profiles found - mock_delete.assert_called_once_with("old-id") - active = tps.get_active() - assert active["profile_id"] == "new-id" - - @pytest.mark.asyncio - async def test_cleanup_success(self): - """cleanup purges and deletes the active temp profile.""" - import services.temp_profile_service as tps - from services.temp_profile_service import ActiveTempProfile - - tps._set_active( - ActiveTempProfile( - profile_id="cleanup-id", profile_name="MeticAI Ratio Pour-Over" - ) - ) - - with ( - patch("services.temp_profile_service.async_delete_profile") as mock_delete, - patch("api.routes.commands._get_snapshot", return_value={"brewing": False}), - patch("api.routes.commands._do_publish"), - ): - mock_delete.return_value = None - result = await tps.cleanup() - - assert result["status"] == "cleaned_up" - assert result["deleted_profile"] == "MeticAI Ratio Pour-Over" - mock_delete.assert_called_once_with("cleanup-id") - assert tps.get_active() is None - - @pytest.mark.asyncio - async def test_cleanup_no_active(self): - """cleanup returns no_active_profile when nothing is active.""" - import services.temp_profile_service as tps - - result = await tps.cleanup() - assert result["status"] == "no_active_profile" - - @pytest.mark.asyncio - async def test_force_cleanup_success(self): - """force_cleanup deletes without purging.""" - import services.temp_profile_service as tps - from services.temp_profile_service import ActiveTempProfile - - tps._set_active( - ActiveTempProfile( - profile_id="force-id", profile_name="MeticAI Ratio Pour-Over" - ) - ) - - with patch("services.temp_profile_service.async_delete_profile") as mock_delete: - mock_delete.return_value = None - result = await tps.force_cleanup() - - assert result["status"] == "force_cleaned_up" - assert tps.get_active() is None - - @pytest.mark.asyncio - async def test_force_cleanup_no_active(self): - """force_cleanup returns no_active_profile when nothing is active.""" - import services.temp_profile_service as tps - - result = await tps.force_cleanup() - assert result["status"] == "no_active_profile" - - @pytest.mark.asyncio - async def test_cleanup_delete_fails_gracefully(self): - """cleanup reports delete_failed when delete raises.""" - import services.temp_profile_service as tps - from services.temp_profile_service import ActiveTempProfile - - tps._set_active( - ActiveTempProfile( - profile_id="fail-id", profile_name="MeticAI Ratio Pour-Over" - ) - ) - - with ( - patch("services.temp_profile_service.async_delete_profile") as mock_delete, - patch("api.routes.commands._get_snapshot", return_value={"brewing": False}), - patch("api.routes.commands._do_publish"), - ): - mock_delete.side_effect = Exception("Delete error") - result = await tps.cleanup() - - assert result["status"] == "delete_failed" - assert "Delete error" in result["error"] - assert tps.get_active() is None - - @pytest.mark.asyncio - async def test_cleanup_stale_success(self): - """cleanup_stale removes orphaned [Temp] profiles.""" - import services.temp_profile_service as tps - - mock_profile1 = Mock() - mock_profile1.name = "MeticAI Ratio Pour-Over" - mock_profile1.id = "stale-1" - - mock_profile2 = Mock() - mock_profile2.name = "Regular Profile" - mock_profile2.id = "regular-1" - - mock_profile3 = Mock() - mock_profile3.name = "Some Other Profile" - mock_profile3.id = "other-1" - - with ( - patch("services.temp_profile_service.async_list_profiles") as mock_list, - patch("services.temp_profile_service.async_delete_profile") as mock_delete, - ): - mock_list.return_value = [mock_profile1, mock_profile2, mock_profile3] - mock_delete.return_value = None - - result = await tps.cleanup_stale() - - assert result["deleted"] == 1 - assert mock_delete.call_count == 1 - # Only the known temp profile name should be deleted - deleted_ids = [call[0][0] for call in mock_delete.call_args_list] - assert "stale-1" in deleted_ids - assert "regular-1" not in deleted_ids - assert "other-1" not in deleted_ids - - @pytest.mark.asyncio - async def test_cleanup_stale_machine_unreachable(self): - """cleanup_stale handles machine unreachable gracefully.""" - import services.temp_profile_service as tps - from services.meticulous_service import MachineUnreachableError - - with patch("services.temp_profile_service.async_list_profiles") as mock_list: - mock_list.side_effect = MachineUnreachableError() - result = await tps.cleanup_stale() - - assert result["deleted"] == 0 - assert result.get("skipped") == "machine_unreachable" - - @pytest.mark.asyncio - async def test_cleanup_stale_no_profiles(self): - """cleanup_stale handles empty profile list.""" - import services.temp_profile_service as tps - - with patch("services.temp_profile_service.async_list_profiles") as mock_list: - mock_list.return_value = [] - result = await tps.cleanup_stale() - assert result["deleted"] == 0 - - -# ============================================================================ -# Pour-Over API Endpoint Tests -# ============================================================================ - - -class TestPourOverEndpoints: - """Tests for /api/pour-over/* endpoints.""" - - @patch("services.temp_profile_service.async_load_profile_from_json") - def test_prepare_success(self, mock_load_json, client): - """POST /api/pour-over/prepare loads an ephemeral temp profile.""" - import services.temp_profile_service as tps - - tps._set_active(None) - - mock_load_json.return_value = { - "id": "prep-id-123", - "name": "MeticAI Ratio Pour-Over", - } - - response = client.post( - "/api/pour-over/prepare", - json={ - "target_weight": 250.0, - "bloom_enabled": True, - "bloom_seconds": 45.0, - "dose_grams": 18.0, - "brew_ratio": 13.9, - }, - ) - - assert response.status_code == 200 - data = response.json() - assert data["profile_name"] == "MeticAI Ratio Pour-Over" - mock_load_json.assert_called_once() - - @patch("services.temp_profile_service.async_load_profile_from_json") - def test_prepare_without_bloom(self, mock_load_json, client): - """POST /api/pour-over/prepare works without bloom.""" - import services.temp_profile_service as tps - - tps._set_active(None) - - mock_load_json.return_value = { - "id": "no-bloom-id", - "name": "MeticAI Ratio Pour-Over", - } - - response = client.post( - "/api/pour-over/prepare", - json={ - "target_weight": 300.0, - "bloom_enabled": False, - }, - ) - - assert response.status_code == 200 - data = response.json() - assert data["profile_name"] == "MeticAI Ratio Pour-Over" - - def test_prepare_invalid_weight(self, client): - """POST /api/pour-over/prepare rejects non-positive weight.""" - response = client.post( - "/api/pour-over/prepare", - json={ - "target_weight": 0, - }, - ) - assert response.status_code == 422 - - def test_prepare_missing_weight(self, client): - """POST /api/pour-over/prepare requires target_weight.""" - response = client.post("/api/pour-over/prepare", json={}) - assert response.status_code == 422 - - def test_cleanup_success(self, client): - """POST /api/pour-over/cleanup cleans up the active ephemeral profile.""" - import services.temp_profile_service as tps - from services.temp_profile_service import ActiveTempProfile - - tps._set_active( - ActiveTempProfile( - profile_id="cleanup-ep-id", - profile_name="MeticAI Ratio Pour-Over", - ephemeral=True, - ) - ) - - with ( - patch("api.routes.commands._get_snapshot", return_value={"brewing": False}), - patch("api.routes.commands._do_publish"), - ): - response = client.post("/api/pour-over/cleanup") - - assert response.status_code == 200 - assert response.json()["status"] == "cleaned_up" - - def test_cleanup_no_active(self, client): - """POST /api/pour-over/cleanup returns no_active_profile.""" - import services.temp_profile_service as tps - - tps._set_active(None) - - response = client.post("/api/pour-over/cleanup") - assert response.status_code == 200 - assert response.json()["status"] == "no_active_profile" - - def test_force_cleanup_success(self, client): - """POST /api/pour-over/force-cleanup cleans up without purge.""" - import services.temp_profile_service as tps - from services.temp_profile_service import ActiveTempProfile - - tps._set_active( - ActiveTempProfile( - profile_id="force-ep-id", - profile_name="MeticAI Ratio Pour-Over", - ephemeral=True, - ) - ) - - response = client.post("/api/pour-over/force-cleanup") - assert response.status_code == 200 - assert response.json()["status"] == "force_cleaned_up" - - def test_force_cleanup_no_active(self, client): - """POST /api/pour-over/force-cleanup returns no_active_profile.""" - import services.temp_profile_service as tps - - tps._set_active(None) - - response = client.post("/api/pour-over/force-cleanup") - assert response.status_code == 200 - assert response.json()["status"] == "no_active_profile" - - def test_get_active_none(self, client): - """GET /api/pour-over/active returns active=false when none.""" - import services.temp_profile_service as tps - - tps._set_active(None) - - response = client.get("/api/pour-over/active") - assert response.status_code == 200 - data = response.json() - assert data["active"] is False - assert data["profile_id"] is None - - def test_get_active_with_profile(self, client): - """GET /api/pour-over/active returns profile details when active.""" - import services.temp_profile_service as tps - from services.temp_profile_service import ActiveTempProfile - - tps._set_active( - ActiveTempProfile( - profile_id="active-id", - profile_name="MeticAI Ratio Pour-Over", - original_params={"target_weight": 250}, - ) - ) - - response = client.get("/api/pour-over/active") - assert response.status_code == 200 - data = response.json() - assert data["active"] is True - assert data["profile_id"] == "active-id" - assert data["profile_name"] == "MeticAI Ratio Pour-Over" - assert data["original_params"]["target_weight"] == 250 - - -# ============================================================================ -# Pour-Over Preferences Tests -# ============================================================================ - - -class TestPourOverPreferencesService: - """Unit tests for services.pour_over_preferences.""" - - def test_load_returns_defaults_when_no_file(self, tmp_path, monkeypatch): - """load_preferences returns defaults when no file exists.""" - import services.pour_over_preferences as prefs - - prefs.reset_cache() - monkeypatch.setattr(prefs, "PREFS_FILE", tmp_path / "prefs.json") - - result = prefs.load_preferences() - assert result["free"]["autoStart"] is True - assert result["free"]["bloomEnabled"] is True - assert result["free"]["bloomSeconds"] == 30 - assert result["free"]["machineIntegration"] is False - assert result["ratio"]["autoStart"] is True - - def test_save_and_load_round_trip(self, tmp_path, monkeypatch): - """Preferences survive a save+clear+load cycle.""" - import services.pour_over_preferences as prefs - - prefs.reset_cache() - monkeypatch.setattr(prefs, "PREFS_FILE", tmp_path / "prefs.json") - - prefs.save_preferences( - { - "free": { - "autoStart": False, - "bloomEnabled": False, - "bloomSeconds": 45, - "machineIntegration": True, - }, - "ratio": { - "autoStart": True, - "bloomEnabled": True, - "bloomSeconds": 20, - "machineIntegration": True, - }, - } - ) - - prefs.reset_cache() - result = prefs.load_preferences() - assert result["free"]["autoStart"] is False - assert result["free"]["bloomSeconds"] == 45 - assert result["ratio"]["bloomSeconds"] == 20 - assert result["ratio"]["machineIntegration"] is True - - def test_save_drops_unknown_keys(self, tmp_path, monkeypatch): - """Unknown keys in the payload are silently dropped.""" - import services.pour_over_preferences as prefs - - prefs.reset_cache() - monkeypatch.setattr(prefs, "PREFS_FILE", tmp_path / "prefs.json") - - prefs.save_preferences( - { - "free": {"autoStart": True, "extraKey": "nope"}, - "ratio": {}, - } - ) - - prefs.reset_cache() - result = prefs.load_preferences() - assert "extraKey" not in result["free"] - # defaults still present - assert result["free"]["bloomEnabled"] is True - - def test_load_handles_corrupt_json(self, tmp_path, monkeypatch): - """Corrupt JSON on disk falls back to defaults.""" - import services.pour_over_preferences as prefs - - prefs.reset_cache() - prefs_file = tmp_path / "prefs.json" - prefs_file.write_text("NOT-JSON!!!") - monkeypatch.setattr(prefs, "PREFS_FILE", prefs_file) - - result = prefs.load_preferences() - assert result["free"]["autoStart"] is True - assert result["ratio"]["bloomSeconds"] == 30 - - def test_load_uses_cache(self, tmp_path, monkeypatch): - """Second call returns cached copy without re-reading disk.""" - import services.pour_over_preferences as prefs - - prefs.reset_cache() - prefs_file = tmp_path / "prefs.json" - monkeypatch.setattr(prefs, "PREFS_FILE", prefs_file) - - first = prefs.load_preferences() - # Overwrite file with garbage – cache should still work - prefs_file.write_text("GARBAGE") - second = prefs.load_preferences() - assert first is second - - def test_save_partial_mode_fills_defaults(self, tmp_path, monkeypatch): - """If a mode dict is missing keys, defaults are used.""" - import services.pour_over_preferences as prefs - - prefs.reset_cache() - monkeypatch.setattr(prefs, "PREFS_FILE", tmp_path / "prefs.json") - - result = prefs.save_preferences({"free": {"autoStart": False}, "ratio": {}}) - assert result["free"]["autoStart"] is False - assert result["free"]["bloomEnabled"] is True # default - assert result["ratio"]["autoStart"] is True # default - - -class TestPourOverPreferencesEndpoints: - """Integration tests for /api/pour-over/preferences endpoints.""" - - def setup_method(self): - import services.pour_over_preferences as prefs - - prefs.reset_cache() - - def test_get_preferences_defaults(self, client, tmp_path, monkeypatch): - """GET /api/pour-over/preferences returns defaults.""" - import services.pour_over_preferences as prefs - - monkeypatch.setattr(prefs, "PREFS_FILE", tmp_path / "prefs.json") - - response = client.get("/api/pour-over/preferences") - assert response.status_code == 200 - data = response.json() - assert data["free"]["autoStart"] is True - assert data["ratio"]["machineIntegration"] is False - - def test_put_preferences(self, client, tmp_path, monkeypatch): - """PUT /api/pour-over/preferences saves and returns preferences.""" - import services.pour_over_preferences as prefs - - monkeypatch.setattr(prefs, "PREFS_FILE", tmp_path / "prefs.json") - - payload = { - "free": { - "autoStart": False, - "bloomEnabled": True, - "bloomSeconds": 45, - "machineIntegration": False, - }, - "ratio": { - "autoStart": True, - "bloomEnabled": False, - "bloomSeconds": 20, - "machineIntegration": True, - }, - } - response = client.put("/api/pour-over/preferences", json=payload) - assert response.status_code == 200 - data = response.json() - assert data["free"]["autoStart"] is False - assert data["free"]["bloomSeconds"] == 45 - assert data["ratio"]["bloomEnabled"] is False - assert data["ratio"]["machineIntegration"] is True - - def test_put_then_get_round_trip(self, client, tmp_path, monkeypatch): - """PUT then GET returns the same preferences.""" - import services.pour_over_preferences as prefs - - monkeypatch.setattr(prefs, "PREFS_FILE", tmp_path / "prefs.json") - - payload = { - "free": { - "autoStart": False, - "bloomEnabled": False, - "bloomSeconds": 60, - "machineIntegration": True, - }, - "ratio": { - "autoStart": False, - "bloomEnabled": True, - "bloomSeconds": 15, - "machineIntegration": False, - }, - } - client.put("/api/pour-over/preferences", json=payload) - - prefs.reset_cache() # force re-read from disk - response = client.get("/api/pour-over/preferences") - data = response.json() - assert data["free"]["autoStart"] is False - assert data["free"]["bloomSeconds"] == 60 - assert data["ratio"]["bloomSeconds"] == 15 - - def test_put_partial_payload_defaults(self, client, tmp_path, monkeypatch): - """PUT with partial data fills missing fields from defaults.""" - import services.pour_over_preferences as prefs - - monkeypatch.setattr(prefs, "PREFS_FILE", tmp_path / "prefs.json") - - response = client.put( - "/api/pour-over/preferences", - json={ - "free": {"autoStart": False}, - "ratio": {}, - }, - ) - assert response.status_code == 200 - data = response.json() - assert data["free"]["autoStart"] is False - assert data["free"]["bloomEnabled"] is True # default - assert data["ratio"]["autoStart"] is True # default - - -# ============================================================================ -# Profile shortDescription Validation Tests -# ============================================================================ - - -class TestShortDescriptionValidation: - """Tests for shortDescription truncation in _normalize_profile_for_machine.""" - - def test_short_desc_under_limit_unchanged(self): - """shortDescription under 99 chars is left unchanged.""" - from services.meticulous_service import _normalize_profile_for_machine - - profile = { - "name": "Test", - "stages": [], - "display": {"shortDescription": "Short desc"}, - } - result = _normalize_profile_for_machine(profile) - assert result["display"]["shortDescription"] == "Short desc" - - def test_short_desc_over_limit_truncated(self): - """shortDescription over 99 chars is truncated.""" - from services.meticulous_service import _normalize_profile_for_machine - - long_desc = "A" * 150 - profile = { - "name": "Test", - "stages": [], - "display": {"shortDescription": long_desc}, - } - result = _normalize_profile_for_machine(profile) - assert len(result["display"]["shortDescription"]) == 99 - - def test_short_desc_exactly_99_unchanged(self): - """shortDescription exactly 99 chars is left unchanged.""" - from services.meticulous_service import _normalize_profile_for_machine - - desc = "A" * 99 - profile = { - "name": "Test", - "stages": [], - "display": {"shortDescription": desc}, - } - result = _normalize_profile_for_machine(profile) - assert result["display"]["shortDescription"] == desc - assert len(result["display"]["shortDescription"]) == 99 - - def test_short_desc_missing_no_error(self): - """Missing shortDescription doesn't cause an error.""" - from services.meticulous_service import _normalize_profile_for_machine - - profile = {"name": "Test", "stages": [], "display": {}} - result = _normalize_profile_for_machine(profile) - assert "shortDescription" not in result["display"] - - def test_short_desc_none_no_error(self): - """None shortDescription doesn't cause an error.""" - from services.meticulous_service import _normalize_profile_for_machine - - profile = {"name": "Test", "stages": [], "display": {"shortDescription": None}} - result = _normalize_profile_for_machine(profile) - # None is not a string, so it's not truncated - assert result["display"]["shortDescription"] is None - - -# ============================================================================ -# Recipe Adapter Tests -# ============================================================================ - - -class TestRecipeAdapter: - """Tests for services/recipe_adapter.py — OPOS → Meticulous profile.""" - - def test_list_recipe_slugs_returns_list(self): - """list_recipe_slugs returns a non-empty sorted list.""" - from services.recipe_adapter import list_recipe_slugs - - slugs = list_recipe_slugs() - assert isinstance(slugs, list) - assert len(slugs) >= 4 - - def test_list_recipe_slugs_contains_known_recipes(self): - """list_recipe_slugs includes the four confirmed bundled recipes.""" - from services.recipe_adapter import list_recipe_slugs - - slugs = list_recipe_slugs() - for expected in ("4-6-method", "hoffmann-v2", "lance-hedrick-single-pour"): - assert expected in slugs, f"Expected slug '{expected}' not found in {slugs}" - - def test_load_recipe_success(self): - """load_recipe loads the 4:6 recipe and injects the slug key.""" - from services.recipe_adapter import load_recipe - - recipe = load_recipe("4-6-method") - assert recipe["slug"] == "4-6-method" - assert recipe["metadata"]["name"] == "4:6 Method (Stronger)" - assert recipe["ingredients"]["coffee_g"] == 20.0 - assert recipe["ingredients"]["water_g"] == 300.0 - assert len(recipe["protocol"]) == 9 - - def test_load_recipe_not_found_raises(self): - """load_recipe raises FileNotFoundError for an unknown slug.""" - from services.recipe_adapter import load_recipe - - with pytest.raises(FileNotFoundError): - load_recipe("nonexistent-recipe-xyz-abc") - - def test_list_recipes_returns_all_bundled(self): - """list_recipes returns at least the four confirmed recipes.""" - from services.recipe_adapter import list_recipes - - recipes = list_recipes() - assert isinstance(recipes, list) - assert len(recipes) >= 4 - names = {r["metadata"]["name"] for r in recipes} - assert any("4:6 Method" in n for n in names) - - def test_list_recipes_each_has_slug(self): - """Every recipe returned by list_recipes has a slug field.""" - from services.recipe_adapter import list_recipes - - for recipe in list_recipes(): - assert "slug" in recipe, ( - f"Recipe missing slug: {recipe.get('metadata', {}).get('name')}" - ) - - def test_adapt_recipe_46_method_stages(self): - """4:6 recipe produces 9 stages: 5 pours (weight exit) + 4 waits (time exit).""" - from services.recipe_adapter import load_recipe, adapt_recipe_to_profile - - recipe = load_recipe("4-6-method") - profile = adapt_recipe_to_profile(recipe) - - assert profile["name"] == "MeticAI Recipe: 4:6 Method (Stronger)" - assert profile["final_weight"] == 300.0 - assert len(profile["stages"]) == 9 - - pour_stages = [s for s in profile["stages"] if "Pour" in s.get("name", "")] - wait_stages = [s for s in profile["stages"] if "Wait" in s.get("name", "")] - assert len(pour_stages) == 5 - assert len(wait_stages) == 4 - - for s in pour_stages: - assert s["exit_triggers"][0]["type"] == "weight" - for s in wait_stages: - assert s["exit_triggers"][0]["type"] == "time" - - # Cumulative weight of all 5 × 60g pours = 300g - assert pour_stages[-1]["exit_triggers"][0]["value"] == pytest.approx(300.0) - - def test_adapt_recipe_hoffmann_has_bloom(self): - """Hoffmann V2 profile has a Bloom stage as first stage.""" - from services.recipe_adapter import load_recipe, adapt_recipe_to_profile - - recipe = load_recipe("hoffmann-v2") - profile = adapt_recipe_to_profile(recipe) - - first_stage = profile["stages"][0] - assert "Bloom" in first_stage["name"] - assert first_stage["exit_triggers"][0]["type"] == "time" - - def test_adapt_recipe_name_prefix(self): - """All adapted profiles have the 'MeticAI Recipe: ' prefix.""" - from services.recipe_adapter import load_recipe, adapt_recipe_to_profile - - recipe = load_recipe("hoffmann-v2") - profile = adapt_recipe_to_profile(recipe) - assert profile["name"].startswith("MeticAI Recipe: ") - - def test_adapt_recipe_unique_ids(self): - """Two calls to adapt_recipe_to_profile produce different UUIDs.""" - from services.recipe_adapter import load_recipe, adapt_recipe_to_profile - - recipe = load_recipe("4-6-method") - p1 = adapt_recipe_to_profile(recipe) - p2 = adapt_recipe_to_profile(recipe) - assert p1["id"] != p2["id"] - - def test_adapt_recipe_stages_use_power_type(self): - """All stages use type 'power' (pour-over has no heating/pressure).""" - from services.recipe_adapter import load_recipe, adapt_recipe_to_profile - - recipe = load_recipe("4-6-method") - for stage in adapt_recipe_to_profile(recipe)["stages"]: - assert stage["type"] == "power" - - def test_adapt_lance_hedrick_final_weight(self): - """Lance Hedrick profile's final_weight matches recipe water_g.""" - from services.recipe_adapter import load_recipe, adapt_recipe_to_profile - - recipe = load_recipe("lance-hedrick-single-pour") - profile = adapt_recipe_to_profile(recipe) - assert profile["final_weight"] == pytest.approx( - recipe["ingredients"]["water_g"] - ) - - def test_adapt_recipe_missing_recipes_dir_returns_empty_list( - self, tmp_path, monkeypatch - ): - """When recipes directory doesn't exist, list_recipe_slugs returns [].""" - import services.recipe_adapter as ra - - monkeypatch.setattr(ra, "_SEARCH_DIRS", (tmp_path / "nonexistent_dir",)) - slugs = ra.list_recipe_slugs() - assert slugs == [] - - def test_adapt_recipe_from_custom_dir(self, tmp_path, monkeypatch): - """adapt_recipe_to_profile works with a custom recipe JSON.""" - import services.recipe_adapter as ra - - custom_recipe = { - "version": "1.1.0", - "metadata": {"name": "Test Custom Recipe"}, - "equipment": {"dripper": {"model": "V60"}}, - "ingredients": { - "coffee_g": 15.0, - "water_g": 250.0, - "grind_setting": "Medium", - }, - "protocol": [ - {"step": 1, "action": "bloom", "water_g": 45, "duration_s": 30}, - {"step": 2, "action": "wait", "duration_s": 15}, - {"step": 3, "action": "pour", "water_g": 205, "duration_s": 90}, - ], - } - - profile = ra.adapt_recipe_to_profile(custom_recipe) - assert profile["name"] == "MeticAI Recipe: Test Custom Recipe" - assert profile["final_weight"] == pytest.approx(250.0) - assert len(profile["stages"]) == 3 - - bloom = profile["stages"][0] - assert "Bloom" in bloom["name"] - assert bloom["exit_triggers"][0]["type"] == "time" - assert bloom["exit_triggers"][0]["value"] == pytest.approx(30.0) - - wait = profile["stages"][1] - assert wait["exit_triggers"][0]["type"] == "time" - assert wait["exit_triggers"][0]["value"] == pytest.approx(15.0) - - pour = profile["stages"][2] - assert pour["exit_triggers"][0]["type"] == "weight" - assert pour["exit_triggers"][0]["value"] == pytest.approx(250.0) # cumulative - - -# ============================================================================ -# Recipe Endpoint Tests -# ============================================================================ - - -class TestRecipeEndpoints: - """Tests for GET /api/recipes and GET /api/recipes/{slug}.""" - - def test_list_recipes_returns_200(self, client): - """GET /api/recipes returns HTTP 200.""" - response = client.get("/api/recipes") - assert response.status_code == 200 - - def test_list_recipes_returns_list(self, client): - """GET /api/recipes body is a JSON list.""" - body = client.get("/api/recipes").json() - assert isinstance(body, list) - assert len(body) >= 4 - - def test_list_recipes_each_has_required_fields(self, client): - """Each item in GET /api/recipes has slug, metadata.name, ingredients, protocol.""" - for recipe in client.get("/api/recipes").json(): - assert "slug" in recipe - assert "metadata" in recipe - assert "name" in recipe["metadata"] - assert "ingredients" in recipe - assert "protocol" in recipe - - def test_get_recipe_by_slug_returns_correct_data(self, client): - """GET /api/recipes/4-6-method returns the stronger 4:6 recipe with 9 steps.""" - response = client.get("/api/recipes/4-6-method") - assert response.status_code == 200 - data = response.json() - assert data["slug"] == "4-6-method" - assert data["metadata"]["name"] == "4:6 Method (Stronger)" - assert data["ingredients"]["water_g"] == 300.0 - assert len(data["protocol"]) == 9 - - def test_get_recipe_unknown_slug_returns_404(self, client): - """GET /api/recipes/ returns HTTP 404.""" - response = client.get("/api/recipes/no-such-recipe-xyz") - assert response.status_code == 404 - - def test_get_all_bundled_recipes_individually(self, client): - """Each recipe that appears in the list can also be fetched by slug.""" - slugs = [r["slug"] for r in client.get("/api/recipes").json()] - for slug in slugs: - resp = client.get(f"/api/recipes/{slug}") - assert resp.status_code == 200, f"Slug '{slug}' returned {resp.status_code}" - - def test_recipe_slug_field_matches_url_slug(self, client): - """The slug field in each recipe matches the URL slug used to fetch it.""" - for recipe in client.get("/api/recipes").json(): - fetched = client.get(f"/api/recipes/{recipe['slug']}").json() - assert fetched["slug"] == recipe["slug"] - - -# ============================================================================ -# Prepare Recipe Endpoint Tests -# ============================================================================ - - -class TestPrepareRecipeEndpoint: - """Tests for POST /api/pour-over/prepare-recipe.""" - - @patch("api.routes.pour_over.temp_profile_service") - @patch("api.routes.pour_over.get_mqtt_subscriber") - def test_prepare_recipe_success(self, mock_mqtt_sub, mock_temp_svc, client): - """POST /api/pour-over/prepare-recipe with valid slug returns 200.""" - mock_mqtt_sub.return_value.get_snapshot.return_value = {} - - async def _fake_load_ephemeral( - profile_json, params, previous_profile_name=None - ): - return { - "profile_id": "test-uuid-1234", - "profile_name": "MeticAI Recipe: 4:6 Method", - } - - mock_temp_svc.load_ephemeral = _fake_load_ephemeral - - response = client.post( - "/api/pour-over/prepare-recipe", - json={"recipe_slug": "4-6-method"}, - ) - assert response.status_code == 200 - data = response.json() - assert "profile_id" in data - assert "profile_name" in data - - def test_prepare_recipe_unknown_slug_returns_404(self, client): - """POST /api/pour-over/prepare-recipe with unknown slug returns 404.""" - response = client.post( - "/api/pour-over/prepare-recipe", - json={"recipe_slug": "nonexistent-slug-xyz-abc"}, - ) - assert response.status_code == 404 - - def test_prepare_recipe_missing_recipe_slug_field_returns_422(self, client): - """POST /api/pour-over/prepare-recipe without recipe_slug returns 422.""" - response = client.post("/api/pour-over/prepare-recipe", json={}) - assert response.status_code == 422 - - def test_prepare_recipe_empty_slug_returns_422(self, client): - """POST /api/pour-over/prepare-recipe with empty string slug returns 422.""" - response = client.post( - "/api/pour-over/prepare-recipe", - json={"recipe_slug": ""}, - ) - assert response.status_code == 422 - - -# ============================================================================ -# Shot Annotation Endpoint Tests -# ============================================================================ - - -class TestShotAnnotationEndpoints: - """Tests for GET/PATCH /api/shots/{date}/{filename}/annotation.""" - - @pytest.fixture(autouse=True) - def isolate_annotations(self, tmp_path, monkeypatch): - """Redirect annotations storage to a temp dir and clear the cache.""" - import services.shot_annotations_service as svc - - annotations_file = tmp_path / "shot_annotations.json" - monkeypatch.setattr(svc, "ANNOTATIONS_FILE", annotations_file) - svc.invalidate_cache() - yield - svc.invalidate_cache() - - def test_get_annotation_missing_returns_null(self, client): - """GET annotation for a shot with no saved annotation returns null.""" - response = client.get("/api/shots/2024-01-15/shot_001.json/annotation") - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["annotation"] is None - - def test_create_annotation(self, client): - """PATCH creates a new annotation and returns it.""" - response = client.patch( - "/api/shots/2024-01-15/shot_001.json/annotation", - json={"annotation": "Great shot, nice crema."}, - ) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["annotation"] == "Great shot, nice crema." - assert data["updated_at"] is not None - - def test_update_annotation(self, client): - """PATCH updates an existing annotation.""" - client.patch( - "/api/shots/2024-01-15/shot_001.json/annotation", - json={"annotation": "First note."}, - ) - response = client.patch( - "/api/shots/2024-01-15/shot_001.json/annotation", - json={"annotation": "Updated note."}, - ) - assert response.status_code == 200 - data = response.json() - assert data["annotation"] == "Updated note." - - def test_get_annotation_after_create(self, client): - """GET returns the annotation after it has been created.""" - client.patch( - "/api/shots/2024-01-15/shot_001.json/annotation", - json={"annotation": "Tasty."}, - ) - response = client.get("/api/shots/2024-01-15/shot_001.json/annotation") - assert response.status_code == 200 - assert response.json()["annotation"] == "Tasty." - - def test_clear_annotation_via_empty_string(self, client): - """PATCH with empty string clears the annotation (returns null).""" - client.patch( - "/api/shots/2024-01-15/shot_001.json/annotation", - json={"annotation": "Remove me."}, - ) - response = client.patch( - "/api/shots/2024-01-15/shot_001.json/annotation", - json={"annotation": ""}, - ) - assert response.status_code == 200 - assert response.json()["annotation"] is None - # GET should also return null after clearing - get_resp = client.get("/api/shots/2024-01-15/shot_001.json/annotation") - assert get_resp.json()["annotation"] is None - - def test_clear_annotation_via_whitespace_only(self, client): - """PATCH with whitespace-only string clears the annotation.""" - client.patch( - "/api/shots/2024-01-15/shot_001.json/annotation", - json={"annotation": "Remove me."}, - ) - response = client.patch( - "/api/shots/2024-01-15/shot_001.json/annotation", - json={"annotation": " "}, - ) - assert response.status_code == 200 - assert response.json()["annotation"] is None - - def test_patch_missing_annotation_field_defaults_to_clear(self, client): - """PATCH body with no annotation key defaults to clearing.""" - client.patch( - "/api/shots/2024-01-15/shot_001.json/annotation", - json={"annotation": "Something."}, - ) - response = client.patch( - "/api/shots/2024-01-15/shot_001.json/annotation", - json={}, - ) - assert response.status_code == 200 - assert response.json()["annotation"] is None - - def test_patch_invalid_json_body_returns_error(self, client): - """PATCH with invalid JSON body returns a 4xx error.""" - response = client.patch( - "/api/shots/2024-01-15/shot_001.json/annotation", - content=b"not json at all", - headers={"Content-Type": "application/json"}, - ) - assert response.status_code in (400, 422, 500) - - def test_annotations_are_isolated_per_shot(self, client): - """Annotations for different shots do not bleed into each other.""" - client.patch( - "/api/shots/2024-01-15/shot_001.json/annotation", - json={"annotation": "Shot one."}, - ) - client.patch( - "/api/shots/2024-01-15/shot_002.json/annotation", - json={"annotation": "Shot two."}, - ) - r1 = client.get("/api/shots/2024-01-15/shot_001.json/annotation") - r2 = client.get("/api/shots/2024-01-15/shot_002.json/annotation") - assert r1.json()["annotation"] == "Shot one." - assert r2.json()["annotation"] == "Shot two." - - def test_annotation_markdown_content_preserved(self, client): - """Annotation text with markdown is stored and returned verbatim.""" - md = "## Notes\n\n- Bold flavour\n- **Nice** crema" - client.patch( - "/api/shots/2024-01-15/shot_001.json/annotation", - json={"annotation": md}, - ) - response = client.get("/api/shots/2024-01-15/shot_001.json/annotation") - assert response.json()["annotation"] == md - - -class TestRecentShotsEndpoint: - """Tests for GET /api/shots/recent and GET /api/shots/recent/by-profile.""" - - @pytest.fixture(autouse=True) - def clear_recent_cache(self): - """Clear the recent-shots in-memory cache between tests.""" - from api.routes.shots import _recent_shots_cache - - _recent_shots_cache.clear() - yield - _recent_shots_cache.clear() - - @pytest.fixture(autouse=True) - def clear_shot_index(self): - """Reset the persistent shot profile index so tests use mocked data.""" - import services.cache_service as _cs - - _cs._shot_index = None - if _cs.SHOT_INDEX_FILE.exists(): - _cs.SHOT_INDEX_FILE.unlink() - yield - _cs._shot_index = None - if _cs.SHOT_INDEX_FILE.exists(): - _cs.SHOT_INDEX_FILE.unlink() - - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_recent_shots_success(self, mock_dates, mock_files, mock_fetch, client): - """Test fetching recent shots across all profiles.""" - date1 = MagicMock() - date1.name = "2024-01-15" - mock_dates.return_value = [date1] - - file1 = MagicMock() - file1.name = "shot_001.json" - file2 = MagicMock() - file2.name = "shot_002.json" - mock_files.return_value = [file2, file1] - - mock_fetch.side_effect = [ - { - "profile_name": "Profile B", - "profile": {"name": "Profile B", "id": "pb"}, - "time": 1705320100, - "data": [{"time": 28000, "shot": {"weight": 38.0}}], - }, - { - "profile_name": "Profile A", - "profile": {"name": "Profile A", "id": "pa"}, - "time": 1705320000, - "data": [{"time": 25000, "shot": {"weight": 36.5}}], - }, - ] - - response = client.get("/api/shots/recent?limit=10&offset=0") - assert response.status_code == 200 - data = response.json() - assert "shots" in data - assert len(data["shots"]) == 2 - # Should be sorted by timestamp descending - assert data["shots"][0]["profile_name"] == "Profile B" - assert data["shots"][1]["profile_name"] == "Profile A" - assert data["shots"][0]["final_weight"] == 38.0 - assert "has_annotation" in data["shots"][0] - - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_recent_shots_empty(self, mock_dates, client): - """Test empty response when no dates exist.""" - mock_dates.return_value = [] - - response = client.get("/api/shots/recent") - assert response.status_code == 200 - data = response.json() - assert data["shots"] == [] - - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_recent_shots_machine_error(self, mock_dates, client): - """Test 502 when machine returns error.""" - result = MagicMock() - result.error = "Connection timeout" - mock_dates.return_value = result - - response = client.get("/api/shots/recent") - assert response.status_code == 502 - - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_recent_shots_by_profile(self, mock_dates, mock_files, mock_fetch, client): - """Test fetching recent shots grouped by profile.""" - date1 = MagicMock() - date1.name = "2024-01-15" - mock_dates.return_value = [date1] - - file1 = MagicMock() - file1.name = "shot_001.json" - file2 = MagicMock() - file2.name = "shot_002.json" - mock_files.return_value = [file1, file2] - - mock_fetch.side_effect = [ - { - "profile_name": "Espresso Classic", - "profile": {"name": "Espresso Classic", "id": "ec"}, - "time": 1705320000, - "data": [{"time": 25000, "shot": {"weight": 36.5}}], - }, - { - "profile_name": "Espresso Classic", - "profile": {"name": "Espresso Classic", "id": "ec"}, - "time": 1705320100, - "data": [{"time": 28000, "shot": {"weight": 38.0}}], - }, - ] - - response = client.get("/api/shots/recent/by-profile") - assert response.status_code == 200 - data = response.json() - assert "profiles" in data - assert len(data["profiles"]) == 1 - assert data["profiles"][0]["profile_name"] == "Espresso Classic" - assert data["profiles"][0]["shot_count"] == 2 - assert len(data["profiles"][0]["shots"]) == 2 - - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_recent_shots_dual_routes(self, mock_dates, mock_files, mock_fetch, client): - """Test that dual routes (with and without /api prefix) both work.""" - mock_dates.return_value = [] - - for path in ["/shots/recent", "/api/shots/recent"]: - response = client.get(path) - assert response.status_code == 200 - - for path in ["/shots/recent/by-profile", "/api/shots/recent/by-profile"]: - response = client.get(path) - assert response.status_code == 200 - - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_shot_files", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_history_dates", new_callable=AsyncMock) - def test_recent_shots_pagination(self, mock_dates, mock_files, mock_fetch, client): - """Test pagination with offset and limit.""" - date1 = MagicMock() - date1.name = "2024-01-15" - mock_dates.return_value = [date1] - - files = [] - for i in range(5): - f = MagicMock() - f.name = f"shot_{i:03d}.json" - files.append(f) - mock_files.return_value = files - - mock_fetch.side_effect = [ - { - "profile_name": f"Profile {i}", - "profile": {"name": f"Profile {i}", "id": f"p{i}"}, - "time": 1705320000 + i * 100, - "data": [{"time": 25000, "shot": {"weight": 36.0 + i}}], - } - for i in range(5) - ] - - response = client.get("/api/shots/recent?limit=2&offset=0") - assert response.status_code == 200 - data = response.json() - assert len(data["shots"]) == 2 - - -class TestEditProfileEndpoint: - """Tests for the PUT /api/profile/{name}/edit endpoint.""" - - def _make_mock_profile( - self, - name="TestProfile", - profile_id="abc-123", - temperature=93.0, - final_weight=36.0, - author="Metic", - ): - """Helper to build a mock profile object.""" - profile = Mock() - profile.id = profile_id - profile.name = name - profile.author = author - profile.author_id = None - profile.temperature = temperature - profile.final_weight = final_weight - profile.stages = [] - var = SimpleNamespace(key="flow_main", name="Main Flow", value=2.5, type="flow") - profile.variables = [var] - profile.display = None - profile.isDefault = False - profile.source = None - profile.beverage_type = None - profile.tank_temperature = None - profile.previous_authors = None - profile.error = None - return profile - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.async_save_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_edit_profile_success(self, mock_list, mock_get, mock_save, client): - """Successful profile edit updates temperature and returns success.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_save.return_value = None - - response = client.put( - "/api/profile/TestProfile/edit", json={"temperature": 90.0} - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["profile"]["temperature"] == 90.0 - mock_save.assert_called_once() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_edit_profile_temperature_below_70_accepted(self, client): - """Temperature below 70 is accepted (warning only, not an error).""" - # The endpoint no longer rejects temperatures below 70 — - # it only blocks temperatures above 100. - # Without a real machine, this will fail at the list_profiles step (500), - # but the key assertion is that we do NOT get a 400 validation error. - response = client.put( - "/api/profile/TestProfile/edit", json={"temperature": 50.0} - ) - assert response.status_code != 400 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_edit_profile_temperature_too_high(self, client): - """Temperature above 100 returns 400.""" - response = client.put( - "/api/profile/TestProfile/edit", json={"temperature": 110.0} - ) - assert response.status_code == 400 - assert "100" in response.json()["detail"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_edit_profile_weight_zero(self, client): - """Final weight of 0 returns 400.""" - response = client.put("/api/profile/TestProfile/edit", json={"final_weight": 0}) - assert response.status_code == 400 - assert "greater than 0" in response.json()["detail"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_edit_profile_empty_name(self, client): - """Empty name returns 400.""" - response = client.put("/api/profile/TestProfile/edit", json={"name": ""}) - assert response.status_code == 400 - assert "non-empty" in response.json()["detail"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_edit_profile_no_fields(self, client): - """No fields to update returns 400.""" - response = client.put("/api/profile/TestProfile/edit", json={}) - assert response.status_code == 400 - assert "At least one" in response.json()["detail"] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_edit_profile_not_found(self, mock_list, client): - """Profile not on machine returns 404.""" - mock_list.return_value = [] - - response = client.put( - "/api/profile/MissingProfile/edit", json={"temperature": 90.0} - ) - assert response.status_code == 404 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.save_history") - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_save_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_edit_profile_rename_cascades_to_history( - self, mock_list, mock_get, mock_save, mock_load_hist, mock_save_hist, client - ): - """Renaming a profile also updates matching history entries.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_save.return_value = None - mock_load_hist.return_value = [ - {"id": "h1", "profile_name": "TestProfile", "reply": "..."}, - {"id": "h2", "profile_name": "OtherProfile", "reply": "..."}, - ] - - response = client.put( - "/api/profile/TestProfile/edit", json={"name": "RenamedProfile"} - ) - - assert response.status_code == 200 - assert response.json()["profile"]["name"] == "RenamedProfile" - - # History should have been saved with the renamed entry - mock_save_hist.assert_called_once() - saved = mock_save_hist.call_args[0][0] - assert saved[0]["profile_name"] == "RenamedProfile" - assert saved[1]["profile_name"] == "OtherProfile" # unchanged - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.async_save_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_edit_profile_variables(self, mock_list, mock_get, mock_save, client): - """Updating variable values persists correctly.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_save.return_value = None - - response = client.put( - "/api/profile/TestProfile/edit", - json={"variables": [{"key": "flow_main", "value": 3.0}]}, - ) - - assert response.status_code == 200 - saved_profile = mock_save.call_args[0][0] - flow_var = next(v for v in saved_profile.variables if v.key == "flow_main") - assert flow_var.value == 3.0 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.async_save_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_edit_profile_dual_route(self, mock_list, mock_get, mock_save, client): - """Both /profile/{name}/edit and /api/profile/{name}/edit work.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_save.return_value = None - - response = client.put("/profile/TestProfile/edit", json={"temperature": 88.0}) - assert response.status_code == 200 - - -class TestProfileSync: - """Tests for profile sync endpoints.""" - - def _make_mock_profile(self, name="TestProfile", pid="prof-1"): - profile = Mock() - profile.id = pid - profile.name = name - profile.author = "Test Author" - profile.temperature = 93.0 - profile.final_weight = 36.0 - profile.error = None - profile.stages = [] - profile.variables = [] - profile.display = None - profile.isDefault = False - profile.source = None - profile.beverage_type = None - profile.tank_temperature = None - return profile - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_sync_returns_new_profiles(self, mock_list, mock_get, mock_history, client): - """Profiles on machine but not in history are listed as 'new'.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_history.return_value = [] - - response = client.post("/api/profiles/sync") - assert response.status_code == 200 - data = response.json() - assert len(data["new"]) == 1 - assert data["new"][0]["profile_name"] == "TestProfile" - assert len(data["updated"]) == 0 - assert len(data["orphaned"]) == 0 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_sync_detects_updated_profiles( - self, mock_list, mock_get, mock_history, client - ): - """Profiles with a different content hash are listed as 'updated'.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_history.return_value = [ - { - "id": "entry-1", - "profile_name": "TestProfile", - "content_hash": "stale_hash_value", - "reply": "test", - } - ] - - response = client.post("/api/profiles/sync") - assert response.status_code == 200 - data = response.json() - assert len(data["new"]) == 0 - assert len(data["updated"]) == 1 - assert data["updated"][0]["profile_name"] == "TestProfile" - assert data["updated"][0]["stored_hash"] == "stale_hash_value" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_sync_detects_orphaned_entries( - self, mock_list, mock_get, mock_history, client - ): - """History entries with no matching machine profile are 'orphaned'.""" - mock_list.return_value = [] - mock_history.return_value = [ - { - "id": "entry-1", - "profile_name": "DeletedProfile", - "reply": "desc", - "profile_json": {"name": "DeletedProfile"}, - } - ] - - response = client.post("/api/profiles/sync") - assert response.status_code == 200 - data = response.json() - assert len(data["new"]) == 0 - assert len(data["updated"]) == 0 - assert len(data["orphaned"]) == 1 - assert data["orphaned"][0]["profile_name"] == "DeletedProfile" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_sync_in_sync_returns_empty( - self, mock_list, mock_get, mock_history, client - ): - """When hash matches, profile is neither new nor updated.""" - from services.history_service import compute_content_hash - - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - from utils.file_utils import deep_convert_to_dict - - expected_hash = compute_content_hash(deep_convert_to_dict(profile)) - mock_history.return_value = [ - { - "id": "entry-1", - "profile_name": "TestProfile", - "content_hash": expected_hash, - "reply": "test", - } - ] - - response = client.post("/api/profiles/sync") - assert response.status_code == 200 - data = response.json() - assert len(data["new"]) == 0 - assert len(data["updated"]) == 0 - assert len(data["orphaned"]) == 0 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_sync_status_counts(self, mock_list, mock_get, mock_history, client): - """GET /api/profiles/sync/status returns correct counts.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_history.return_value = [ - { - "id": "orphan-1", - "profile_name": "GoneProfile", - "reply": "x", - } - ] - - response = client.get("/api/profiles/sync/status") - assert response.status_code == 200 - data = response.json() - assert data["new_count"] == 1 - assert data["orphaned_count"] == 1 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_sync_status_dual_route(self, mock_list, mock_get, mock_history, client): - """Both /profiles/sync/status and /api/profiles/sync/status work.""" - mock_list.return_value = [] - mock_get.return_value = None - mock_history.return_value = [] - - for path in ["/profiles/sync/status", "/api/profiles/sync/status"]: - response = client.get(path) - assert response.status_code == 200 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.update_entry_sync_fields") - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.fetch_machine_profile_dict", new_callable=AsyncMock) - def test_accept_sync_update(self, mock_fetch, mock_history, mock_update, client): - """POST /api/profiles/sync/accept/{id} updates history entry.""" - machine_json = { - "id": "prof-1", - "name": "TestProfile", - "author": "Metic", - "stages": [], - "variables": [], - } - mock_fetch.return_value = machine_json - mock_history.return_value = [ - { - "id": "entry-1", - "profile_name": "TestProfile", - "content_hash": "old_hash", - "reply": "old desc", - } - ] - mock_update.return_value = {"id": "entry-1", "profile_name": "TestProfile"} - - response = client.post("/api/profiles/sync/accept/prof-1") - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["profile_name"] == "TestProfile" - mock_update.assert_called_once() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.fetch_machine_profile_dict", new_callable=AsyncMock) - def test_accept_sync_update_not_found(self, mock_fetch, mock_history, client): - """Accept returns 404 when no matching history entry exists.""" - machine_json = { - "id": "prof-1", - "name": "Unknown", - "author": "Metic", - "stages": [], - } - mock_fetch.return_value = machine_json - mock_history.return_value = [] - - response = client.post("/api/profiles/sync/accept/prof-1") - assert response.status_code == 404 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_sync_dual_route(self, mock_list, mock_get, mock_history, client): - """Both /profiles/sync and /api/profiles/sync work.""" - mock_list.return_value = [] - mock_get.return_value = None - mock_history.return_value = [] - - for path in ["/profiles/sync", "/api/profiles/sync"]: - response = client.post(path) - assert response.status_code == 200 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.update_entry_sync_fields") - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_sync_backfills_missing_hash( - self, mock_list, mock_get, mock_history, mock_update, client - ): - """Entries without content_hash get backfilled, not flagged as updated.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_update.return_value = {"id": "entry-1"} - mock_history.return_value = [ - { - "id": "entry-1", - "profile_name": "TestProfile", - "reply": "test", - # No content_hash — should trigger backfill - } - ] - - response = client.post("/api/profiles/sync") - assert response.status_code == 200 - data = response.json() - assert len(data["updated"]) == 0, "Backfill should NOT flag as updated" - assert len(data["new"]) == 0 - mock_update.assert_called_once() - call_kwargs = mock_update.call_args - assert call_kwargs[0][0] == "entry-1" - assert "content_hash" in call_kwargs[1] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.update_entry_sync_fields") - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_sync_status_detects_updated( - self, mock_list, mock_get, mock_history, mock_update, client - ): - """sync/status returns accurate updated_count when hashes differ.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_update.return_value = None - mock_history.return_value = [ - { - "id": "entry-1", - "profile_name": "TestProfile", - "content_hash": "stale_hash_value", - "reply": "test", - } - ] - - response = client.get("/api/profiles/sync/status") - assert response.status_code == 200 - data = response.json() - assert data["updated_count"] == 1 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.update_entry_sync_fields") - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_sync_status_backfills_missing_hash( - self, mock_list, mock_get, mock_history, mock_update, client - ): - """sync/status backfills missing hashes without counting as updated.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_update.return_value = {"id": "entry-1"} - mock_history.return_value = [ - { - "id": "entry-1", - "profile_name": "TestProfile", - "reply": "test", - # No content_hash - } - ] - - response = client.get("/api/profiles/sync/status") - assert response.status_code == 200 - data = response.json() - assert data["updated_count"] == 0, "Backfill should not count as updated" - mock_update.assert_called_once() - - @patch("services.history_service.save_history") - @patch("services.history_service.load_history") - def test_save_to_history_stores_content_hash(self, mock_load, mock_save): - """save_to_history() computes and stores content_hash from profile_json.""" - from services.history_service import save_to_history - - mock_load.return_value = [] - - reply = '**Profile Created:** Test\n```json\n{"name": "Test", "temperature": 93}\n```' - entry = save_to_history( - coffee_analysis="test beans", user_prefs="normal", reply=reply - ) - - assert "content_hash" in entry - assert len(entry["content_hash"]) == 64 # SHA-256 hex digest - - -class TestHistoryNotesEndpoints: - """Tests for the history notes GET/PATCH endpoints.""" - - @pytest.fixture - def sample_entry_with_notes(self): - """Create a sample history entry that has notes.""" - return { - "id": "note-entry-1", - "profile_name": "Test Profile", - "notes": "These are my tasting notes.", - "notes_updated_at": "2026-03-01T12:00:00+00:00", - } - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.history_service.get_entry_by_id") - def test_get_notes_success(self, mock_get_entry, client, sample_entry_with_notes): - """GET notes for a valid entry returns notes and timestamp.""" - mock_get_entry.return_value = sample_entry_with_notes - - response = client.get("/api/history/note-entry-1/notes") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["notes"] == "These are my tasting notes." - assert data["notes_updated_at"] == "2026-03-01T12:00:00+00:00" - mock_get_entry.assert_called_once_with("note-entry-1") - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.history_service.get_entry_by_id") - def test_get_notes_entry_not_found(self, mock_get_entry, client): - """GET notes for a missing entry returns 404.""" - mock_get_entry.return_value = None - - response = client.get("/api/history/nonexistent-id/notes") - - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.history_service.update_entry_notes") - def test_patch_notes_success(self, mock_update, client): - """PATCH notes with valid text returns updated notes.""" - mock_update.return_value = { - "id": "note-entry-1", - "notes": "Updated tasting notes.", - "notes_updated_at": "2026-03-02T08:00:00+00:00", - } - - response = client.patch( - "/api/history/note-entry-1/notes", - json={"notes": "Updated tasting notes."}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["notes"] == "Updated tasting notes." - assert data["notes_updated_at"] == "2026-03-02T08:00:00+00:00" - mock_update.assert_called_once_with("note-entry-1", "Updated tasting notes.") - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.history_service.update_entry_notes") - def test_patch_notes_empty_clears(self, mock_update, client): - """PATCH notes with empty string clears notes.""" - mock_update.return_value = { - "id": "note-entry-1", - "notes": "", - "notes_updated_at": "2026-03-02T09:00:00+00:00", - } - - response = client.patch( - "/api/history/note-entry-1/notes", - json={"notes": ""}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["notes"] == "" - mock_update.assert_called_once_with("note-entry-1", "") - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.history_service.update_entry_notes") - def test_patch_notes_entry_not_found(self, mock_update, client): - """PATCH notes for a missing entry returns 404.""" - mock_update.return_value = None - - response = client.patch( - "/api/history/nonexistent-id/notes", - json={"notes": "text"}, - ) - - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - - -class TestMachineDetectEndpoint: - """Tests for POST /api/machine/detect auto-discovery.""" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.machine_discovery_service.verify_machine", new_callable=AsyncMock) - @patch( - "services.machine_discovery_service.discover_machine", new_callable=AsyncMock - ) - def test_detect_found_and_verified(self, mock_discover, mock_verify, client): - """Machine found and verified returns full details with verified=True.""" - from services.machine_discovery_service import DiscoveryResult - - mock_discover.return_value = DiscoveryResult( - found=True, - ip="192.168.1.42", - hostname="meticulous.local", - method="mdns", - ) - mock_verify.return_value = True - - response = client.post("/api/machine/detect") - - assert response.status_code == 200 - data = response.json() - assert data["found"] is True - assert data["ip"] == "192.168.1.42" - assert data["hostname"] == "meticulous.local" - assert data["method"] == "mdns" - assert data["verified"] is True - mock_verify.assert_called_once_with("192.168.1.42") - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.machine_discovery_service.verify_machine", new_callable=AsyncMock) - @patch( - "services.machine_discovery_service.discover_machine", new_callable=AsyncMock - ) - def test_detect_found_not_verified(self, mock_discover, mock_verify, client): - """Machine found but not responding returns verified=False.""" - from services.machine_discovery_service import DiscoveryResult - - mock_discover.return_value = DiscoveryResult( - found=True, - ip="10.0.0.5", - hostname="meticulous.local", - method="hostname", - ) - mock_verify.return_value = False - - response = client.post("/api/machine/detect") - - assert response.status_code == 200 - data = response.json() - assert data["found"] is True - assert data["ip"] == "10.0.0.5" - assert data["verified"] is False - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch( - "services.machine_discovery_service.discover_machine", new_callable=AsyncMock - ) - def test_detect_not_found(self, mock_discover, client): - """No machine found returns guidance text.""" - from services.machine_discovery_service import DiscoveryResult - - mock_discover.return_value = DiscoveryResult( - found=False, - guidance="Could not automatically detect your Meticulous machine.", - ) - - response = client.post("/api/machine/detect") - - assert response.status_code == 200 - data = response.json() - assert data["found"] is False - assert "guidance" in data - assert len(data["guidance"]) > 0 - assert "ip" not in data - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch( - "services.machine_discovery_service.discover_machine", new_callable=AsyncMock - ) - def test_detect_discovery_raises_exception(self, mock_discover, client): - """Discovery raising an exception propagates as a server error.""" - mock_discover.side_effect = Exception("Network timeout") - - with pytest.raises(Exception, match="Network timeout"): - client.post("/api/machine/detect") - - -# ============================================================================ -# Recommendation Pipeline Tests (#258) -# ============================================================================ - - -class TestRecommendationParsing: - """Tests for _parse_recommendations_json and _classify_recommendation_patchable.""" - - def test_parse_valid_recommendations_json(self): - """Valid RECOMMENDATIONS_JSON block is parsed correctly.""" - from api.routes.shots import _parse_recommendations_json - - text = """## 1. Shot Performance -**What Happened:** -- Test - -RECOMMENDATIONS_JSON: -[ - { - "variable": "pressure", - "current_value": 6.0, - "recommended_value": 7.0, - "stage": "extraction", - "confidence": "high", - "reason": "Under-extraction detected" - }, - { - "variable": "temperature", - "current_value": 92, - "recommended_value": 94, - "stage": "global", - "confidence": "medium", - "reason": "Higher temp for dark roast" - } -] -END_RECOMMENDATIONS_JSON -""" - recs = _parse_recommendations_json(text) - assert len(recs) == 2 - assert recs[0]["variable"] == "pressure" - assert recs[0]["recommended_value"] == 7.0 - assert recs[1]["confidence"] == "medium" - - def test_parse_empty_recommendations(self): - """Empty array block is parsed as empty list.""" - from api.routes.shots import _parse_recommendations_json - - text = """Some analysis text -RECOMMENDATIONS_JSON: -[] -END_RECOMMENDATIONS_JSON -""" - recs = _parse_recommendations_json(text) - assert recs == [] - - def test_parse_missing_block(self): - """Missing RECOMMENDATIONS_JSON block returns empty list.""" - from api.routes.shots import _parse_recommendations_json - - text = "## 1. Shot Performance\nJust regular analysis text" - recs = _parse_recommendations_json(text) - assert recs == [] - - def test_parse_malformed_json(self): - """Malformed JSON in block returns empty list.""" - from api.routes.shots import _parse_recommendations_json - - text = """RECOMMENDATIONS_JSON: -[{broken json -END_RECOMMENDATIONS_JSON -""" - recs = _parse_recommendations_json(text) - assert recs == [] - - def test_parse_bare_array_without_delimiters(self): - """Bare recommendations array (no delimiters) is recovered. - - Reproduces the weak on-device model bug where the JSON array leaks - into prose without RECOMMENDATIONS_JSON markers. - """ - from api.routes.shots import _parse_recommendations_json - - text = """## 5. Profile Design Observations -**Potential Improvements:** -- Increase pre-infusion duration. - -[ - { - "variable": "pressure_PreBrew", - "current_value": 1.8, - "recommended_value": 2.5, - "stage": "PreBrew", - "confidence": "high", - "reason": "Increase pressure during pre-infusion." - } -] -""" - recs = _parse_recommendations_json(text) - assert len(recs) == 1 - assert recs[0]["variable"] == "pressure_PreBrew" - assert recs[0]["recommended_value"] == 2.5 - - def test_parse_tolerates_trailing_comma(self): - """Trailing commas emitted by weak models are tolerated.""" - from api.routes.shots import _parse_recommendations_json - - text = """RECOMMENDATIONS_JSON: -[{"variable":"flow","current_value":2.5,"recommended_value":3.0,"stage":"main","confidence":"high","reason":"r"},] -END_RECOMMENDATIONS_JSON -""" - recs = _parse_recommendations_json(text) - assert len(recs) == 1 - assert recs[0]["variable"] == "flow" - - def test_parse_ignores_prose_mentioning_variables(self): - """Prose mentioning 'variable' without an array is not misparsed.""" - from api.routes.shots import _parse_recommendations_json - - text = "## 1. Shot Performance\n**Notes:**\n- The flow variable was stable." - assert _parse_recommendations_json(text) == [] - - def test_parse_drops_nonfinite_values(self): - """Hallucinated recs with NaN values (weak on-device models) are dropped.""" - from api.routes.shots import _parse_recommendations_json - - text = """RECOMMENDATIONS_JSON: -[{"variable":"flow_0","current_value":"NaN","recommended_value":"NaN","stage":"main","confidence":"low","reason":"r"}, -{"variable":"flow","current_value":2.5,"recommended_value":3.0,"stage":"main","confidence":"high","reason":"r"}] -END_RECOMMENDATIONS_JSON -""" - recs = _parse_recommendations_json(text) - assert len(recs) == 1 - assert recs[0]["variable"] == "flow" - - def test_parse_drops_blank_variable(self): - """Recs with an empty variable name are not actionable and are dropped.""" - from api.routes.shots import _parse_recommendations_json - - text = """RECOMMENDATIONS_JSON: -[{"variable":"","current_value":1,"recommended_value":2,"stage":"main","confidence":"low","reason":"r"}] -END_RECOMMENDATIONS_JSON -""" - assert _parse_recommendations_json(text) == [] - - def test_parse_keeps_advisory_zero_values(self): - """Advisory recs with finite 0 values are kept (not confused with NaN).""" - from api.routes.shots import _parse_recommendations_json - - text = """RECOMMENDATIONS_JSON: -[{"variable":"info_note","current_value":0,"recommended_value":0,"stage":"global","confidence":"low","reason":"General advice","is_patchable":false}] -END_RECOMMENDATIONS_JSON -""" - recs = _parse_recommendations_json(text) - assert len(recs) == 1 - assert recs[0]["variable"] == "info_note" - - def test_classify_adjustable_variable(self): - """Adjustable variable (no info_ prefix) is patchable.""" - from api.routes.shots import _classify_recommendation_patchable - - rec = {"variable": "flow_main", "stage": "extraction"} - variables = [ - {"key": "flow_main", "name": "Main Flow", "type": "flow", "value": 2.5} - ] - assert _classify_recommendation_patchable(rec, variables) is True - - def test_classify_info_variable(self): - """Info variable (info_ prefix) is not patchable.""" - from api.routes.shots import _classify_recommendation_patchable - - rec = {"variable": "info_dose", "stage": "extraction"} - variables = [ - {"key": "info_dose", "name": "☕ Dose", "type": "weight", "value": 18} - ] - assert _classify_recommendation_patchable(rec, variables) is False - - def test_classify_adjustable_false_variable(self): - """Variable with adjustable=false is not patchable.""" - from api.routes.shots import _classify_recommendation_patchable - - rec = {"variable": "grind_info", "stage": "extraction"} - variables = [ - { - "key": "grind_info", - "name": "Grind", - "type": "power", - "value": 100, - "adjustable": False, - } - ] - assert _classify_recommendation_patchable(rec, variables) is False - - def test_classify_global_temperature(self): - """Global temperature setting is always patchable.""" - from api.routes.shots import _classify_recommendation_patchable - - rec = {"variable": "temperature", "stage": "global"} - assert _classify_recommendation_patchable(rec, []) is True - - def test_classify_global_final_weight(self): - """Global final_weight setting is always patchable.""" - from api.routes.shots import _classify_recommendation_patchable - - rec = {"variable": "final_weight", "stage": "global"} - assert _classify_recommendation_patchable(rec, []) is True - - def test_classify_unknown_variable(self): - """Unknown variable not in profile is patchable (LLM may use descriptive names).""" - from api.routes.shots import _classify_recommendation_patchable - - rec = {"variable": "unknown_var", "stage": "extraction"} - variables = [ - {"key": "flow_main", "name": "Main Flow", "type": "flow", "value": 2.5} - ] - assert _classify_recommendation_patchable(rec, variables) is True - - -class TestApplyRecommendationsEndpoint: - """Tests for POST /api/profile/{name}/apply-recommendations.""" - - def _make_mock_profile( - self, - name="TestProfile", - profile_id="abc-123", - temperature=93.0, - final_weight=36.0, - ): - profile = Mock() - profile.id = profile_id - profile.name = name - profile.temperature = temperature - profile.final_weight = final_weight - profile.stages = [] - var = SimpleNamespace(key="flow_main", name="Main Flow", value=2.5, type="flow") - info_var = SimpleNamespace( - key="info_dose", name="☕ Dose", value=18.0, type="weight" - ) - profile.variables = [var, info_var] - profile.error = None - profile.author = "Metic" - profile.author_id = None - profile.display = None - profile.isDefault = False - profile.source = None - profile.beverage_type = None - profile.tank_temperature = None - profile.previous_authors = None - return profile - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.async_save_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_apply_valid_recommendations(self, mock_list, mock_get, mock_save, client): - """Applying valid adjustable recommendations succeeds.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_save.return_value = None - - recs = json.dumps( - [ - { - "variable": "flow_main", - "recommended_value": 3.0, - "stage": "extraction", - }, - ] - ) - response = client.post( - "/api/profile/TestProfile/apply-recommendations", - data={"recommendations": recs}, - ) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert len(data["applied"]) == 1 - assert data["applied"][0]["variable"] == "flow_main" - mock_save.assert_called_once() - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.async_save_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_apply_info_only_skipped(self, mock_list, mock_get, mock_save, client): - """Info-only variables are skipped when applying recommendations.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_save.return_value = None - - recs = json.dumps( - [ - { - "variable": "info_dose", - "recommended_value": 20.0, - "stage": "extraction", - }, - ] - ) - response = client.post( - "/api/profile/TestProfile/apply-recommendations", - data={"recommendations": recs}, - ) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "no_changes" - assert len(data["skipped"]) == 1 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.async_save_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_apply_resolves_invented_positional_variable( - self, mock_list, mock_get, mock_save, client - ): - """An invented id like 'pressure_2' resolves to the real key by type + value.""" - profile = self._make_mock_profile() - profile.variables = [ - SimpleNamespace( - key="pressure_Max Pressure", - name="Max Pressure", - value=6.0, - type="pressure", - ), - SimpleNamespace( - key="pressure_PreBrew pressure", - name="PreBrew pressure", - value=1.8, - type="pressure", - ), - ] - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_save.return_value = None - - recs = json.dumps( - [ - { - "variable": "pressure_2", - "current_value": 6, - "recommended_value": 5, - "stage": "Pressure Ramp Up", - }, - ] - ) - response = client.post( - "/api/profile/TestProfile/apply-recommendations", - data={"recommendations": recs}, - ) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert len(data["applied"]) == 1 - assert data["applied"][0]["variable"] == "pressure_Max Pressure" - assert data["applied"][0]["value"] == 5 - assert data["applied"][0]["matched_from"] == "pressure_2" - # The unrelated pressure variable must remain unchanged. - assert profile.variables[0].value == 5 - assert profile.variables[1].value == 1.8 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.async_save_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_apply_unresolvable_invented_variable_reports_no_changes( - self, mock_list, mock_get, mock_save, client - ): - """An invented id with no matching variable type is skipped, not silently applied.""" - profile = self._make_mock_profile() # only a 'flow' variable - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_save.return_value = None - - recs = json.dumps( - [ - { - "variable": "pressure_2", - "current_value": 6, - "recommended_value": 5, - "stage": "Ghost Stage", - }, - ] - ) - response = client.post( - "/api/profile/TestProfile/apply-recommendations", - data={"recommendations": recs}, - ) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "no_changes" - assert data["skipped"][0]["variable"] == "pressure_2" - - - @patch("api.routes.profiles.async_save_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_apply_global_temperature(self, mock_list, mock_get, mock_save, client): - """Global temperature recommendation is applied correctly.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - mock_get.return_value = profile - mock_save.return_value = None - - recs = json.dumps( - [ - { - "variable": "temperature", - "recommended_value": 95.0, - "stage": "global", - }, - ] - ) - response = client.post( - "/api/profile/TestProfile/apply-recommendations", - data={"recommendations": recs}, - ) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["applied"][0]["variable"] == "temperature" - assert profile.temperature == 95.0 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - def test_apply_invalid_json(self, client): - """Invalid JSON in recommendations returns 400.""" - response = client.post( - "/api/profile/TestProfile/apply-recommendations", - data={"recommendations": "not json"}, - ) - assert response.status_code == 400 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_apply_profile_not_found(self, mock_list, client): - """Applying to non-existent profile returns 404.""" - mock_list.return_value = [] - recs = json.dumps( - [{"variable": "flow_main", "recommended_value": 3.0, "stage": "extraction"}] - ) - response = client.post( - "/api/profile/NonExistent/apply-recommendations", - data={"recommendations": recs}, - ) - assert response.status_code == 404 - - -class TestRunProfileWithOverrides: - """Tests for the run-profile-with-overrides endpoint and apply_variable_overrides.""" - - @pytest.fixture - def client(self): - return TestClient(app) - - # ---- apply_variable_overrides unit tests ---- - - def test_apply_variable_overrides_basic(self): - """Test basic variable override application.""" - from services.temp_profile_service import apply_variable_overrides - - profile = { - "id": "p1", - "variables": [ - { - "key": "pressure_main", - "name": "Pressure", - "type": "pressure", - "value": 9.0, - }, - {"key": "flow_main", "name": "Flow", "type": "flow", "value": 4.0}, - ], - } - result = apply_variable_overrides(profile, {"pressure_main": 7.5}) - # Original unchanged - assert profile["variables"][0]["value"] == 9.0 - # Result modified - assert result["variables"][0]["value"] == 7.5 - assert result["variables"][1]["value"] == 4.0 - - def test_apply_variable_overrides_skips_info_keys(self): - """Info_ prefixed variables should be silently skipped.""" - from services.temp_profile_service import apply_variable_overrides - - profile = { - "variables": [ - { - "key": "info_beans", - "name": "☕ Beans", - "type": "pressure", - "value": 0, - }, - { - "key": "pressure_main", - "name": "Pressure", - "type": "pressure", - "value": 9.0, - }, - ], - } - result = apply_variable_overrides( - profile, {"info_beans": 99, "pressure_main": 8.0} - ) - assert result["variables"][0]["value"] == 0 # unchanged - assert result["variables"][1]["value"] == 8.0 # applied - - def test_apply_variable_overrides_empty(self): - """Empty overrides should return an identical deep copy.""" - from services.temp_profile_service import apply_variable_overrides - - profile = { - "variables": [{"key": "x", "name": "X", "type": "pressure", "value": 1}] - } - result = apply_variable_overrides(profile, {}) - assert result == profile - assert result is not profile - - def test_apply_variable_overrides_no_variables(self): - """Profile without variables key should return a deep copy.""" - from services.temp_profile_service import apply_variable_overrides - - profile = {"id": "p1", "name": "Simple"} - result = apply_variable_overrides(profile, {"x": 1}) - assert result == profile - assert result is not profile - - # ---- endpoint tests ---- - - @patch("services.temp_profile_service.load_ephemeral", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_execute_action", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.scheduling.get_meticulous_api") - def test_endpoint_run_with_overrides_none_mode( - self, mock_get_api, mock_get_profile, mock_execute, mock_load_ephemeral, client - ): - """Test run-profile-with-overrides with save_mode=none.""" - mock_get_api.return_value = MagicMock() - mock_get_profile.return_value = { - "id": "p1", - "name": "Test Profile", - "variables": [ - {"key": "pressure_main", "name": "P", "type": "pressure", "value": 9.0}, - ], - } - mock_load_ephemeral.return_value = { - "profile_id": "p1", - "profile_name": "Test Profile", - } - mock_result = MagicMock(spec=["status", "action"]) - mock_result.status = "ok" - mock_execute.return_value = mock_result - - response = client.post( - "/api/machine/run-profile-with-overrides/p1", - data={"overrides_json": '{"pressure_main": 7.5}', "save_mode": "none"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["save_mode"] == "none" - assert data["profile_name"] == "Test Profile" - mock_get_profile.assert_called_once() - mock_load_ephemeral.assert_called_once() - - @patch("api.routes.scheduling.async_save_profile", new_callable=AsyncMock) - @patch("services.temp_profile_service.load_ephemeral", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_execute_action", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.scheduling.get_meticulous_api") - def test_endpoint_save_original_mode( - self, - mock_get_api, - mock_get_profile, - mock_execute, - mock_load_ephemeral, - mock_save_profile, - client, - ): - """Test save_mode=save_original persists overrides back.""" - mock_get_api.return_value = MagicMock() - mock_get_profile.return_value = { - "id": "p1", - "name": "Test Profile", - "variables": [ - {"key": "flow_main", "name": "F", "type": "flow", "value": 4.0}, - ], - } - mock_load_ephemeral.return_value = { - "profile_id": "p1", - "profile_name": "Test Profile", - } - mock_result = MagicMock(spec=["status", "action"]) - mock_result.status = "ok" - mock_execute.return_value = mock_result - mock_save_profile.return_value = MagicMock() - - response = client.post( - "/api/machine/run-profile-with-overrides/p1", - data={"overrides_json": '{"flow_main": 3.0}', "save_mode": "save_original"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["save_mode"] == "save_original" - mock_save_profile.assert_called_once() - - @patch("api.routes.scheduling.async_create_profile", new_callable=AsyncMock) - @patch("services.temp_profile_service.load_ephemeral", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_execute_action", new_callable=AsyncMock) - @patch("api.routes.scheduling.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.scheduling.get_meticulous_api") - def test_endpoint_save_new_mode( - self, - mock_get_api, - mock_get_profile, - mock_execute, - mock_load_ephemeral, - mock_create_profile, - client, - ): - """Test save_mode=save_new creates a new profile.""" - mock_get_api.return_value = MagicMock() - mock_get_profile.return_value = { - "id": "p1", - "name": "Original", - "variables": [ - {"key": "weight", "name": "W", "type": "weight", "value": 36.0}, - ], - } - mock_load_ephemeral.return_value = { - "profile_id": "p1", - "profile_name": "Original", - } - mock_result = MagicMock(spec=["status", "action"]) - mock_result.status = "ok" - mock_execute.return_value = mock_result - mock_create_profile.return_value = MagicMock(id="new-1") - - response = client.post( - "/api/machine/run-profile-with-overrides/p1", - data={ - "overrides_json": '{"weight": 40.0}', - "save_mode": "save_new", - "new_name": "My Custom Profile", - }, - ) - - assert response.status_code == 200 - data = response.json() - assert data["save_mode"] == "save_new" - mock_create_profile.assert_called_once() - - @patch("api.routes.scheduling.get_meticulous_api") - def test_endpoint_no_connection(self, mock_get_api, client): - """Test that 503 is returned when machine is not connected.""" - mock_get_api.return_value = None - - response = client.post( - "/api/machine/run-profile-with-overrides/p1", - data={"overrides_json": '{"pressure_main": 7.5}', "save_mode": "none"}, - ) - - assert response.status_code == 503 - - @patch("api.routes.scheduling.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.scheduling.get_meticulous_api") - def test_endpoint_profile_not_found(self, mock_get_api, mock_get_profile, client): - """Test 404 when profile fetch returns error.""" - mock_get_api.return_value = MagicMock() - result = MagicMock() - result.error = "Not found" - mock_get_profile.return_value = result - - response = client.post( - "/api/machine/run-profile-with-overrides/nonexistent", - data={"overrides_json": "{}", "save_mode": "none"}, - ) - - assert response.status_code == 404 - - -class TestDialInGuide: - """Tests for the Dial-In Guide endpoints.""" - - def _clear(self): - from services import dialin_service - - dialin_service._sessions.clear() - - def test_create_session(self): - self._clear() - client = TestClient(app) - resp = client.post( - "/api/dialin/sessions", - json={"coffee": {"roast_level": "medium"}, "profile_name": "Test Profile"}, - ) - assert resp.status_code == 201 - data = resp.json() - assert "id" in data - assert data["coffee"]["roast_level"] == "medium" - assert data["profile_name"] == "Test Profile" - assert data["status"] == "active" - - def test_list_sessions(self): - self._clear() - client = TestClient(app) - client.post("/api/dialin/sessions", json={"coffee": {"roast_level": "light"}}) - resp = client.get("/api/dialin/sessions") - assert resp.status_code == 200 - assert "sessions" in resp.json() - assert len(resp.json()["sessions"]) >= 1 - - def test_get_session(self): - self._clear() - client = TestClient(app) - create_resp = client.post( - "/api/dialin/sessions", json={"coffee": {"roast_level": "dark"}} - ) - session_id = create_resp.json()["id"] - resp = client.get(f"/api/dialin/sessions/{session_id}") - assert resp.status_code == 200 - assert resp.json()["id"] == session_id - - def test_get_session_not_found(self): - self._clear() - client = TestClient(app) - resp = client.get("/api/dialin/sessions/nonexistent") - assert resp.status_code == 404 - - def test_add_iteration(self): - self._clear() - client = TestClient(app) - create_resp = client.post( - "/api/dialin/sessions", json={"coffee": {"roast_level": "medium"}} - ) - session_id = create_resp.json()["id"] - resp = client.post( - f"/api/dialin/sessions/{session_id}/iterations", - json={"taste": {"x": 0.3, "y": -0.2, "descriptors": ["Sour", "Watery"]}}, - ) - assert resp.status_code == 201 - assert resp.json()["iteration_number"] == 1 - assert resp.json()["taste"]["x"] == 0.3 - - def test_add_multiple_iterations(self): - self._clear() - client = TestClient(app) - create_resp = client.post( - "/api/dialin/sessions", json={"coffee": {"roast_level": "medium"}} - ) - session_id = create_resp.json()["id"] - client.post( - f"/api/dialin/sessions/{session_id}/iterations", - json={"taste": {"x": -0.5, "y": 0.1, "descriptors": ["Sour"]}}, - ) - resp = client.post( - f"/api/dialin/sessions/{session_id}/iterations", - json={"taste": {"x": -0.2, "y": 0.3, "descriptors": ["Sweet"]}}, - ) - assert resp.json()["iteration_number"] == 2 - - def test_update_recommendations(self): - self._clear() - client = TestClient(app) - create_resp = client.post( - "/api/dialin/sessions", json={"coffee": {"roast_level": "medium"}} - ) - session_id = create_resp.json()["id"] - client.post( - f"/api/dialin/sessions/{session_id}/iterations", - json={"taste": {"x": 0.5, "y": 0.0, "descriptors": []}}, - ) - resp = client.put( - f"/api/dialin/sessions/{session_id}/iterations/1/recommendations", - json={"recommendations": ["Grind 2 steps finer", "Reduce temp by 1°C"]}, - ) - assert resp.status_code == 200 - assert len(resp.json()["recommendations"]) == 2 - - def test_complete_session(self): - self._clear() - client = TestClient(app) - create_resp = client.post( - "/api/dialin/sessions", json={"coffee": {"roast_level": "medium"}} - ) - session_id = create_resp.json()["id"] - resp = client.post(f"/api/dialin/sessions/{session_id}/complete") - assert resp.status_code == 200 - assert resp.json()["status"] == "completed" - - def test_delete_session(self): - self._clear() - client = TestClient(app) - create_resp = client.post( - "/api/dialin/sessions", json={"coffee": {"roast_level": "medium"}} - ) - session_id = create_resp.json()["id"] - resp = client.delete(f"/api/dialin/sessions/{session_id}") - assert resp.status_code == 200 - assert resp.json()["deleted"] is True - resp = client.get(f"/api/dialin/sessions/{session_id}") - assert resp.status_code == 404 - - def test_delete_session_not_found(self): - self._clear() - client = TestClient(app) - resp = client.delete("/api/dialin/sessions/nonexistent") - assert resp.status_code == 404 - - def test_dual_registration(self): - """Verify both /dialin/... and /api/dialin/... paths work.""" - self._clear() - client = TestClient(app) - resp1 = client.post( - "/dialin/sessions", json={"coffee": {"roast_level": "medium"}} - ) - assert resp1.status_code == 201 - resp2 = client.post( - "/api/dialin/sessions", json={"coffee": {"roast_level": "light"}} - ) - assert resp2.status_code == 201 - - def test_create_session_invalid_roast(self): - self._clear() - client = TestClient(app) - resp = client.post( - "/api/dialin/sessions", json={"coffee": {"roast_level": "invalid"}} - ) - assert resp.status_code == 422 - - def test_add_iteration_to_nonexistent_session(self): - self._clear() - client = TestClient(app) - resp = client.post( - "/api/dialin/sessions/nonexistent/iterations", - json={"taste": {"x": 0, "y": 0, "descriptors": []}}, - ) - assert resp.status_code == 404 - - def test_list_sessions_filter_by_status(self): - self._clear() - client = TestClient(app) - create_resp = client.post( - "/api/dialin/sessions", json={"coffee": {"roast_level": "medium"}} - ) - session_id = create_resp.json()["id"] - client.post(f"/api/dialin/sessions/{session_id}/complete") - resp = client.get("/api/dialin/sessions?status=completed") - assert resp.status_code == 200 - for s in resp.json()["sessions"]: - assert s["status"] == "completed" - - -class TestDialInPromptBuilder: - """Tests for the dial-in recommendation prompt builder.""" - - def test_basic_prompt(self): - from prompt_builder import build_dialin_recommendation_prompt - - prompt = build_dialin_recommendation_prompt(roast_level="medium") - assert "medium" in prompt - assert "Dial-In" in prompt - - def test_prompt_with_iterations(self): - from prompt_builder import build_dialin_recommendation_prompt - - iterations = [ - { - "iteration_number": 1, - "taste": {"x": -0.5, "y": 0.2, "descriptors": ["Sour"]}, - "recommendations": [], - }, - { - "iteration_number": 2, - "taste": {"x": -0.1, "y": 0.1, "descriptors": ["Sweet"]}, - "recommendations": ["Grind finer"], - }, - ] - prompt = build_dialin_recommendation_prompt( - roast_level="light", - origin="Ethiopia", - process="washed", - profile_name="Blooming", - iterations=iterations, - ) - assert "Ethiopia" in prompt - assert "washed" in prompt - assert "Blooming" in prompt - assert "Iteration 1" in prompt - assert "Iteration 2" in prompt - assert "Sour" in prompt - assert "Grind finer" in prompt - - def test_prompt_empty_iterations(self): - from prompt_builder import build_dialin_recommendation_prompt - - prompt = build_dialin_recommendation_prompt(roast_level="dark", iterations=[]) - assert "dark" in prompt - assert "Iteration" not in prompt - - -class TestImportFromUrl: - """Tests for the /api/import-from-url endpoint.""" - - VALID_PROFILE = { - "name": "URL Espresso", - "temperature": 93.0, - "stages": [{"name": "extraction"}], - } - - @staticmethod - def _mock_httpx_stream(response_bytes=b"{}", raise_for_status_error=None): - """Build a mock httpx.AsyncClient that streams response_bytes.""" - - class FakeStream: - def __init__(self): - self.status_code = 200 - - def raise_for_status(self): - if raise_for_status_error: - raise raise_for_status_error - - async def aiter_bytes(self, chunk_size=8192): - yield response_bytes - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - return False - - class FakeClient: - def __init__(self, **kwargs): - pass - - def stream(self, method, url): - return FakeStream() - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - return False - - return FakeClient - - def test_missing_url(self, client): - """Returns 400 when URL is missing or empty.""" - resp = client.post("/api/import-from-url", json={"url": ""}) - assert resp.status_code == 400 - assert "No URL" in resp.json()["detail"] - - def test_invalid_scheme(self, client): - """Returns 400 for non-http(s) schemes.""" - resp = client.post( - "/api/import-from-url", json={"url": "ftp://example.com/profile.json"} - ) - assert resp.status_code == 400 - assert ( - "http" in resp.json()["detail"].lower() - or "scheme" in resp.json()["detail"].lower() - ) - - @patch("api.routes.profiles._validate_url_for_ssrf") - def test_ssrf_blocked(self, mock_validate, client): - """Returns 400 when SSRF validation blocks the URL.""" - mock_validate.side_effect = ValueError( - "URL resolves to private/reserved IP: 127.0.0.1" - ) - resp = client.post( - "/api/import-from-url", json={"url": "http://localhost:8080/profile.json"} - ) - assert resp.status_code == 400 - assert "Blocked URL" in resp.json()["detail"] - - @patch("api.routes.profiles._validate_url_for_ssrf") - @patch("httpx.AsyncClient") - def test_remote_non_200(self, mock_client_class, mock_ssrf, client): - """Returns 502 when the remote server returns an error.""" - mock_ssrf.return_value = None - mock_client_class.side_effect = [ - TestImportFromUrl._mock_httpx_stream( - raise_for_status_error=httpx.HTTPStatusError( - "Not Found", - request=MagicMock(), - response=MagicMock(status_code=404), - ) - ) - ] - # Replace with direct class substitution - fake_cls = TestImportFromUrl._mock_httpx_stream( - raise_for_status_error=httpx.HTTPStatusError( - "Not Found", request=MagicMock(), response=MagicMock(status_code=404) - ) - ) - mock_client_class.side_effect = None - mock_client_class.return_value = fake_cls() - resp = client.post( - "/api/import-from-url", json={"url": "https://example.com/missing.json"} - ) - assert resp.status_code == 502 - assert "404" in resp.json()["detail"] - - @patch("api.routes.profiles._validate_url_for_ssrf") - @patch("httpx.AsyncClient") - def test_non_json_body(self, mock_client_class, mock_ssrf, client): - """Returns 400 when URL returns non-JSON content.""" - mock_ssrf.return_value = None - fake_cls = TestImportFromUrl._mock_httpx_stream( - response_bytes=b"Not JSON" - ) - mock_client_class.return_value = fake_cls() - resp = client.post( - "/api/import-from-url", json={"url": "https://example.com/page.html"} - ) - assert resp.status_code == 400 - assert "valid JSON" in resp.json()["detail"] - - @patch("api.routes.profiles._validate_url_for_ssrf") - @patch("httpx.AsyncClient") - def test_missing_name_field(self, mock_client_class, mock_ssrf, client): - """Returns 400 when profile JSON is missing 'name'.""" - mock_ssrf.return_value = None - fake_cls = TestImportFromUrl._mock_httpx_stream( - response_bytes=json.dumps({"temperature": 93.0}).encode() - ) - mock_client_class.return_value = fake_cls() - resp = client.post( - "/api/import-from-url", json={"url": "https://example.com/profile.json"} - ) - assert resp.status_code == 400 - assert "name" in resp.json()["detail"].lower() - - @patch( - "api.routes.profiles.async_create_profile", - new_callable=AsyncMock, - return_value={"id": "m-1"}, - ) - @patch("api.routes.profiles.save_history") - @patch( - "api.routes.profiles.load_history", - return_value=[{"id": "old-1", "profile_name": "URL Espresso", "reply": "desc"}], - ) - @patch("api.routes.profiles._validate_url_for_ssrf") - @patch("httpx.AsyncClient") - def test_dedupe_exists( - self, mock_client_class, mock_ssrf, mock_load, mock_save, mock_create, client - ): - """Returns 'exists' when a profile with the same name is already in history.""" - mock_ssrf.return_value = None - fake_cls = TestImportFromUrl._mock_httpx_stream( - response_bytes=json.dumps(self.VALID_PROFILE).encode() - ) - mock_client_class.return_value = fake_cls() - resp = client.post( - "/api/import-from-url", json={"url": "https://example.com/profile.json"} - ) - assert resp.status_code == 200 - data = resp.json() - assert data["status"] == "exists" - assert data["profile_name"] == "URL Espresso" - mock_save.assert_not_called() - - @patch( - "api.routes.profiles.async_create_profile", - new_callable=AsyncMock, - return_value={"id": "m-2"}, - ) - @patch("api.routes.profiles.save_history") - @patch("api.routes.profiles.load_history", return_value=[]) - @patch( - "api.routes.profiles._generate_profile_description", - new_callable=AsyncMock, - return_value="Rich espresso", - ) - @patch("api.routes.profiles._validate_url_for_ssrf") - @patch("httpx.AsyncClient") - def test_success_path( - self, - mock_client_class, - mock_ssrf, - mock_gen_desc, - mock_load, - mock_save, - mock_create, - client, - ): - """Full success path: fetch, parse, save, upload.""" - mock_ssrf.return_value = None - fake_cls = TestImportFromUrl._mock_httpx_stream( - response_bytes=json.dumps(self.VALID_PROFILE).encode() - ) - mock_client_class.return_value = fake_cls() - resp = client.post( - "/api/import-from-url", json={"url": "https://example.com/profile.json"} - ) - assert resp.status_code == 200 - data = resp.json() - assert data["status"] == "success" - assert data["profile_name"] == "URL Espresso" - assert data["has_description"] is True - assert data["uploaded_to_machine"] is True - assert "entry_id" in data - mock_save.assert_called_once() - mock_create.assert_called_once() - - -# ─── Profile Export JSON Fix Tests ────────────────────────────────────────── - - -class TestSaveToHistoryOverride: - """Tests for the profile_json_override parameter in save_to_history().""" - - @patch("services.history_service.save_history") - @patch("services.history_service.load_history", return_value=[]) - def test_override_used_when_provided(self, mock_load, mock_save): - """When profile_json_override is provided, it should be stored instead of LLM-parsed JSON.""" - override = {"name": "Override Profile", "id": "test-uuid", "stages": []} - reply = '**Profile Created:** Test\n```json\n{"name": "LLM Version"}\n```' - - entry = save_to_history( - coffee_analysis="test", - user_prefs="test prefs", - reply=reply, - profile_json_override=override, - ) - - assert entry["profile_json"] == override - assert entry["profile_json"]["name"] == "Override Profile" - # Verify it was saved - saved_history = mock_save.call_args[0][0] - assert saved_history[0]["profile_json"] == override - - @patch("services.history_service.save_history") - @patch("services.history_service.load_history", return_value=[]) - def test_fallback_to_llm_parsing_without_override(self, mock_load, mock_save): - """Without override, should fall back to LLM text extraction.""" - reply = '**Profile Created:** Test\n```json\n{"name": "LLM Version"}\n```' - - entry = save_to_history( - coffee_analysis="test", - user_prefs="test prefs", - reply=reply, - ) - - assert entry["profile_json"] is not None - assert entry["profile_json"]["name"] == "LLM Version" - - -class TestAsyncCreateProfileReturnsNormalized: - """Tests that async_create_profile() returns _normalised_json in its result.""" - - @patch.dict(os.environ, {"METICULOUS_IP": "192.168.1.100"}) - @patch("services.meticulous_service._get_http_client") - def test_create_profile_includes_normalised_json(self, mock_http_factory): - """The result dict from async_create_profile should include _normalised_json.""" - from services.meticulous_service import async_create_profile - - # Mock the httpx AsyncClient - mock_client = AsyncMock() - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = {"id": "returned-id", "name": "Test"} - mock_client.post.return_value = mock_response - mock_http_factory.return_value = mock_client - - profile_json = { - "name": "Test Profile", - "stages": [ - { - "type": "flow", - "dynamics": {"points": [[0, 3]], "interpolation": "linear"}, - "exit_triggers": [], - } - ], - } - - result = asyncio.run(async_create_profile(profile_json)) - - # The result should contain _normalised_json with machine-ready fields - assert "_normalised_json" in result - normalised = result["_normalised_json"] - assert "id" in normalised - assert normalised.get("author") == "Metic" - assert "author_id" in normalised - assert isinstance(normalised.get("variables"), list) - - -class TestFetchMachineProfileDict: - """Tests for the fetch_machine_profile_dict helper.""" - - @patch.dict(os.environ, {"METICULOUS_IP": "192.168.1.100"}) - @patch("services.meticulous_service._get_http_client") - def test_returns_dict_from_machine(self, mock_http_factory): - """fetch_machine_profile_dict should return a proper dict from the machine API.""" - from services.meticulous_service import fetch_machine_profile_dict - - machine_json = { - "id": "abc-123", - "name": "Machine Profile", - "author": "Metic", - "stages": [], - } - mock_client = AsyncMock() - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = machine_json - mock_client.get.return_value = mock_response - mock_http_factory.return_value = mock_client - - result = asyncio.run(fetch_machine_profile_dict("abc-123")) - - assert result == machine_json - assert result["name"] == "Machine Profile" - - @patch.dict(os.environ, {"METICULOUS_IP": "192.168.1.100"}) - @patch("services.meticulous_service._get_http_client") - def test_raises_on_not_found(self, mock_http_factory): - """fetch_machine_profile_dict should raise on 404.""" - from services.meticulous_service import fetch_machine_profile_dict - import httpx - - mock_client = AsyncMock() - mock_response = Mock() - mock_response.status_code = 404 - mock_response.text = "Not found" - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( - "404 Not Found", request=Mock(), response=mock_response - ) - mock_client.get.return_value = mock_response - mock_http_factory.return_value = mock_client - - with pytest.raises(httpx.HTTPStatusError): - asyncio.run(fetch_machine_profile_dict("nonexistent")) - - -class TestProfileExportEndpoint: - """Tests for the fixed /api/machine/profile/{id}/json endpoint.""" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.fetch_machine_profile_dict", new_callable=AsyncMock) - def test_export_returns_raw_machine_json(self, mock_fetch, client): - """The export endpoint should return JSON fetched directly from the machine.""" - machine_json = { - "id": "prof-1", - "name": "Exported Profile", - "author": "Metic", - "author_id": "uuid-123", - "stages": [{"key": "stage-0-flow", "type": "flow"}], - "variables": [], - } - mock_fetch.return_value = machine_json - - response = client.get("/api/machine/profile/prof-1/json") - assert response.status_code == 200 - data = response.json() - assert data["profile"] == machine_json - assert data["status"] == "success" - mock_fetch.assert_called_once_with("prof-1") - - -class TestRepairEndpoint: - """Tests for the /api/profiles/repair endpoint.""" - - def _make_mock_profile(self, name="TestProfile", pid="prof-1"): - profile = Mock() - profile.id = pid - profile.name = name - profile.error = None - return profile - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.update_entry_sync_fields") - @patch("api.routes.profiles.fetch_machine_profile_dict", new_callable=AsyncMock) - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_repair_fetches_from_machine( - self, mock_list, mock_history, mock_fetch, mock_update, client - ): - """Entries with machine matches should be repaired with canonical JSON.""" - profile = self._make_mock_profile() - mock_list.return_value = [profile] - - mock_history.return_value = [ - { - "id": "entry-1", - "profile_name": "TestProfile", - "profile_json": {"name": "TestProfile", "stages": []}, - } - ] - - canonical = { - "id": "prof-1", - "name": "TestProfile", - "author": "Metic", - "author_id": "uuid-1", - "stages": [], - "variables": [], - } - mock_fetch.return_value = canonical - - response = client.post("/api/profiles/repair") - assert response.status_code == 200 - data = response.json() - assert data["repaired_from_machine"] == 1 - assert data["normalized_locally"] == 0 - assert data["errors"] == 0 - - mock_fetch.assert_called_once_with("prof-1") - mock_update.assert_called_once() - call_kwargs = mock_update.call_args - assert call_kwargs[1]["profile_json"] == canonical - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.update_entry_sync_fields") - @patch("api.routes.profiles._normalize_profile_for_machine") - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_repair_normalizes_orphaned_entries( - self, mock_list, mock_history, mock_normalize, mock_update, client - ): - """Entries without a machine match should be re-normalized locally.""" - mock_list.return_value = [] # No machine profiles - - mock_history.return_value = [ - { - "id": "entry-orphan", - "profile_name": "DeletedProfile", - "profile_json": {"name": "DeletedProfile", "stages": []}, - } - ] - - normalized = { - "name": "DeletedProfile", - "id": "new-uuid", - "stages": [], - "variables": [], - } - mock_normalize.return_value = normalized - - response = client.post("/api/profiles/repair") - assert response.status_code == 200 - data = response.json() - assert data["repaired_from_machine"] == 0 - assert data["normalized_locally"] == 1 - assert data["errors"] == 0 - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("api.routes.profiles.load_history") - @patch("api.routes.profiles.async_list_profiles", new_callable=AsyncMock) - def test_repair_skips_entries_without_json(self, mock_list, mock_history, client): - """Entries with no profile_json should be skipped.""" - mock_list.return_value = [] - - mock_history.return_value = [ - { - "id": "entry-no-json", - "profile_name": "NoJson", - } - ] - - response = client.post("/api/profiles/repair") - assert response.status_code == 200 - data = response.json() - assert data["skipped_no_json"] == 1 - assert data["repaired_from_machine"] == 0 - assert data["normalized_locally"] == 0 - - -class TestAvailableModelsEndpoint: - """Tests for the /api/available-models endpoint.""" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.gemini_service.get_gemini_client") - def test_available_models_returns_list(self, mock_client, client): - """Test that /api/available-models returns expected format.""" - mock_model = Mock() - mock_model.name = "gemini-2.5-flash" - mock_model.display_name = "Gemini 2.5 Flash" - mock_model.description = "Fast model" - mock_model.supported_actions = ["generateContent"] - - mock_client.return_value.models.list.return_value = [mock_model] - - response = client.get("/api/available-models") - assert response.status_code == 200 - data = response.json() - assert "models" in data - assert "current" in data - assert isinstance(data["models"], list) - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.gemini_service.get_gemini_client") - def test_available_models_filters_non_generative(self, mock_client, client): - """Test that models without generateContent are filtered out.""" - gen_model = Mock() - gen_model.name = "gemini-2.5-flash" - gen_model.display_name = "Gemini 2.5 Flash" - gen_model.description = "Fast model" - gen_model.supported_actions = ["generateContent"] - - embed_model = Mock() - embed_model.name = "text-embedding-004" - embed_model.display_name = "Text Embedding" - embed_model.description = "Embedding model" - embed_model.supported_actions = ["embedContent"] - - mock_client.return_value.models.list.return_value = [gen_model, embed_model] - - response = client.get("/api/available-models") - data = response.json() - assert len(data["models"]) == 1 - assert data["models"][0]["id"] == "gemini-2.5-flash" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.gemini_service.get_gemini_client") - def test_available_models_filters_non_text_families(self, mock_client, client): - """Only Gemini text-chat models are returned; image/tts/computer-use/ - robotics/nano-banana/gemma/deep-research are excluded.""" - def _m(name, actions=("generateContent",)): - mk = Mock() - mk.name = name - mk.display_name = name - mk.description = "" - mk.supported_actions = list(actions) - return mk - - models = [ - _m("gemini-2.5-flash"), - _m("gemini-3.1-pro-preview"), - _m("gemini-2.5-flash-image"), - _m("gemini-2.5-flash-preview-tts"), - _m("gemini-2.5-computer-use-preview-10-2025"), - _m("gemini-robotics-er-1.5-preview"), - _m("nano-banana-pro-preview"), - _m("lyria-3-pro-preview"), - _m("gemma-4-31b-it"), - _m("deep-research-pro-preview-12-2025"), - ] - mock_client.return_value.models.list.return_value = models - - response = client.get("/api/available-models") - ids = {m["id"] for m in response.json()["models"]} - assert ids == {"gemini-2.5-flash", "gemini-3.1-pro-preview"} - - - """Test graceful handling when Gemini API fails.""" - mock_client.return_value.models.list.side_effect = Exception("API error") - - response = client.get("/api/available-models") - assert response.status_code == 200 - data = response.json() - assert data["models"] == [] - - @patch.dict(os.environ, {}, clear=False) - def test_available_models_no_api_key(self, client): - """Test response when no API key is configured.""" - with patch.dict(os.environ, {}, clear=False): - os.environ.pop("GEMINI_API_KEY", None) - # Reset cached client - import services.gemini_service - services.gemini_service._gemini_client = None - - response = client.get("/api/available-models") - assert response.status_code == 200 - data = response.json() - assert data["models"] == [] - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.gemini_service.get_gemini_client") - def test_available_models_includes_current(self, mock_client, client): - """Test that current model name is included in response.""" - mock_client.return_value.models.list.return_value = [] - - response = client.get("/api/available-models") - data = response.json() - assert data["current"] == "gemini-2.5-flash" - - -class TestModelValidation: - """Tests for model validation and fallback logic.""" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.gemini_service.get_gemini_client") - def test_validate_model_success(self, mock_client): - """Test validate_model returns True for available model.""" - mock_client.return_value.models.get.return_value = Mock() - - from services.gemini_service import validate_model - result = asyncio.run(validate_model("gemini-2.5-flash")) - assert result is True - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.gemini_service.get_gemini_client") - def test_validate_model_failure(self, mock_client): - """Test validate_model returns False for unavailable model.""" - mock_client.return_value.models.get.side_effect = Exception("Not found") - - from services.gemini_service import validate_model - result = asyncio.run(validate_model("gemini-old-model")) - assert result is False - - def test_validate_model_no_api_key(self): - """Test validate_model returns False when no API key.""" - import services.gemini_service - services.gemini_service._gemini_client = None - with patch.dict(os.environ, {}, clear=False): - os.environ.pop("GEMINI_API_KEY", None) - from services.gemini_service import validate_model - result = asyncio.run(validate_model("gemini-2.5-flash")) - assert result is False - - @patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key"}) - @patch("services.gemini_service.get_gemini_client") - def test_get_working_model_configured_works(self, mock_client): - """Test get_working_model returns configured model when valid.""" - mock_client.return_value.models.get.return_value = Mock() - - from services.gemini_service import get_working_model - result = asyncio.run(get_working_model()) - assert result == "gemini-2.5-flash" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "k"}) - @patch("services.gemini_service.get_available_models") - @patch("services.gemini_service.validate_model") - def test_working_model_uses_dynamic_when_configured_dead(self, mock_validate, mock_list): - """Test discovery path is used and result is cached when configured model fails.""" - from services.gemini_service import get_working_model, _validated_model_cache - _validated_model_cache.clear() - async def _validate(name): - return False - mock_validate.side_effect = _validate - async def _list(): - return [{"id": "gemini-2.5-pro"}, {"id": "gemini-2.5-flash"}] - mock_list.side_effect = _list - result = asyncio.run(get_working_model()) - assert result == "gemini-2.5-flash" - assert _validated_model_cache.get("model") == "gemini-2.5-flash" - - @patch.dict(os.environ, {"GEMINI_API_KEY": "k"}) - @patch("services.gemini_service.get_available_models") - @patch("services.gemini_service.validate_model") - def test_working_model_raises_when_nothing_available(self, mock_validate, mock_list): - """Test ModelUnavailableError is raised when discovery returns no models.""" - from services.gemini_service import get_working_model, ModelUnavailableError, _validated_model_cache - _validated_model_cache.clear() - async def _validate(name): - return False - mock_validate.side_effect = _validate - async def _list(): - return [] - mock_list.side_effect = _list - with pytest.raises(ModelUnavailableError): - asyncio.run(get_working_model()) - - -# ─── Machine Status Endpoint Tests ────────────────────────────────────────── - - -@patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key", "METICULOUS_IP": "http://meticulous.local"}) -class TestMachineStatusHealth: - """Tests for GET /api/machine/status/health.""" - - @pytest.fixture - def client(self): - return TestClient(app) - - @patch("api.routes.machine_status.httpx.AsyncClient") - def test_returns_watcher_data(self, mock_client_cls, client): - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "services": [{"name": "meticulous", "status": "running"}], - "system": {"cpu_temperature": 55, "uptime": 3600}, - } - mock_response.raise_for_status = Mock() - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_response) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client_cls.return_value = mock_client - - response = client.get("/api/machine/status/health") - assert response.status_code == 200 - data = response.json() - assert "services" in data - assert data["services"][0]["name"] == "meticulous" - - @patch("api.routes.machine_status.httpx.AsyncClient") - def test_returns_error_when_unreachable(self, mock_client_cls, client): - mock_client = AsyncMock() - mock_client.get = AsyncMock(side_effect=httpx.ConnectError("Connection refused")) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client_cls.return_value = mock_client - - response = client.get("/api/machine/status/health") - assert response.status_code == 200 - data = response.json() - assert data["error"] == "Watcher service unavailable" - assert data["services"] == [] - - -class TestWatcherHelpers: - """Unit tests for machine_status helper functions.""" - - def test_watcher_url_ipv4(self): - from api.routes.machine_status import _watcher_url - - assert _watcher_url("http://192.168.1.50:8080") == "http://192.168.1.50:3000" - assert _watcher_url("http://meticulous.local") == "http://meticulous.local:3000" - - def test_watcher_url_brackets_ipv6(self): - from api.routes.machine_status import _watcher_url - - assert _watcher_url("http://[fe80::1]:8080") == "http://[fe80::1]:3000" - assert _watcher_url("https://[2001:db8::1]") == "https://[2001:db8::1]:3000" - - def test_transform_parses_per_service_uptime(self): - from api.routes.machine_status import _transform_watcher_response - - out = _transform_watcher_response({ - "services": { - "meticulous": {"status": "running", "uptime": "1 hours 2 minutes"}, - "watcher": {"status": "running", "uptime": 90}, - "idle": {"status": "stopped"}, - } - }) - by_name = {s["name"]: s for s in out["services"]} - assert by_name["meticulous"]["uptime"] == 3720 - assert by_name["watcher"]["uptime"] == 90 - assert by_name["idle"]["uptime"] is None - - -@patch.dict(os.environ, {"GEMINI_API_KEY": "test_api_key", "METICULOUS_IP": "http://meticulous.local"}) -class TestMachineSystemInfo: - """Tests for GET /api/machine/system-info.""" - - @pytest.fixture - def client(self): - return TestClient(app) - - @patch("api.routes.machine_status.httpx.AsyncClient") - def test_returns_system_info(self, mock_client_cls, client): - def mock_get(url): - resp = Mock() - resp.status_code = 200 - if "firmware" in url: - resp.json.return_value = {"version": "1.2.3"} - elif "wifi/status" in url: - resp.json.return_value = {"ssid": "HomeWiFi"} - elif "hostname" in url: - resp.json.return_value = {"hostname": "meticulous"} - return resp - - mock_client = AsyncMock() - mock_client.get = AsyncMock(side_effect=mock_get) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client_cls.return_value = mock_client - - response = client.get("/api/machine/system-info") - assert response.status_code == 200 - data = response.json() - assert data["firmware"]["version"] == "1.2.3" - assert data["network"]["ssid"] == "HomeWiFi" - assert data["hostname"]["hostname"] == "meticulous" - - @patch("api.routes.machine_status.httpx.AsyncClient") - def test_handles_partial_failure(self, mock_client_cls, client): - def mock_get(url): - if "firmware" in url: - raise httpx.ConnectError("Connection refused") - resp = Mock() - resp.status_code = 200 - resp.json.return_value = {"ssid": "HomeWiFi"} - return resp - - mock_client = AsyncMock() - mock_client.get = AsyncMock(side_effect=mock_get) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client_cls.return_value = mock_client - - response = client.get("/api/machine/system-info") - assert response.status_code == 200 - data = response.json() - assert data["firmware"] is None - - -class TestDecentConverter: - """Tests for the Decent Espresso profile converter.""" - - VALID_DECENT = { - "title": "Londinium", - "author": "John Doe", - "notes": "A classic lever profile", - "beverage_type": "espresso", - "steps": [ - { - "name": "preinfusion", - "temperature": 92.0, - "sensor": "coffee", - "pump": "flow", - "transition": "fast", - "flow": 4.0, - "seconds": 8.0, - "exit": { - "type": "pressure_over", - "condition": 4.0, - "or": {"type": "time_over", "condition": 30.0}, - }, - }, - { - "name": "extraction", - "temperature": 93.0, - "pump": "pressure", - "pressure": 9.0, - "seconds": 60.0, - "exit": {"type": "weight_over", "condition": 36.0}, - }, - ], - } - - def test_detect_decent_format_valid(self): - """Recognises valid Decent profiles.""" - from services.decent_converter import detect_decent_format - - assert detect_decent_format(self.VALID_DECENT) is True - - def test_detect_decent_format_meticulous(self): - """Rejects Meticulous-format profiles.""" - from services.decent_converter import detect_decent_format - - meticulous = {"name": "Test", "stages": [{"type": "flow"}]} - assert detect_decent_format(meticulous) is False - - def test_detect_decent_format_empty(self): - """Rejects empty or non-dict data.""" - from services.decent_converter import detect_decent_format - - assert detect_decent_format({}) is False - assert detect_decent_format(None) is False - assert detect_decent_format([]) is False - assert detect_decent_format("string") is False - - def test_detect_decent_format_no_steps(self): - """Rejects profiles without steps.""" - from services.decent_converter import detect_decent_format - - assert detect_decent_format({"title": "No Steps"}) is False - assert detect_decent_format({"steps": []}) is False - - def test_convert_basic(self): - """Converts a basic Decent profile to Meticulous format.""" - from services.decent_converter import convert_decent_to_meticulous - - result = convert_decent_to_meticulous(self.VALID_DECENT) - profile = result["profile"] - warnings = result["warnings"] - - assert profile["name"] == "Londinium" - assert profile["author"] == "John Doe" - assert len(profile["stages"]) == 2 - assert profile["temperature"] == 92.0 - assert profile["final_weight"] == 36.0 - assert len(warnings) == 0 - - # Check first stage (flow) - s0 = profile["stages"][0] - assert s0["type"] == "flow" - assert s0["name"] == "preinfusion" - assert s0["dynamics"]["type"] == "flow" - assert s0["dynamics"]["points"] == [[0.0, 4.0]] - # Should have pressure_over and time_over triggers from OR chain - assert len(s0["exit_triggers"]) == 2 - assert s0["exit_triggers"][0]["type"] == "pressure" - assert s0["exit_triggers"][0]["direction"] == "above" - assert s0["exit_triggers"][1]["type"] == "time" - - # Check second stage (pressure) - s1 = profile["stages"][1] - assert s1["type"] == "pressure" - assert s1["dynamics"]["type"] == "pressure" - assert s1["dynamics"]["points"] == [[0.0, 9.0]] - assert s1["exit_triggers"][0]["type"] == "weight" - assert s1["exit_triggers"][0]["value"] == 36.0 - - def test_convert_smooth_transition(self): - """Smooth transitions create ramped dynamics with two points.""" - from services.decent_converter import convert_decent_to_meticulous - - data = { - "title": "Ramp Test", - "steps": [ - { - "name": "ramp", - "pump": "pressure", - "pressure": 9.0, - "transition": "smooth", - "seconds": 10.0, - "sensor": "coffee", - } - ], - } - result = convert_decent_to_meticulous(data) - stage = result["profile"]["stages"][0] - assert len(stage["dynamics"]["points"]) == 2 - assert stage["dynamics"]["points"][0] == [0.0, 0.0] - assert stage["dynamics"]["points"][1] == [10.0, 9.0] - - def test_convert_unknown_pump(self): - """Unknown pump types produce a warning and default to pressure.""" - from services.decent_converter import convert_decent_to_meticulous - - data = { - "title": "Unknown Pump", - "steps": [{"name": "test", "pump": "steam", "sensor": "coffee"}], - } - result = convert_decent_to_meticulous(data) - assert len(result["warnings"]) == 1 - assert "steam" in result["warnings"][0] - assert result["profile"]["stages"][0]["type"] == "pressure" - - def test_convert_empty_steps(self): - """Empty steps list produces a warning.""" - from services.decent_converter import convert_decent_to_meticulous - - result = convert_decent_to_meticulous({"title": "Empty", "steps": []}) - assert "No stages" in result["warnings"][0] - - def test_convert_unknown_exit_type(self): - """Unknown exit types produce a warning.""" - from services.decent_converter import convert_decent_to_meticulous - - data = { - "title": "Bad Exit", - "steps": [ - { - "name": "test", - "pump": "flow", - "flow": 3.0, - "sensor": "coffee", - "exit": {"type": "magic_over", "condition": 5.0}, - } - ], - } - result = convert_decent_to_meticulous(data) - assert any("magic_over" in w for w in result["warnings"]) - - def test_convert_preserves_notes(self): - """Profile notes become display.description.""" - from services.decent_converter import convert_decent_to_meticulous - - result = convert_decent_to_meticulous(self.VALID_DECENT) - assert result["profile"]["display"]["description"] == "A classic lever profile" - - def test_convert_stage_structure(self): - """Converted stages have all required Meticulous fields.""" - from services.decent_converter import convert_decent_to_meticulous - - result = convert_decent_to_meticulous(self.VALID_DECENT) - for stage in result["profile"]["stages"]: - assert "key" in stage - assert "type" in stage - assert "name" in stage - assert "dynamics" in stage - assert "exit_triggers" in stage - assert "limits" in stage - assert isinstance(stage["limits"], list) - assert stage["dynamics"]["interpolation"] == "linear" - assert stage["dynamics"]["over"] == "time" - - def test_convert_exit_relative_and_comparison(self): - """Exit triggers get correct relative and comparison defaults.""" - from services.decent_converter import convert_decent_to_meticulous - - result = convert_decent_to_meticulous(self.VALID_DECENT) - # time trigger should have relative=True - time_triggers = [ - t - for s in result["profile"]["stages"] - for t in s["exit_triggers"] - if t["type"] == "time" - ] - assert all(t["relative"] is True for t in time_triggers) - # non-time triggers should have relative=False - non_time = [ - t - for s in result["profile"]["stages"] - for t in s["exit_triggers"] - if t["type"] != "time" - ] - assert all(t["relative"] is False for t in non_time) - # All should have comparison >= - all_triggers = [ - t for s in result["profile"]["stages"] for t in s["exit_triggers"] - ] - assert all(t["comparison"] == ">=" for t in all_triggers) - - -class TestConvertDecentEndpoint: - """Tests for the /api/convert-decent endpoint.""" - - VALID_DECENT = TestDecentConverter.VALID_DECENT - - def test_convert_decent_success(self, client): - """Returns converted profile for valid Decent input.""" - resp = client.post("/api/convert-decent", json=self.VALID_DECENT) - assert resp.status_code == 200 - data = resp.json() - assert "profile" in data - assert "warnings" in data - assert data["profile"]["name"] == "Londinium" - assert len(data["profile"]["stages"]) == 2 - - def test_convert_decent_not_decent_format(self, client): - """Returns 400 for non-Decent profiles.""" - meticulous = {"name": "Test", "stages": [{"type": "flow"}]} - resp = client.post("/api/convert-decent", json=meticulous) - assert resp.status_code == 400 - assert "Decent" in resp.json()["detail"] - - def test_convert_decent_dual_route(self, client): - """Both /convert-decent and /api/convert-decent work.""" - resp = client.post("/convert-decent", json=self.VALID_DECENT) - assert resp.status_code == 200 - - def test_convert_decent_empty_body(self, client): - """Returns 400 for empty body.""" - resp = client.post("/api/convert-decent", json={}) - assert resp.status_code == 400 - - -class TestImportFromUrlDecentAutoDetect: - """Tests for Decent auto-detection in /api/import-from-url.""" - - DECENT_PROFILE = { - "title": "Decent URL Import", - "author": "URL Author", - "steps": [ - { - "name": "pi", - "pump": "flow", - "flow": 3.5, - "sensor": "coffee", - "seconds": 10, - "exit": {"type": "pressure_over", "condition": 3.0}, - } - ], - } - - @staticmethod - def _mock_httpx_stream(response_bytes=b"{}", raise_for_status_error=None): - """Build a mock httpx.AsyncClient that streams response_bytes.""" - - class FakeStream: - def __init__(self): - self.status_code = 200 - - def raise_for_status(self): - if raise_for_status_error: - raise raise_for_status_error - - async def aiter_bytes(self, chunk_size=8192): - yield response_bytes - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - return False - - class FakeClient: - def __init__(self, **kwargs): - pass - - def stream(self, method, url): - return FakeStream() - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - return False - - return FakeClient - - @patch("api.routes.profiles._validate_url_for_ssrf") - @patch("api.routes.profiles.save_history") - @patch("api.routes.profiles.load_history", return_value=[]) - @patch("api.routes.profiles.async_create_profile", new_callable=AsyncMock) - @patch("api.routes.profiles._generate_profile_description") - def test_decent_auto_detected_from_url( - self, - mock_desc, - mock_create, - mock_load, - mock_save, - mock_ssrf, - client, - ): - """Decent profiles from URL are auto-detected and converted.""" - import json as _json - - mock_desc.return_value = "Converted Decent profile" - mock_create.return_value = {"id": "machine-123"} - - content = _json.dumps(self.DECENT_PROFILE).encode() - with patch("httpx.AsyncClient", self._mock_httpx_stream(content)): - resp = client.post( - "/api/import-from-url", - json={"url": "https://example.com/decent.json"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["status"] == "success" - assert data["converted_from_decent"] is True - # The profile name should come from "title" field after conversion - assert data["profile_name"] == "Decent URL Import" - - -class TestRankModels: - """Tests for the dynamic model ranking heuristic.""" - - def _m(self, name): - return {"id": name, "display_name": name, "description": ""} - - def test_prefers_flash_over_pro(self): - from services.gemini_service import rank_models - models = [self._m("gemini-2.5-pro"), self._m("gemini-2.5-flash")] - assert rank_models(models) == "gemini-2.5-flash" - - def test_prefers_higher_version(self): - from services.gemini_service import rank_models - models = [self._m("gemini-2.0-flash"), self._m("gemini-2.5-flash")] - assert rank_models(models) == "gemini-2.5-flash" - - def test_flash_beats_flash_lite(self): - from services.gemini_service import rank_models - models = [self._m("gemini-2.5-flash-lite"), self._m("gemini-2.5-flash")] - assert rank_models(models) == "gemini-2.5-flash" - - def test_flash_lite_beats_pro(self): - from services.gemini_service import rank_models - models = [self._m("gemini-2.5-pro"), self._m("gemini-2.5-flash-lite")] - assert rank_models(models) == "gemini-2.5-flash-lite" - - def test_prefers_stable_over_preview(self): - from services.gemini_service import rank_models - models = [self._m("gemini-3.0-flash-preview-09-2025"), self._m("gemini-2.5-flash")] - assert rank_models(models) == "gemini-2.5-flash" - - def test_allows_preview_as_last_resort(self): - from services.gemini_service import rank_models - models = [self._m("gemini-3.0-flash-exp")] - assert rank_models(models) == "gemini-3.0-flash-exp" - - def test_excludes_non_text_families(self): - from services.gemini_service import rank_models - models = [self._m("imagen-4.0-generate-001"), self._m("text-embedding-004")] - assert rank_models(models) is None - - def test_excludes_special_gemini_and_non_gemini_families(self): - from services.gemini_service import rank_models - models = [ - self._m("gemini-2.5-computer-use-preview-10-2025"), - self._m("gemini-robotics-er-1.5-preview"), - self._m("gemini-3.1-flash-image"), - self._m("nano-banana-pro-preview"), - self._m("lyria-3-pro-preview"), - self._m("gemma-4-31b-it"), - self._m("gemini-2.5-flash"), - ] - assert rank_models(models) == "gemini-2.5-flash" - - def test_excludes_all_non_text_returns_none(self): - from services.gemini_service import rank_models - models = [ - self._m("gemini-2.5-computer-use-preview-10-2025"), - self._m("nano-banana-pro-preview"), - self._m("gemma-4-31b-it"), - ] - assert rank_models(models) is None - - def test_strips_models_prefix(self): - from services.gemini_service import rank_models - models = [self._m("models/gemini-2.5-flash")] - assert rank_models(models) == "gemini-2.5-flash" - - def test_empty_returns_none(self): - from services.gemini_service import rank_models - assert rank_models([]) is None - - -class TestReactiveRetry: - @patch.dict(os.environ, {"GEMINI_API_KEY": "k"}) - def test_generate_reresolves_and_retries_on_model_not_found(self): - from services.gemini_service import _GeminiModelWrapper, _validated_model_cache - _validated_model_cache["model"] = "gemini-2.5-flash" - - calls = {"n": 0} - class FakeResp: - text = "ok" - class FakeModels: - def generate_content(self, model, contents): - calls["n"] += 1 - if calls["n"] == 1: - raise Exception("404 NOT_FOUND: model gemini-2.5-flash is not found") - return FakeResp() - class FakeClient: - models = FakeModels() - - vm = _GeminiModelWrapper(FakeClient()) - with patch("services.gemini_service.get_working_model_force", return_value="gemini-2.5-pro"): - resp = vm.generate_content("hi") - assert resp.text == "ok" - assert calls["n"] == 2 - - -@pytest.mark.skipif( - os.environ.get("RUN_LIVE_GEMINI_TESTS") != "1", - reason="opt-in live integration test; set RUN_LIVE_GEMINI_TESTS=1 with a real GEMINI_API_KEY", -) -class TestLiveModelListing: - """Opt-in: hits the real Gemini API to prove discovery works end-to-end.""" - - def test_get_available_models_returns_real_models(self): - import asyncio - # Reset any cached client so the env key is used. - import services.gemini_service as gs - gs._gemini_client = None - models = asyncio.run(gs.get_available_models()) - assert isinstance(models, list) - assert len(models) > 0, "Expected at least one generateContent model" - assert all("id" in m for m in models) - - def test_rank_models_picks_a_real_model(self): - import asyncio - import services.gemini_service as gs - gs._gemini_client = None - best = gs.rank_models(asyncio.run(gs.get_available_models())) - assert best, "rank_models should select a model from the live list" - # The selected model must actually validate against the API. - assert asyncio.run(gs.validate_model(best)) is True - - -class TestAITags: - """AI-generated sensory tags during description generation (#400).""" - - def test_parse_ai_tags_extracts_valid_labels(self): - from services.analysis_service import parse_ai_tags - - text = "Some description.\n\nTags: Chocolate, Creamy, Sweet" - assert parse_ai_tags(text) == ["Chocolate", "Creamy", "Sweet"] - - def test_parse_ai_tags_validates_against_vocabulary(self): - from services.analysis_service import parse_ai_tags - - text = "Body text\nTags: Chocolate, Spaceship, Nutty" - assert parse_ai_tags(text) == ["Chocolate", "Nutty"] - - def test_parse_ai_tags_is_case_insensitive_and_dedupes(self): - from services.analysis_service import parse_ai_tags - - text = "Tags: chocolate, CHOCOLATE, creamy." - assert parse_ai_tags(text) == ["Chocolate", "Creamy"] - - def test_parse_ai_tags_empty_when_absent(self): - from services.analysis_service import parse_ai_tags - - assert parse_ai_tags("No tags line here") == [] - assert parse_ai_tags(None) == [] - assert parse_ai_tags("Tags:") == [] - - def test_parse_ai_tags_tolerates_literal_brackets(self): - from services.analysis_service import parse_ai_tags - - assert parse_ai_tags("Tags: [Chocolate, Sweet]") == ["Chocolate", "Sweet"] - assert parse_ai_tags("Tags: [Chocolate]") == ["Chocolate"] - - def test_parse_ai_tags_tolerates_markdown_decoration(self): - from services.analysis_service import parse_ai_tags - - assert parse_ai_tags("**Tags:** Chocolate, Sweet") == ["Chocolate", "Sweet"] - assert parse_ai_tags("**Tags: Chocolate, Sweet**") == ["Chocolate", "Sweet"] - assert parse_ai_tags("- Tags: Chocolate, Sweet") == ["Chocolate", "Sweet"] - assert parse_ai_tags("* **Tags**: Chocolate, Sweet") == ["Chocolate", "Sweet"] - assert parse_ai_tags("# Tags: Chocolate") == ["Chocolate"] - - def test_strip_tags_line_removes_markdown_decorated_line(self): - from services.analysis_service import strip_tags_line - - assert strip_tags_line("Great coffee.\n**Tags:** Sweet, Berry").endswith( - "Great coffee." - ) - assert "Tags" not in strip_tags_line("Great coffee.\n- Tags: Sweet") - - def test_resolve_description_placeholders_resolves_variables(self): - from services.analysis_service import resolve_description_placeholders - - variables = [ - {"key": "pressure_Max Pressure", "name": "Max Pressure", "value": 6}, - {"key": "time_PreBrew Duration", "name": "PreBrew Duration", "value": 30}, - ] - assert ( - resolve_description_placeholders("Peaks at $pressure_Max Pressure.", variables) - == "Peaks at 6 bar." - ) - assert ( - resolve_description_placeholders("Runs $time\\_PreBrew Duration$.", variables) - == "Runs 30 s." - ) - - def test_resolve_description_placeholders_strips_invented_tokens(self): - from services.analysis_service import resolve_description_placeholders - - assert ( - resolve_description_placeholders("Adjust $pressure_1$ upward.", []) - == "Adjust upward." - ) - assert ( - resolve_description_placeholders("See $pressure\\_1$ here.", []) - == "See here." - ) - - def test_resolve_description_placeholders_leaves_prose_untouched(self): - from services.analysis_service import resolve_description_placeholders - - text = "A balanced, chocolatey shot with 9 bar peak pressure." - assert resolve_description_placeholders(text, []) == text - assert resolve_description_placeholders("", []) == "" - assert resolve_description_placeholders(None, []) == "" - - def test_resolve_description_placeholders_tidies_whitespace_linearly(self): - import time - - from services.analysis_service import resolve_description_placeholders - - assert resolve_description_placeholders("word .", []) == "word." - assert resolve_description_placeholders("a\t\tb ,c", []) == "a b,c" - # A long whitespace run with no trailing punctuation must resolve quickly - # (guards against the previously polynomial regex on many tabs/spaces). - text = "x" + (" " * 20000) + "y" - start = time.perf_counter() - assert resolve_description_placeholders(text, []) == "x y" - assert time.perf_counter() - start < 0.5 - - def test_strip_tags_line_removes_trailing_line(self): - from services.analysis_service import strip_tags_line - - text = "Profile Created: X\n\nDescription:\nGreat coffee.\n\nTags: Sweet, Berry" - stripped = strip_tags_line(text) - assert "Tags:" not in stripped - assert stripped.endswith("Great coffee.") - - def test_description_result_carries_tags(self): - from services.analysis_service import DescriptionResult - - result = DescriptionResult("hello", ["Sweet"]) - assert result == "hello" - assert isinstance(result, str) - assert result.ai_tags == ["Sweet"] - assert DescriptionResult("x").ai_tags == [] - - def test_regenerate_persists_ai_tags(self): - from services.analysis_service import DescriptionResult - - history = [ - { - "id": "entry-1", - "profile_name": "Test Profile", - "profile_json": {"name": "Test Profile"}, - "reply": "old static description", - } - ] - - with patch( - "api.routes.profiles._generate_profile_description", - new_callable=AsyncMock, - ) as mock_gen, patch( - "api.routes.profiles.load_history", return_value=history - ), patch( - "api.routes.profiles.save_history" - ): - mock_gen.return_value = DescriptionResult( - "A fresh AI description.", ["Chocolate", "Creamy"] - ) - client = TestClient(app) - resp = client.post("/api/profile/entry-1/regenerate-description") - - assert resp.status_code == 200 - assert history[0]["ai_tags"] == ["Chocolate", "Creamy"] - assert history[0]["reply"] == "A fresh AI description." - - def test_regenerate_invalidates_profile_list_cache(self): - """Regenerating a description must bust the profile-list cache so the - catalogue reloads the freshly-generated ai_tags (beta feedback: tags - never appeared after generating an AI explanation).""" - from services.analysis_service import DescriptionResult - - history = [ - { - "id": "entry-1", - "profile_name": "Test Profile", - "profile_json": {"name": "Test Profile"}, - "reply": "old static description", - } - ] - - with patch( - "api.routes.profiles._generate_profile_description", - new_callable=AsyncMock, - ) as mock_gen, patch( - "api.routes.profiles.load_history", return_value=history - ), patch( - "api.routes.profiles.save_history" - ), patch( - "api.routes.profiles.invalidate_profile_list_cache" - ) as mock_invalidate: - mock_gen.return_value = DescriptionResult( - "A fresh AI description.", ["Chocolate", "Creamy"] - ) - client = TestClient(app) - resp = client.post("/api/profile/entry-1/regenerate-description") - - assert resp.status_code == 200 - mock_invalidate.assert_called_once() - - -class TestShotFactsClassify: - """#423 Targeted vs Failsafe trigger classification.""" - - def test_weight_is_always_targeted(self): - from services.shot_facts import classify_trigger - r = classify_trigger("pressure", "weight", total_triggers=2) - assert r["kind"] == "targeted" - assert "yield" in r["label"].lower() - - def test_time_only_trigger_is_planned_duration(self): - from services.shot_facts import classify_trigger - r = classify_trigger("flow", "time", total_triggers=1) - assert r["kind"] == "targeted" - assert "planned" in r["label"].lower() - - def test_time_with_other_triggers_is_targeted_timed_transition(self): - from services.shot_facts import classify_trigger - r = classify_trigger("flow", "time", total_triggers=2) - assert r["kind"] == "targeted" - assert "timed transition" in r["label"].lower() - - def test_flow_control_pressure_trigger_is_puck_resistance(self): - from services.shot_facts import classify_trigger - r = classify_trigger("flow", "pressure", total_triggers=2) - assert r["kind"] == "targeted" - assert "resistance" in r["label"].lower() - - def test_flow_control_flow_trigger_is_flow_target_reached(self): - from services.shot_facts import classify_trigger - r = classify_trigger("flow", "flow", total_triggers=2) - assert r["kind"] == "targeted" - assert "flow target" in r["label"].lower() - - def test_pressure_control_flow_only_trigger_is_planned_transition(self): - from services.shot_facts import classify_trigger - r = classify_trigger("pressure", "flow", total_triggers=1) - assert r["kind"] == "targeted" - - def test_pressure_control_flow_with_others_is_failsafe_channeling(self): - from services.shot_facts import classify_trigger - r = classify_trigger("pressure", "flow", total_triggers=2) - assert r["kind"] == "failsafe" - assert "channel" in r["label"].lower() or "chok" in r["label"].lower() - - def test_pressure_control_pressure_trigger_is_threshold(self): - from services.shot_facts import classify_trigger - r = classify_trigger("pressure", "pressure", total_triggers=1) - assert r["kind"] == "targeted" - - def test_unknown_combo_returns_unknown(self): - from services.shot_facts import classify_trigger - r = classify_trigger("power", "weird", total_triggers=1) - assert r["kind"] == "unknown" - - def test_power_pressure_trigger_is_puck_resistance(self): - from services.shot_facts import classify_trigger - r = classify_trigger("power", "pressure", total_triggers=1) - assert r["kind"] == "targeted" - assert "resistance" in r["label"].lower() - - def test_terminal_stage_no_trigger_hits_weight_is_targeted_yield(self): - from services.shot_facts import classify_trigger - r = classify_trigger( - "pressure", "", total_triggers=0, - is_terminal_stage=True, weight_on_target=True, - ) - assert r["kind"] == "targeted" - assert r["label"] == "Targeted (yield reached)" - - def test_terminal_stage_no_trigger_misses_weight_is_unknown(self): - from services.shot_facts import classify_trigger - r = classify_trigger( - "pressure", "", total_triggers=0, - is_terminal_stage=True, weight_on_target=False, - ) - assert r["kind"] == "unknown" - - def test_intermediate_stage_no_trigger_is_planned_transition(self): - from services.shot_facts import classify_trigger - r = classify_trigger("flow", "", total_triggers=0, is_terminal_stage=False) - assert r["kind"] == "targeted" - assert r["label"] == "Targeted (planned transition)" - - def test_triggers_defined_but_none_fired_is_unknown(self): - from services.shot_facts import classify_trigger - r = classify_trigger( - "pressure", "", total_triggers=2, - is_terminal_stage=True, weight_on_target=True, - ) - assert r["kind"] == "unknown" - - def test_build_shot_facts_terminal_stage_no_trigger_is_targeted_yield(self): - from services.shot_facts import build_shot_facts - ed = { - "duration": 10, "weight_gain": 30, "end_weight": 36, - "start_pressure": 6, "end_pressure": 6, "avg_pressure": 6, - "max_pressure": 6, "min_pressure": 6, - "start_flow": 2, "end_flow": 2, "avg_flow": 2, "max_flow": 2, - } - analysis = { - "weight_analysis": {"actual": 36, "target": 36}, - "stage_analyses": [ - { - "stage_name": "PreBrew", "stage_type": "flow", - "exit_triggers": [{"type": "time"}], - "exit_trigger_result": {"triggered": {"type": "time"}}, - "execution_data": ed, - }, - { - "stage_name": "Extraction", "stage_type": "flow", - "exit_triggers": [], - "exit_trigger_result": None, - "execution_data": ed, - }, - ], - } - facts = build_shot_facts(analysis) - assert facts["stages"][0]["trigger_class"]["kind"] == "targeted" - ext = facts["stages"][1]["trigger_class"] - assert ext["kind"] == "targeted" - assert ext["label"] == "Targeted (yield reached)" - - -class TestEffectiveControlMode: - """#423 effective-mode detection (declared type vs true control intent).""" - - def test_aggressive_flow_with_pressure_limit_is_pressure(self): - # Slayer "Extraction": flow target 10.8 ml/s capped by a 6 bar pressure limit. - from services.shot_facts import effective_control_mode - stage = { - "stage_type": "flow", - "profile_max_target": 10.8, - "limits": [{"type": "pressure", "value": 6.0}], - } - assert effective_control_mode(stage) == "pressure" - - def test_gentle_flow_with_pressure_limit_stays_flow(self): - # Slayer "PreBrew": flow target 1.2 ml/s + 1.8 bar limit — genuinely flow-led. - from services.shot_facts import effective_control_mode - stage = { - "stage_type": "flow", - "profile_max_target": 1.2, - "limits": [{"type": "pressure", "value": 1.8}], - } - assert effective_control_mode(stage) == "flow" - - def test_pressure_with_restricted_flow_limit_is_flow(self): - from services.shot_facts import effective_control_mode - stage = { - "stage_type": "pressure", - "profile_max_target": 9.0, - "limits": [{"type": "flow", "value": 2.5}], - } - assert effective_control_mode(stage) == "flow" - - def test_pressure_with_loose_flow_limit_stays_pressure(self): - # Damian "Fill": pressure 2 bar + 8 ml/s limit — the limit isn't restrictive. - from services.shot_facts import effective_control_mode - stage = { - "stage_type": "pressure", - "profile_max_target": 2.0, - "limits": [{"type": "flow", "value": 8.0}], - } - assert effective_control_mode(stage) == "pressure" - - def test_power_stage_is_power(self): - from services.shot_facts import effective_control_mode - assert effective_control_mode({"stage_type": "power"}) == "power" - - def test_build_shot_facts_exposes_effective_and_declared_mode(self): - from services.shot_facts import build_shot_facts - local = { - "stage_analyses": [ - { - "stage_name": "Extraction", - "stage_type": "flow", - "profile_max_target": 10.8, - "limits": [{"type": "pressure", "value": 6.0}], - "exit_triggers": [{"type": "pressure"}], - "exit_trigger_result": {"triggered": {"type": "pressure"}}, - "execution_data": {"weight_gain": 20.0, "avg_pressure": 6.0}, - } - ] - } - facts = build_shot_facts(local) - stage = facts["stages"][0] - assert stage["control_mode"] == "pressure" - assert stage["declared_mode"] == "flow" - assert stage["mode_overridden"] is True - # Effective pressure + pressure trigger ⇒ targeted threshold, not "puck resistance". - assert stage["trigger_class"]["kind"] == "targeted" - - -class TestPuckFailure: - """#423 puck-failure refinement: weight-terminated yield hit off-curve.""" - - def _pressure_governed_stage(self, max_pressure, weight_target=36.0): - # Slayer-style: declared flow, high flow target capped by a 6 bar pressure - # limit ⇒ effective pressure. Ends on a near-final weight trigger. - return { - "stage_name": "Extraction", - "stage_type": "flow", - "profile_max_target": 10.8, - "limits": [{"type": "pressure", "value": 6.0}], - "exit_triggers": [{"type": "weight"}], - "exit_trigger_result": { - "triggered": {"type": "weight", "target": weight_target} - }, - "execution_data": {"weight_gain": 30.0, "max_pressure": max_pressure}, - } - - def test_yield_hit_off_target_is_puck_failure(self): - from services.shot_facts import build_shot_facts - # Pressure never reached the 6 bar band (max 3.0 < 0.8×6 = 4.8) → puck failure. - facts = build_shot_facts( - { - "weight_analysis": {"target": 36.0}, - "stage_analyses": [self._pressure_governed_stage(max_pressure=3.0)], - } - ) - tc = facts["stages"][0]["trigger_class"] - assert tc["kind"] == "failsafe" - assert "puck failure" in tc["label"].lower() - - def test_yield_hit_on_target_is_normal_completion(self): - from services.shot_facts import build_shot_facts - # Pressure reached the band (6.2 ≥ 4.8) → normal targeted yield. - facts = build_shot_facts( - { - "weight_analysis": {"target": 36.0}, - "stage_analyses": [self._pressure_governed_stage(max_pressure=6.2)], - } - ) - tc = facts["stages"][0]["trigger_class"] - assert tc["kind"] == "targeted" - assert "puck failure" not in tc["label"].lower() - - def test_flow_governed_weight_completion_is_never_puck_failure(self): - from services.shot_facts import build_shot_facts - # Plain flow stage, no pressure limit ⇒ effective flow. Low pressure is - # expected for a volumetric pour and must NOT be flagged as puck failure. - facts = build_shot_facts( - { - "weight_analysis": {"target": 36.0}, - "stage_analyses": [ - { - "stage_name": "Pour", - "stage_type": "flow", - "profile_max_target": 2.0, - "limits": [], - "exit_triggers": [{"type": "weight"}], - "exit_trigger_result": { - "triggered": {"type": "weight", "target": 36.0} - }, - "execution_data": {"weight_gain": 30.0, "max_pressure": 2.0}, - } - ], - } - ) - tc = facts["stages"][0]["trigger_class"] - assert tc["kind"] == "targeted" - assert "puck failure" not in tc["label"].lower() - - def test_intermediate_weight_milestone_is_first_drip_check(self): - from services.shot_facts import build_shot_facts - facts = build_shot_facts( - { - "weight_analysis": {"target": 36.0}, - "stage_analyses": [self._pressure_governed_stage(max_pressure=3.0, weight_target=4.0)], - } - ) - tc = facts["stages"][0]["trigger_class"] - assert tc["kind"] == "targeted" - assert "first-drip" in tc["label"].lower() or "milestone" in tc["reason"].lower() - - def test_classify_trigger_weight_backward_compatible(self): - from services.shot_facts import classify_trigger - # No context supplied ⇒ legacy behaviour: always targeted yield. - r = classify_trigger("pressure", "weight", 1) - assert r["kind"] == "targeted" - assert "yield" in r["label"].lower() - - -class TestShotFacts: - def _stage(self, **kw): - base = { - "stage_name": "Infusion", - "stage_type": "flow", - "exit_triggers": [], - "execution_data": { - "duration": 10.0, "weight_gain": 2.0, "end_weight": 8.0, - "start_pressure": 1.0, "end_pressure": 6.0, "avg_pressure": 4.0, - "max_pressure": 6.5, "min_pressure": 1.0, - "start_flow": 4.0, "end_flow": 0.5, "avg_flow": 2.0, "max_flow": 4.5, - }, - "exit_trigger_result": {"triggered": {"type": "time"}}, - } - base.update(kw) - return base - - def test_stall_detected_on_time_failsafe_with_low_gain(self): - from services.shot_facts import detect_stall - stage = self._stage( - stage_type="pressure", - exit_triggers=[{"type": "flow"}, {"type": "time"}], - exit_trigger_result={"triggered": {"type": "time"}}, - execution_data={**self._stage()["execution_data"], "weight_gain": 0.3}, - ) - assert detect_stall(stage)["stalled"] is True - - def test_no_stall_when_weight_trigger(self): - from services.shot_facts import detect_stall - stage = self._stage(exit_trigger_result={"triggered": {"type": "weight"}}) - assert detect_stall(stage)["stalled"] is False - - def test_channeling_flag_on_pressure_drop_with_flow_rise(self): - from services.shot_facts import detect_channeling - ed = {**self._stage()["execution_data"], - "start_pressure": 8.0, "end_pressure": 3.0, - "start_flow": 1.0, "end_flow": 5.0} - assert detect_channeling(ed)["channeling"] is True - - def test_no_channeling_on_stable_stage(self): - from services.shot_facts import detect_channeling - ed = {**self._stage()["execution_data"], - "start_pressure": 6.0, "end_pressure": 6.2, - "start_flow": 2.0, "end_flow": 2.1} - assert detect_channeling(ed)["channeling"] is False - - def test_build_shot_facts_classifies_each_stage(self): - from services.shot_facts import build_shot_facts - local = { - "stage_analyses": [ - self._stage( - stage_type="pressure", - exit_triggers=[{"type": "weight"}], - exit_trigger_result={"triggered": {"type": "weight"}}, - ), - ], - "weight_analysis": {"actual": 36.0, "target": 36.0, "deviation_percent": 0.0}, - "overall_metrics": {"total_time": 30.0}, - } - facts = build_shot_facts(local) - assert len(facts["stages"]) == 1 - assert facts["stages"][0]["trigger_class"]["kind"] == "targeted" - assert "phases" in facts - - def test_total_time_falls_back_to_shot_summary(self): - from services.shot_facts import build_shot_facts - local = { - "stage_analyses": [], - "weight_analysis": {}, - "shot_summary": {"total_time": 28.5}, - } - assert build_shot_facts(local)["total_time_s"] == 28.5 - - def test_curve_adherence_uses_profile_target_value(self): - from services.shot_facts import build_shot_facts - local = { - "stage_analyses": [ - self._stage( - stage_type="pressure", - profile_target_value=9.0, - execution_data={**self._stage()["execution_data"], "avg_pressure": 8.5}, - ), - ], - "weight_analysis": {}, - "shot_summary": {"total_time": 30.0}, - } - ca = build_shot_facts(local)["stages"][0]["curve_adherence"] - assert ca is not None - assert ca["target"] == 9.0 - assert ca["measured"] == 8.5 - assert ca["delta"] == -0.5 - - def test_curve_adherence_none_without_target(self): - from services.shot_facts import build_shot_facts - local = { - "stage_analyses": [self._stage(stage_type="pressure")], - "weight_analysis": {}, - "shot_summary": {"total_time": 30.0}, - } - assert build_shot_facts(local)["stages"][0]["curve_adherence"] is None - - -class TestMeanDynamicsTarget: - def test_mean_of_pressure_setpoints(self): - from services.analysis_service import _mean_dynamics_target - stage = {"type": "pressure", "dynamics_points": [[0, 2.0], [10, 8.0]]} - assert _mean_dynamics_target(stage) == 5.0 - - def test_resolves_variable_references(self): - from services.analysis_service import _mean_dynamics_target - stage = {"type": "flow", "dynamics_points": [[0, "$f"], [5, 4.0]]} - variables = [{"key": "f", "name": "Flow", "value": 2.0}] - assert _mean_dynamics_target(stage, variables) == 3.0 - - def test_none_for_non_pressure_flow_stage(self): - from services.analysis_service import _mean_dynamics_target - assert _mean_dynamics_target({"type": "power", "dynamics_points": [[0, 5]]}) is None - - def test_none_when_no_numeric_points(self): - from services.analysis_service import _mean_dynamics_target - assert _mean_dynamics_target({"type": "pressure", "dynamics_points": []}) is None - - -class TestLocalAnalysisIncludesFacts: - def test_local_analysis_attaches_shot_facts(self): - from services.analysis_service import _perform_local_shot_analysis - - shot_data = { - "data": [ - {"time": 0, "shot": {"weight": 0, "pressure": 2.0, "flow": 0.5}, "status": "Bloom"}, - {"time": 5000, "shot": {"weight": 2.0, "pressure": 2.0, "flow": 0.5}, "status": "Bloom"}, - {"time": 6000, "shot": {"weight": 5.0, "pressure": 9.0, "flow": 2.5}, "status": "Main"}, - {"time": 25000, "shot": {"weight": 36.0, "pressure": 9.0, "flow": 2.5}, "status": "Main"}, - ] - } - - profile_data = { - "name": "Test Profile", - "final_weight": 36.0, - "stages": [ - { - "name": "Bloom", - "key": "bloom", - "type": "pressure", - "dynamics_points": [[0, 2.0]], - "dynamics_over": "time", - "exit_triggers": [{"type": "time", "value": 5, "comparison": ">="}], - }, - { - "name": "Main", - "key": "main", - "type": "pressure", - "dynamics_points": [[0, 9.0]], - "dynamics_over": "time", - "exit_triggers": [{"type": "weight", "value": 36, "comparison": ">="}], - }, - ], - "variables": [], - } - - result = _perform_local_shot_analysis(shot_data, profile_data) - - assert "shot_facts" in result - assert "stages" in result["shot_facts"] - - -class TestCompassRules: - def test_sour_and_weak_suggests_finer_and_hotter(self): - from services.compass_rules import compass_adjustments - adj = compass_adjustments(taste_x=-0.8, taste_y=-0.6) - kinds = {a["kind"] for a in adj} - assert "grind_finer" in kinds - assert any(a["kind"] in ("temp_up", "ratio_up", "dose_up") for a in adj) - - def test_bitter_and_strong_suggests_coarser_and_cooler(self): - from services.compass_rules import compass_adjustments - adj = compass_adjustments(taste_x=0.8, taste_y=0.7) - kinds = {a["kind"] for a in adj} - assert "grind_coarser" in kinds - - def test_centered_taste_returns_no_changes(self): - from services.compass_rules import compass_adjustments - assert compass_adjustments(taste_x=0.0, taste_y=0.0) == [] - - -class TestAnalysisKnowledge: - def test_knowledge_constant_covers_trigger_classes(self): - from analysis_knowledge import ANALYSIS_KNOWLEDGE - assert "Targeted" in ANALYSIS_KNOWLEDGE - assert "Failsafe" in ANALYSIS_KNOWLEDGE - assert "channeling" in ANALYSIS_KNOWLEDGE.lower() - - def test_fact_sheet_renders_stage_classification(self): - from analysis_knowledge import build_fact_sheet - facts = { - "stages": [{ - "stage_name": "Infusion", "reached": True, "control_mode": "pressure", - "trigger_type": "weight", - "trigger_class": {"kind": "targeted", "label": "Targeted (yield reached)", "reason": "x"}, - "stall": {"stalled": False, "weight_gain": 30.0}, - "channeling": {"channeling": False, "pressure_drop": 0.1, "flow_rise": 0.1}, - "curve_adherence": {"target": 9.0, "measured": 8.5, "delta": -0.5}, - }], - "phases": [], - "weight": {"actual": 36.0, "target": 36.0, "deviation_pct": 0.0}, - "total_time_s": 30.0, - } - sheet = build_fact_sheet(facts) - assert "Infusion" in sheet - assert "Targeted (yield reached)" in sheet - assert "36" in sheet - - def test_fact_sheet_flags_stall_and_channeling(self): - from analysis_knowledge import build_fact_sheet - facts = { - "stages": [{ - "stage_name": "Decline", "reached": True, "control_mode": "pressure", - "trigger_type": "time", - "trigger_class": {"kind": "failsafe", "label": "Failsafe (timeout limit)", "reason": "x"}, - "stall": {"stalled": True, "weight_gain": 0.2}, - "channeling": {"channeling": True, "pressure_drop": 3.0, "flow_rise": 2.0}, - "curve_adherence": None, - }], - "phases": [], "weight": {}, "total_time_s": 25.0, - } - sheet = build_fact_sheet(facts).lower() - assert "stall" in sheet - assert "channel" in sheet - - -class TestAnalysisPromptContent: - """The analyze-llm prompt must carry ANALYSIS_KNOWLEDGE, the fact sheet, and the few-shot.""" - - @patch("api.routes.shots.fetch_shot_data", new_callable=AsyncMock) - @patch("api.routes.shots.async_get_profile", new_callable=AsyncMock) - @patch("api.routes.shots.async_list_profiles", new_callable=AsyncMock) - @patch("api.routes.shots.get_vision_model") - @patch("api.routes.shots._perform_local_shot_analysis") - def test_prompt_includes_knowledge_factsheet_fewshot( - self, - mock_local_analysis, - mock_get_model, - mock_list_profiles, - mock_get_profile, - mock_fetch_shot, - client, - ): - mock_fetch_shot.return_value = { - "profile_name": "Test", - "time": 1705320000, - "data": [{"time": 25000, "shot": {"weight": 36.0}}], - } - - partial = type("P", (), {})() - partial.name = "Test" - partial.id = "p-123" - partial.error = None - mock_list_profiles.return_value = [partial] - - full = type("F", (), {})() - full.name = "Test" - full.temperature = 93.0 - full.final_weight = 36.0 - full.variables = [] - full.stages = [] - full.error = None - mock_get_profile.return_value = full - - mock_local_analysis.return_value = { - "shot_summary": {"final_weight": 36.0, "total_time": 28.0}, - "weight_analysis": {"actual": 36.0, "target": 36.0, "deviation_percent": 0.0}, - "stage_analyses": [], - "shot_facts": { - "stages": [], - "phases": [], - "weight": {"actual": 36.0, "target": 36.0, "deviation_pct": 0.0}, - "total_time_s": 28.0, - }, - } - - mock_model = MagicMock() - mock_model.async_generate_content = AsyncMock( - return_value=MagicMock( - text="## 1. Shot Performance\n**What Happened:**\n- ok\n**Assessment:** Good" - ) - ) - mock_get_model.return_value = mock_model - - resp = client.post( - "/api/shots/analyze-llm", - data={ - "profile_name": "Test", - "shot_date": "2024-01-15", - "shot_filename": "shot.json", - "force_refresh": "true", - }, - ) - assert resp.status_code == 200 - prompt = mock_model.async_generate_content.call_args[0][0] - assert "EXIT TRIGGER CLASSIFICATION" in prompt # ANALYSIS_KNOWLEDGE - assert "Deterministic Shot Facts" in prompt # fact sheet - assert "Worked Example" in prompt # few-shot - - -class TestAnalysisValidator: - def _facts_targeted_weight(self): - return { - "stages": [{ - "stage_name": "Hold", "reached": True, "control_mode": "pressure", - "trigger_type": "weight", - "trigger_class": {"kind": "targeted", "label": "Targeted (yield reached)", "reason": ""}, - "stall": {"stalled": False, "weight_gain": 30.0}, - "channeling": {"channeling": False, "pressure_drop": 0.0, "flow_rise": 0.0}, - "curve_adherence": None, - }], - "phases": [], "weight": {"actual": 36.0, "target": 36.0, "deviation_pct": 0.0}, - "total_time_s": 28.0, - } - - def test_flags_targeted_exit_called_early_termination(self): - from services.analysis_validator import validate_against_facts - text = "- The hold stage terminated early before reaching its goal." - r = validate_against_facts(text, self._facts_targeted_weight()) - assert r["valid"] is False - assert "mischaracterized-targeted-exit" in r["issues"] - - def test_accepts_success_framing(self): - from services.analysis_validator import validate_against_facts - text = "- The hold stage ended exactly on the weight target, a correct finish." - assert validate_against_facts(text, self._facts_targeted_weight())["valid"] is True - - def test_flags_unsupported_channeling(self): - from services.analysis_validator import validate_against_facts - text = "- Severe channeling caused the pressure to collapse." - assert "unsupported-channeling" in validate_against_facts(text, self._facts_targeted_weight())["issues"] - - -class TestAnalysisStructure: - def test_schema_lists_core_sections(self): - from analysis_schema import REQUIRED_ANALYSIS_SECTIONS - assert "Shot Performance" in REQUIRED_ANALYSIS_SECTIONS - assert len(REQUIRED_ANALYSIS_SECTIONS) >= 5 - - def test_check_structure_flags_missing_sections(self): - from services.analysis_validator import check_structure - r = check_structure("## 1. Shot Performance\n- ok") - assert r["valid"] is False - assert "missing-sections" in r["issues"] - - def test_check_structure_accepts_full_text(self): - from analysis_schema import REQUIRED_ANALYSIS_SECTIONS - from services.analysis_validator import check_structure - text = "\n".join(f"## {i+1}. {s}\n- content" for i, s in enumerate(REQUIRED_ANALYSIS_SECTIONS)) - assert check_structure(text)["valid"] is True - - -class TestAnalysisCoverageMatrix: - def test_server_exposes_all_analysis_checks(self): - from services import analysis_validator as v - assert callable(v.validate_against_facts) - assert callable(v.check_structure) - from analysis_knowledge import build_fact_sheet, ANALYSIS_KNOWLEDGE # noqa: F401 - from services.shot_facts import build_shot_facts, classify_trigger # noqa: F401 - from services.compass_rules import compass_adjustments # noqa: F401 diff --git a/apps/server/test_recommendations.py b/apps/server/test_recommendations.py deleted file mode 100644 index 2de934bf..00000000 --- a/apps/server/test_recommendations.py +++ /dev/null @@ -1,504 +0,0 @@ -"""Tests for profile recommendation service — structural scoring.""" - -import pytest -from unittest.mock import patch, AsyncMock -from types import SimpleNamespace - -import os -import sys - -sys.path.insert(0, os.path.dirname(__file__)) - -os.environ.setdefault("METICULOUS_IP", "127.0.0.1") -os.environ.setdefault("TEST_MODE", "true") - - -from services.profile_recommendation_service import ( - _jaccard, - extract_fingerprint, - _extract_name_tags, - _score_profile, - _proximity_score, - _LRUCache, - ProfileRecommendationService, -) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_stage( - name: str, - stype: str, - points: list, - over: str = "time", - interpolation: str = "linear", - exit_triggers: list | None = None, - limits: list | None = None, -): - """Create a mock Stage object.""" - dynamics = SimpleNamespace(points=points, over=over, interpolation=interpolation) - return SimpleNamespace( - name=name, - key=f"{stype}_0", - type=stype, - dynamics=dynamics, - exit_triggers=exit_triggers or [], - limits=limits or [], - ) - - -def _make_profile( - name: str, - stages: list | None = None, - temperature: float = 93.0, - final_weight: float = 36.0, - variables: list | None = None, -): - """Create a mock Profile object.""" - return SimpleNamespace( - name=name, - id=f"id-{name}", - author="test", - author_id="test-id", - temperature=temperature, - final_weight=final_weight, - stages=stages or [], - variables=variables or [], - ) - - -# A realistic pressure-controlled espresso profile -PRESSURE_PROFILE = _make_profile( - "Classic Italian Espresso", - stages=[ - _make_stage("Preinfusion", "pressure", [[0, 2.0], [5, 4.0]]), - _make_stage("Ramp", "pressure", [[0, 4.0], [3, 9.0]]), - _make_stage("Extraction", "pressure", [[0, 9.0], [25, 8.5]]), - ], - temperature=93.0, - final_weight=36.0, -) - -# A flow-controlled profile with bloom -FLOW_PROFILE = _make_profile( - "Modern Flow Bloom", - stages=[ - _make_stage("Preinfusion", "flow", [[0, 2.0], [5, 2.0]]), - _make_stage("Bloom", "flow", [[0, 0.5], [10, 0.5]]), - _make_stage("Main Extraction", "flow", [[0, 2.5], [20, 2.0]]), - ], - temperature=90.0, - final_weight=40.0, -) - -# A simple flat pressure profile -FLAT_PROFILE = _make_profile( - "Simple 6 Bar", - stages=[ - _make_stage("Flat Pressure", "pressure", [[0, 6.0], [30, 6.0]]), - ], - temperature=93.0, - final_weight=36.0, -) - -# A turbo shot (high flow, low weight) -TURBO_PROFILE = _make_profile( - "Turbo Shot", - stages=[ - _make_stage("Turbo", "flow", [[0, 5.0], [8, 5.0]]), - ], - temperature=96.0, - final_weight=20.0, -) - -# A lever-style with declining pressure -LEVER_PROFILE = _make_profile( - "Lever Decline", - stages=[ - _make_stage("Preinfusion", "pressure", [[0, 2.0], [5, 4.0]]), - _make_stage("Peak", "pressure", [[0, 9.0], [2, 9.0]]), - _make_stage("Decline", "pressure", [[0, 9.0], [20, 3.0]]), - ], - temperature=92.0, - final_weight=38.0, -) - - -# --------------------------------------------------------------------------- -# Jaccard tests -# --------------------------------------------------------------------------- - - -class TestJaccard: - def test_both_empty(self): - assert _jaccard(set(), set()) == 0.0 - - def test_identical(self): - assert _jaccard({"a", "b"}, {"a", "b"}) == 1.0 - - def test_no_overlap(self): - assert _jaccard({"a"}, {"b"}) == 0.0 - - def test_partial_overlap(self): - assert _jaccard({"a", "b", "c"}, {"b", "c", "d"}) == pytest.approx(0.5) - - -# --------------------------------------------------------------------------- -# Proximity score tests -# --------------------------------------------------------------------------- - - -class TestProximityScore: - def test_identical_values(self): - score, _ = _proximity_score(36.0, 36.0, 2.0, 10.0, 15) - assert score == 15 - - def test_within_full_range(self): - score, _ = _proximity_score(36.0, 37.5, 2.0, 10.0, 15) - assert score == 15 - - def test_partial_range(self): - score, _ = _proximity_score(36.0, 42.0, 2.0, 10.0, 15) - assert 0 < score < 15 - - def test_out_of_range(self): - score, _ = _proximity_score(36.0, 50.0, 2.0, 10.0, 15) - assert score == 0.0 - - def test_none_values(self): - score, _ = _proximity_score(None, 36.0, 2.0, 10.0, 15) - assert score == 0.0 - - -# --------------------------------------------------------------------------- -# Fingerprint extraction tests -# --------------------------------------------------------------------------- - - -class TestExtractFingerprint: - def test_pressure_profile(self): - fp = extract_fingerprint(PRESSURE_PROFILE) - assert fp["control_mode"] == "pressure" - assert fp["has_preinfusion"] is True - assert fp["stage_count"] == 3 - assert fp["peak_pressure"] > 0 - assert "pressure-profile" in fp["technique_tags"] - assert "preinfusion" in fp["technique_tags"] - - def test_flow_profile_with_bloom(self): - fp = extract_fingerprint(FLOW_PROFILE) - assert fp["control_mode"] == "flow" - assert fp["has_bloom"] is True - assert fp["has_preinfusion"] is True - assert "flow-profile" in fp["technique_tags"] - assert "bloom" in fp["technique_tags"] - - def test_flat_profile(self): - fp = extract_fingerprint(FLAT_PROFILE) - assert fp["is_flat"] is True - assert fp["stage_count"] == 1 - assert "flat" in fp["technique_tags"] - - def test_turbo_profile(self): - fp = extract_fingerprint(TURBO_PROFILE) - assert fp["control_mode"] == "flow" - assert fp["temperature"] == 96.0 - assert fp["final_weight"] == 20.0 - - def test_lever_with_decline(self): - fp = extract_fingerprint(LEVER_PROFILE) - assert fp["has_preinfusion"] is True - assert "decline" in fp["technique_tags"] - assert fp["peak_pressure"] >= 9.0 - - def test_empty_stages(self): - profile = _make_profile("Empty", stages=[]) - fp = extract_fingerprint(profile) - assert fp["stage_count"] == 0 - assert fp["control_mode"] == "unknown" - assert fp["peak_pressure"] == 0 - - def test_pulse_detection_many_stages(self): - stages = [ - _make_stage(f"Step {i}", "pressure", [[0, 3], [1, 6]]) for i in range(6) - ] - profile = _make_profile("Pulse Profile", stages=stages) - fp = extract_fingerprint(profile) - assert fp["has_pulse"] is True - assert "pulse" in fp["technique_tags"] - - -# --------------------------------------------------------------------------- -# Tag extraction tests -# --------------------------------------------------------------------------- - - -class TestExtractNameTags: - def test_keywords_in_name(self): - profile = _make_profile("Fruity Bloom Light", stages=[]) - tags = _extract_name_tags(profile) - assert "fruity" in tags - assert "bloom" in tags - assert "light" in tags - - def test_stage_keywords(self): - stages = [_make_stage("Preinfusion", "pressure", [[0, 3], [5, 6]])] - profile = _make_profile("Test", stages=stages) - tags = _extract_name_tags(profile) - assert "preinfusion" in tags - - def test_no_match(self): - profile = _make_profile("Standard", stages=[]) - tags = _extract_name_tags(profile) - assert len(tags) == 0 - - -# --------------------------------------------------------------------------- -# Scoring tests -# --------------------------------------------------------------------------- - - -class TestScoreProfile: - def test_similar_pressure_profiles_score_high(self): - source_fp = extract_fingerprint(PRESSURE_PROFILE) - source_tags = _extract_name_tags(PRESSURE_PROFILE) - score, reasons, explanation = _score_profile( - source_tags, source_fp, LEVER_PROFILE - ) - # Both pressure-controlled with preinfusion - assert score > 30 - - def test_different_control_modes_score_lower(self): - source_fp = extract_fingerprint(PRESSURE_PROFILE) - source_tags = _extract_name_tags(PRESSURE_PROFILE) - score_pressure, _, _ = _score_profile(source_tags, source_fp, LEVER_PROFILE) - score_flow, _, _ = _score_profile(source_tags, source_fp, TURBO_PROFILE) - assert score_pressure > score_flow - - def test_weight_similarity_matters(self): - # Two profiles with similar weight should score higher - p_close = _make_profile("Test A", stages=[], temperature=93, final_weight=37) - p_far = _make_profile("Test B", stages=[], temperature=93, final_weight=60) - source_fp = { - "final_weight": 36, - "peak_pressure": 0, - "temperature": 93, - "control_mode": "unknown", - "stage_count": 0, - "is_flat": False, - "technique_tags": set(), - "has_preinfusion": False, - "has_bloom": False, - "has_pulse": False, - } - score_close, _, _ = _score_profile(set(), source_fp, p_close) - score_far, _, _ = _score_profile(set(), source_fp, p_far) - assert score_close > score_far - - def test_temperature_similarity(self): - p_close = _make_profile("Test A", stages=[], temperature=93, final_weight=36) - p_far = _make_profile("Test B", stages=[], temperature=80, final_weight=36) - source_fp = { - "final_weight": 36, - "peak_pressure": 0, - "temperature": 93, - "control_mode": "unknown", - "stage_count": 0, - "is_flat": False, - "technique_tags": set(), - "has_preinfusion": False, - "has_bloom": False, - "has_pulse": False, - } - score_close, _, _ = _score_profile(set(), source_fp, p_close) - score_far, _, _ = _score_profile(set(), source_fp, p_far) - assert score_close > score_far - - def test_score_capped_at_100(self): - # Even with maximum overlap, score should not exceed 100 - source_fp = extract_fingerprint(PRESSURE_PROFILE) - source_tags = _extract_name_tags(PRESSURE_PROFILE) - score, _, _ = _score_profile(source_tags, source_fp, PRESSURE_PROFILE) - assert score <= 100 - - def test_explanation_is_string(self): - source_fp = extract_fingerprint(PRESSURE_PROFILE) - source_tags = _extract_name_tags(PRESSURE_PROFILE) - _, _, explanation = _score_profile(source_tags, source_fp, LEVER_PROFILE) - assert isinstance(explanation, str) - - -# --------------------------------------------------------------------------- -# LRU Cache tests -# --------------------------------------------------------------------------- - - -class TestLRUCache: - def test_get_set(self): - cache = _LRUCache(3) - cache.put("a", [{"x": 1}]) - assert cache.get("a") == [{"x": 1}] - - def test_eviction(self): - cache = _LRUCache(2) - cache.put("a", []) - cache.put("b", []) - cache.put("c", []) - assert cache.get("a") is None - assert cache.get("b") is not None - - def test_clear(self): - cache = _LRUCache(5) - cache.put("a", []) - cache.clear() - assert cache.get("a") is None - - -# --------------------------------------------------------------------------- -# Service integration tests -# --------------------------------------------------------------------------- - - -class TestRecommendationService: - @pytest.fixture - def service(self): - return ProfileRecommendationService() - - @pytest.mark.asyncio - async def test_recommendations_return_format(self, service): - profiles = [PRESSURE_PROFILE, FLOW_PROFILE, FLAT_PROFILE] - with patch( - "services.profile_recommendation_service.async_fetch_all_profiles", - new_callable=AsyncMock, - return_value=profiles, - ): - results = await service.get_recommendations( - tags=["preinfusion", "bloom"], limit=5 - ) - - assert isinstance(results, list) - for r in results: - assert "profile_name" in r - assert "score" in r - assert "match_reasons" in r - assert "explanation" in r - - @pytest.mark.asyncio - async def test_recommendations_filters_zero_score(self, service): - profiles = [_make_profile("Totally Unrelated", stages=[])] - with patch( - "services.profile_recommendation_service.async_fetch_all_profiles", - new_callable=AsyncMock, - return_value=profiles, - ): - results = await service.get_recommendations(tags=["fruity"], limit=5) - - for r in results: - assert r["score"] > 0 - - @pytest.mark.asyncio - async def test_recommendations_empty_catalogue(self, service): - with patch( - "services.profile_recommendation_service.async_fetch_all_profiles", - new_callable=AsyncMock, - return_value=[], - ): - results = await service.get_recommendations(tags=["fruity"], limit=5) - - assert results == [] - - @pytest.mark.asyncio - async def test_find_similar_excludes_source(self, service): - profiles = [PRESSURE_PROFILE, FLOW_PROFILE, LEVER_PROFILE] - with patch( - "services.profile_recommendation_service.async_fetch_all_profiles", - new_callable=AsyncMock, - return_value=profiles, - ): - results = await service.find_similar("Classic Italian Espresso", limit=5) - - assert all(r["profile_name"] != "Classic Italian Espresso" for r in results) - - @pytest.mark.asyncio - async def test_find_similar_unknown_profile(self, service): - profiles = [PRESSURE_PROFILE, FLOW_PROFILE] - with patch( - "services.profile_recommendation_service.async_fetch_all_profiles", - new_callable=AsyncMock, - return_value=profiles, - ): - results = await service.find_similar("DoesNotExist", limit=5) - - assert results == [] - - @pytest.mark.asyncio - async def test_cache_invalidation(self, service): - with patch( - "services.profile_recommendation_service.async_fetch_all_profiles", - new_callable=AsyncMock, - return_value=[PRESSURE_PROFILE], - ): - r1 = await service.get_recommendations(tags=["preinfusion"], limit=5) - - service.invalidate_cache() - - with patch( - "services.profile_recommendation_service.async_fetch_all_profiles", - new_callable=AsyncMock, - return_value=[FLOW_PROFILE], - ): - r2 = await service.get_recommendations(tags=["preinfusion"], limit=5) - - names1 = {r["profile_name"] for r in r1} - names2 = {r["profile_name"] for r in r2} - assert names1 != names2 or (not r1 and not r2) - - @pytest.mark.asyncio - async def test_structural_ranking_pressure(self, service): - """Pressure-controlled profiles should rank higher when searching for pressure-like tags.""" - profiles = [ - PRESSURE_PROFILE, - FLOW_PROFILE, - FLAT_PROFILE, - TURBO_PROFILE, - LEVER_PROFILE, - ] - with patch( - "services.profile_recommendation_service.async_fetch_all_profiles", - new_callable=AsyncMock, - return_value=profiles, - ): - results = await service.get_recommendations( - tags=["preinfusion", "pressure"], limit=5 - ) - - if results: - # First result should be a pressure-controlled profile - top_names = [r["profile_name"] for r in results[:2]] - assert any( - "Italian" in n or "Lever" in n or "Simple" in n for n in top_names - ) - - @pytest.mark.asyncio - async def test_find_similar_structural(self, service): - """Find similar to a pressure profile should rank other pressure profiles higher.""" - profiles = [PRESSURE_PROFILE, FLOW_PROFILE, LEVER_PROFILE, TURBO_PROFILE] - with patch( - "services.profile_recommendation_service.async_fetch_all_profiles", - new_callable=AsyncMock, - return_value=profiles, - ): - results = await service.find_similar("Classic Italian Espresso", limit=5) - - if results: - # Lever should rank higher than flow/turbo for a pressure profile - lever_score = next( - (r["score"] for r in results if "Lever" in r["profile_name"]), 0 - ) - turbo_score = next( - (r["score"] for r in results if "Turbo" in r["profile_name"]), 0 - ) - assert lever_score > turbo_score diff --git a/apps/server/test_stage_boundary.py b/apps/server/test_stage_boundary.py deleted file mode 100644 index 0fe67b13..00000000 --- a/apps/server/test_stage_boundary.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Regression tests for boundary-aware stage exit-trigger evaluation. - -The Meticulous machine flips a telemetry sample's ``status`` to the next stage -on the control tick where the current stage's exit condition becomes true. That -means the sample which actually satisfies a rising ``pressure >= X`` / ``flow ->= X`` trigger is labeled as the FIRST sample of the next stage. Without -boundary awareness, the exiting stage under-reports its exit metric and is -falsely assessed as "failed" even though the target was reached exactly at the -transition. -""" - -from services.analysis_service import ( - _analyze_stage_execution, - _extract_shot_stage_data, -) - - -def _build_shot(samples): - data = [] - t = 0 - for status, pressure, flow, weight in samples: - data.append( - { - "status": status, - "time": t, - "profile_time": t, - "shot": {"pressure": pressure, "flow": flow, "weight": weight}, - } - ) - t += 130 - return {"data": data} - - -def test_pressure_target_reached_at_transition_is_not_failed(): - """A Fill stage that exits exactly when pressure hits 3 bar (the spike lands - on the first Bloom sample) must read as reached_goal, not failed.""" - shot = _build_shot( - [("Fill", p, 7.5, 0.0) for p in (0.2, 0.5, 0.9, 1.3, 1.7, 2.0, 2.2)] - + [("Bloom", p, 1.0, 0.5) for p in (3.1, 3.4, 2.0, 1.5, 1.2)] - ) - stages = _extract_shot_stage_data(shot) - - # The in-stage max stays at 2.2; the crossing sample (3.1) is labeled Bloom. - assert round(stages["Fill"]["max_pressure"], 1) == 2.2 - assert round(stages["Fill"]["boundary_pressure"], 1) == 3.1 - - profile_stage = { - "name": "Fill", - "type": "flow", - "exit_triggers": [{"type": "pressure", "value": 3, "comparison": ">="}], - "limits": [], - "dynamics": {"points": [[0, 8.1]]}, - } - result = _analyze_stage_execution(profile_stage, stages["Fill"], 5.0, []) - - assert result["assessment"]["status"] == "reached_goal" - triggered = result["exit_trigger_result"]["triggered"] - assert triggered is not None - assert triggered["type"] == "pressure" - assert triggered["actual"] >= 3.0 - - -def test_flow_target_reached_at_transition_is_not_failed(): - """A pressure stage exiting on a rising flow trigger is credited for the - transition-sample flow value.""" - shot = _build_shot( - [("Ramp", 6.0, f, 0.0) for f in (0.5, 1.0, 1.5, 1.8)] - + [("Hold", 6.0, f, 1.0) for f in (2.6, 2.4, 2.2)] - ) - stages = _extract_shot_stage_data(shot) - - profile_stage = { - "name": "Ramp", - "type": "pressure", - "exit_triggers": [{"type": "flow", "value": 2.5, "comparison": ">="}], - "limits": [], - "dynamics": {"points": [[0, 6.0]]}, - } - result = _analyze_stage_execution(profile_stage, stages["Ramp"], 5.0, []) - assert result["assessment"]["status"] == "reached_goal" - - -def test_genuinely_unreached_target_still_fails(): - """When neither the stage nor the transition sample reaches the target, the - stage must still be reported as failed (no false positive from the fix).""" - shot = _build_shot( - [("Fill", p, 7.5, 0.0) for p in (0.2, 0.5, 0.9, 1.3, 1.7)] - + [("Bloom", p, 1.0, 0.5) for p in (1.9, 1.8, 1.5)] - ) - stages = _extract_shot_stage_data(shot) - profile_stage = { - "name": "Fill", - "type": "flow", - "exit_triggers": [{"type": "pressure", "value": 3, "comparison": ">="}], - "limits": [], - "dynamics": {"points": [[0, 8.1]]}, - } - result = _analyze_stage_execution(profile_stage, stages["Fill"], 5.0, []) - assert result["assessment"]["status"] == "failed" - - -def test_last_stage_has_no_boundary(): - """The final stage has no following stage, so no boundary keys are set.""" - shot = _build_shot([("Fill", 1.0, 7.5, 0.0), ("Bloom", 2.0, 1.0, 0.5)]) - stages = _extract_shot_stage_data(shot) - assert "boundary_pressure" in stages["Fill"] - assert "boundary_pressure" not in stages["Bloom"] - - -def test_in_stage_satisfied_trigger_keeps_in_stage_actual(): - """Rescue is only for triggers the in-stage value falls short of. A weight - trigger already satisfied in-stage must report the in-stage value, not the - (larger, monotonically accumulating) boundary weight.""" - shot = _build_shot( - [("Fill", 1.0, 7.5, w) for w in (0.5, 1.0, 1.5, 2.0)] - + [("Bloom", 2.0, 1.0, w) for w in (5.0, 6.0, 7.0)] - ) - stages = _extract_shot_stage_data(shot) - profile_stage = { - "name": "Fill", - "type": "flow", - "exit_triggers": [{"type": "weight", "value": 2, "comparison": ">="}], - "limits": [], - "dynamics": {"points": [[0, 8.1]]}, - } - result = _analyze_stage_execution(profile_stage, stages["Fill"], 5.0, []) - triggered = result["exit_trigger_result"]["triggered"] - assert triggered is not None - assert triggered["actual"] == 2.0 - diff --git a/apps/server/test_taste_compass.py b/apps/server/test_taste_compass.py deleted file mode 100644 index 61f65af0..00000000 --- a/apps/server/test_taste_compass.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Tests for Espresso Compass (taste feedback) feature. - -Covers: -- Taste context prompt building -- Cache differentiation with taste hash -- Backward compatibility (no taste params) -- analyze_shot_with_llm taste parameter plumbing -""" - -import os -import sys - - -sys.path.insert(0, os.path.dirname(__file__)) -os.environ.setdefault("TEST_MODE", "true") - -from prompt_builder import build_taste_context, _describe_axis_value -from services.gemini_service import compute_taste_hash - - -# ============================================================================ -# build_taste_context tests -# ============================================================================ - - -class TestBuildTasteContext: - """Tests for prompt_builder.build_taste_context.""" - - def test_returns_empty_when_no_data(self): - assert build_taste_context(None, None, None) == "" - - def test_returns_empty_when_no_coords_and_no_descriptors(self): - assert build_taste_context(None, None, []) == "" - - def test_includes_balance_and_body_when_coords_present(self): - result = build_taste_context(-0.3, 0.7, None) - assert "Balance:" in result - assert "Body:" in result - assert "X: -0.30" in result - assert "Y: 0.70" in result - - def test_includes_descriptors_when_present(self): - result = build_taste_context(None, None, ["Sweet", "Complex"]) - assert "Descriptors: Sweet, Complex" in result - - def test_includes_both_coords_and_descriptors(self): - result = build_taste_context(0.5, -0.2, ["Bitter", "Harsh"]) - assert "Balance:" in result - assert "Body:" in result - assert "Descriptors: Bitter, Harsh" in result - - def test_includes_domain_knowledge(self): - result = build_taste_context(0.5, 0.5, ["Sweet"]) - assert "under-extraction" in result - assert "over-extraction" in result - assert "increase dose" in result.lower() or "Weak/Thin" in result - - def test_includes_taste_section_instruction(self): - result = build_taste_context(0.1, 0.1, ["Clean"]) - assert "Taste-Based Recommendations" in result - - def test_center_values_show_balanced(self): - result = build_taste_context(0.0, 0.0, None) - assert "Balanced" in result - - def test_extreme_sour(self): - result = build_taste_context(-0.9, 0.0, None) - assert "Very" in result and "Sour" in result - - def test_extreme_bitter(self): - result = build_taste_context(0.9, 0.0, None) - assert "Very" in result and "Bitter" in result - - def test_includes_quadrant_knowledge(self): - result = build_taste_context(0.5, 0.5, None) - assert "Quadrant:" in result - - -class TestDescribeAxisValue: - """Tests for prompt_builder._describe_axis_value.""" - - def test_balanced_near_zero(self): - assert _describe_axis_value(0.05, "Sour", "Bitter") == "Balanced" - - def test_slightly_positive(self): - assert _describe_axis_value(0.25, "Sour", "Bitter") == "Slightly Bitter" - - def test_slightly_negative(self): - assert _describe_axis_value(-0.25, "Sour", "Bitter") == "Slightly Sour" - - def test_moderately_positive(self): - assert _describe_axis_value(0.55, "Weak", "Strong") == "Moderately Strong" - - def test_very_negative(self): - assert _describe_axis_value(-0.85, "Weak", "Strong") == "Very Weak" - - -# ============================================================================ -# compute_taste_hash tests -# ============================================================================ - - -class TestComputeTasteHash: - """Tests for gemini_service.compute_taste_hash.""" - - def test_returns_none_when_no_data(self): - assert compute_taste_hash(None, None, None) is None - - def test_returns_none_when_no_coords_and_empty_descriptors(self): - assert compute_taste_hash(None, None, []) is None - - def test_returns_hash_with_coords_only(self): - h = compute_taste_hash(0.5, -0.3, None) - assert h is not None - assert isinstance(h, str) - assert len(h) == 12 - - def test_returns_hash_with_descriptors_only(self): - h = compute_taste_hash(None, None, ["Sweet", "Clean"]) - assert h is not None - assert len(h) == 12 - - def test_different_coords_produce_different_hashes(self): - h1 = compute_taste_hash(0.5, 0.5, None) - h2 = compute_taste_hash(-0.5, 0.5, None) - assert h1 != h2 - - def test_different_descriptors_produce_different_hashes(self): - h1 = compute_taste_hash(0.5, 0.5, ["Sweet"]) - h2 = compute_taste_hash(0.5, 0.5, ["Bitter"]) - assert h1 != h2 - - def test_same_input_produces_same_hash(self): - h1 = compute_taste_hash(0.5, 0.5, ["Sweet", "Clean"]) - h2 = compute_taste_hash(0.5, 0.5, ["Sweet", "Clean"]) - assert h1 == h2 - - def test_descriptor_order_does_not_matter(self): - """Descriptors are sorted internally, so order shouldn't affect hash.""" - h1 = compute_taste_hash(0.5, 0.5, ["Clean", "Sweet"]) - h2 = compute_taste_hash(0.5, 0.5, ["Sweet", "Clean"]) - assert h1 == h2 - - def test_coords_without_descriptors_differs_from_with(self): - h1 = compute_taste_hash(0.5, 0.5, None) - h2 = compute_taste_hash(0.5, 0.5, ["Sweet"]) - assert h1 != h2 - - -# ============================================================================ -# Cache differentiation integration test -# ============================================================================ - - -class TestCacheDifferentiation: - """Test that the cache key generation works for taste-aware analysis.""" - - def test_no_taste_returns_original_filename(self): - taste_hash = compute_taste_hash(None, None, None) - shot_filename = "shot_2024-01-01.json" - cache_filename = ( - f"{shot_filename}_taste_{taste_hash}" if taste_hash else shot_filename - ) - assert cache_filename == shot_filename - - def test_with_taste_appends_hash_to_filename(self): - taste_hash = compute_taste_hash(0.5, -0.3, ["Sweet"]) - shot_filename = "shot_2024-01-01.json" - cache_filename = ( - f"{shot_filename}_taste_{taste_hash}" if taste_hash else shot_filename - ) - assert cache_filename != shot_filename - assert "_taste_" in cache_filename - assert cache_filename.startswith(shot_filename) - - def test_different_taste_different_cache_keys(self): - h1 = compute_taste_hash(0.5, 0.5, ["Sweet"]) - h2 = compute_taste_hash(-0.5, -0.5, ["Bitter"]) - filename = "shot.json" - c1 = f"{filename}_taste_{h1}" - c2 = f"{filename}_taste_{h2}" - assert c1 != c2 - - -# ============================================================================ -# Backward compatibility -# ============================================================================ - - -class TestBackwardCompatibility: - """Verify that the new taste params don't break existing behavior.""" - - def test_build_taste_context_graceful_with_none(self): - """No taste data should produce empty string, not crash.""" - assert build_taste_context(None, None, None) == "" - - def test_compute_taste_hash_graceful_with_none(self): - """No taste data should produce None hash.""" - assert compute_taste_hash(None, None, None) is None - - def test_prompt_omits_taste_when_no_data(self): - """Taste context should be empty string when no data present.""" - ctx = build_taste_context(None, None, None) - assert ctx == "" - # This means the prompt will have no taste section - - def test_prompt_includes_taste_when_data_present(self): - """Taste context should be non-empty when data present.""" - ctx = build_taste_context(0.3, -0.5, ["Juicy"]) - assert len(ctx) > 0 - assert "Taste Feedback" in ctx diff --git a/apps/server/tools/__init__.py b/apps/server/tools/__init__.py deleted file mode 100644 index 7c0e092f..00000000 --- a/apps/server/tools/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Developer tools for tinkering with Metic's shot-analysis algorithm. - -Run the shot analyzer from the ``apps/server`` directory: - - python -m tools.analyze_shot [profile.json] - -See ``docs/local-shot-analysis.md`` for the full guide. -""" diff --git a/apps/server/tools/analyze_shot.py b/apps/server/tools/analyze_shot.py deleted file mode 100644 index 4d929b8e..00000000 --- a/apps/server/tools/analyze_shot.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Command-line runner for Metic's deterministic (no-LLM) shot analysis. - -This wraps the exact same algorithm the app uses in production -(``analysis_service._perform_local_shot_analysis`` → -``shot_facts.build_shot_facts``) so you can run it against real shot logs -and profiles from the terminal — perfect for tinkering with the -stage-classification / effective-control-mode / puck-failure logic without -touching the UI. - -Usage (from ``apps/server``): - - python -m tools.analyze_shot [profile.json] - python -m tools.analyze_shot [profile.json] --json - -- ``shot.json`` A machine shot log (the object with a ``data`` array; it may - also embed its own ``profile``). -- ``profile.json`` Optional. The profile used for the shot. When omitted, the - profile embedded in the shot file is used. - -Where the algorithm lives (edit these to tinker): -- ``services/analysis_service.py`` — stage execution + curve extraction. -- ``services/shot_facts.py`` — control-mode / trigger / stall / puck logic. -""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import Any - -from services.analysis_service import _perform_local_shot_analysis - - -def load_json(path: str) -> dict[str, Any]: - """Load and parse a JSON file, raising a clear error on failure.""" - data = json.loads(Path(path).read_text(encoding="utf-8")) - if not isinstance(data, dict): - raise ValueError(f"{path}: expected a JSON object, got {type(data).__name__}") - return data - - -def run_analysis(shot_path: str, profile_path: str | None = None) -> dict[str, Any]: - """Load the shot (and profile) and run the local analysis pipeline. - - When ``profile_path`` is omitted, the profile embedded in the shot file - (``shot["profile"]``) is used. Returns the full analysis dict, including - the deterministic ``shot_facts`` object. - """ - shot_data = load_json(shot_path) - if profile_path: - profile_data = load_json(profile_path) - else: - profile_data = shot_data.get("profile") or {} - if not profile_data: - raise ValueError( - f"{shot_path} has no embedded 'profile'; pass a profile file as " - "the second argument." - ) - return _perform_local_shot_analysis(shot_data, profile_data) - - -def _fmt(value: Any) -> str: - return "—" if value is None else str(value) - - -def format_report(analysis: dict[str, Any]) -> str: - """Render a concise, human-readable report of the analysis.""" - lines: list[str] = [] - summary = analysis.get("shot_summary", {}) - profile_info = analysis.get("profile_info", {}) - facts = analysis.get("shot_facts", {}) - - lines.append(f"Profile: {_fmt(profile_info.get('name'))}") - lines.append(f"Temperature: {_fmt(profile_info.get('temperature'))} °C") - lines.append( - "Weight: " - f"{_fmt(summary.get('final_weight'))} g " - f"(target {_fmt(summary.get('target_weight'))} g)" - ) - lines.append(f"Total time: {_fmt(summary.get('total_time'))} s") - lines.append(f"Max pressure: {_fmt(summary.get('max_pressure'))} bar") - lines.append(f"Max flow: {_fmt(summary.get('max_flow'))} ml/s") - lines.append("") - lines.append("Stage facts") - lines.append("-----------") - - for stage in facts.get("stages", []): - name = _fmt(stage.get("stage_name")) - if not stage.get("reached"): - lines.append(f"• {name}: not reached") - continue - declared = _fmt(stage.get("declared_mode")) - effective = _fmt(stage.get("control_mode")) - mode = effective - if stage.get("mode_overridden"): - mode = f"{effective} (declared {declared})" - trig_class = stage.get("trigger_class") or {} - stall = stage.get("stall") or {} - chan = stage.get("channeling") or {} - curve = stage.get("curve_adherence") - - lines.append(f"• {name}") - lines.append(f" control mode: {mode}") - lines.append( - " trigger: " - f"{_fmt(stage.get('trigger_type'))} → {_fmt(trig_class.get('label'))}" - ) - if trig_class.get("reason"): - lines.append(f" {trig_class['reason']}") - lines.append( - " stall: " - f"{'yes' if stall.get('stalled') else 'no'} " - f"(weight gain {_fmt(stall.get('weight_gain'))} g)" - ) - if isinstance(chan, dict) and chan.get("detected") is not None: - lines.append(f" channeling: {'yes' if chan.get('detected') else 'no'}") - if isinstance(curve, dict): - lines.append( - " curve delta: " - f"target {_fmt(curve.get('target'))}, " - f"measured {_fmt(curve.get('measured'))}, " - f"Δ {_fmt(curve.get('delta'))}" - ) - - return "\n".join(lines) - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser( - prog="python -m tools.analyze_shot", - description="Run Metic's deterministic shot analysis on a shot log.", - ) - parser.add_argument("shot", help="Path to a machine shot-log JSON file.") - parser.add_argument( - "profile", - nargs="?", - default=None, - help="Path to a profile JSON file (defaults to the shot's embedded profile).", - ) - parser.add_argument( - "--json", - action="store_true", - help="Print the full analysis object as JSON instead of a report.", - ) - args = parser.parse_args(argv) - - try: - analysis = run_analysis(args.shot, args.profile) - except (OSError, ValueError, json.JSONDecodeError) as exc: - print(f"error: {exc}", file=sys.stderr) - return 1 - - if args.json: - print(json.dumps(analysis, indent=2, ensure_ascii=False)) - else: - print(format_report(analysis)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/apps/server/tools/samples/slayer_at_home.shot.json b/apps/server/tools/samples/slayer_at_home.shot.json deleted file mode 100644 index 5e7f8586..00000000 --- a/apps/server/tools/samples/slayer_at_home.shot.json +++ /dev/null @@ -1 +0,0 @@ -{"time": 1782971169.204821, "profile_name": "Slayer at Home", "data": [{"shot": {"pressure": 0.0, "flow": 2.55, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "pressure": 0.0}}, "time": 47, "profile_time": 47, "status": "PreBrew", "sensors": {"external_1": 107.28, "external_2": 106.44, "bar_up": 83.06, "bar_mid_up": 87.93, "bar_mid_down": 87.93, "bar_down": 86.72, "tube": 86.42, "motor_temp": 30.84, "lam_temp": 29.28, "motor_position": 34.84, "motor_speed": 2.22, "motor_power": 17.98, "motor_current": 0.1, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 3.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 131, "profile_time": 131, "status": "PreBrew", "sensors": {"external_1": 107.21, "external_2": 106.4, "bar_up": 82.87, "bar_mid_up": 87.73, "bar_mid_down": 87.93, "bar_down": 86.52, "tube": 86.37, "motor_temp": 30.82, "lam_temp": 29.28, "motor_position": 35.04, "motor_speed": 1.71, "motor_power": 16.04, "motor_current": 0.51, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 3.82, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 263, "profile_time": 263, "status": "PreBrew", "sensors": {"external_1": 107.18, "external_2": 106.37, "bar_up": 82.87, "bar_mid_up": 87.93, "bar_mid_down": 87.73, "bar_down": 86.72, "tube": 86.35, "motor_temp": 30.82, "lam_temp": 29.28, "motor_position": 35.24, "motor_speed": 1.45, "motor_power": 13.54, "motor_current": 0.45, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 4.46, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 392, "profile_time": 392, "status": "PreBrew", "sensors": {"external_1": 107.18, "external_2": 106.33, "bar_up": 82.69, "bar_mid_up": 87.73, "bar_mid_down": 87.73, "bar_down": 86.52, "tube": 86.23, "motor_temp": 30.84, "lam_temp": 29.28, "motor_position": 35.34, "motor_speed": 1.26, "motor_power": 12.4, "motor_current": 0.49, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 4.97, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 527, "profile_time": 527, "status": "PreBrew", "sensors": {"external_1": 107.11, "external_2": 106.26, "bar_up": 82.5, "bar_mid_up": 87.73, "bar_mid_down": 87.73, "bar_down": 86.52, "tube": 86.19, "motor_temp": 30.84, "lam_temp": 29.28, "motor_position": 35.44, "motor_speed": 1.11, "motor_power": 11.41, "motor_current": 0.49, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 5.41, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 656, "profile_time": 656, "status": "PreBrew", "sensors": {"external_1": 107.07, "external_2": 106.23, "bar_up": 82.5, "bar_mid_up": 87.73, "bar_mid_down": 87.73, "bar_down": 86.52, "tube": 86.14, "motor_temp": 30.84, "lam_temp": 29.28, "motor_position": 35.64, "motor_speed": 0.87, "motor_power": 9.65, "motor_current": 0.49, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 5.62, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 770, "profile_time": 770, "status": "PreBrew", "sensors": {"external_1": 107.04, "external_2": 106.19, "bar_up": 82.5, "bar_mid_up": 87.73, "bar_mid_down": 87.73, "bar_down": 86.32, "tube": 86.12, "motor_temp": 30.84, "lam_temp": 29.25, "motor_position": 35.64, "motor_speed": 0.81, "motor_power": 9.02, "motor_current": 0.47, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 5.59, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 901, "profile_time": 901, "status": "PreBrew", "sensors": {"external_1": 107.04, "external_2": 106.19, "bar_up": 82.32, "bar_mid_up": 87.52, "bar_mid_down": 87.73, "bar_down": 86.32, "tube": 86.06, "motor_temp": 30.87, "lam_temp": 29.28, "motor_position": 35.74, "motor_speed": 0.69, "motor_power": 9.05, "motor_current": 0.48, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 5.51, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 1025, "profile_time": 1025, "status": "PreBrew", "sensors": {"external_1": 107.0, "external_2": 106.16, "bar_up": 82.32, "bar_mid_up": 87.52, "bar_mid_down": 87.52, "bar_down": 86.32, "tube": 86.01, "motor_temp": 30.87, "lam_temp": 29.28, "motor_position": 35.84, "motor_speed": 0.62, "motor_power": 8.63, "motor_current": 0.45, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 5.3, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 1153, "profile_time": 1153, "status": "PreBrew", "sensors": {"external_1": 106.97, "external_2": 106.09, "bar_up": 82.14, "bar_mid_up": 87.52, "bar_mid_down": 87.52, "bar_down": 86.12, "tube": 85.95, "motor_temp": 30.87, "lam_temp": 29.3, "motor_position": 35.94, "motor_speed": 0.56, "motor_power": 8.56, "motor_current": 0.49, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 5.05, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 1277, "profile_time": 1277, "status": "PreBrew", "sensors": {"external_1": 106.89, "external_2": 106.06, "bar_up": 82.14, "bar_mid_up": 87.52, "bar_mid_down": 87.52, "bar_down": 86.32, "tube": 85.9, "motor_temp": 30.84, "lam_temp": 29.3, "motor_position": 35.94, "motor_speed": 0.56, "motor_power": 8.53, "motor_current": 0.46, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 4.77, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 1416, "profile_time": 1416, "status": "PreBrew", "sensors": {"external_1": 106.79, "external_2": 105.95, "bar_up": 81.96, "bar_mid_up": 87.52, "bar_mid_down": 87.52, "bar_down": 86.12, "tube": 85.83, "motor_temp": 30.82, "lam_temp": 29.28, "motor_position": 36.04, "motor_speed": 0.55, "motor_power": 8.53, "motor_current": 0.47, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 4.51, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 1543, "profile_time": 1543, "status": "PreBrew", "sensors": {"external_1": 106.75, "external_2": 105.95, "bar_up": 81.96, "bar_mid_up": 87.52, "bar_mid_down": 87.52, "bar_down": 85.92, "tube": 85.8, "motor_temp": 30.82, "lam_temp": 29.28, "motor_position": 36.14, "motor_speed": 0.54, "motor_power": 8.52, "motor_current": 0.45, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 4.1, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 1676, "profile_time": 1676, "status": "PreBrew", "sensors": {"external_1": 106.68, "external_2": 105.88, "bar_up": 81.96, "bar_mid_up": 87.52, "bar_mid_down": 87.52, "bar_down": 86.12, "tube": 85.78, "motor_temp": 30.82, "lam_temp": 29.28, "motor_position": 36.14, "motor_speed": 0.54, "motor_power": 8.53, "motor_current": 0.48, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 3.77, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 1815, "profile_time": 1815, "status": "PreBrew", "sensors": {"external_1": 106.65, "external_2": 105.85, "bar_up": 81.77, "bar_mid_up": 87.52, "bar_mid_down": 87.52, "bar_down": 85.92, "tube": 85.75, "motor_temp": 30.84, "lam_temp": 29.3, "motor_position": 36.24, "motor_speed": 0.52, "motor_power": 8.62, "motor_current": 0.49, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 3.44, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 1932, "profile_time": 1932, "status": "PreBrew", "sensors": {"external_1": 106.58, "external_2": 105.81, "bar_up": 81.77, "bar_mid_up": 87.32, "bar_mid_down": 87.32, "bar_down": 85.92, "tube": 85.71, "motor_temp": 30.91, "lam_temp": 29.33, "motor_position": 36.35, "motor_speed": 0.52, "motor_power": 8.66, "motor_current": 0.45, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 3.09, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 2065, "profile_time": 2065, "status": "PreBrew", "sensors": {"external_1": 106.58, "external_2": 105.81, "bar_up": 81.77, "bar_mid_up": 87.32, "bar_mid_down": 87.32, "bar_down": 85.92, "tube": 85.66, "motor_temp": 30.94, "lam_temp": 29.33, "motor_position": 36.35, "motor_speed": 0.52, "motor_power": 8.72, "motor_current": 0.49, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 2.8, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 2198, "profile_time": 2198, "status": "PreBrew", "sensors": {"external_1": 106.54, "external_2": 105.74, "bar_up": 81.59, "bar_mid_up": 87.32, "bar_mid_down": 87.32, "bar_down": 85.73, "tube": 85.61, "motor_temp": 30.91, "lam_temp": 29.3, "motor_position": 36.45, "motor_speed": 0.53, "motor_power": 8.79, "motor_current": 0.48, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 2.54, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 2338, "profile_time": 2338, "status": "PreBrew", "sensors": {"external_1": 106.51, "external_2": 105.67, "bar_up": 81.59, "bar_mid_up": 87.32, "bar_mid_down": 87.32, "bar_down": 85.73, "tube": 85.54, "motor_temp": 30.87, "lam_temp": 29.28, "motor_position": 36.55, "motor_speed": 0.51, "motor_power": 8.88, "motor_current": 0.47, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 2.26, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 2453, "profile_time": 2453, "status": "PreBrew", "sensors": {"external_1": 106.4, "external_2": 105.6, "bar_up": 81.59, "bar_mid_up": 87.32, "bar_mid_down": 87.32, "bar_down": 85.53, "tube": 85.48, "motor_temp": 30.84, "lam_temp": 29.28, "motor_position": 36.55, "motor_speed": 0.51, "motor_power": 8.95, "motor_current": 0.51, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 2.02, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 2592, "profile_time": 2592, "status": "PreBrew", "sensors": {"external_1": 106.4, "external_2": 105.57, "bar_up": 81.41, "bar_mid_up": 87.12, "bar_mid_down": 87.12, "bar_down": 85.53, "tube": 85.43, "motor_temp": 30.87, "lam_temp": 29.3, "motor_position": 36.65, "motor_speed": 0.5, "motor_power": 9.06, "motor_current": 0.51, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.78, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 2718, "profile_time": 2718, "status": "PreBrew", "sensors": {"external_1": 106.37, "external_2": 105.57, "bar_up": 81.41, "bar_mid_up": 87.12, "bar_mid_down": 87.32, "bar_down": 85.53, "tube": 85.39, "motor_temp": 30.87, "lam_temp": 29.33, "motor_position": 36.75, "motor_speed": 0.5, "motor_power": 9.23, "motor_current": 0.49, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.53, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 2833, "profile_time": 2833, "status": "PreBrew", "sensors": {"external_1": 106.33, "external_2": 105.53, "bar_up": 81.41, "bar_mid_up": 87.12, "bar_mid_down": 87.12, "bar_down": 85.53, "tube": 85.34, "motor_temp": 30.89, "lam_temp": 29.33, "motor_position": 36.75, "motor_speed": 0.5, "motor_power": 9.3, "motor_current": 0.5, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.42, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 2957, "profile_time": 2957, "status": "PreBrew", "sensors": {"external_1": 106.3, "external_2": 105.5, "bar_up": 81.23, "bar_mid_up": 87.12, "bar_mid_down": 87.12, "bar_down": 85.53, "tube": 85.3, "motor_temp": 30.89, "lam_temp": 29.33, "motor_position": 36.85, "motor_speed": 0.53, "motor_power": 9.3, "motor_current": 0.5, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.32, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 3084, "profile_time": 3084, "status": "PreBrew", "sensors": {"external_1": 106.26, "external_2": 105.46, "bar_up": 81.23, "bar_mid_up": 87.32, "bar_mid_down": 87.12, "bar_down": 85.34, "tube": 85.27, "motor_temp": 30.89, "lam_temp": 29.3, "motor_position": 36.95, "motor_speed": 0.53, "motor_power": 9.35, "motor_current": 0.49, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.28, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 3217, "profile_time": 3217, "status": "PreBrew", "sensors": {"external_1": 106.23, "external_2": 105.43, "bar_up": 81.23, "bar_mid_up": 87.12, "bar_mid_down": 87.32, "bar_down": 85.34, "tube": 85.26, "motor_temp": 30.89, "lam_temp": 29.3, "motor_position": 36.95, "motor_speed": 0.53, "motor_power": 9.38, "motor_current": 0.53, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.25, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 3348, "profile_time": 3348, "status": "PreBrew", "sensors": {"external_1": 106.19, "external_2": 105.39, "bar_up": 81.23, "bar_mid_up": 87.12, "bar_mid_down": 87.12, "bar_down": 85.34, "tube": 85.24, "motor_temp": 30.89, "lam_temp": 29.3, "motor_position": 37.05, "motor_speed": 0.53, "motor_power": 9.4, "motor_current": 0.5, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.23, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 3471, "profile_time": 3471, "status": "PreBrew", "sensors": {"external_1": 106.13, "external_2": 105.29, "bar_up": 81.06, "bar_mid_up": 86.92, "bar_mid_down": 87.12, "bar_down": 85.14, "tube": 85.17, "motor_temp": 30.89, "lam_temp": 29.33, "motor_position": 37.15, "motor_speed": 0.54, "motor_power": 9.43, "motor_current": 0.51, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.21, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 3604, "profile_time": 3604, "status": "PreBrew", "sensors": {"external_1": 106.13, "external_2": 105.26, "bar_up": 81.06, "bar_mid_up": 87.12, "bar_mid_down": 86.92, "bar_down": 85.14, "tube": 85.12, "motor_temp": 30.91, "lam_temp": 29.35, "motor_position": 37.15, "motor_speed": 0.54, "motor_power": 9.44, "motor_current": 0.52, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 3727, "profile_time": 3727, "status": "PreBrew", "sensors": {"external_1": 106.09, "external_2": 105.22, "bar_up": 81.06, "bar_mid_up": 86.92, "bar_mid_down": 87.12, "bar_down": 85.14, "tube": 85.08, "motor_temp": 30.91, "lam_temp": 29.35, "motor_position": 37.25, "motor_speed": 0.54, "motor_power": 9.45, "motor_current": 0.51, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 3865, "profile_time": 3865, "status": "PreBrew", "sensors": {"external_1": 106.02, "external_2": 105.19, "bar_up": 81.06, "bar_mid_up": 86.92, "bar_mid_down": 87.12, "bar_down": 85.14, "tube": 85.06, "motor_temp": 30.91, "lam_temp": 29.35, "motor_position": 37.35, "motor_speed": 0.53, "motor_power": 9.49, "motor_current": 0.5, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 3987, "profile_time": 3987, "status": "PreBrew", "sensors": {"external_1": 105.92, "external_2": 105.08, "bar_up": 80.88, "bar_mid_up": 86.92, "bar_mid_down": 86.92, "bar_down": 84.95, "tube": 84.99, "motor_temp": 30.87, "lam_temp": 29.33, "motor_position": 37.45, "motor_speed": 0.53, "motor_power": 9.5, "motor_current": 0.51, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.16, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 4126, "profile_time": 4126, "status": "PreBrew", "sensors": {"external_1": 105.88, "external_2": 105.05, "bar_up": 80.88, "bar_mid_up": 86.72, "bar_mid_down": 86.92, "bar_down": 84.95, "tube": 84.94, "motor_temp": 30.87, "lam_temp": 29.33, "motor_position": 37.45, "motor_speed": 0.53, "motor_power": 9.54, "motor_current": 0.53, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.15, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 4238, "profile_time": 4238, "status": "PreBrew", "sensors": {"external_1": 105.88, "external_2": 105.05, "bar_up": 80.7, "bar_mid_up": 86.72, "bar_mid_down": 86.92, "bar_down": 84.76, "tube": 84.87, "motor_temp": 30.84, "lam_temp": 29.33, "motor_position": 37.55, "motor_speed": 0.53, "motor_power": 9.57, "motor_current": 0.5, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.13, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 4366, "profile_time": 4366, "status": "PreBrew", "sensors": {"external_1": 105.81, "external_2": 105.01, "bar_up": 80.7, "bar_mid_up": 86.72, "bar_mid_down": 86.92, "bar_down": 84.76, "tube": 84.8, "motor_temp": 30.84, "lam_temp": 29.33, "motor_position": 37.65, "motor_speed": 0.52, "motor_power": 9.6, "motor_current": 0.53, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 4499, "profile_time": 4499, "status": "PreBrew", "sensors": {"external_1": 105.78, "external_2": 104.94, "bar_up": 80.7, "bar_mid_up": 86.72, "bar_mid_down": 86.92, "bar_down": 84.76, "tube": 84.77, "motor_temp": 30.82, "lam_temp": 29.33, "motor_position": 37.65, "motor_speed": 0.52, "motor_power": 9.72, "motor_current": 0.53, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.11, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 4636, "profile_time": 4636, "status": "PreBrew", "sensors": {"external_1": 105.71, "external_2": 104.91, "bar_up": 80.52, "bar_mid_up": 86.72, "bar_mid_down": 86.92, "bar_down": 84.56, "tube": 84.75, "motor_temp": 30.82, "lam_temp": 29.33, "motor_position": 37.75, "motor_speed": 0.51, "motor_power": 9.78, "motor_current": 0.53, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.1, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 4763, "profile_time": 4763, "status": "PreBrew", "sensors": {"external_1": 105.71, "external_2": 104.87, "bar_up": 80.52, "bar_mid_up": 86.52, "bar_mid_down": 86.92, "bar_down": 84.56, "tube": 84.71, "motor_temp": 30.82, "lam_temp": 29.33, "motor_position": 37.85, "motor_speed": 0.51, "motor_power": 9.84, "motor_current": 0.52, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.08, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 4898, "profile_time": 4898, "status": "PreBrew", "sensors": {"external_1": 105.6, "external_2": 104.81, "bar_up": 80.52, "bar_mid_up": 86.52, "bar_mid_down": 86.92, "bar_down": 84.56, "tube": 84.66, "motor_temp": 30.84, "lam_temp": 29.33, "motor_position": 37.85, "motor_speed": 0.51, "motor_power": 9.96, "motor_current": 0.54, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.08, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 5030, "profile_time": 5030, "status": "PreBrew", "sensors": {"external_1": 105.57, "external_2": 104.77, "bar_up": 80.35, "bar_mid_up": 86.52, "bar_mid_down": 86.72, "bar_down": 84.37, "tube": 84.61, "motor_temp": 30.84, "lam_temp": 29.3, "motor_position": 37.95, "motor_speed": 0.52, "motor_power": 9.99, "motor_current": 0.53, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.08, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 5167, "profile_time": 5167, "status": "PreBrew", "sensors": {"external_1": 105.53, "external_2": 104.77, "bar_up": 80.35, "bar_mid_up": 86.52, "bar_mid_down": 86.72, "bar_down": 84.37, "tube": 84.57, "motor_temp": 30.87, "lam_temp": 29.3, "motor_position": 38.05, "motor_speed": 0.53, "motor_power": 10.05, "motor_current": 1.07, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.08, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 5297, "profile_time": 5297, "status": "PreBrew", "sensors": {"external_1": 105.5, "external_2": 104.74, "bar_up": 80.35, "bar_mid_up": 86.52, "bar_mid_down": 86.72, "bar_down": 84.37, "tube": 84.52, "motor_temp": 30.89, "lam_temp": 29.3, "motor_position": 38.05, "motor_speed": 0.53, "motor_power": 10.08, "motor_current": 0.53, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.08, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 5411, "profile_time": 5411, "status": "PreBrew", "sensors": {"external_1": 105.46, "external_2": 104.63, "bar_up": 80.17, "bar_mid_up": 86.52, "bar_mid_down": 86.72, "bar_down": 84.18, "tube": 84.47, "motor_temp": 30.87, "lam_temp": 29.33, "motor_position": 38.15, "motor_speed": 0.54, "motor_power": 10.09, "motor_current": 0.53, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.08, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 5539, "profile_time": 5539, "status": "PreBrew", "sensors": {"external_1": 105.43, "external_2": 104.6, "bar_up": 80.17, "bar_mid_up": 86.32, "bar_mid_down": 86.72, "bar_down": 84.18, "tube": 84.43, "motor_temp": 30.84, "lam_temp": 29.33, "motor_position": 38.25, "motor_speed": 0.54, "motor_power": 10.12, "motor_current": 0.55, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.08, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 5672, "profile_time": 5672, "status": "PreBrew", "sensors": {"external_1": 105.39, "external_2": 104.56, "bar_up": 80.17, "bar_mid_up": 86.32, "bar_mid_down": 86.52, "bar_down": 84.18, "tube": 84.38, "motor_temp": 30.82, "lam_temp": 29.33, "motor_position": 38.25, "motor_speed": 0.54, "motor_power": 10.12, "motor_current": 0.55, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.08, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 5817, "profile_time": 5817, "status": "PreBrew", "sensors": {"external_1": 105.36, "external_2": 104.53, "bar_up": 80.0, "bar_mid_up": 86.52, "bar_mid_down": 86.72, "bar_down": 84.18, "tube": 84.34, "motor_temp": 30.87, "lam_temp": 29.33, "motor_position": 38.35, "motor_speed": 0.55, "motor_power": 10.11, "motor_current": 0.53, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.08, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 5934, "profile_time": 5934, "status": "PreBrew", "sensors": {"external_1": 105.32, "external_2": 104.46, "bar_up": 80.0, "bar_mid_up": 86.32, "bar_mid_down": 86.52, "bar_down": 83.99, "tube": 84.3, "motor_temp": 30.89, "lam_temp": 29.33, "motor_position": 38.45, "motor_speed": 0.54, "motor_power": 10.12, "motor_current": 0.55, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.09, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 6070, "profile_time": 6070, "status": "PreBrew", "sensors": {"external_1": 105.29, "external_2": 104.43, "bar_up": 80.0, "bar_mid_up": 86.32, "bar_mid_down": 86.52, "bar_down": 84.18, "tube": 84.29, "motor_temp": 30.89, "lam_temp": 29.33, "motor_position": 38.45, "motor_speed": 0.54, "motor_power": 10.13, "motor_current": 0.53, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.09, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 6183, "profile_time": 6183, "status": "PreBrew", "sensors": {"external_1": 105.26, "external_2": 104.39, "bar_up": 79.82, "bar_mid_up": 86.32, "bar_mid_down": 86.52, "bar_down": 83.99, "tube": 84.24, "motor_temp": 30.89, "lam_temp": 29.33, "motor_position": 38.55, "motor_speed": 0.55, "motor_power": 10.12, "motor_current": 0.54, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.1, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 6311, "profile_time": 6311, "status": "PreBrew", "sensors": {"external_1": 105.19, "external_2": 104.36, "bar_up": 80.0, "bar_mid_up": 86.12, "bar_mid_down": 86.52, "bar_down": 83.99, "tube": 84.18, "motor_temp": 30.82, "lam_temp": 29.33, "motor_position": 38.65, "motor_speed": 0.54, "motor_power": 10.13, "motor_current": 0.56, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.11, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 6443, "profile_time": 6443, "status": "PreBrew", "sensors": {"external_1": 105.15, "external_2": 104.32, "bar_up": 79.82, "bar_mid_up": 86.12, "bar_mid_down": 86.52, "bar_down": 83.99, "tube": 84.15, "motor_temp": 30.82, "lam_temp": 29.33, "motor_position": 38.76, "motor_speed": 0.54, "motor_power": 10.14, "motor_current": 0.55, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.0, "flow": 1.13, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 6584, "profile_time": 6584, "status": "PreBrew", "sensors": {"external_1": 105.12, "external_2": 104.29, "bar_up": 79.82, "bar_mid_up": 86.12, "bar_mid_down": 86.52, "bar_down": 83.8, "tube": 84.12, "motor_temp": 30.84, "lam_temp": 29.33, "motor_position": 38.76, "motor_speed": 0.54, "motor_power": 10.18, "motor_current": 0.56, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.3, "flow": 1.14, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 6705, "profile_time": 6705, "status": "PreBrew", "sensors": {"external_1": 105.08, "external_2": 104.25, "bar_up": 79.65, "bar_mid_up": 86.12, "bar_mid_down": 86.32, "bar_down": 83.8, "tube": 84.08, "motor_temp": 30.84, "lam_temp": 29.33, "motor_position": 38.86, "motor_speed": 0.53, "motor_power": 10.19, "motor_current": 0.56, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.31, "flow": 1.16, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 6850, "profile_time": 6850, "status": "PreBrew", "sensors": {"external_1": 105.05, "external_2": 104.15, "bar_up": 79.65, "bar_mid_up": 86.12, "bar_mid_down": 86.32, "bar_down": 83.8, "tube": 84.01, "motor_temp": 30.91, "lam_temp": 29.35, "motor_position": 38.96, "motor_speed": 0.54, "motor_power": 10.22, "motor_current": 0.54, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.32, "flow": 1.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 6962, "profile_time": 6962, "status": "PreBrew", "sensors": {"external_1": 105.05, "external_2": 104.12, "bar_up": 79.65, "bar_mid_up": 86.12, "bar_mid_down": 86.52, "bar_down": 83.62, "tube": 83.98, "motor_temp": 30.91, "lam_temp": 29.38, "motor_position": 38.96, "motor_speed": 0.54, "motor_power": 10.23, "motor_current": 0.57, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.34, "flow": 1.18, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 7091, "profile_time": 7091, "status": "PreBrew", "sensors": {"external_1": 105.05, "external_2": 104.08, "bar_up": 79.65, "bar_mid_up": 86.12, "bar_mid_down": 86.52, "bar_down": 83.62, "tube": 83.99, "motor_temp": 30.94, "lam_temp": 29.4, "motor_position": 39.06, "motor_speed": 0.53, "motor_power": 10.25, "motor_current": 0.55, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.34, "flow": 1.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 7223, "profile_time": 7223, "status": "PreBrew", "sensors": {"external_1": 105.01, "external_2": 104.08, "bar_up": 79.65, "bar_mid_up": 86.12, "bar_mid_down": 86.52, "bar_down": 83.62, "tube": 83.98, "motor_temp": 30.94, "lam_temp": 29.4, "motor_position": 39.16, "motor_speed": 0.53, "motor_power": 10.33, "motor_current": 0.56, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.35, "flow": 1.21, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 7352, "profile_time": 7352, "status": "PreBrew", "sensors": {"external_1": 104.94, "external_2": 104.05, "bar_up": 79.48, "bar_mid_up": 86.12, "bar_mid_down": 86.32, "bar_down": 83.62, "tube": 83.92, "motor_temp": 30.89, "lam_temp": 29.35, "motor_position": 39.16, "motor_speed": 0.53, "motor_power": 10.36, "motor_current": 0.58, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.36, "flow": 1.23, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 7496, "profile_time": 7496, "status": "PreBrew", "sensors": {"external_1": 104.91, "external_2": 104.05, "bar_up": 79.48, "bar_mid_up": 86.12, "bar_mid_down": 86.32, "bar_down": 83.43, "tube": 83.88, "motor_temp": 30.87, "lam_temp": 29.33, "motor_position": 39.26, "motor_speed": 0.53, "motor_power": 10.38, "motor_current": 0.56, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.37, "flow": 1.23, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 7612, "profile_time": 7612, "status": "PreBrew", "sensors": {"external_1": 104.87, "external_2": 104.01, "bar_up": 79.3, "bar_mid_up": 85.92, "bar_mid_down": 86.32, "bar_down": 83.43, "tube": 83.82, "motor_temp": 30.84, "lam_temp": 29.33, "motor_position": 39.36, "motor_speed": 0.55, "motor_power": 10.39, "motor_current": 0.55, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.38, "flow": 1.23, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 7742, "profile_time": 7742, "status": "PreBrew", "sensors": {"external_1": 104.84, "external_2": 104.01, "bar_up": 79.48, "bar_mid_up": 85.92, "bar_mid_down": 86.32, "bar_down": 83.62, "tube": 83.82, "motor_temp": 30.84, "lam_temp": 29.33, "motor_position": 39.36, "motor_speed": 0.55, "motor_power": 10.38, "motor_current": 0.55, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.38, "flow": 1.21, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 7868, "profile_time": 7868, "status": "PreBrew", "sensors": {"external_1": 104.77, "external_2": 103.91, "bar_up": 79.3, "bar_mid_up": 85.92, "bar_mid_down": 86.32, "bar_down": 83.43, "tube": 83.77, "motor_temp": 30.91, "lam_temp": 29.35, "motor_position": 39.46, "motor_speed": 0.55, "motor_power": 10.38, "motor_current": 0.57, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.39, "flow": 1.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 7992, "profile_time": 7992, "status": "PreBrew", "sensors": {"external_1": 104.77, "external_2": 103.88, "bar_up": 79.3, "bar_mid_up": 85.92, "bar_mid_down": 86.32, "bar_down": 83.43, "tube": 83.77, "motor_temp": 30.94, "lam_temp": 29.35, "motor_position": 39.56, "motor_speed": 0.55, "motor_power": 10.37, "motor_current": 0.55, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.4, "flow": 1.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 8129, "profile_time": 8129, "status": "PreBrew", "sensors": {"external_1": 104.74, "external_2": 103.88, "bar_up": 79.3, "bar_mid_up": 85.92, "bar_mid_down": 86.32, "bar_down": 83.24, "tube": 83.73, "motor_temp": 30.94, "lam_temp": 29.35, "motor_position": 39.66, "motor_speed": 0.54, "motor_power": 10.37, "motor_current": 0.56, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.41, "flow": 1.18, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 8263, "profile_time": 8263, "status": "PreBrew", "sensors": {"external_1": 104.67, "external_2": 103.81, "bar_up": 79.13, "bar_mid_up": 85.73, "bar_mid_down": 86.32, "bar_down": 83.24, "tube": 83.69, "motor_temp": 30.89, "lam_temp": 29.35, "motor_position": 39.66, "motor_speed": 0.54, "motor_power": 10.37, "motor_current": 0.57, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.43, "flow": 1.16, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 8392, "profile_time": 8392, "status": "PreBrew", "sensors": {"external_1": 104.6, "external_2": 103.77, "bar_up": 79.13, "bar_mid_up": 85.73, "bar_mid_down": 86.12, "bar_down": 83.24, "tube": 83.64, "motor_temp": 30.89, "lam_temp": 29.38, "motor_position": 39.76, "motor_speed": 0.54, "motor_power": 10.36, "motor_current": 0.56, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.44, "flow": 1.15, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 8528, "profile_time": 8528, "status": "PreBrew", "sensors": {"external_1": 104.6, "external_2": 103.7, "bar_up": 79.13, "bar_mid_up": 85.73, "bar_mid_down": 86.12, "bar_down": 83.24, "tube": 83.6, "motor_temp": 30.89, "lam_temp": 29.38, "motor_position": 39.86, "motor_speed": 0.55, "motor_power": 10.36, "motor_current": 0.56, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.45, "flow": 1.14, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 8671, "profile_time": 8671, "status": "PreBrew", "sensors": {"external_1": 104.6, "external_2": 103.67, "bar_up": 79.13, "bar_mid_up": 85.73, "bar_mid_down": 86.12, "bar_down": 83.06, "tube": 83.56, "motor_temp": 30.87, "lam_temp": 29.38, "motor_position": 39.86, "motor_speed": 0.55, "motor_power": 10.35, "motor_current": 0.57, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.46, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 8783, "profile_time": 8783, "status": "PreBrew", "sensors": {"external_1": 104.56, "external_2": 103.67, "bar_up": 79.13, "bar_mid_up": 85.73, "bar_mid_down": 86.12, "bar_down": 83.06, "tube": 83.52, "motor_temp": 30.89, "lam_temp": 29.38, "motor_position": 39.96, "motor_speed": 0.52, "motor_power": 10.4, "motor_current": 0.58, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.47, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 8918, "profile_time": 8918, "status": "PreBrew", "sensors": {"external_1": 104.53, "external_2": 103.64, "bar_up": 79.13, "bar_mid_up": 85.73, "bar_mid_down": 86.12, "bar_down": 83.06, "tube": 83.51, "motor_temp": 30.89, "lam_temp": 29.38, "motor_position": 40.06, "motor_speed": 0.52, "motor_power": 10.48, "motor_current": 0.58, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.48, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 9054, "profile_time": 9054, "status": "PreBrew", "sensors": {"external_1": 104.46, "external_2": 103.6, "bar_up": 78.96, "bar_mid_up": 85.53, "bar_mid_down": 86.12, "bar_down": 82.87, "tube": 83.47, "motor_temp": 30.89, "lam_temp": 29.38, "motor_position": 40.06, "motor_speed": 0.52, "motor_power": 10.54, "motor_current": 0.6, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.48, "flow": 1.14, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 9182, "profile_time": 9182, "status": "PreBrew", "sensors": {"external_1": 104.39, "external_2": 103.5, "bar_up": 78.96, "bar_mid_up": 85.53, "bar_mid_down": 85.92, "bar_down": 82.87, "tube": 83.39, "motor_temp": 30.89, "lam_temp": 29.38, "motor_position": 40.16, "motor_speed": 0.51, "motor_power": 10.6, "motor_current": 0.57, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.49, "flow": 1.15, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 9315, "profile_time": 9315, "status": "PreBrew", "sensors": {"external_1": 104.32, "external_2": 103.5, "bar_up": 78.96, "bar_mid_up": 85.53, "bar_mid_down": 85.92, "bar_down": 82.87, "tube": 83.34, "motor_temp": 30.87, "lam_temp": 29.4, "motor_position": 40.26, "motor_speed": 0.53, "motor_power": 10.68, "motor_current": 0.58, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.5, "flow": 1.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 9453, "profile_time": 9453, "status": "PreBrew", "sensors": {"external_1": 104.29, "external_2": 103.46, "bar_up": 78.96, "bar_mid_up": 85.53, "bar_mid_down": 86.12, "bar_down": 82.69, "tube": 83.33, "motor_temp": 30.87, "lam_temp": 29.4, "motor_position": 40.26, "motor_speed": 0.53, "motor_power": 10.72, "motor_current": 0.59, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.51, "flow": 1.18, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 9568, "profile_time": 9568, "status": "PreBrew", "sensors": {"external_1": 104.29, "external_2": 103.43, "bar_up": 78.96, "bar_mid_up": 85.53, "bar_mid_down": 85.92, "bar_down": 82.69, "tube": 83.31, "motor_temp": 30.87, "lam_temp": 29.38, "motor_position": 40.36, "motor_speed": 0.52, "motor_power": 10.75, "motor_current": 0.59, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.52, "flow": 1.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 9692, "profile_time": 9692, "status": "PreBrew", "sensors": {"external_1": 104.29, "external_2": 103.33, "bar_up": 78.96, "bar_mid_up": 85.53, "bar_mid_down": 85.92, "bar_down": 82.69, "tube": 83.28, "motor_temp": 30.87, "lam_temp": 29.38, "motor_position": 40.46, "motor_speed": 0.52, "motor_power": 10.83, "motor_current": 0.59, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.53, "flow": 1.21, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 9825, "profile_time": 9825, "status": "PreBrew", "sensors": {"external_1": 104.29, "external_2": 103.33, "bar_up": 78.79, "bar_mid_up": 85.53, "bar_mid_down": 85.92, "bar_down": 82.69, "tube": 83.25, "motor_temp": 30.89, "lam_temp": 29.4, "motor_position": 40.46, "motor_speed": 0.52, "motor_power": 10.87, "motor_current": 0.58, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.54, "flow": 1.22, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 9962, "profile_time": 9962, "status": "PreBrew", "sensors": {"external_1": 104.22, "external_2": 103.29, "bar_up": 78.79, "bar_mid_up": 85.53, "bar_mid_down": 85.92, "bar_down": 82.69, "tube": 83.24, "motor_temp": 30.91, "lam_temp": 29.43, "motor_position": 40.56, "motor_speed": 0.52, "motor_power": 10.94, "motor_current": 0.58, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.55, "flow": 1.24, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 10104, "profile_time": 10104, "status": "PreBrew", "sensors": {"external_1": 104.18, "external_2": 103.29, "bar_up": 78.79, "bar_mid_up": 85.34, "bar_mid_down": 85.73, "bar_down": 82.69, "tube": 83.21, "motor_temp": 30.94, "lam_temp": 29.4, "motor_position": 40.66, "motor_speed": 0.54, "motor_power": 10.94, "motor_current": 0.59, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.56, "flow": 1.24, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 10233, "profile_time": 10233, "status": "PreBrew", "sensors": {"external_1": 104.15, "external_2": 103.22, "bar_up": 78.79, "bar_mid_up": 85.53, "bar_mid_down": 85.73, "bar_down": 82.69, "tube": 83.2, "motor_temp": 30.94, "lam_temp": 29.38, "motor_position": 40.76, "motor_speed": 0.53, "motor_power": 10.95, "motor_current": 0.59, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.57, "flow": 1.24, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 10357, "profile_time": 10357, "status": "PreBrew", "sensors": {"external_1": 104.15, "external_2": 103.22, "bar_up": 78.79, "bar_mid_up": 85.53, "bar_mid_down": 85.92, "bar_down": 82.69, "tube": 83.2, "motor_temp": 30.91, "lam_temp": 29.38, "motor_position": 40.76, "motor_speed": 0.53, "motor_power": 11.01, "motor_current": 0.59, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.58, "flow": 1.23, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 10472, "profile_time": 10472, "status": "PreBrew", "sensors": {"external_1": 104.12, "external_2": 103.19, "bar_up": 78.79, "bar_mid_up": 85.34, "bar_mid_down": 85.73, "bar_down": 82.5, "tube": 83.16, "motor_temp": 30.91, "lam_temp": 29.38, "motor_position": 40.86, "motor_speed": 0.54, "motor_power": 11.01, "motor_current": 0.57, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.59, "flow": 1.21, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 10600, "profile_time": 10600, "status": "PreBrew", "sensors": {"external_1": 104.05, "external_2": 103.19, "bar_up": 78.62, "bar_mid_up": 85.34, "bar_mid_down": 85.73, "bar_down": 82.5, "tube": 83.13, "motor_temp": 30.91, "lam_temp": 29.43, "motor_position": 40.96, "motor_speed": 0.53, "motor_power": 11.03, "motor_current": 0.59, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.6, "flow": 1.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 10756, "profile_time": 10756, "status": "PreBrew", "sensors": {"external_1": 104.05, "external_2": 103.16, "bar_up": 78.79, "bar_mid_up": 85.34, "bar_mid_down": 85.73, "bar_down": 82.5, "tube": 83.09, "motor_temp": 30.94, "lam_temp": 29.43, "motor_position": 40.96, "motor_speed": 0.53, "motor_power": 11.06, "motor_current": 0.57, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.62, "flow": 1.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 10867, "profile_time": 10867, "status": "PreBrew", "sensors": {"external_1": 104.05, "external_2": 103.19, "bar_up": 78.79, "bar_mid_up": 85.34, "bar_mid_down": 85.73, "bar_down": 82.5, "tube": 83.09, "motor_temp": 30.91, "lam_temp": 29.43, "motor_position": 41.06, "motor_speed": 0.54, "motor_power": 11.06, "motor_current": 0.59, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.63, "flow": 1.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 10992, "profile_time": 10992, "status": "PreBrew", "sensors": {"external_1": 104.01, "external_2": 103.19, "bar_up": 78.79, "bar_mid_up": 85.34, "bar_mid_down": 85.73, "bar_down": 82.5, "tube": 83.08, "motor_temp": 30.91, "lam_temp": 29.43, "motor_position": 41.16, "motor_speed": 0.54, "motor_power": 11.07, "motor_current": 0.59, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.64, "flow": 1.16, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 11128, "profile_time": 11128, "status": "PreBrew", "sensors": {"external_1": 103.98, "external_2": 103.09, "bar_up": 78.79, "bar_mid_up": 85.14, "bar_mid_down": 85.73, "bar_down": 82.5, "tube": 83.08, "motor_temp": 30.89, "lam_temp": 29.43, "motor_position": 41.16, "motor_speed": 0.54, "motor_power": 11.08, "motor_current": 0.6, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.65, "flow": 1.15, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 11256, "profile_time": 11256, "status": "PreBrew", "sensors": {"external_1": 103.94, "external_2": 103.02, "bar_up": 78.62, "bar_mid_up": 85.34, "bar_mid_down": 85.73, "bar_down": 82.5, "tube": 83.07, "motor_temp": 30.91, "lam_temp": 29.43, "motor_position": 41.27, "motor_speed": 0.53, "motor_power": 11.11, "motor_current": 0.61, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.66, "flow": 1.13, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 11381, "profile_time": 11381, "status": "PreBrew", "sensors": {"external_1": 103.94, "external_2": 102.98, "bar_up": 78.62, "bar_mid_up": 85.14, "bar_mid_down": 85.73, "bar_down": 82.5, "tube": 83.04, "motor_temp": 30.91, "lam_temp": 29.43, "motor_position": 41.37, "motor_speed": 0.52, "motor_power": 11.19, "motor_current": 0.61, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.67, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 11516, "profile_time": 11516, "status": "PreBrew", "sensors": {"external_1": 103.94, "external_2": 102.95, "bar_up": 78.62, "bar_mid_up": 85.14, "bar_mid_down": 85.73, "bar_down": 82.5, "tube": 83.02, "motor_temp": 30.91, "lam_temp": 29.43, "motor_position": 41.37, "motor_speed": 0.52, "motor_power": 11.24, "motor_current": 0.61, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.68, "flow": 1.11, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 11645, "profile_time": 11645, "status": "PreBrew", "sensors": {"external_1": 103.88, "external_2": 102.92, "bar_up": 78.62, "bar_mid_up": 85.14, "bar_mid_down": 85.73, "bar_down": 82.5, "tube": 83.0, "motor_temp": 30.91, "lam_temp": 29.45, "motor_position": 41.47, "motor_speed": 0.52, "motor_power": 11.27, "motor_current": 0.61, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.69, "flow": 1.11, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 11783, "profile_time": 11783, "status": "PreBrew", "sensors": {"external_1": 103.81, "external_2": 102.88, "bar_up": 78.62, "bar_mid_up": 85.14, "bar_mid_down": 85.73, "bar_down": 82.5, "tube": 83.0, "motor_temp": 30.91, "lam_temp": 29.45, "motor_position": 41.57, "motor_speed": 0.51, "motor_power": 11.36, "motor_current": 0.6, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.7, "flow": 1.11, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 11902, "profile_time": 11902, "status": "PreBrew", "sensors": {"external_1": 103.77, "external_2": 102.88, "bar_up": 78.62, "bar_mid_up": 85.14, "bar_mid_down": 85.73, "bar_down": 82.5, "tube": 83.0, "motor_temp": 30.91, "lam_temp": 29.45, "motor_position": 41.57, "motor_speed": 0.52, "motor_power": 11.44, "motor_current": 0.61, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.71, "flow": 1.11, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 12054, "profile_time": 12054, "status": "PreBrew", "sensors": {"external_1": 103.77, "external_2": 102.85, "bar_up": 78.45, "bar_mid_up": 85.14, "bar_mid_down": 85.53, "bar_down": 82.32, "tube": 82.94, "motor_temp": 30.91, "lam_temp": 29.43, "motor_position": 41.67, "motor_speed": 0.53, "motor_power": 11.47, "motor_current": 0.6, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.73, "flow": 1.11, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 12165, "profile_time": 12165, "status": "PreBrew", "sensors": {"external_1": 103.74, "external_2": 102.78, "bar_up": 78.45, "bar_mid_up": 85.14, "bar_mid_down": 85.53, "bar_down": 82.32, "tube": 82.91, "motor_temp": 30.89, "lam_temp": 29.43, "motor_position": 41.77, "motor_speed": 0.54, "motor_power": 11.47, "motor_current": 0.61, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.74, "flow": 1.11, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 12290, "profile_time": 12290, "status": "PreBrew", "sensors": {"external_1": 103.7, "external_2": 102.75, "bar_up": 78.45, "bar_mid_up": 84.95, "bar_mid_down": 85.53, "bar_down": 82.14, "tube": 82.85, "motor_temp": 30.87, "lam_temp": 29.43, "motor_position": 41.87, "motor_speed": 0.55, "motor_power": 11.48, "motor_current": 0.61, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.75, "flow": 1.11, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 12432, "profile_time": 12432, "status": "PreBrew", "sensors": {"external_1": 103.67, "external_2": 102.71, "bar_up": 78.62, "bar_mid_up": 84.95, "bar_mid_down": 85.53, "bar_down": 82.14, "tube": 82.82, "motor_temp": 30.89, "lam_temp": 29.45, "motor_position": 41.87, "motor_speed": 0.55, "motor_power": 11.46, "motor_current": 0.6, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.76, "flow": 1.11, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 12555, "profile_time": 12555, "status": "PreBrew", "sensors": {"external_1": 103.67, "external_2": 102.71, "bar_up": 78.45, "bar_mid_up": 84.95, "bar_mid_down": 85.53, "bar_down": 82.14, "tube": 82.8, "motor_temp": 30.89, "lam_temp": 29.48, "motor_position": 41.97, "motor_speed": 0.53, "motor_power": 11.49, "motor_current": 0.61, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.78, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 12666, "profile_time": 12666, "status": "PreBrew", "sensors": {"external_1": 103.6, "external_2": 102.68, "bar_up": 78.45, "bar_mid_up": 84.95, "bar_mid_down": 85.53, "bar_down": 82.14, "tube": 82.78, "motor_temp": 30.94, "lam_temp": 29.48, "motor_position": 42.07, "motor_speed": 0.53, "motor_power": 11.51, "motor_current": 0.59, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.79, "flow": 1.14, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 12794, "profile_time": 12794, "status": "PreBrew", "sensors": {"external_1": 103.57, "external_2": 102.64, "bar_up": 78.45, "bar_mid_up": 84.95, "bar_mid_down": 85.53, "bar_down": 82.14, "tube": 82.77, "motor_temp": 30.91, "lam_temp": 29.45, "motor_position": 42.07, "motor_speed": 0.53, "motor_power": 11.56, "motor_current": 0.62, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.8, "flow": 1.15, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 12936, "profile_time": 12936, "status": "PreBrew", "sensors": {"external_1": 103.53, "external_2": 102.61, "bar_up": 78.45, "bar_mid_up": 84.95, "bar_mid_down": 85.53, "bar_down": 82.14, "tube": 82.77, "motor_temp": 30.91, "lam_temp": 29.43, "motor_position": 42.17, "motor_speed": 0.53, "motor_power": 11.58, "motor_current": 0.6, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.81, "flow": 1.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 13074, "profile_time": 13074, "status": "PreBrew", "sensors": {"external_1": 103.53, "external_2": 102.54, "bar_up": 78.45, "bar_mid_up": 84.95, "bar_mid_down": 85.53, "bar_down": 82.14, "tube": 82.77, "motor_temp": 30.94, "lam_temp": 29.48, "motor_position": 42.27, "motor_speed": 0.53, "motor_power": 11.63, "motor_current": 0.6, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.83, "flow": 1.18, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 13191, "profile_time": 13191, "status": "PreBrew", "sensors": {"external_1": 103.53, "external_2": 102.54, "bar_up": 78.28, "bar_mid_up": 84.95, "bar_mid_down": 85.53, "bar_down": 82.14, "tube": 82.76, "motor_temp": 30.94, "lam_temp": 29.48, "motor_position": 42.27, "motor_speed": 0.53, "motor_power": 11.66, "motor_current": 0.61, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.84, "flow": 1.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 13344, "profile_time": 13344, "status": "PreBrew", "sensors": {"external_1": 103.5, "external_2": 102.51, "bar_up": 78.28, "bar_mid_up": 84.95, "bar_mid_down": 85.34, "bar_down": 81.96, "tube": 82.72, "motor_temp": 30.94, "lam_temp": 29.5, "motor_position": 42.37, "motor_speed": 0.53, "motor_power": 11.7, "motor_current": 0.64, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.85, "flow": 1.21, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 13455, "profile_time": 13455, "status": "PreBrew", "sensors": {"external_1": 103.46, "external_2": 102.51, "bar_up": 78.28, "bar_mid_up": 84.95, "bar_mid_down": 85.34, "bar_down": 81.96, "tube": 82.69, "motor_temp": 30.89, "lam_temp": 29.5, "motor_position": 42.47, "motor_speed": 0.52, "motor_power": 11.79, "motor_current": 0.61, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.86, "flow": 1.22, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 13582, "profile_time": 13582, "status": "PreBrew", "sensors": {"external_1": 103.36, "external_2": 102.41, "bar_up": 78.28, "bar_mid_up": 84.76, "bar_mid_down": 85.34, "bar_down": 81.96, "tube": 82.62, "motor_temp": 30.89, "lam_temp": 29.53, "motor_position": 42.47, "motor_speed": 0.52, "motor_power": 11.84, "motor_current": 0.62, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.87, "flow": 1.24, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 13721, "profile_time": 13721, "status": "PreBrew", "sensors": {"external_1": 103.33, "external_2": 102.37, "bar_up": 78.28, "bar_mid_up": 84.76, "bar_mid_down": 85.34, "bar_down": 81.96, "tube": 82.61, "motor_temp": 30.89, "lam_temp": 29.53, "motor_position": 42.57, "motor_speed": 0.53, "motor_power": 11.85, "motor_current": 0.64, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.88, "flow": 1.25, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 13872, "profile_time": 13872, "status": "PreBrew", "sensors": {"external_1": 103.33, "external_2": 102.34, "bar_up": 78.28, "bar_mid_up": 84.76, "bar_mid_down": 85.34, "bar_down": 81.96, "tube": 82.59, "motor_temp": 30.91, "lam_temp": 29.53, "motor_position": 42.67, "motor_speed": 0.52, "motor_power": 11.93, "motor_current": 0.64, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.9, "flow": 1.25, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 13993, "profile_time": 13993, "status": "PreBrew", "sensors": {"external_1": 103.29, "external_2": 102.3, "bar_up": 78.28, "bar_mid_up": 84.76, "bar_mid_down": 85.34, "bar_down": 81.96, "tube": 82.58, "motor_temp": 30.96, "lam_temp": 29.53, "motor_position": 42.67, "motor_speed": 0.52, "motor_power": 11.98, "motor_current": 0.66, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.91, "flow": 1.25, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 14125, "profile_time": 14125, "status": "PreBrew", "sensors": {"external_1": 103.33, "external_2": 102.3, "bar_up": 78.11, "bar_mid_up": 84.76, "bar_mid_down": 85.34, "bar_down": 81.96, "tube": 82.57, "motor_temp": 30.96, "lam_temp": 29.53, "motor_position": 42.77, "motor_speed": 0.52, "motor_power": 12.01, "motor_current": 0.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.92, "flow": 1.24, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 14254, "profile_time": 14254, "status": "PreBrew", "sensors": {"external_1": 103.29, "external_2": 102.3, "bar_up": 78.11, "bar_mid_up": 84.76, "bar_mid_down": 85.14, "bar_down": 81.96, "tube": 82.55, "motor_temp": 30.96, "lam_temp": 29.53, "motor_position": 42.87, "motor_speed": 0.5, "motor_power": 12.15, "motor_current": 0.64, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.93, "flow": 1.23, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 14397, "profile_time": 14397, "status": "PreBrew", "sensors": {"external_1": 103.29, "external_2": 102.27, "bar_up": 78.11, "bar_mid_up": 84.56, "bar_mid_down": 85.14, "bar_down": 81.96, "tube": 82.49, "motor_temp": 30.91, "lam_temp": 29.53, "motor_position": 42.87, "motor_speed": 0.52, "motor_power": 12.24, "motor_current": 0.63, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.94, "flow": 1.21, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 14509, "profile_time": 14509, "status": "PreBrew", "sensors": {"external_1": 103.26, "external_2": 102.2, "bar_up": 78.11, "bar_mid_up": 84.56, "bar_mid_down": 85.14, "bar_down": 81.96, "tube": 82.47, "motor_temp": 30.91, "lam_temp": 29.53, "motor_position": 42.97, "motor_speed": 0.53, "motor_power": 12.26, "motor_current": 0.65, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.96, "flow": 1.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 14633, "profile_time": 14633, "status": "PreBrew", "sensors": {"external_1": 103.22, "external_2": 102.17, "bar_up": 78.11, "bar_mid_up": 84.56, "bar_mid_down": 85.14, "bar_down": 81.96, "tube": 82.46, "motor_temp": 30.91, "lam_temp": 29.53, "motor_position": 43.07, "motor_speed": 0.53, "motor_power": 12.28, "motor_current": 0.64, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.97, "flow": 1.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 14772, "profile_time": 14772, "status": "PreBrew", "sensors": {"external_1": 103.19, "external_2": 102.17, "bar_up": 78.11, "bar_mid_up": 84.56, "bar_mid_down": 85.14, "bar_down": 81.77, "tube": 82.44, "motor_temp": 30.91, "lam_temp": 29.53, "motor_position": 43.07, "motor_speed": 0.53, "motor_power": 12.3, "motor_current": 0.65, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 0.98, "flow": 1.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 14886, "profile_time": 14886, "status": "PreBrew", "sensors": {"external_1": 103.16, "external_2": 102.24, "bar_up": 78.11, "bar_mid_up": 84.56, "bar_mid_down": 85.34, "bar_down": 81.96, "tube": 82.44, "motor_temp": 30.94, "lam_temp": 29.53, "motor_position": 43.17, "motor_speed": 0.54, "motor_power": 12.33, "motor_current": 0.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.0, "flow": 1.16, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 15029, "profile_time": 15029, "status": "PreBrew", "sensors": {"external_1": 103.12, "external_2": 102.17, "bar_up": 77.94, "bar_mid_up": 84.56, "bar_mid_down": 85.14, "bar_down": 81.77, "tube": 82.4, "motor_temp": 30.96, "lam_temp": 29.55, "motor_position": 43.27, "motor_speed": 0.52, "motor_power": 12.4, "motor_current": 0.65, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.01, "flow": 1.15, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 15137, "profile_time": 15137, "status": "PreBrew", "sensors": {"external_1": 103.09, "external_2": 102.13, "bar_up": 78.11, "bar_mid_up": 84.37, "bar_mid_down": 85.14, "bar_down": 81.77, "tube": 82.39, "motor_temp": 30.96, "lam_temp": 29.55, "motor_position": 43.27, "motor_speed": 0.52, "motor_power": 12.45, "motor_current": 0.64, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.02, "flow": 1.13, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 15261, "profile_time": 15261, "status": "PreBrew", "sensors": {"external_1": 103.05, "external_2": 102.07, "bar_up": 77.94, "bar_mid_up": 84.56, "bar_mid_down": 85.14, "bar_down": 81.77, "tube": 82.35, "motor_temp": 30.94, "lam_temp": 29.58, "motor_position": 43.37, "motor_speed": 1.05, "motor_power": 12.48, "motor_current": 0.66, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.04, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 15392, "profile_time": 15392, "status": "PreBrew", "sensors": {"external_1": 103.05, "external_2": 102.07, "bar_up": 77.94, "bar_mid_up": 84.56, "bar_mid_down": 85.14, "bar_down": 81.96, "tube": 82.37, "motor_temp": 30.94, "lam_temp": 29.58, "motor_position": 43.47, "motor_speed": 0.53, "motor_power": 12.54, "motor_current": 0.64, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.05, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 15530, "profile_time": 15530, "status": "PreBrew", "sensors": {"external_1": 103.02, "external_2": 102.07, "bar_up": 77.94, "bar_mid_up": 84.56, "bar_mid_down": 85.14, "bar_down": 81.77, "tube": 82.37, "motor_temp": 30.94, "lam_temp": 29.58, "motor_position": 43.57, "motor_speed": 0.54, "motor_power": 12.57, "motor_current": 0.66, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.06, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 15670, "profile_time": 15670, "status": "PreBrew", "sensors": {"external_1": 103.02, "external_2": 102.07, "bar_up": 77.94, "bar_mid_up": 84.37, "bar_mid_down": 85.14, "bar_down": 81.77, "tube": 82.36, "motor_temp": 30.96, "lam_temp": 29.55, "motor_position": 43.57, "motor_speed": 0.54, "motor_power": 12.58, "motor_current": 0.64, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.08, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 15782, "profile_time": 15782, "status": "PreBrew", "sensors": {"external_1": 102.98, "external_2": 102.03, "bar_up": 77.94, "bar_mid_up": 84.37, "bar_mid_down": 85.14, "bar_down": 81.77, "tube": 82.34, "motor_temp": 30.96, "lam_temp": 29.55, "motor_position": 43.67, "motor_speed": 0.52, "motor_power": 12.62, "motor_current": 0.65, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.09, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 15924, "profile_time": 15924, "status": "PreBrew", "sensors": {"external_1": 102.95, "external_2": 101.96, "bar_up": 77.78, "bar_mid_up": 84.37, "bar_mid_down": 84.95, "bar_down": 81.77, "tube": 82.26, "motor_temp": 30.94, "lam_temp": 29.58, "motor_position": 43.78, "motor_speed": 0.53, "motor_power": 12.68, "motor_current": 0.66, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.11, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 16055, "profile_time": 16055, "status": "PreBrew", "sensors": {"external_1": 102.92, "external_2": 101.93, "bar_up": 77.78, "bar_mid_up": 84.37, "bar_mid_down": 84.95, "bar_down": 81.77, "tube": 82.24, "motor_temp": 30.94, "lam_temp": 29.6, "motor_position": 43.78, "motor_speed": 0.53, "motor_power": 12.7, "motor_current": 0.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.13, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 16184, "profile_time": 16184, "status": "PreBrew", "sensors": {"external_1": 102.88, "external_2": 101.9, "bar_up": 77.78, "bar_mid_up": 84.37, "bar_mid_down": 84.95, "bar_down": 81.59, "tube": 82.21, "motor_temp": 30.94, "lam_temp": 29.58, "motor_position": 43.88, "motor_speed": 0.5, "motor_power": 12.74, "motor_current": 0.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.14, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 16318, "profile_time": 16318, "status": "PreBrew", "sensors": {"external_1": 102.85, "external_2": 101.86, "bar_up": 77.78, "bar_mid_up": 84.37, "bar_mid_down": 84.95, "bar_down": 81.77, "tube": 82.21, "motor_temp": 30.94, "lam_temp": 29.58, "motor_position": 43.98, "motor_speed": 0.53, "motor_power": 12.9, "motor_current": 0.64, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.16, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 16441, "profile_time": 16441, "status": "PreBrew", "sensors": {"external_1": 102.81, "external_2": 101.83, "bar_up": 77.78, "bar_mid_up": 84.18, "bar_mid_down": 84.95, "bar_down": 81.59, "tube": 82.17, "motor_temp": 30.94, "lam_temp": 29.6, "motor_position": 43.98, "motor_speed": 0.53, "motor_power": 12.92, "motor_current": 0.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.17, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 16573, "profile_time": 16573, "status": "PreBrew", "sensors": {"external_1": 102.78, "external_2": 101.79, "bar_up": 77.61, "bar_mid_up": 84.37, "bar_mid_down": 84.95, "bar_down": 81.59, "tube": 82.16, "motor_temp": 30.94, "lam_temp": 29.63, "motor_position": 44.08, "motor_speed": 0.51, "motor_power": 13.0, "motor_current": 0.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.19, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 16701, "profile_time": 16701, "status": "PreBrew", "sensors": {"external_1": 102.75, "external_2": 101.79, "bar_up": 77.61, "bar_mid_up": 84.18, "bar_mid_down": 84.76, "bar_down": 81.59, "tube": 82.12, "motor_temp": 30.94, "lam_temp": 29.63, "motor_position": 44.18, "motor_speed": 0.52, "motor_power": 13.11, "motor_current": 0.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.21, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 16848, "profile_time": 16848, "status": "PreBrew", "sensors": {"external_1": 102.71, "external_2": 101.76, "bar_up": 77.61, "bar_mid_up": 84.18, "bar_mid_down": 84.76, "bar_down": 81.59, "tube": 82.07, "motor_temp": 30.91, "lam_temp": 29.58, "motor_position": 44.18, "motor_speed": 0.52, "motor_power": 13.16, "motor_current": 0.69, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.22, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 16959, "profile_time": 16959, "status": "PreBrew", "sensors": {"external_1": 102.68, "external_2": 101.69, "bar_up": 77.61, "bar_mid_up": 84.18, "bar_mid_down": 84.76, "bar_down": 81.59, "tube": 82.05, "motor_temp": 30.89, "lam_temp": 29.55, "motor_position": 44.28, "motor_speed": 0.52, "motor_power": 13.21, "motor_current": 0.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.24, "flow": 1.14, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 17097, "profile_time": 17097, "status": "PreBrew", "sensors": {"external_1": 102.68, "external_2": 101.66, "bar_up": 77.61, "bar_mid_up": 84.18, "bar_mid_down": 84.76, "bar_down": 81.59, "tube": 82.05, "motor_temp": 30.89, "lam_temp": 29.55, "motor_position": 44.38, "motor_speed": 0.52, "motor_power": 13.26, "motor_current": 0.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.26, "flow": 1.15, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 17217, "profile_time": 17217, "status": "PreBrew", "sensors": {"external_1": 102.68, "external_2": 101.66, "bar_up": 77.61, "bar_mid_up": 83.99, "bar_mid_down": 84.76, "bar_down": 81.41, "tube": 82.01, "motor_temp": 30.91, "lam_temp": 29.58, "motor_position": 44.38, "motor_speed": 0.52, "motor_power": 13.35, "motor_current": 0.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.28, "flow": 1.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 17352, "profile_time": 17352, "status": "PreBrew", "sensors": {"external_1": 102.64, "external_2": 101.59, "bar_up": 77.61, "bar_mid_up": 83.99, "bar_mid_down": 84.76, "bar_down": 81.59, "tube": 82.0, "motor_temp": 30.99, "lam_temp": 29.63, "motor_position": 44.48, "motor_speed": 0.5, "motor_power": 13.44, "motor_current": 0.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.29, "flow": 1.18, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 17493, "profile_time": 17493, "status": "PreBrew", "sensors": {"external_1": 102.61, "external_2": 101.59, "bar_up": 77.44, "bar_mid_up": 84.18, "bar_mid_down": 84.76, "bar_down": 81.41, "tube": 81.98, "motor_temp": 30.99, "lam_temp": 29.63, "motor_position": 44.58, "motor_speed": 0.51, "motor_power": 13.57, "motor_current": 0.69, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.31, "flow": 1.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 17614, "profile_time": 17614, "status": "PreBrew", "sensors": {"external_1": 102.58, "external_2": 101.56, "bar_up": 77.44, "bar_mid_up": 83.99, "bar_mid_down": 84.76, "bar_down": 81.59, "tube": 81.98, "motor_temp": 30.96, "lam_temp": 29.63, "motor_position": 44.58, "motor_speed": 0.51, "motor_power": 13.64, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.33, "flow": 1.21, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 17752, "profile_time": 17752, "status": "PreBrew", "sensors": {"external_1": 102.58, "external_2": 101.52, "bar_up": 77.44, "bar_mid_up": 83.99, "bar_mid_down": 84.76, "bar_down": 81.59, "tube": 81.95, "motor_temp": 30.96, "lam_temp": 29.63, "motor_position": 44.68, "motor_speed": 0.52, "motor_power": 13.69, "motor_current": 0.71, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.35, "flow": 1.23, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 17878, "profile_time": 17878, "status": "PreBrew", "sensors": {"external_1": 102.58, "external_2": 101.56, "bar_up": 77.44, "bar_mid_up": 84.18, "bar_mid_down": 84.76, "bar_down": 81.41, "tube": 81.95, "motor_temp": 30.96, "lam_temp": 29.63, "motor_position": 44.78, "motor_speed": 0.51, "motor_power": 13.81, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.37, "flow": 1.24, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 18010, "profile_time": 18010, "status": "PreBrew", "sensors": {"external_1": 102.58, "external_2": 101.56, "bar_up": 77.28, "bar_mid_up": 83.99, "bar_mid_down": 84.56, "bar_down": 81.41, "tube": 81.91, "motor_temp": 30.96, "lam_temp": 29.63, "motor_position": 44.78, "motor_speed": 0.51, "motor_power": 13.87, "motor_current": 0.71, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.39, "flow": 1.25, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 18143, "profile_time": 18143, "status": "PreBrew", "sensors": {"external_1": 102.54, "external_2": 101.56, "bar_up": 77.44, "bar_mid_up": 83.99, "bar_mid_down": 84.56, "bar_down": 81.41, "tube": 81.89, "motor_temp": 30.94, "lam_temp": 29.63, "motor_position": 44.88, "motor_speed": 0.53, "motor_power": 13.9, "motor_current": 0.69, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.41, "flow": 1.27, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 18266, "profile_time": 18266, "status": "PreBrew", "sensors": {"external_1": 102.44, "external_2": 101.46, "bar_up": 77.28, "bar_mid_up": 83.99, "bar_mid_down": 84.56, "bar_down": 81.41, "tube": 81.83, "motor_temp": 30.91, "lam_temp": 29.63, "motor_position": 44.98, "motor_speed": 0.52, "motor_power": 13.92, "motor_current": 0.71, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.42, "flow": 1.27, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 18412, "profile_time": 18412, "status": "PreBrew", "sensors": {"external_1": 102.41, "external_2": 101.42, "bar_up": 77.44, "bar_mid_up": 83.99, "bar_mid_down": 84.56, "bar_down": 81.41, "tube": 81.84, "motor_temp": 30.94, "lam_temp": 29.65, "motor_position": 44.98, "motor_speed": 0.52, "motor_power": 14.04, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.45, "flow": 1.27, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 18523, "profile_time": 18523, "status": "PreBrew", "sensors": {"external_1": 102.37, "external_2": 101.39, "bar_up": 77.28, "bar_mid_up": 83.8, "bar_mid_down": 84.56, "bar_down": 81.41, "tube": 81.82, "motor_temp": 30.94, "lam_temp": 29.68, "motor_position": 45.08, "motor_speed": 0.53, "motor_power": 14.08, "motor_current": 0.71, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.47, "flow": 1.26, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 18654, "profile_time": 18654, "status": "PreBrew", "sensors": {"external_1": 102.34, "external_2": 101.35, "bar_up": 77.28, "bar_mid_up": 83.8, "bar_mid_down": 84.56, "bar_down": 81.41, "tube": 81.8, "motor_temp": 30.96, "lam_temp": 29.71, "motor_position": 45.18, "motor_speed": 0.5, "motor_power": 14.17, "motor_current": 0.73, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.49, "flow": 1.24, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 18784, "profile_time": 18784, "status": "PreBrew", "sensors": {"external_1": 102.34, "external_2": 101.25, "bar_up": 77.28, "bar_mid_up": 83.8, "bar_mid_down": 84.37, "bar_down": 81.41, "tube": 81.75, "motor_temp": 30.99, "lam_temp": 29.73, "motor_position": 45.18, "motor_speed": 0.5, "motor_power": 14.28, "motor_current": 0.73, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.51, "flow": 1.23, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 18911, "profile_time": 18911, "status": "PreBrew", "sensors": {"external_1": 102.34, "external_2": 101.22, "bar_up": 77.28, "bar_mid_up": 83.8, "bar_mid_down": 84.56, "bar_down": 81.23, "tube": 81.74, "motor_temp": 30.99, "lam_temp": 29.73, "motor_position": 45.28, "motor_speed": 0.51, "motor_power": 14.35, "motor_current": 0.72, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.53, "flow": 1.22, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 19039, "profile_time": 19039, "status": "PreBrew", "sensors": {"external_1": 102.34, "external_2": 101.22, "bar_up": 77.11, "bar_mid_up": 83.8, "bar_mid_down": 84.56, "bar_down": 81.23, "tube": 81.72, "motor_temp": 30.96, "lam_temp": 29.71, "motor_position": 45.28, "motor_speed": 0.49, "motor_power": 14.43, "motor_current": 0.73, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.55, "flow": 1.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 19166, "profile_time": 19166, "status": "PreBrew", "sensors": {"external_1": 102.27, "external_2": 101.22, "bar_up": 77.11, "bar_mid_up": 83.8, "bar_mid_down": 84.56, "bar_down": 81.23, "tube": 81.7, "motor_temp": 30.94, "lam_temp": 29.68, "motor_position": 45.38, "motor_speed": 0.49, "motor_power": 14.65, "motor_current": 0.73, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.57, "flow": 1.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 19297, "profile_time": 19297, "status": "PreBrew", "sensors": {"external_1": 102.24, "external_2": 101.22, "bar_up": 77.11, "bar_mid_up": 83.8, "bar_mid_down": 84.37, "bar_down": 81.23, "tube": 81.67, "motor_temp": 30.94, "lam_temp": 29.68, "motor_position": 45.48, "motor_speed": 0.52, "motor_power": 14.69, "motor_current": 0.73, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.6, "flow": 1.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 19450, "profile_time": 19450, "status": "PreBrew", "sensors": {"external_1": 102.2, "external_2": 101.19, "bar_up": 76.95, "bar_mid_up": 83.8, "bar_mid_down": 84.37, "bar_down": 81.41, "tube": 81.66, "motor_temp": 30.91, "lam_temp": 29.68, "motor_position": 45.58, "motor_speed": 0.51, "motor_power": 14.74, "motor_current": 0.73, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.62, "flow": 1.16, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 19574, "profile_time": 19574, "status": "PreBrew", "sensors": {"external_1": 102.2, "external_2": 101.12, "bar_up": 76.95, "bar_mid_up": 83.8, "bar_mid_down": 84.37, "bar_down": 81.23, "tube": 81.63, "motor_temp": 30.91, "lam_temp": 29.68, "motor_position": 45.58, "motor_speed": 0.51, "motor_power": 14.85, "motor_current": 0.74, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.65, "flow": 1.15, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 19690, "profile_time": 19690, "status": "PreBrew", "sensors": {"external_1": 102.17, "external_2": 101.08, "bar_up": 77.11, "bar_mid_up": 83.8, "bar_mid_down": 84.37, "bar_down": 81.23, "tube": 81.62, "motor_temp": 30.91, "lam_temp": 29.68, "motor_position": 45.68, "motor_speed": 0.52, "motor_power": 14.9, "motor_current": 0.73, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.68, "flow": 1.13, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 19814, "profile_time": 19814, "status": "PreBrew", "sensors": {"external_1": 102.1, "external_2": 101.05, "bar_up": 76.95, "bar_mid_up": 83.62, "bar_mid_down": 84.37, "bar_down": 81.23, "tube": 81.58, "motor_temp": 30.94, "lam_temp": 29.68, "motor_position": 45.68, "motor_speed": 0.51, "motor_power": 14.95, "motor_current": 0.75, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.71, "flow": 1.13, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 19945, "profile_time": 19945, "status": "PreBrew", "sensors": {"external_1": 102.07, "external_2": 101.05, "bar_up": 76.95, "bar_mid_up": 83.62, "bar_mid_down": 84.37, "bar_down": 81.23, "tube": 81.57, "motor_temp": 30.94, "lam_temp": 29.71, "motor_position": 45.78, "motor_speed": 0.51, "motor_power": 15.07, "motor_current": 0.76, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.73, "flow": 1.13, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 20072, "profile_time": 20072, "status": "PreBrew", "sensors": {"external_1": 102.03, "external_2": 101.02, "bar_up": 76.95, "bar_mid_up": 83.62, "bar_mid_down": 84.37, "bar_down": 81.23, "tube": 81.54, "motor_temp": 30.91, "lam_temp": 29.71, "motor_position": 45.88, "motor_speed": 0.52, "motor_power": 15.12, "motor_current": 0.74, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.76, "flow": 1.13, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 20206, "profile_time": 20206, "status": "PreBrew", "sensors": {"external_1": 102.0, "external_2": 100.95, "bar_up": 76.78, "bar_mid_up": 83.62, "bar_mid_down": 84.18, "bar_down": 81.23, "tube": 81.51, "motor_temp": 30.91, "lam_temp": 29.73, "motor_position": 45.88, "motor_speed": 0.52, "motor_power": 15.17, "motor_current": 0.76, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.79, "flow": 1.14, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 20335, "profile_time": 20335, "status": "PreBrew", "sensors": {"external_1": 101.96, "external_2": 100.92, "bar_up": 76.78, "bar_mid_up": 83.62, "bar_mid_down": 84.18, "bar_down": 81.06, "tube": 81.48, "motor_temp": 30.91, "lam_temp": 29.71, "motor_position": 45.98, "motor_speed": 0.51, "motor_power": 15.29, "motor_current": 0.75, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.83, "flow": 1.14, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 20475, "profile_time": 20475, "status": "PreBrew", "sensors": {"external_1": 101.96, "external_2": 100.88, "bar_up": 76.78, "bar_mid_up": 83.62, "bar_mid_down": 84.18, "bar_down": 81.06, "tube": 81.44, "motor_temp": 30.94, "lam_temp": 29.71, "motor_position": 46.08, "motor_speed": 0.52, "motor_power": 15.34, "motor_current": 0.76, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.86, "flow": 1.14, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 20605, "profile_time": 20605, "status": "PreBrew", "sensors": {"external_1": 101.93, "external_2": 100.85, "bar_up": 76.95, "bar_mid_up": 83.62, "bar_mid_down": 84.37, "bar_down": 81.06, "tube": 81.44, "motor_temp": 30.94, "lam_temp": 29.71, "motor_position": 46.08, "motor_speed": 0.52, "motor_power": 15.39, "motor_current": 0.76, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.89, "flow": 1.14, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 20724, "profile_time": 20724, "status": "PreBrew", "sensors": {"external_1": 101.93, "external_2": 100.81, "bar_up": 76.78, "bar_mid_up": 83.62, "bar_mid_down": 84.37, "bar_down": 81.23, "tube": 81.44, "motor_temp": 30.94, "lam_temp": 29.73, "motor_position": 46.18, "motor_speed": 0.49, "motor_power": 15.52, "motor_current": 0.74, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.92, "flow": 1.14, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 20863, "profile_time": 20863, "status": "PreBrew", "sensors": {"external_1": 101.86, "external_2": 100.81, "bar_up": 76.78, "bar_mid_up": 83.43, "bar_mid_down": 84.18, "bar_down": 81.06, "tube": 81.43, "motor_temp": 30.94, "lam_temp": 29.73, "motor_position": 46.29, "motor_speed": 0.54, "motor_power": 11.01, "motor_current": 0.72, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.96, "flow": 1.14, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 20995, "profile_time": 20995, "status": "PreBrew", "sensors": {"external_1": 101.83, "external_2": 100.81, "bar_up": 76.78, "bar_mid_up": 83.43, "bar_mid_down": 84.18, "bar_down": 81.06, "tube": 81.4, "motor_temp": 30.94, "lam_temp": 29.73, "motor_position": 46.29, "motor_speed": 0.54, "motor_power": 11.46, "motor_current": 0.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.97, "flow": 1.14, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 21124, "profile_time": 21124, "status": "PreBrew", "sensors": {"external_1": 101.79, "external_2": 100.81, "bar_up": 76.78, "bar_mid_up": 83.43, "bar_mid_down": 84.18, "bar_down": 81.06, "tube": 81.4, "motor_temp": 30.94, "lam_temp": 29.73, "motor_position": 46.29, "motor_speed": 0.0, "motor_power": 11.86, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.98, "flow": 1.12, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 21248, "profile_time": 21248, "status": "PreBrew", "sensors": {"external_1": 101.76, "external_2": 100.75, "bar_up": 76.62, "bar_mid_up": 83.43, "bar_mid_down": 84.18, "bar_down": 81.06, "tube": 81.33, "motor_temp": 30.91, "lam_temp": 29.73, "motor_position": 46.29, "motor_speed": 0.0, "motor_power": 12.08, "motor_current": 0.69, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.98, "flow": 1.11, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 21366, "profile_time": 21366, "status": "PreBrew", "sensors": {"external_1": 101.76, "external_2": 100.68, "bar_up": 76.62, "bar_mid_up": 83.43, "bar_mid_down": 83.99, "bar_down": 81.06, "tube": 81.31, "motor_temp": 30.94, "lam_temp": 29.73, "motor_position": 46.29, "motor_speed": 0.0, "motor_power": 12.25, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.97, "flow": 1.08, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 21499, "profile_time": 21499, "status": "PreBrew", "sensors": {"external_1": 101.73, "external_2": 100.61, "bar_up": 76.62, "bar_mid_up": 83.43, "bar_mid_down": 83.99, "bar_down": 81.06, "tube": 81.29, "motor_temp": 30.94, "lam_temp": 29.73, "motor_position": 46.39, "motor_speed": 0.16, "motor_power": 12.33, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.95, "flow": 1.05, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 21636, "profile_time": 21636, "status": "PreBrew", "sensors": {"external_1": 101.66, "external_2": 100.58, "bar_up": 76.46, "bar_mid_up": 83.43, "bar_mid_down": 84.18, "bar_down": 81.06, "tube": 81.29, "motor_temp": 30.94, "lam_temp": 29.73, "motor_position": 46.39, "motor_speed": 0.16, "motor_power": 12.31, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.93, "flow": 1.03, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 21763, "profile_time": 21763, "status": "PreBrew", "sensors": {"external_1": 101.63, "external_2": 100.58, "bar_up": 76.62, "bar_mid_up": 83.24, "bar_mid_down": 83.99, "bar_down": 81.06, "tube": 81.28, "motor_temp": 30.91, "lam_temp": 29.73, "motor_position": 46.39, "motor_speed": 0.0, "motor_power": 12.13, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.92, "flow": 0.98, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 21886, "profile_time": 21886, "status": "PreBrew", "sensors": {"external_1": 101.66, "external_2": 100.58, "bar_up": 76.46, "bar_mid_up": 83.43, "bar_mid_down": 83.99, "bar_down": 81.06, "tube": 81.27, "motor_temp": 30.94, "lam_temp": 29.73, "motor_position": 46.39, "motor_speed": 0.0, "motor_power": 12.05, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.91, "flow": 0.94, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 22019, "profile_time": 22019, "status": "PreBrew", "sensors": {"external_1": 101.66, "external_2": 100.55, "bar_up": 76.46, "bar_mid_up": 83.24, "bar_mid_down": 83.99, "bar_down": 81.06, "tube": 81.23, "motor_temp": 30.94, "lam_temp": 29.73, "motor_position": 46.49, "motor_speed": 0.18, "motor_power": 12.01, "motor_current": 0.69, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.91, "flow": 0.88, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 22163, "profile_time": 22163, "status": "PreBrew", "sensors": {"external_1": 101.63, "external_2": 100.51, "bar_up": 76.46, "bar_mid_up": 83.24, "bar_mid_down": 83.99, "bar_down": 80.88, "tube": 81.19, "motor_temp": 30.96, "lam_temp": 29.78, "motor_position": 46.49, "motor_speed": 0.18, "motor_power": 12.25, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.9, "flow": 0.83, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 22280, "profile_time": 22280, "status": "PreBrew", "sensors": {"external_1": 101.56, "external_2": 100.48, "bar_up": 76.46, "bar_mid_up": 83.24, "bar_mid_down": 83.99, "bar_down": 81.06, "tube": 81.18, "motor_temp": 30.94, "lam_temp": 29.81, "motor_position": 46.49, "motor_speed": 0.0, "motor_power": 12.2, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.9, "flow": 0.77, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 22419, "profile_time": 22419, "status": "PreBrew", "sensors": {"external_1": 101.52, "external_2": 100.48, "bar_up": 76.46, "bar_mid_up": 83.24, "bar_mid_down": 83.99, "bar_down": 80.88, "tube": 81.17, "motor_temp": 30.94, "lam_temp": 29.83, "motor_position": 46.49, "motor_speed": 0.0, "motor_power": 12.15, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.89, "flow": 0.71, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 22563, "profile_time": 22563, "status": "PreBrew", "sensors": {"external_1": 101.49, "external_2": 100.44, "bar_up": 76.46, "bar_mid_up": 83.24, "bar_mid_down": 83.99, "bar_down": 80.88, "tube": 81.15, "motor_temp": 30.94, "lam_temp": 29.83, "motor_position": 46.49, "motor_speed": 0.0, "motor_power": 12.14, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.89, "flow": 0.64, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 22685, "profile_time": 22685, "status": "PreBrew", "sensors": {"external_1": 101.46, "external_2": 100.38, "bar_up": 76.46, "bar_mid_up": 83.06, "bar_mid_down": 83.8, "bar_down": 80.88, "tube": 81.12, "motor_temp": 30.99, "lam_temp": 29.83, "motor_position": 46.49, "motor_speed": 0.0, "motor_power": 12.22, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.88, "flow": 0.59, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 22803, "profile_time": 22803, "status": "PreBrew", "sensors": {"external_1": 101.42, "external_2": 100.38, "bar_up": 76.3, "bar_mid_up": 83.24, "bar_mid_down": 83.99, "bar_down": 80.88, "tube": 81.11, "motor_temp": 31.01, "lam_temp": 29.83, "motor_position": 46.59, "motor_speed": 0.13, "motor_power": 12.21, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.88, "flow": 0.53, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 22931, "profile_time": 22931, "status": "PreBrew", "sensors": {"external_1": 101.39, "external_2": 100.38, "bar_up": 76.3, "bar_mid_up": 83.06, "bar_mid_down": 83.8, "bar_down": 80.88, "tube": 81.08, "motor_temp": 31.03, "lam_temp": 29.86, "motor_position": 46.59, "motor_speed": 0.13, "motor_power": 12.2, "motor_current": 0.69, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.87, "flow": 0.49, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 23070, "profile_time": 23070, "status": "PreBrew", "sensors": {"external_1": 101.35, "external_2": 100.34, "bar_up": 76.3, "bar_mid_up": 83.06, "bar_mid_down": 83.8, "bar_down": 80.88, "tube": 81.02, "motor_temp": 31.01, "lam_temp": 29.86, "motor_position": 46.59, "motor_speed": 0.0, "motor_power": 12.12, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.87, "flow": 0.46, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 23187, "profile_time": 23187, "status": "PreBrew", "sensors": {"external_1": 101.35, "external_2": 100.28, "bar_up": 76.3, "bar_mid_up": 83.06, "bar_mid_down": 83.8, "bar_down": 80.88, "tube": 81.0, "motor_temp": 31.01, "lam_temp": 29.86, "motor_position": 46.59, "motor_speed": 0.0, "motor_power": 12.11, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.87, "flow": 0.41, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 23336, "profile_time": 23336, "status": "PreBrew", "sensors": {"external_1": 101.35, "external_2": 100.24, "bar_up": 76.3, "bar_mid_up": 83.24, "bar_mid_down": 83.8, "bar_down": 80.88, "tube": 81.01, "motor_temp": 30.96, "lam_temp": 29.83, "motor_position": 46.59, "motor_speed": 0.0, "motor_power": 12.12, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.86, "flow": 0.39, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 23451, "profile_time": 23451, "status": "PreBrew", "sensors": {"external_1": 101.32, "external_2": 100.21, "bar_up": 76.13, "bar_mid_up": 83.06, "bar_mid_down": 83.8, "bar_down": 80.7, "tube": 80.99, "motor_temp": 30.96, "lam_temp": 29.83, "motor_position": 46.59, "motor_speed": 0.0, "motor_power": 12.05, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.86, "flow": 0.36, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 23580, "profile_time": 23580, "status": "PreBrew", "sensors": {"external_1": 101.25, "external_2": 100.18, "bar_up": 76.13, "bar_mid_up": 83.06, "bar_mid_down": 83.62, "bar_down": 80.88, "tube": 80.96, "motor_temp": 30.96, "lam_temp": 29.83, "motor_position": 46.69, "motor_speed": 0.12, "motor_power": 12.01, "motor_current": 0.68, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.86, "flow": 0.33, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 23716, "profile_time": 23716, "status": "PreBrew", "sensors": {"external_1": 101.22, "external_2": 100.11, "bar_up": 76.13, "bar_mid_up": 83.06, "bar_mid_down": 83.62, "bar_down": 80.7, "tube": 80.91, "motor_temp": 30.96, "lam_temp": 29.83, "motor_position": 46.69, "motor_speed": 0.12, "motor_power": 12.03, "motor_current": 0.69, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.86, "flow": 0.31, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 23856, "profile_time": 23856, "status": "PreBrew", "sensors": {"external_1": 101.22, "external_2": 100.07, "bar_up": 76.13, "bar_mid_up": 82.87, "bar_mid_down": 83.62, "bar_down": 80.7, "tube": 80.89, "motor_temp": 30.94, "lam_temp": 29.83, "motor_position": 46.69, "motor_speed": 0.12, "motor_power": 12.05, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.85, "flow": 0.31, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 23966, "profile_time": 23966, "status": "PreBrew", "sensors": {"external_1": 101.22, "external_2": 100.04, "bar_up": 75.97, "bar_mid_up": 82.87, "bar_mid_down": 83.62, "bar_down": 80.7, "tube": 80.85, "motor_temp": 30.91, "lam_temp": 29.83, "motor_position": 46.69, "motor_speed": 0.0, "motor_power": 12.14, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.85, "flow": 0.31, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 24095, "profile_time": 24095, "status": "PreBrew", "sensors": {"external_1": 101.22, "external_2": 100.01, "bar_up": 75.97, "bar_mid_up": 83.06, "bar_mid_down": 83.62, "bar_down": 80.7, "tube": 80.83, "motor_temp": 30.94, "lam_temp": 29.83, "motor_position": 46.69, "motor_speed": 0.0, "motor_power": 12.07, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.85, "flow": 0.31, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 24233, "profile_time": 24233, "status": "PreBrew", "sensors": {"external_1": 101.22, "external_2": 100.01, "bar_up": 75.97, "bar_mid_up": 82.87, "bar_mid_down": 83.62, "bar_down": 80.7, "tube": 80.82, "motor_temp": 30.94, "lam_temp": 29.83, "motor_position": 46.69, "motor_speed": 0.0, "motor_power": 12.05, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.84, "flow": 0.3, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 24372, "profile_time": 24372, "status": "PreBrew", "sensors": {"external_1": 101.19, "external_2": 99.97, "bar_up": 75.97, "bar_mid_up": 82.87, "bar_mid_down": 83.62, "bar_down": 80.7, "tube": 80.82, "motor_temp": 30.94, "lam_temp": 29.83, "motor_position": 46.69, "motor_speed": 0.0, "motor_power": 12.18, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.84, "flow": 0.29, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 24483, "profile_time": 24483, "status": "PreBrew", "sensors": {"external_1": 101.12, "external_2": 99.94, "bar_up": 75.97, "bar_mid_up": 82.87, "bar_mid_down": 83.62, "bar_down": 80.7, "tube": 80.8, "motor_temp": 30.94, "lam_temp": 29.83, "motor_position": 46.69, "motor_speed": 0.0, "motor_power": 12.27, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.83, "flow": 0.27, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 24636, "profile_time": 24636, "status": "PreBrew", "sensors": {"external_1": 101.05, "external_2": 99.91, "bar_up": 75.97, "bar_mid_up": 82.87, "bar_mid_down": 83.62, "bar_down": 80.7, "tube": 80.8, "motor_temp": 30.94, "lam_temp": 29.83, "motor_position": 46.69, "motor_speed": 0.0, "motor_power": 12.26, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.83, "flow": 0.26, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 24753, "profile_time": 24753, "status": "PreBrew", "sensors": {"external_1": 101.02, "external_2": 99.91, "bar_up": 75.81, "bar_mid_up": 82.87, "bar_mid_down": 83.62, "bar_down": 80.52, "tube": 80.78, "motor_temp": 30.94, "lam_temp": 29.83, "motor_position": 46.79, "motor_speed": 0.09, "motor_power": 12.22, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.83, "flow": 0.24, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 24886, "profile_time": 24886, "status": "PreBrew", "sensors": {"external_1": 101.02, "external_2": 99.87, "bar_up": 75.97, "bar_mid_up": 82.87, "bar_mid_down": 83.62, "bar_down": 80.52, "tube": 80.74, "motor_temp": 30.91, "lam_temp": 29.88, "motor_position": 46.79, "motor_speed": 0.09, "motor_power": 12.17, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.83, "flow": 0.24, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 25022, "profile_time": 25022, "status": "PreBrew", "sensors": {"external_1": 100.98, "external_2": 99.84, "bar_up": 75.81, "bar_mid_up": 82.87, "bar_mid_down": 83.43, "bar_down": 80.7, "tube": 80.71, "motor_temp": 30.91, "lam_temp": 29.88, "motor_position": 46.79, "motor_speed": 0.09, "motor_power": 12.11, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.83, "flow": 0.24, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 25153, "profile_time": 25153, "status": "PreBrew", "sensors": {"external_1": 100.95, "external_2": 99.81, "bar_up": 75.81, "bar_mid_up": 82.69, "bar_mid_down": 83.43, "bar_down": 80.52, "tube": 80.69, "motor_temp": 30.94, "lam_temp": 29.88, "motor_position": 46.79, "motor_speed": 0.0, "motor_power": 12.25, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.82, "flow": 0.24, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 25289, "profile_time": 25289, "status": "PreBrew", "sensors": {"external_1": 100.88, "external_2": 99.77, "bar_up": 75.81, "bar_mid_up": 82.87, "bar_mid_down": 83.43, "bar_down": 80.52, "tube": 80.67, "motor_temp": 30.94, "lam_temp": 29.86, "motor_position": 46.79, "motor_speed": 0.0, "motor_power": 12.19, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.82, "flow": 0.24, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 25409, "profile_time": 25409, "status": "PreBrew", "sensors": {"external_1": 100.81, "external_2": 99.71, "bar_up": 75.81, "bar_mid_up": 82.69, "bar_mid_down": 83.43, "bar_down": 80.52, "tube": 80.66, "motor_temp": 30.96, "lam_temp": 29.83, "motor_position": 46.79, "motor_speed": 0.0, "motor_power": 12.12, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.82, "flow": 0.23, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 25556, "profile_time": 25556, "status": "PreBrew", "sensors": {"external_1": 100.81, "external_2": 99.67, "bar_up": 75.81, "bar_mid_up": 82.69, "bar_mid_down": 83.62, "bar_down": 80.7, "tube": 80.68, "motor_temp": 30.96, "lam_temp": 29.83, "motor_position": 46.79, "motor_speed": 0.0, "motor_power": 12.26, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.82, "flow": 0.21, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 25668, "profile_time": 25668, "status": "PreBrew", "sensors": {"external_1": 100.81, "external_2": 99.64, "bar_up": 75.81, "bar_mid_up": 82.69, "bar_mid_down": 83.43, "bar_down": 80.52, "tube": 80.67, "motor_temp": 30.99, "lam_temp": 29.86, "motor_position": 46.79, "motor_speed": 0.0, "motor_power": 12.19, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.82, "flow": 0.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 25803, "profile_time": 25803, "status": "PreBrew", "sensors": {"external_1": 100.78, "external_2": 99.61, "bar_up": 75.65, "bar_mid_up": 82.69, "bar_mid_down": 83.43, "bar_down": 80.52, "tube": 80.63, "motor_temp": 30.96, "lam_temp": 29.86, "motor_position": 46.79, "motor_speed": 0.0, "motor_power": 12.3, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.82, "flow": 0.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 25936, "profile_time": 25936, "status": "PreBrew", "sensors": {"external_1": 100.71, "external_2": 99.57, "bar_up": 75.65, "bar_mid_up": 82.69, "bar_mid_down": 83.43, "bar_down": 80.52, "tube": 80.59, "motor_temp": 30.99, "lam_temp": 29.91, "motor_position": 46.79, "motor_speed": 0.0, "motor_power": 12.32, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.82, "flow": 0.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 26067, "profile_time": 26067, "status": "PreBrew", "sensors": {"external_1": 100.68, "external_2": 99.54, "bar_up": 75.65, "bar_mid_up": 82.69, "bar_mid_down": 83.43, "bar_down": 80.52, "tube": 80.58, "motor_temp": 30.96, "lam_temp": 29.88, "motor_position": 46.89, "motor_speed": 0.08, "motor_power": 12.33, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.81, "flow": 0.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 26191, "profile_time": 26191, "status": "PreBrew", "sensors": {"external_1": 100.65, "external_2": 99.51, "bar_up": 75.65, "bar_mid_up": 82.69, "bar_mid_down": 83.43, "bar_down": 80.52, "tube": 80.58, "motor_temp": 30.99, "lam_temp": 29.91, "motor_position": 46.89, "motor_speed": 0.08, "motor_power": 12.24, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 2.09, "flow": 0.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 26324, "profile_time": 26324, "status": "PreBrew", "sensors": {"external_1": 100.65, "external_2": 99.47, "bar_up": 75.65, "bar_mid_up": 82.69, "bar_mid_down": 83.43, "bar_down": 80.52, "tube": 80.57, "motor_temp": 30.99, "lam_temp": 29.91, "motor_position": 46.89, "motor_speed": 0.0, "motor_power": 12.36, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 2.09, "flow": 0.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 26457, "profile_time": 26457, "status": "PreBrew", "sensors": {"external_1": 100.58, "external_2": 99.47, "bar_up": 75.65, "bar_mid_up": 82.69, "bar_mid_down": 83.43, "bar_down": 80.52, "tube": 80.57, "motor_temp": 30.99, "lam_temp": 29.93, "motor_position": 46.89, "motor_speed": 0.0, "motor_power": 12.41, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 2.09, "flow": 0.2, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 26593, "profile_time": 26593, "status": "PreBrew", "sensors": {"external_1": 100.58, "external_2": 99.51, "bar_up": 75.49, "bar_mid_up": 82.69, "bar_mid_down": 83.43, "bar_down": 80.52, "tube": 80.56, "motor_temp": 30.96, "lam_temp": 29.93, "motor_position": 46.89, "motor_speed": 0.0, "motor_power": 12.4, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 2.08, "flow": 0.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 26711, "profile_time": 26711, "status": "PreBrew", "sensors": {"external_1": 100.51, "external_2": 99.51, "bar_up": 75.65, "bar_mid_up": 82.69, "bar_mid_down": 83.24, "bar_down": 80.52, "tube": 80.55, "motor_temp": 30.96, "lam_temp": 29.93, "motor_position": 46.89, "motor_speed": 0.0, "motor_power": 12.36, "motor_current": 0.71, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 2.08, "flow": 0.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 26843, "profile_time": 26843, "status": "PreBrew", "sensors": {"external_1": 100.48, "external_2": 99.47, "bar_up": 75.49, "bar_mid_up": 82.69, "bar_mid_down": 83.43, "bar_down": 80.52, "tube": 80.52, "motor_temp": 30.99, "lam_temp": 29.96, "motor_position": 46.89, "motor_speed": 0.0, "motor_power": 12.37, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 26974, "profile_time": 26974, "status": "PreBrew", "sensors": {"external_1": 100.48, "external_2": 99.41, "bar_up": 75.49, "bar_mid_up": 82.69, "bar_mid_down": 83.24, "bar_down": 80.35, "tube": 80.5, "motor_temp": 30.99, "lam_temp": 29.96, "motor_position": 46.89, "motor_speed": 0.0, "motor_power": 12.45, "motor_current": 0.71, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 27108, "profile_time": 27108, "status": "PreBrew", "sensors": {"external_1": 100.51, "external_2": 99.41, "bar_up": 75.49, "bar_mid_up": 82.5, "bar_mid_down": 83.24, "bar_down": 80.35, "tube": 80.46, "motor_temp": 30.99, "lam_temp": 29.96, "motor_position": 46.89, "motor_speed": 0.0, "motor_power": 12.35, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 27241, "profile_time": 27241, "status": "PreBrew", "sensors": {"external_1": 100.48, "external_2": 99.37, "bar_up": 75.49, "bar_mid_up": 82.69, "bar_mid_down": 83.24, "bar_down": 80.35, "tube": 80.45, "motor_temp": 30.99, "lam_temp": 29.96, "motor_position": 46.89, "motor_speed": 0.0, "motor_power": 12.28, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 27380, "profile_time": 27380, "status": "PreBrew", "sensors": {"external_1": 100.41, "external_2": 99.3, "bar_up": 75.33, "bar_mid_up": 82.5, "bar_mid_down": 83.06, "bar_down": 80.35, "tube": 80.39, "motor_temp": 30.99, "lam_temp": 29.93, "motor_position": 46.99, "motor_speed": 0.07, "motor_power": 12.19, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 27502, "profile_time": 27502, "status": "PreBrew", "sensors": {"external_1": 100.34, "external_2": 99.24, "bar_up": 75.33, "bar_mid_up": 82.5, "bar_mid_down": 83.24, "bar_down": 80.35, "tube": 80.38, "motor_temp": 30.99, "lam_temp": 29.93, "motor_position": 46.99, "motor_speed": 0.07, "motor_power": 12.28, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.81, "flow": 0.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 27634, "profile_time": 27634, "status": "PreBrew", "sensors": {"external_1": 100.31, "external_2": 99.24, "bar_up": 75.33, "bar_mid_up": 82.5, "bar_mid_down": 83.24, "bar_down": 80.35, "tube": 80.35, "motor_temp": 30.99, "lam_temp": 29.93, "motor_position": 46.99, "motor_speed": 0.07, "motor_power": 12.34, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.81, "flow": 0.19, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 27766, "profile_time": 27766, "status": "PreBrew", "sensors": {"external_1": 100.24, "external_2": 99.14, "bar_up": 75.33, "bar_mid_up": 82.5, "bar_mid_down": 83.06, "bar_down": 80.35, "tube": 80.33, "motor_temp": 31.01, "lam_temp": 29.93, "motor_position": 46.99, "motor_speed": 0.0, "motor_power": 12.49, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 27894, "profile_time": 27894, "status": "PreBrew", "sensors": {"external_1": 100.24, "external_2": 99.14, "bar_up": 75.33, "bar_mid_up": 82.5, "bar_mid_down": 83.24, "bar_down": 80.35, "tube": 80.33, "motor_temp": 31.01, "lam_temp": 29.96, "motor_position": 46.99, "motor_speed": 0.0, "motor_power": 12.64, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 28031, "profile_time": 28031, "status": "PreBrew", "sensors": {"external_1": 100.21, "external_2": 99.1, "bar_up": 75.17, "bar_mid_up": 82.32, "bar_mid_down": 83.06, "bar_down": 80.35, "tube": 80.3, "motor_temp": 31.01, "lam_temp": 29.98, "motor_position": 46.99, "motor_speed": 0.0, "motor_power": 12.61, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 28153, "profile_time": 28153, "status": "PreBrew", "sensors": {"external_1": 100.21, "external_2": 99.1, "bar_up": 75.17, "bar_mid_up": 82.32, "bar_mid_down": 83.06, "bar_down": 80.35, "tube": 80.28, "motor_temp": 30.99, "lam_temp": 29.98, "motor_position": 46.99, "motor_speed": 0.0, "motor_power": 12.57, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 28293, "profile_time": 28293, "status": "PreBrew", "sensors": {"external_1": 100.14, "external_2": 99.07, "bar_up": 75.17, "bar_mid_up": 82.32, "bar_mid_down": 83.06, "bar_down": 80.35, "tube": 80.22, "motor_temp": 30.96, "lam_temp": 29.98, "motor_position": 46.99, "motor_speed": 0.0, "motor_power": 12.49, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 28414, "profile_time": 28414, "status": "PreBrew", "sensors": {"external_1": 100.11, "external_2": 99.07, "bar_up": 75.17, "bar_mid_up": 82.32, "bar_mid_down": 83.06, "bar_down": 80.35, "tube": 80.22, "motor_temp": 30.94, "lam_temp": 29.96, "motor_position": 46.99, "motor_speed": 0.0, "motor_power": 12.46, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 28551, "profile_time": 28551, "status": "PreBrew", "sensors": {"external_1": 100.07, "external_2": 99.04, "bar_up": 75.17, "bar_mid_up": 82.32, "bar_mid_down": 83.06, "bar_down": 80.35, "tube": 80.22, "motor_temp": 30.94, "lam_temp": 29.96, "motor_position": 46.99, "motor_speed": 0.0, "motor_power": 12.43, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 28687, "profile_time": 28687, "status": "PreBrew", "sensors": {"external_1": 100.04, "external_2": 99.0, "bar_up": 75.17, "bar_mid_up": 82.32, "bar_mid_down": 83.06, "bar_down": 80.35, "tube": 80.22, "motor_temp": 30.91, "lam_temp": 29.93, "motor_position": 46.99, "motor_speed": 0.0, "motor_power": 12.34, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 28801, "profile_time": 28801, "status": "PreBrew", "sensors": {"external_1": 100.01, "external_2": 98.94, "bar_up": 75.17, "bar_mid_up": 82.14, "bar_mid_down": 83.06, "bar_down": 80.35, "tube": 80.21, "motor_temp": 30.94, "lam_temp": 29.96, "motor_position": 47.09, "motor_speed": 0.07, "motor_power": 12.34, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.16, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 28949, "profile_time": 28949, "status": "PreBrew", "sensors": {"external_1": 99.97, "external_2": 98.9, "bar_up": 75.01, "bar_mid_up": 82.14, "bar_mid_down": 83.06, "bar_down": 80.17, "tube": 80.18, "motor_temp": 30.96, "lam_temp": 29.98, "motor_position": 47.09, "motor_speed": 0.07, "motor_power": 12.36, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.16, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 29070, "profile_time": 29070, "status": "PreBrew", "sensors": {"external_1": 99.94, "external_2": 98.87, "bar_up": 75.17, "bar_mid_up": 82.32, "bar_mid_down": 82.87, "bar_down": 80.17, "tube": 80.16, "motor_temp": 30.99, "lam_temp": 30.01, "motor_position": 47.09, "motor_speed": 0.0, "motor_power": 12.28, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 29198, "profile_time": 29198, "status": "PreBrew", "sensors": {"external_1": 99.87, "external_2": 98.8, "bar_up": 75.01, "bar_mid_up": 82.14, "bar_mid_down": 82.87, "bar_down": 80.17, "tube": 80.1, "motor_temp": 31.01, "lam_temp": 30.03, "motor_position": 47.09, "motor_speed": 0.0, "motor_power": 12.38, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 29317, "profile_time": 29317, "status": "PreBrew", "sensors": {"external_1": 99.87, "external_2": 98.8, "bar_up": 75.01, "bar_mid_up": 82.14, "bar_mid_down": 83.06, "bar_down": 80.35, "tube": 80.11, "motor_temp": 30.99, "lam_temp": 30.03, "motor_position": 47.09, "motor_speed": 0.0, "motor_power": 12.38, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 29440, "profile_time": 29440, "status": "PreBrew", "sensors": {"external_1": 99.87, "external_2": 98.77, "bar_up": 75.01, "bar_mid_up": 82.14, "bar_mid_down": 82.87, "bar_down": 80.35, "tube": 80.1, "motor_temp": 30.96, "lam_temp": 30.01, "motor_position": 47.09, "motor_speed": 0.0, "motor_power": 12.38, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 29567, "profile_time": 29567, "status": "PreBrew", "sensors": {"external_1": 99.84, "external_2": 98.74, "bar_up": 75.01, "bar_mid_up": 82.14, "bar_mid_down": 82.87, "bar_down": 80.35, "tube": 80.09, "motor_temp": 30.96, "lam_temp": 30.01, "motor_position": 47.09, "motor_speed": 0.0, "motor_power": 12.31, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 29696, "profile_time": 29696, "status": "PreBrew", "sensors": {"external_1": 99.81, "external_2": 98.67, "bar_up": 75.01, "bar_mid_up": 82.14, "bar_mid_down": 82.87, "bar_down": 80.17, "tube": 80.08, "motor_temp": 30.99, "lam_temp": 30.01, "motor_position": 47.09, "motor_speed": 0.0, "motor_power": 12.31, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 29826, "profile_time": 29826, "status": "PreBrew", "sensors": {"external_1": 99.77, "external_2": 98.67, "bar_up": 75.01, "bar_mid_up": 82.14, "bar_mid_down": 82.87, "bar_down": 80.35, "tube": 80.08, "motor_temp": 31.03, "lam_temp": 30.03, "motor_position": 47.09, "motor_speed": 0.0, "motor_power": 12.32, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.0}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "pressure", "flow": 1.2, "pressure": 1.8}}, "time": 29971, "profile_time": 29971, "status": "PreBrew", "sensors": {"external_1": 99.77, "external_2": 98.67, "bar_up": 74.85, "bar_mid_up": 81.96, "bar_mid_down": 82.87, "bar_down": 80.17, "tube": 80.05, "motor_temp": 31.03, "lam_temp": 30.03, "motor_position": 47.09, "motor_speed": 0.0, "motor_power": 12.4, "motor_current": 0.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.1}}, {"shot": {"pressure": 1.8, "flow": 0.17, "weight": 0.0, "gravimetric_flow": 0.0, "setpoints": {"active": "flow", "flow": 1.2, "pressure": 1.8}}, "time": 30099, "profile_time": 30099, "status": "Extraction", "sensors": {"external_1": 99.74, "external_2": 98.67, "bar_up": 74.85, "bar_mid_up": 82.14, "bar_mid_down": 82.87, "bar_down": 80.35, "tube": 80.03, "motor_temp": 31.03, "lam_temp": 30.03, "motor_position": 47.09, "motor_speed": 0.0, "motor_power": 0.0, "motor_current": -0.11, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.1}}, {"shot": {"pressure": 1.8, "flow": 0.16, "weight": 0.0, "gravimetric_flow": 0.01, "setpoints": {"active": "flow", "flow": 10.8, "pressure": 6.0}}, "time": 30247, "profile_time": 30247, "status": "Extraction", "sensors": {"external_1": 99.71, "external_2": 98.64, "bar_up": 74.85, "bar_mid_up": 81.96, "bar_mid_down": 82.69, "bar_down": 80.17, "tube": 79.99, "motor_temp": 30.99, "lam_temp": 30.03, "motor_position": 47.19, "motor_speed": 0.07, "motor_power": 35.9, "motor_current": 1.09, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.17}}, {"shot": {"pressure": 1.81, "flow": 0.13, "weight": 0.0, "gravimetric_flow": 0.02, "setpoints": {"active": "flow", "flow": 10.8, "pressure": 6.0}}, "time": 30371, "profile_time": 30371, "status": "Extraction", "sensors": {"external_1": 99.67, "external_2": 98.6, "bar_up": 74.85, "bar_mid_up": 81.96, "bar_mid_down": 82.87, "bar_down": 80.17, "tube": 79.99, "motor_temp": 30.99, "lam_temp": 30.03, "motor_position": 47.49, "motor_speed": 2.68, "motor_power": 41.42, "motor_current": 1.42, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.26}}, {"shot": {"pressure": 1.89, "flow": 0.18, "weight": 0.0, "gravimetric_flow": 0.03, "setpoints": {"active": "flow", "flow": 10.8, "pressure": 6.0}}, "time": 30489, "profile_time": 30489, "status": "Extraction", "sensors": {"external_1": 99.64, "external_2": 98.6, "bar_up": 74.85, "bar_mid_up": 82.14, "bar_mid_down": 82.87, "bar_down": 80.17, "tube": 79.99, "motor_temp": 30.99, "lam_temp": 30.03, "motor_position": 47.99, "motor_speed": 3.1, "motor_power": 46.78, "motor_current": 1.74, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.44}}, {"shot": {"pressure": 2.11, "flow": 0.39, "weight": 0.0, "gravimetric_flow": 0.05, "setpoints": {"active": "flow", "flow": 10.8, "pressure": 6.0}}, "time": 30647, "profile_time": 30647, "status": "Extraction", "sensors": {"external_1": 99.61, "external_2": 98.57, "bar_up": 74.85, "bar_mid_up": 81.96, "bar_mid_down": 82.69, "bar_down": 80.17, "tube": 79.96, "motor_temp": 30.99, "lam_temp": 30.03, "motor_position": 48.39, "motor_speed": 3.32, "motor_power": 50.83, "motor_current": 2.04, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.55}}, {"shot": {"pressure": 2.48, "flow": 0.61, "weight": 0.0, "gravimetric_flow": 0.06, "setpoints": {"active": "flow", "flow": 10.8, "pressure": 6.0}}, "time": 30771, "profile_time": 30771, "status": "Extraction", "sensors": {"external_1": 99.57, "external_2": 98.57, "bar_up": 74.85, "bar_mid_up": 81.96, "bar_mid_down": 82.69, "bar_down": 80.17, "tube": 79.95, "motor_temp": 30.99, "lam_temp": 30.06, "motor_position": 48.8, "motor_speed": 3.37, "motor_power": 55.41, "motor_current": 2.32, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.69}}, {"shot": {"pressure": 3.02, "flow": 0.92, "weight": 0.0, "gravimetric_flow": 0.08, "setpoints": {"active": "flow", "flow": 10.8, "pressure": 6.0}}, "time": 30884, "profile_time": 30884, "status": "Extraction", "sensors": {"external_1": 99.57, "external_2": 98.5, "bar_up": 74.85, "bar_mid_up": 81.96, "bar_mid_down": 82.69, "bar_down": 80.35, "tube": 79.94, "motor_temp": 30.99, "lam_temp": 30.06, "motor_position": 49.2, "motor_speed": 3.37, "motor_power": 58.99, "motor_current": 2.83, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.83}}, {"shot": {"pressure": 3.76, "flow": 1.22, "weight": 0.0, "gravimetric_flow": 0.1, "setpoints": {"active": "flow", "flow": 10.8, "pressure": 6.0}}, "time": 31014, "profile_time": 31014, "status": "Extraction", "sensors": {"external_1": 99.57, "external_2": 98.44, "bar_up": 74.7, "bar_mid_up": 81.96, "bar_mid_down": 82.69, "bar_down": 80.17, "tube": 79.92, "motor_temp": 30.99, "lam_temp": 30.08, "motor_position": 49.7, "motor_speed": 3.53, "motor_power": 51.53, "motor_current": 3.28, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.95}}, {"shot": {"pressure": 4.64, "flow": 1.61, "weight": 0.2, "gravimetric_flow": 0.12, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 31148, "profile_time": 31148, "status": "Extraction", "sensors": {"external_1": 99.54, "external_2": 98.44, "bar_up": 74.7, "bar_mid_up": 81.77, "bar_mid_down": 82.69, "bar_down": 80.17, "tube": 79.89, "motor_temp": 30.99, "lam_temp": 30.08, "motor_position": 49.9, "motor_speed": 1.7, "motor_power": 34.38, "motor_current": 1.97, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 289.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 1.08}}, {"shot": {"pressure": 5.49, "flow": 2.04, "weight": 0.21, "gravimetric_flow": 0.13, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 31277, "profile_time": 31277, "status": "Extraction", "sensors": {"external_1": 99.51, "external_2": 98.44, "bar_up": 74.7, "bar_mid_up": 81.77, "bar_mid_down": 82.69, "bar_down": 80.17, "tube": 79.85, "motor_temp": 30.96, "lam_temp": 30.08, "motor_position": 50.0, "motor_speed": 0.88, "motor_power": 30.13, "motor_current": 1.73, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 1.01}}, {"shot": {"pressure": 6.23, "flow": 2.47, "weight": 0.21, "gravimetric_flow": 0.14, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 31427, "profile_time": 31427, "status": "Extraction", "sensors": {"external_1": 99.47, "external_2": 98.44, "bar_up": 74.7, "bar_mid_up": 81.77, "bar_mid_down": 82.69, "bar_down": 80.17, "tube": 79.84, "motor_temp": 30.96, "lam_temp": 30.08, "motor_position": 50.0, "motor_speed": 0.88, "motor_power": 28.25, "motor_current": 1.65, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.83}}, {"shot": {"pressure": 6.79, "flow": 2.89, "weight": 0.22, "gravimetric_flow": 0.14, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 31556, "profile_time": 31556, "status": "Extraction", "sensors": {"external_1": 99.44, "external_2": 98.34, "bar_up": 74.7, "bar_mid_up": 81.77, "bar_mid_down": 82.69, "bar_down": 80.17, "tube": 79.83, "motor_temp": 30.99, "lam_temp": 30.11, "motor_position": 50.1, "motor_speed": 0.4, "motor_power": 28.82, "motor_current": 1.72, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.71}}, {"shot": {"pressure": 7.13, "flow": 3.33, "weight": 0.22, "gravimetric_flow": 0.13, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 31669, "profile_time": 31669, "status": "Extraction", "sensors": {"external_1": 99.37, "external_2": 98.3, "bar_up": 74.7, "bar_mid_up": 81.77, "bar_mid_down": 82.69, "bar_down": 80.17, "tube": 79.83, "motor_temp": 31.01, "lam_temp": 30.13, "motor_position": 50.1, "motor_speed": 0.4, "motor_power": 29.79, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.6}}, {"shot": {"pressure": 7.21, "flow": 3.68, "weight": 0.25, "gravimetric_flow": 0.13, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 31801, "profile_time": 31801, "status": "Extraction", "sensors": {"external_1": 99.34, "external_2": 98.27, "bar_up": 74.7, "bar_mid_up": 81.77, "bar_mid_down": 82.69, "bar_down": 80.17, "tube": 79.83, "motor_temp": 31.03, "lam_temp": 30.13, "motor_position": 50.1, "motor_speed": 0.0, "motor_power": 30.28, "motor_current": 1.83, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.44}}, {"shot": {"pressure": 7.18, "flow": 3.89, "weight": 0.25, "gravimetric_flow": 0.12, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 31950, "profile_time": 31950, "status": "Extraction", "sensors": {"external_1": 99.34, "external_2": 98.27, "bar_up": 74.54, "bar_mid_up": 81.77, "bar_mid_down": 82.69, "bar_down": 80.17, "tube": 79.82, "motor_temp": 31.03, "lam_temp": 30.13, "motor_position": 50.1, "motor_speed": 0.0, "motor_power": 30.32, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.28}}, {"shot": {"pressure": 7.11, "flow": 4.06, "weight": 0.28, "gravimetric_flow": 0.11, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 32063, "profile_time": 32063, "status": "Extraction", "sensors": {"external_1": 99.34, "external_2": 98.3, "bar_up": 74.7, "bar_mid_up": 81.77, "bar_mid_down": 82.69, "bar_down": 80.0, "tube": 79.81, "motor_temp": 31.03, "lam_temp": 30.13, "motor_position": 50.2, "motor_speed": 0.18, "motor_power": 29.44, "motor_current": 1.77, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 0.76}}, {"shot": {"pressure": 7.04, "flow": 4.14, "weight": 0.31, "gravimetric_flow": 0.1, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 32195, "profile_time": 32195, "status": "Extraction", "sensors": {"external_1": 99.3, "external_2": 98.3, "bar_up": 74.54, "bar_mid_up": 81.59, "bar_mid_down": 82.69, "bar_down": 80.17, "tube": 79.79, "motor_temp": 31.01, "lam_temp": 30.13, "motor_position": 50.2, "motor_speed": 0.18, "motor_power": 29.67, "motor_current": 1.73, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 1.13}}, {"shot": {"pressure": 6.97, "flow": 4.15, "weight": 0.75, "gravimetric_flow": 0.12, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 32346, "profile_time": 32346, "status": "Extraction", "sensors": {"external_1": 99.27, "external_2": 98.27, "bar_up": 74.7, "bar_mid_up": 81.59, "bar_mid_down": 82.5, "bar_down": 80.17, "tube": 79.78, "motor_temp": 31.01, "lam_temp": 30.13, "motor_position": 50.2, "motor_speed": 0.18, "motor_power": 29.52, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 1.7}}, {"shot": {"pressure": 6.92, "flow": 4.0, "weight": 0.88, "gravimetric_flow": 0.17, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 32465, "profile_time": 32465, "status": "Extraction", "sensors": {"external_1": 99.2, "external_2": 98.24, "bar_up": 74.54, "bar_mid_up": 81.77, "bar_mid_down": 82.5, "bar_down": 80.0, "tube": 79.75, "motor_temp": 31.01, "lam_temp": 30.16, "motor_position": 50.2, "motor_speed": 0.0, "motor_power": 28.95, "motor_current": 1.72, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 2.03}}, {"shot": {"pressure": 6.88, "flow": 3.8, "weight": 0.9, "gravimetric_flow": 0.23, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 32594, "profile_time": 32594, "status": "Extraction", "sensors": {"external_1": 99.17, "external_2": 98.21, "bar_up": 74.54, "bar_mid_up": 81.59, "bar_mid_down": 82.5, "bar_down": 80.17, "tube": 79.73, "motor_temp": 31.01, "lam_temp": 30.16, "motor_position": 50.3, "motor_speed": 0.2, "motor_power": 29.9, "motor_current": 1.79, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 2.28}}, {"shot": {"pressure": 6.83, "flow": 3.5, "weight": 1.0, "gravimetric_flow": 0.29, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 32730, "profile_time": 32730, "status": "Extraction", "sensors": {"external_1": 99.14, "external_2": 98.17, "bar_up": 74.54, "bar_mid_up": 81.59, "bar_mid_down": 82.5, "bar_down": 80.0, "tube": 79.71, "motor_temp": 31.01, "lam_temp": 30.16, "motor_position": 50.3, "motor_speed": 0.2, "motor_power": 30.62, "motor_current": 1.86, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 2.77}}, {"shot": {"pressure": 6.77, "flow": 3.15, "weight": 1.04, "gravimetric_flow": 0.36, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 32859, "profile_time": 32859, "status": "Extraction", "sensors": {"external_1": 99.1, "external_2": 98.11, "bar_up": 74.54, "bar_mid_up": 81.59, "bar_mid_down": 82.5, "bar_down": 80.0, "tube": 79.67, "motor_temp": 31.01, "lam_temp": 30.13, "motor_position": 50.3, "motor_speed": 0.0, "motor_power": 30.59, "motor_current": 1.9, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 2.94}}, {"shot": {"pressure": 6.72, "flow": 2.79, "weight": 1.15, "gravimetric_flow": 0.44, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 33004, "profile_time": 33004, "status": "Extraction", "sensors": {"external_1": 99.07, "external_2": 98.04, "bar_up": 74.38, "bar_mid_up": 81.59, "bar_mid_down": 82.5, "bar_down": 80.0, "tube": 79.65, "motor_temp": 31.01, "lam_temp": 30.16, "motor_position": 50.3, "motor_speed": 0.0, "motor_power": 29.76, "motor_current": 1.78, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 3.0}}, {"shot": {"pressure": 6.67, "flow": 2.4, "weight": 1.18, "gravimetric_flow": 0.52, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 33119, "profile_time": 33119, "status": "Extraction", "sensors": {"external_1": 99.04, "external_2": 98.04, "bar_up": 74.38, "bar_mid_up": 81.59, "bar_mid_down": 82.5, "bar_down": 80.0, "tube": 79.64, "motor_temp": 31.03, "lam_temp": 30.16, "motor_position": 50.4, "motor_speed": 0.21, "motor_power": 29.35, "motor_current": 1.71, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 3.02}}, {"shot": {"pressure": 6.63, "flow": 2.01, "weight": 1.18, "gravimetric_flow": 0.6, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 33253, "profile_time": 33253, "status": "Extraction", "sensors": {"external_1": 99.0, "external_2": 98.01, "bar_up": 74.38, "bar_mid_up": 81.59, "bar_mid_down": 82.5, "bar_down": 80.0, "tube": 79.63, "motor_temp": 31.03, "lam_temp": 30.16, "motor_position": 50.4, "motor_speed": 0.21, "motor_power": 28.95, "motor_current": 1.68, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 3.1}}, {"shot": {"pressure": 6.61, "flow": 1.65, "weight": 1.21, "gravimetric_flow": 0.64, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 33397, "profile_time": 33397, "status": "Extraction", "sensors": {"external_1": 98.94, "external_2": 97.91, "bar_up": 74.38, "bar_mid_up": 81.59, "bar_mid_down": 82.5, "bar_down": 80.0, "tube": 79.62, "motor_temp": 31.03, "lam_temp": 30.13, "motor_position": 50.4, "motor_speed": 0.0, "motor_power": 28.61, "motor_current": 1.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 3.18}}, {"shot": {"pressure": 6.59, "flow": 1.29, "weight": 1.41, "gravimetric_flow": 0.67, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 33515, "profile_time": 33515, "status": "Extraction", "sensors": {"external_1": 98.94, "external_2": 97.87, "bar_up": 74.23, "bar_mid_up": 81.59, "bar_mid_down": 82.5, "bar_down": 80.0, "tube": 79.61, "motor_temp": 30.99, "lam_temp": 30.13, "motor_position": 50.5, "motor_speed": 0.22, "motor_power": 29.5, "motor_current": 1.74, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 3.34}}, {"shot": {"pressure": 6.57, "flow": 1.02, "weight": 1.59, "gravimetric_flow": 0.68, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 33645, "profile_time": 33645, "status": "Extraction", "sensors": {"external_1": 98.94, "external_2": 97.84, "bar_up": 74.23, "bar_mid_up": 81.59, "bar_mid_down": 82.32, "bar_down": 80.0, "tube": 79.59, "motor_temp": 30.99, "lam_temp": 30.13, "motor_position": 50.5, "motor_speed": 0.22, "motor_power": 30.65, "motor_current": 1.83, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 3.34}}, {"shot": {"pressure": 6.52, "flow": 0.83, "weight": 1.64, "gravimetric_flow": 0.68, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 33787, "profile_time": 33787, "status": "Extraction", "sensors": {"external_1": 98.94, "external_2": 97.81, "bar_up": 74.23, "bar_mid_up": 81.59, "bar_mid_down": 82.5, "bar_down": 80.0, "tube": 79.58, "motor_temp": 30.99, "lam_temp": 30.13, "motor_position": 50.5, "motor_speed": 0.0, "motor_power": 30.87, "motor_current": 1.86, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 3.59}}, {"shot": {"pressure": 6.48, "flow": 0.63, "weight": 1.9, "gravimetric_flow": 0.68, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 33903, "profile_time": 33903, "status": "Extraction", "sensors": {"external_1": 98.87, "external_2": 97.77, "bar_up": 74.07, "bar_mid_up": 81.41, "bar_mid_down": 82.32, "bar_down": 80.0, "tube": 79.52, "motor_temp": 31.01, "lam_temp": 30.16, "motor_position": 50.5, "motor_speed": 0.0, "motor_power": 30.23, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 3.64}}, {"shot": {"pressure": 6.44, "flow": 0.52, "weight": 1.97, "gravimetric_flow": 0.68, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 34031, "profile_time": 34031, "status": "Extraction", "sensors": {"external_1": 98.84, "external_2": 97.81, "bar_up": 74.23, "bar_mid_up": 81.41, "bar_mid_down": 82.5, "bar_down": 80.0, "tube": 79.52, "motor_temp": 31.01, "lam_temp": 30.18, "motor_position": 50.6, "motor_speed": 0.22, "motor_power": 29.64, "motor_current": 1.72, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 3.64}}, {"shot": {"pressure": 6.41, "flow": 0.49, "weight": 1.96, "gravimetric_flow": 0.69, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 34159, "profile_time": 34159, "status": "Extraction", "sensors": {"external_1": 98.8, "external_2": 97.77, "bar_up": 74.23, "bar_mid_up": 81.41, "bar_mid_down": 82.32, "bar_down": 80.0, "tube": 79.5, "motor_temp": 31.03, "lam_temp": 30.21, "motor_position": 50.6, "motor_speed": 0.22, "motor_power": 29.17, "motor_current": 1.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 3.78}}, {"shot": {"pressure": 6.39, "flow": 0.48, "weight": 2.0, "gravimetric_flow": 0.68, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 34287, "profile_time": 34287, "status": "Extraction", "sensors": {"external_1": 98.77, "external_2": 97.77, "bar_up": 74.23, "bar_mid_up": 81.41, "bar_mid_down": 82.32, "bar_down": 80.0, "tube": 79.5, "motor_temp": 31.06, "lam_temp": 30.23, "motor_position": 50.6, "motor_speed": 0.0, "motor_power": 29.11, "motor_current": 1.73, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 3.8}}, {"shot": {"pressure": 6.38, "flow": 0.49, "weight": 2.05, "gravimetric_flow": 0.67, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 34420, "profile_time": 34420, "status": "Extraction", "sensors": {"external_1": 98.74, "external_2": 97.71, "bar_up": 74.23, "bar_mid_up": 81.41, "bar_mid_down": 82.5, "bar_down": 80.0, "tube": 79.5, "motor_temp": 31.06, "lam_temp": 30.23, "motor_position": 50.7, "motor_speed": 0.25, "motor_power": 30.28, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 3.82}}, {"shot": {"pressure": 6.37, "flow": 0.49, "weight": 2.06, "gravimetric_flow": 0.66, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 34570, "profile_time": 34570, "status": "Extraction", "sensors": {"external_1": 98.7, "external_2": 97.74, "bar_up": 74.23, "bar_mid_up": 81.41, "bar_mid_down": 82.32, "bar_down": 80.0, "tube": 79.5, "motor_temp": 31.06, "lam_temp": 30.23, "motor_position": 50.7, "motor_speed": 0.25, "motor_power": 30.84, "motor_current": 1.82, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 4.04}}, {"shot": {"pressure": 6.34, "flow": 0.51, "weight": 2.14, "gravimetric_flow": 0.68, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 34687, "profile_time": 34687, "status": "Extraction", "sensors": {"external_1": 98.67, "external_2": 97.74, "bar_up": 74.23, "bar_mid_up": 81.41, "bar_mid_down": 82.32, "bar_down": 79.82, "tube": 79.48, "motor_temp": 31.08, "lam_temp": 30.23, "motor_position": 50.7, "motor_speed": 0.0, "motor_power": 30.93, "motor_current": 1.81, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 4.16}}, {"shot": {"pressure": 6.31, "flow": 0.51, "weight": 2.41, "gravimetric_flow": 0.72, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 34827, "profile_time": 34827, "status": "Extraction", "sensors": {"external_1": 98.64, "external_2": 97.71, "bar_up": 74.07, "bar_mid_up": 81.41, "bar_mid_down": 82.32, "bar_down": 80.0, "tube": 79.46, "motor_temp": 31.1, "lam_temp": 30.23, "motor_position": 50.8, "motor_speed": 0.24, "motor_power": 29.74, "motor_current": 1.72, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 4.27}}, {"shot": {"pressure": 6.28, "flow": 0.5, "weight": 2.52, "gravimetric_flow": 0.74, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 34954, "profile_time": 34954, "status": "Extraction", "sensors": {"external_1": 98.6, "external_2": 97.64, "bar_up": 74.07, "bar_mid_up": 81.41, "bar_mid_down": 82.32, "bar_down": 80.0, "tube": 79.45, "motor_temp": 31.08, "lam_temp": 30.23, "motor_position": 50.8, "motor_speed": 0.24, "motor_power": 28.95, "motor_current": 1.66, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 4.34}}, {"shot": {"pressure": 6.27, "flow": 0.5, "weight": 2.62, "gravimetric_flow": 0.75, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 35095, "profile_time": 35095, "status": "Extraction", "sensors": {"external_1": 98.6, "external_2": 97.57, "bar_up": 74.07, "bar_mid_up": 81.23, "bar_mid_down": 82.32, "bar_down": 80.0, "tube": 79.42, "motor_temp": 31.03, "lam_temp": 30.23, "motor_position": 50.9, "motor_speed": 0.32, "motor_power": 28.58, "motor_current": 1.65, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 4.4}}, {"shot": {"pressure": 6.27, "flow": 0.51, "weight": 2.69, "gravimetric_flow": 0.75, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 35231, "profile_time": 35231, "status": "Extraction", "sensors": {"external_1": 98.5, "external_2": 97.48, "bar_up": 73.91, "bar_mid_up": 81.23, "bar_mid_down": 82.32, "bar_down": 80.0, "tube": 79.4, "motor_temp": 31.01, "lam_temp": 30.23, "motor_position": 50.9, "motor_speed": 0.32, "motor_power": 29.49, "motor_current": 1.75, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 4.66}}, {"shot": {"pressure": 6.26, "flow": 0.52, "weight": 2.84, "gravimetric_flow": 0.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 35365, "profile_time": 35365, "status": "Extraction", "sensors": {"external_1": 98.47, "external_2": 97.44, "bar_up": 73.91, "bar_mid_up": 81.23, "bar_mid_down": 82.14, "bar_down": 79.82, "tube": 79.35, "motor_temp": 31.01, "lam_temp": 30.23, "motor_position": 50.9, "motor_speed": 0.0, "motor_power": 29.97, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 4.8}}, {"shot": {"pressure": 6.26, "flow": 0.54, "weight": 3.05, "gravimetric_flow": 0.77, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 35511, "profile_time": 35511, "status": "Extraction", "sensors": {"external_1": 98.47, "external_2": 97.44, "bar_up": 73.91, "bar_mid_up": 81.23, "bar_mid_down": 82.14, "bar_down": 80.0, "tube": 79.33, "motor_temp": 31.01, "lam_temp": 30.23, "motor_position": 50.9, "motor_speed": 0.23, "motor_power": 29.98, "motor_current": 1.72, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 4.78}}, {"shot": {"pressure": 6.24, "flow": 0.54, "weight": 3.01, "gravimetric_flow": 0.79, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 35633, "profile_time": 35633, "status": "Extraction", "sensors": {"external_1": 98.4, "external_2": 97.41, "bar_up": 73.91, "bar_mid_up": 81.06, "bar_mid_down": 82.14, "bar_down": 79.82, "tube": 79.29, "motor_temp": 31.01, "lam_temp": 30.23, "motor_position": 51.0, "motor_speed": 0.23, "motor_power": 29.68, "motor_current": 1.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 4.77}}, {"shot": {"pressure": 6.22, "flow": 0.54, "weight": 3.0, "gravimetric_flow": 0.78, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 35763, "profile_time": 35763, "status": "Extraction", "sensors": {"external_1": 98.37, "external_2": 97.38, "bar_up": 73.76, "bar_mid_up": 81.06, "bar_mid_down": 82.14, "bar_down": 79.82, "tube": 79.27, "motor_temp": 31.01, "lam_temp": 30.23, "motor_position": 51.0, "motor_speed": 0.23, "motor_power": 29.12, "motor_current": 1.7, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 4.77}}, {"shot": {"pressure": 6.2, "flow": 0.55, "weight": 3.0, "gravimetric_flow": 0.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 35907, "profile_time": 35907, "status": "Extraction", "sensors": {"external_1": 98.34, "external_2": 97.34, "bar_up": 73.91, "bar_mid_up": 81.23, "bar_mid_down": 82.14, "bar_down": 80.0, "tube": 79.27, "motor_temp": 31.01, "lam_temp": 30.23, "motor_position": 51.1, "motor_speed": 0.32, "motor_power": 29.05, "motor_current": 1.68, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 4.96}}, {"shot": {"pressure": 6.19, "flow": 0.57, "weight": 3.21, "gravimetric_flow": 0.77, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 36026, "profile_time": 36026, "status": "Extraction", "sensors": {"external_1": 98.3, "external_2": 97.24, "bar_up": 73.76, "bar_mid_up": 81.06, "bar_mid_down": 82.14, "bar_down": 79.82, "tube": 79.23, "motor_temp": 31.01, "lam_temp": 30.23, "motor_position": 51.1, "motor_speed": 0.32, "motor_power": 29.94, "motor_current": 1.71, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 5.02}}, {"shot": {"pressure": 6.18, "flow": 0.6, "weight": 3.29, "gravimetric_flow": 0.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 36171, "profile_time": 36171, "status": "Extraction", "sensors": {"external_1": 98.3, "external_2": 97.24, "bar_up": 73.91, "bar_mid_up": 81.06, "bar_mid_down": 82.14, "bar_down": 79.82, "tube": 79.24, "motor_temp": 31.01, "lam_temp": 30.23, "motor_position": 51.1, "motor_speed": 0.0, "motor_power": 30.35, "motor_current": 1.78, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 5.16}}, {"shot": {"pressure": 6.17, "flow": 0.61, "weight": 3.36, "gravimetric_flow": 0.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 36284, "profile_time": 36284, "status": "Extraction", "sensors": {"external_1": 98.24, "external_2": 97.21, "bar_up": 73.76, "bar_mid_up": 81.06, "bar_mid_down": 82.14, "bar_down": 79.82, "tube": 79.2, "motor_temp": 31.01, "lam_temp": 30.23, "motor_position": 51.2, "motor_speed": 0.24, "motor_power": 30.42, "motor_current": 1.78, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 5.29}}, {"shot": {"pressure": 6.15, "flow": 0.61, "weight": 3.62, "gravimetric_flow": 0.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 36429, "profile_time": 36429, "status": "Extraction", "sensors": {"external_1": 98.21, "external_2": 97.21, "bar_up": 73.76, "bar_mid_up": 81.06, "bar_mid_down": 82.14, "bar_down": 79.82, "tube": 79.2, "motor_temp": 31.01, "lam_temp": 30.23, "motor_position": 51.2, "motor_speed": 0.24, "motor_power": 30.14, "motor_current": 1.75, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 5.39}}, {"shot": {"pressure": 6.13, "flow": 0.63, "weight": 3.73, "gravimetric_flow": 0.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 36555, "profile_time": 36555, "status": "Extraction", "sensors": {"external_1": 98.17, "external_2": 97.18, "bar_up": 73.76, "bar_mid_up": 81.06, "bar_mid_down": 82.14, "bar_down": 79.82, "tube": 79.2, "motor_temp": 31.01, "lam_temp": 30.23, "motor_position": 51.31, "motor_speed": 0.36, "motor_power": 29.3, "motor_current": 1.66, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 5.44}}, {"shot": {"pressure": 6.13, "flow": 0.63, "weight": 3.77, "gravimetric_flow": 0.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 36668, "profile_time": 36668, "status": "Extraction", "sensors": {"external_1": 98.11, "external_2": 97.14, "bar_up": 73.6, "bar_mid_up": 81.06, "bar_mid_down": 82.14, "bar_down": 79.82, "tube": 79.17, "motor_temp": 31.01, "lam_temp": 30.23, "motor_position": 51.31, "motor_speed": 0.36, "motor_power": 29.2, "motor_current": 1.71, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 5.7}}, {"shot": {"pressure": 6.13, "flow": 0.63, "weight": 4.0, "gravimetric_flow": 0.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 36811, "profile_time": 36811, "status": "Extraction", "sensors": {"external_1": 98.07, "external_2": 97.11, "bar_up": 73.6, "bar_mid_up": 81.06, "bar_mid_down": 82.14, "bar_down": 79.82, "tube": 79.16, "motor_temp": 31.01, "lam_temp": 30.23, "motor_position": 51.31, "motor_speed": 0.36, "motor_power": 30.22, "motor_current": 1.76, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 5.73}}, {"shot": {"pressure": 6.12, "flow": 0.63, "weight": 3.99, "gravimetric_flow": 0.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 36933, "profile_time": 36933, "status": "Extraction", "sensors": {"external_1": 98.04, "external_2": 97.08, "bar_up": 73.6, "bar_mid_up": 81.06, "bar_mid_down": 81.96, "bar_down": 79.82, "tube": 79.14, "motor_temp": 31.01, "lam_temp": 30.26, "motor_position": 51.41, "motor_speed": 0.27, "motor_power": 30.64, "motor_current": 1.81, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 5.72}}, {"shot": {"pressure": 6.11, "flow": 0.63, "weight": 3.98, "gravimetric_flow": 0.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 37080, "profile_time": 37080, "status": "Extraction", "sensors": {"external_1": 98.01, "external_2": 97.08, "bar_up": 73.6, "bar_mid_up": 81.06, "bar_mid_down": 81.96, "bar_down": 79.82, "tube": 79.11, "motor_temp": 31.03, "lam_temp": 30.31, "motor_position": 51.41, "motor_speed": 0.27, "motor_power": 30.43, "motor_current": 1.77, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 5.75}}, {"shot": {"pressure": 6.1, "flow": 0.63, "weight": 3.99, "gravimetric_flow": 0.77, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 37208, "profile_time": 37208, "status": "Extraction", "sensors": {"external_1": 98.01, "external_2": 97.04, "bar_up": 73.45, "bar_mid_up": 81.06, "bar_mid_down": 81.96, "bar_down": 79.82, "tube": 79.09, "motor_temp": 31.03, "lam_temp": 30.33, "motor_position": 51.51, "motor_speed": 0.35, "motor_power": 29.99, "motor_current": 1.75, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 286.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 5.77}}, {"shot": {"pressure": 6.09, "flow": 0.63, "weight": 4.04, "gravimetric_flow": 0.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 37325, "profile_time": 37325, "status": "Extraction", "sensors": {"external_1": 98.01, "external_2": 97.04, "bar_up": 73.6, "bar_mid_up": 81.06, "bar_mid_down": 81.96, "bar_down": 79.82, "tube": 79.09, "motor_temp": 31.03, "lam_temp": 30.33, "motor_position": 51.51, "motor_speed": 0.35, "motor_power": 29.99, "motor_current": 1.73, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 5.95}}, {"shot": {"pressure": 6.08, "flow": 0.63, "weight": 4.23, "gravimetric_flow": 0.75, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 37472, "profile_time": 37472, "status": "Extraction", "sensors": {"external_1": 97.97, "external_2": 96.98, "bar_up": 73.45, "bar_mid_up": 80.88, "bar_mid_down": 81.96, "bar_down": 79.82, "tube": 79.07, "motor_temp": 31.03, "lam_temp": 30.33, "motor_position": 51.51, "motor_speed": 0.35, "motor_power": 30.19, "motor_current": 1.74, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 6.21}}, {"shot": {"pressure": 6.07, "flow": 0.64, "weight": 4.51, "gravimetric_flow": 0.75, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 37588, "profile_time": 37588, "status": "Extraction", "sensors": {"external_1": 97.94, "external_2": 96.95, "bar_up": 73.45, "bar_mid_up": 81.06, "bar_mid_down": 81.96, "bar_down": 79.82, "tube": 79.07, "motor_temp": 31.03, "lam_temp": 30.33, "motor_position": 51.61, "motor_speed": 0.3, "motor_power": 30.98, "motor_current": 1.81, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 6.3}}, {"shot": {"pressure": 6.06, "flow": 0.64, "weight": 4.64, "gravimetric_flow": 0.75, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 37727, "profile_time": 37727, "status": "Extraction", "sensors": {"external_1": 97.91, "external_2": 96.95, "bar_up": 73.45, "bar_mid_up": 80.88, "bar_mid_down": 81.96, "bar_down": 79.82, "tube": 79.05, "motor_temp": 31.03, "lam_temp": 30.33, "motor_position": 51.61, "motor_speed": 0.3, "motor_power": 30.93, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 6.41}}, {"shot": {"pressure": 6.06, "flow": 0.64, "weight": 4.75, "gravimetric_flow": 0.75, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 37844, "profile_time": 37844, "status": "Extraction", "sensors": {"external_1": 97.87, "external_2": 96.91, "bar_up": 73.45, "bar_mid_up": 80.88, "bar_mid_down": 81.77, "bar_down": 79.82, "tube": 79.03, "motor_temp": 31.03, "lam_temp": 30.33, "motor_position": 51.71, "motor_speed": 0.35, "motor_power": 30.43, "motor_current": 1.76, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 6.56}}, {"shot": {"pressure": 6.05, "flow": 0.66, "weight": 4.85, "gravimetric_flow": 0.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 37982, "profile_time": 37982, "status": "Extraction", "sensors": {"external_1": 97.81, "external_2": 96.85, "bar_up": 73.45, "bar_mid_up": 80.88, "bar_mid_down": 81.96, "bar_down": 79.82, "tube": 79.0, "motor_temp": 31.01, "lam_temp": 30.33, "motor_position": 51.71, "motor_speed": 0.35, "motor_power": 30.27, "motor_current": 1.72, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 6.73}}, {"shot": {"pressure": 6.04, "flow": 0.67, "weight": 5.01, "gravimetric_flow": 0.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 38130, "profile_time": 38130, "status": "Extraction", "sensors": {"external_1": 97.77, "external_2": 96.81, "bar_up": 73.45, "bar_mid_up": 80.88, "bar_mid_down": 81.96, "bar_down": 79.65, "tube": 78.99, "motor_temp": 31.01, "lam_temp": 30.33, "motor_position": 51.81, "motor_speed": 0.34, "motor_power": 30.47, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 6.86}}, {"shot": {"pressure": 6.04, "flow": 0.69, "weight": 5.11, "gravimetric_flow": 0.78, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 38262, "profile_time": 38262, "status": "Extraction", "sensors": {"external_1": 97.74, "external_2": 96.81, "bar_up": 73.45, "bar_mid_up": 80.7, "bar_mid_down": 81.96, "bar_down": 79.65, "tube": 78.98, "motor_temp": 31.01, "lam_temp": 30.33, "motor_position": 51.81, "motor_speed": 0.34, "motor_power": 30.77, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 6.93}}, {"shot": {"pressure": 6.04, "flow": 0.7, "weight": 5.16, "gravimetric_flow": 0.81, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 38384, "profile_time": 38384, "status": "Extraction", "sensors": {"external_1": 97.67, "external_2": 96.78, "bar_up": 73.3, "bar_mid_up": 80.88, "bar_mid_down": 81.77, "bar_down": 79.65, "tube": 78.93, "motor_temp": 31.01, "lam_temp": 30.36, "motor_position": 51.91, "motor_speed": 0.36, "motor_power": 30.53, "motor_current": 1.76, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 6.94}}, {"shot": {"pressure": 6.04, "flow": 0.72, "weight": 5.14, "gravimetric_flow": 0.83, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 38512, "profile_time": 38512, "status": "Extraction", "sensors": {"external_1": 97.64, "external_2": 96.75, "bar_up": 73.45, "bar_mid_up": 80.88, "bar_mid_down": 81.77, "bar_down": 79.65, "tube": 78.92, "motor_temp": 31.01, "lam_temp": 30.36, "motor_position": 51.91, "motor_speed": 0.36, "motor_power": 30.16, "motor_current": 1.72, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 6.95}}, {"shot": {"pressure": 6.04, "flow": 0.73, "weight": 5.16, "gravimetric_flow": 0.83, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 38645, "profile_time": 38645, "status": "Extraction", "sensors": {"external_1": 97.64, "external_2": 96.71, "bar_up": 73.3, "bar_mid_up": 80.7, "bar_mid_down": 81.77, "bar_down": 79.65, "tube": 78.9, "motor_temp": 31.01, "lam_temp": 30.36, "motor_position": 51.91, "motor_speed": 0.36, "motor_power": 30.29, "motor_current": 1.76, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 6.97}}, {"shot": {"pressure": 6.04, "flow": 0.75, "weight": 5.2, "gravimetric_flow": 0.83, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 38787, "profile_time": 38787, "status": "Extraction", "sensors": {"external_1": 97.61, "external_2": 96.71, "bar_up": 73.3, "bar_mid_up": 80.88, "bar_mid_down": 81.77, "bar_down": 79.65, "tube": 78.9, "motor_temp": 31.03, "lam_temp": 30.33, "motor_position": 52.01, "motor_speed": 0.37, "motor_power": 31.05, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 7.41}}, {"shot": {"pressure": 6.03, "flow": 0.76, "weight": 5.65, "gravimetric_flow": 0.84, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 38914, "profile_time": 38914, "status": "Extraction", "sensors": {"external_1": 97.57, "external_2": 96.65, "bar_up": 73.3, "bar_mid_up": 80.7, "bar_mid_down": 81.77, "bar_down": 79.65, "tube": 78.89, "motor_temp": 31.03, "lam_temp": 30.33, "motor_position": 52.01, "motor_speed": 0.37, "motor_power": 30.93, "motor_current": 1.71, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 7.44}}, {"shot": {"pressure": 6.03, "flow": 0.78, "weight": 5.72, "gravimetric_flow": 0.85, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 39040, "profile_time": 39040, "status": "Extraction", "sensors": {"external_1": 97.54, "external_2": 96.62, "bar_up": 73.14, "bar_mid_up": 80.7, "bar_mid_down": 81.77, "bar_down": 79.65, "tube": 78.88, "motor_temp": 31.06, "lam_temp": 30.33, "motor_position": 52.11, "motor_speed": 0.37, "motor_power": 30.13, "motor_current": 1.67, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 7.49}}, {"shot": {"pressure": 6.03, "flow": 0.78, "weight": 5.79, "gravimetric_flow": 0.85, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 39159, "profile_time": 39159, "status": "Extraction", "sensors": {"external_1": 97.51, "external_2": 96.62, "bar_up": 73.14, "bar_mid_up": 80.7, "bar_mid_down": 81.77, "bar_down": 79.65, "tube": 78.86, "motor_temp": 31.03, "lam_temp": 30.33, "motor_position": 52.11, "motor_speed": 0.37, "motor_power": 29.7, "motor_current": 1.73, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 7.7}}, {"shot": {"pressure": 6.03, "flow": 0.79, "weight": 5.91, "gravimetric_flow": 0.84, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 39295, "profile_time": 39295, "status": "Extraction", "sensors": {"external_1": 97.48, "external_2": 96.58, "bar_up": 73.14, "bar_mid_up": 80.7, "bar_mid_down": 81.77, "bar_down": 79.65, "tube": 78.83, "motor_temp": 31.03, "lam_temp": 30.33, "motor_position": 52.21, "motor_speed": 0.41, "motor_power": 30.24, "motor_current": 1.73, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 7.77}}, {"shot": {"pressure": 6.03, "flow": 0.8, "weight": 6.07, "gravimetric_flow": 0.84, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 39421, "profile_time": 39421, "status": "Extraction", "sensors": {"external_1": 97.44, "external_2": 96.55, "bar_up": 73.14, "bar_mid_up": 80.52, "bar_mid_down": 81.59, "bar_down": 79.65, "tube": 78.78, "motor_temp": 31.03, "lam_temp": 30.33, "motor_position": 52.21, "motor_speed": 0.41, "motor_power": 30.63, "motor_current": 1.71, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 7.96}}, {"shot": {"pressure": 6.03, "flow": 0.82, "weight": 6.21, "gravimetric_flow": 0.84, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 39543, "profile_time": 39543, "status": "Extraction", "sensors": {"external_1": 97.41, "external_2": 96.52, "bar_up": 73.14, "bar_mid_up": 80.52, "bar_mid_down": 81.77, "bar_down": 79.65, "tube": 78.77, "motor_temp": 31.03, "lam_temp": 30.36, "motor_position": 52.31, "motor_speed": 0.36, "motor_power": 30.17, "motor_current": 1.72, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 8.06}}, {"shot": {"pressure": 6.04, "flow": 0.83, "weight": 6.3, "gravimetric_flow": 0.84, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 39674, "profile_time": 39674, "status": "Extraction", "sensors": {"external_1": 97.38, "external_2": 96.48, "bar_up": 73.14, "bar_mid_up": 80.52, "bar_mid_down": 81.77, "bar_down": 79.65, "tube": 78.76, "motor_temp": 31.03, "lam_temp": 30.36, "motor_position": 52.31, "motor_speed": 0.36, "motor_power": 30.27, "motor_current": 1.71, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 8.07}}, {"shot": {"pressure": 6.03, "flow": 0.85, "weight": 6.26, "gravimetric_flow": 0.86, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 39820, "profile_time": 39820, "status": "Extraction", "sensors": {"external_1": 97.34, "external_2": 96.45, "bar_up": 73.14, "bar_mid_up": 80.52, "bar_mid_down": 81.59, "bar_down": 79.65, "tube": 78.75, "motor_temp": 31.06, "lam_temp": 30.38, "motor_position": 52.41, "motor_speed": 0.38, "motor_power": 31.39, "motor_current": 1.84, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 8.07}}, {"shot": {"pressure": 6.01, "flow": 0.85, "weight": 6.28, "gravimetric_flow": 0.87, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 39946, "profile_time": 39946, "status": "Extraction", "sensors": {"external_1": 97.31, "external_2": 96.42, "bar_up": 72.99, "bar_mid_up": 80.52, "bar_mid_down": 81.59, "bar_down": 79.65, "tube": 78.73, "motor_temp": 31.06, "lam_temp": 30.38, "motor_position": 52.41, "motor_speed": 0.38, "motor_power": 31.83, "motor_current": 1.84, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 8.21}}, {"shot": {"pressure": 6.0, "flow": 0.85, "weight": 6.43, "gravimetric_flow": 0.89, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 40071, "profile_time": 40071, "status": "Extraction", "sensors": {"external_1": 97.28, "external_2": 96.42, "bar_up": 72.99, "bar_mid_up": 80.52, "bar_mid_down": 81.59, "bar_down": 79.48, "tube": 78.7, "motor_temp": 31.06, "lam_temp": 30.38, "motor_position": 52.51, "motor_speed": 0.37, "motor_power": 31.4, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 8.63}}, {"shot": {"pressure": 5.99, "flow": 0.85, "weight": 6.59, "gravimetric_flow": 0.89, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 40184, "profile_time": 40184, "status": "Extraction", "sensors": {"external_1": 97.24, "external_2": 96.38, "bar_up": 72.99, "bar_mid_up": 80.52, "bar_mid_down": 81.59, "bar_down": 79.65, "tube": 78.69, "motor_temp": 31.06, "lam_temp": 30.38, "motor_position": 52.51, "motor_speed": 0.37, "motor_power": 30.91, "motor_current": 1.78, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 8.71}}, {"shot": {"pressure": 5.99, "flow": 0.86, "weight": 6.93, "gravimetric_flow": 0.91, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 40312, "profile_time": 40312, "status": "Extraction", "sensors": {"external_1": 97.21, "external_2": 96.32, "bar_up": 72.99, "bar_mid_up": 80.35, "bar_mid_down": 81.59, "bar_down": 79.65, "tube": 78.67, "motor_temp": 31.03, "lam_temp": 30.41, "motor_position": 52.61, "motor_speed": 0.46, "motor_power": 31.29, "motor_current": 1.79, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 8.82}}, {"shot": {"pressure": 5.98, "flow": 0.88, "weight": 7.06, "gravimetric_flow": 0.92, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 40461, "profile_time": 40461, "status": "Extraction", "sensors": {"external_1": 97.18, "external_2": 96.29, "bar_up": 72.99, "bar_mid_up": 80.52, "bar_mid_down": 81.59, "bar_down": 79.65, "tube": 78.68, "motor_temp": 31.03, "lam_temp": 30.43, "motor_position": 52.71, "motor_speed": 0.42, "motor_power": 31.44, "motor_current": 1.81, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 8.87}}, {"shot": {"pressure": 5.98, "flow": 0.88, "weight": 7.12, "gravimetric_flow": 0.94, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 40589, "profile_time": 40589, "status": "Extraction", "sensors": {"external_1": 97.18, "external_2": 96.25, "bar_up": 72.84, "bar_mid_up": 80.52, "bar_mid_down": 81.59, "bar_down": 79.65, "tube": 78.67, "motor_temp": 31.01, "lam_temp": 30.43, "motor_position": 52.71, "motor_speed": 0.4, "motor_power": 31.06, "motor_current": 1.82, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 9.09}}, {"shot": {"pressure": 5.99, "flow": 0.89, "weight": 7.33, "gravimetric_flow": 0.94, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 40706, "profile_time": 40706, "status": "Extraction", "sensors": {"external_1": 97.14, "external_2": 96.25, "bar_up": 72.99, "bar_mid_up": 80.35, "bar_mid_down": 81.59, "bar_down": 79.65, "tube": 78.66, "motor_temp": 31.03, "lam_temp": 30.43, "motor_position": 52.81, "motor_speed": 0.44, "motor_power": 31.36, "motor_current": 1.78, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 9.22}}, {"shot": {"pressure": 5.99, "flow": 0.91, "weight": 7.43, "gravimetric_flow": 0.94, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 40849, "profile_time": 40849, "status": "Extraction", "sensors": {"external_1": 97.11, "external_2": 96.22, "bar_up": 72.84, "bar_mid_up": 80.35, "bar_mid_down": 81.59, "bar_down": 79.65, "tube": 78.63, "motor_temp": 31.06, "lam_temp": 30.43, "motor_position": 52.81, "motor_speed": 0.44, "motor_power": 32.32, "motor_current": 1.91, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 9.28}}, {"shot": {"pressure": 5.97, "flow": 0.92, "weight": 7.43, "gravimetric_flow": 0.94, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 40963, "profile_time": 40963, "status": "Extraction", "sensors": {"external_1": 97.08, "external_2": 96.25, "bar_up": 72.99, "bar_mid_up": 80.35, "bar_mid_down": 81.59, "bar_down": 79.65, "tube": 78.63, "motor_temp": 31.08, "lam_temp": 30.43, "motor_position": 52.91, "motor_speed": 0.39, "motor_power": 32.25, "motor_current": 1.81, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 9.38}}, {"shot": {"pressure": 5.97, "flow": 0.92, "weight": 7.53, "gravimetric_flow": 0.93, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 41095, "profile_time": 41095, "status": "Extraction", "sensors": {"external_1": 97.08, "external_2": 96.22, "bar_up": 72.84, "bar_mid_up": 80.35, "bar_mid_down": 81.59, "bar_down": 79.65, "tube": 78.62, "motor_temp": 31.06, "lam_temp": 30.43, "motor_position": 52.91, "motor_speed": 0.39, "motor_power": 31.94, "motor_current": 1.75, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 9.52}}, {"shot": {"pressure": 5.96, "flow": 0.92, "weight": 7.68, "gravimetric_flow": 0.95, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 41222, "profile_time": 41222, "status": "Extraction", "sensors": {"external_1": 97.04, "external_2": 96.22, "bar_up": 72.84, "bar_mid_up": 80.35, "bar_mid_down": 81.41, "bar_down": 79.65, "tube": 78.61, "motor_temp": 31.06, "lam_temp": 30.43, "motor_position": 53.01, "motor_speed": 0.48, "motor_power": 32.36, "motor_current": 1.88, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 9.73}}, {"shot": {"pressure": 5.95, "flow": 0.92, "weight": 7.9, "gravimetric_flow": 0.95, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 41358, "profile_time": 41358, "status": "Extraction", "sensors": {"external_1": 96.98, "external_2": 96.09, "bar_up": 72.84, "bar_mid_up": 80.35, "bar_mid_down": 81.59, "bar_down": 79.65, "tube": 78.58, "motor_temp": 31.06, "lam_temp": 30.49, "motor_position": 53.01, "motor_speed": 0.48, "motor_power": 32.86, "motor_current": 1.9, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 10.01}}, {"shot": {"pressure": 5.94, "flow": 0.94, "weight": 8.02, "gravimetric_flow": 0.97, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 41485, "profile_time": 41485, "status": "Extraction", "sensors": {"external_1": 96.95, "external_2": 96.09, "bar_up": 72.84, "bar_mid_up": 80.17, "bar_mid_down": 81.41, "bar_down": 79.48, "tube": 78.55, "motor_temp": 31.06, "lam_temp": 30.49, "motor_position": 53.11, "motor_speed": 0.42, "motor_power": 31.95, "motor_current": 1.87, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 10.11}}, {"shot": {"pressure": 5.95, "flow": 0.95, "weight": 8.28, "gravimetric_flow": 0.97, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 41633, "profile_time": 41633, "status": "Extraction", "sensors": {"external_1": 96.95, "external_2": 96.06, "bar_up": 72.69, "bar_mid_up": 80.35, "bar_mid_down": 81.41, "bar_down": 79.48, "tube": 78.53, "motor_temp": 31.08, "lam_temp": 30.49, "motor_position": 53.21, "motor_speed": 0.52, "motor_power": 31.65, "motor_current": 1.83, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 10.21}}, {"shot": {"pressure": 5.96, "flow": 0.97, "weight": 8.39, "gravimetric_flow": 0.99, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 41763, "profile_time": 41763, "status": "Extraction", "sensors": {"external_1": 96.95, "external_2": 96.06, "bar_up": 72.84, "bar_mid_up": 80.35, "bar_mid_down": 81.41, "bar_down": 79.48, "tube": 78.52, "motor_temp": 31.08, "lam_temp": 30.49, "motor_position": 53.21, "motor_speed": 0.52, "motor_power": 32.43, "motor_current": 1.89, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 10.43}}, {"shot": {"pressure": 5.95, "flow": 0.98, "weight": 8.5, "gravimetric_flow": 1.0, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 41887, "profile_time": 41887, "status": "Extraction", "sensors": {"external_1": 96.88, "external_2": 96.06, "bar_up": 72.84, "bar_mid_up": 80.17, "bar_mid_down": 81.41, "bar_down": 79.65, "tube": 78.49, "motor_temp": 31.13, "lam_temp": 30.51, "motor_position": 53.31, "motor_speed": 0.42, "motor_power": 32.42, "motor_current": 1.9, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 10.54}}, {"shot": {"pressure": 5.96, "flow": 0.98, "weight": 8.69, "gravimetric_flow": 1.03, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 42009, "profile_time": 42009, "status": "Extraction", "sensors": {"external_1": 96.85, "external_2": 96.06, "bar_up": 72.69, "bar_mid_up": 80.17, "bar_mid_down": 81.41, "bar_down": 79.65, "tube": 78.49, "motor_temp": 31.13, "lam_temp": 30.54, "motor_position": 53.41, "motor_speed": 0.54, "motor_power": 31.64, "motor_current": 1.81, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 10.54}}, {"shot": {"pressure": 5.97, "flow": 0.98, "weight": 8.65, "gravimetric_flow": 1.05, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 42143, "profile_time": 42143, "status": "Extraction", "sensors": {"external_1": 96.81, "external_2": 96.06, "bar_up": 72.69, "bar_mid_up": 80.17, "bar_mid_down": 81.41, "bar_down": 79.65, "tube": 78.48, "motor_temp": 31.13, "lam_temp": 30.54, "motor_position": 53.41, "motor_speed": 0.54, "motor_power": 32.06, "motor_current": 1.84, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 10.56}}, {"shot": {"pressure": 5.97, "flow": 0.98, "weight": 8.66, "gravimetric_flow": 1.05, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 42277, "profile_time": 42277, "status": "Extraction", "sensors": {"external_1": 96.78, "external_2": 95.99, "bar_up": 72.69, "bar_mid_up": 80.0, "bar_mid_down": 81.41, "bar_down": 79.65, "tube": 78.47, "motor_temp": 31.1, "lam_temp": 30.54, "motor_position": 53.51, "motor_speed": 0.43, "motor_power": 32.59, "motor_current": 1.91, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 10.9}}, {"shot": {"pressure": 5.96, "flow": 1.0, "weight": 8.98, "gravimetric_flow": 1.05, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 42394, "profile_time": 42394, "status": "Extraction", "sensors": {"external_1": 96.78, "external_2": 95.96, "bar_up": 72.69, "bar_mid_up": 80.17, "bar_mid_down": 81.41, "bar_down": 79.65, "tube": 78.47, "motor_temp": 31.08, "lam_temp": 30.54, "motor_position": 53.51, "motor_speed": 0.43, "motor_power": 32.27, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 11.1}}, {"shot": {"pressure": 5.97, "flow": 1.01, "weight": 9.18, "gravimetric_flow": 1.08, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 42533, "profile_time": 42533, "status": "Extraction", "sensors": {"external_1": 96.75, "external_2": 95.92, "bar_up": 72.54, "bar_mid_up": 80.17, "bar_mid_down": 81.41, "bar_down": 79.48, "tube": 78.45, "motor_temp": 31.08, "lam_temp": 30.54, "motor_position": 53.61, "motor_speed": 0.55, "motor_power": 32.12, "motor_current": 1.85, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 11.25}}, {"shot": {"pressure": 5.97, "flow": 1.04, "weight": 9.32, "gravimetric_flow": 1.11, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 42657, "profile_time": 42657, "status": "Extraction", "sensors": {"external_1": 96.75, "external_2": 95.89, "bar_up": 72.54, "bar_mid_up": 80.0, "bar_mid_down": 81.23, "bar_down": 79.65, "tube": 78.42, "motor_temp": 31.08, "lam_temp": 30.54, "motor_position": 53.71, "motor_speed": 0.46, "motor_power": 32.74, "motor_current": 1.9, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 11.52}}, {"shot": {"pressure": 5.96, "flow": 1.06, "weight": 9.47, "gravimetric_flow": 1.13, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 42784, "profile_time": 42784, "status": "Extraction", "sensors": {"external_1": 96.65, "external_2": 95.89, "bar_up": 72.54, "bar_mid_up": 80.0, "bar_mid_down": 81.41, "bar_down": 79.48, "tube": 78.38, "motor_temp": 31.1, "lam_temp": 30.54, "motor_position": 53.71, "motor_speed": 0.46, "motor_power": 32.29, "motor_current": 1.84, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 11.66}}, {"shot": {"pressure": 5.97, "flow": 1.07, "weight": 9.74, "gravimetric_flow": 1.14, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 42927, "profile_time": 42927, "status": "Extraction", "sensors": {"external_1": 96.65, "external_2": 95.86, "bar_up": 72.54, "bar_mid_up": 80.0, "bar_mid_down": 81.41, "bar_down": 79.65, "tube": 78.38, "motor_temp": 31.1, "lam_temp": 30.54, "motor_position": 53.82, "motor_speed": 0.58, "motor_power": 31.95, "motor_current": 1.82, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 11.81}}, {"shot": {"pressure": 5.97, "flow": 1.09, "weight": 9.88, "gravimetric_flow": 1.16, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 43075, "profile_time": 43075, "status": "Extraction", "sensors": {"external_1": 96.62, "external_2": 95.82, "bar_up": 72.54, "bar_mid_up": 80.0, "bar_mid_down": 81.23, "bar_down": 79.65, "tube": 78.38, "motor_temp": 31.13, "lam_temp": 30.54, "motor_position": 53.92, "motor_speed": 0.5, "motor_power": 32.57, "motor_current": 1.83, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 12.02}}, {"shot": {"pressure": 5.97, "flow": 1.1, "weight": 10.07, "gravimetric_flow": 1.18, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 43192, "profile_time": 43192, "status": "Extraction", "sensors": {"external_1": 96.55, "external_2": 95.76, "bar_up": 72.54, "bar_mid_up": 80.0, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.34, "motor_temp": 31.1, "lam_temp": 30.54, "motor_position": 54.02, "motor_speed": 0.54, "motor_power": 32.52, "motor_current": 1.82, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 12.03}}, {"shot": {"pressure": 5.97, "flow": 1.12, "weight": 10.04, "gravimetric_flow": 1.19, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 43329, "profile_time": 43329, "status": "Extraction", "sensors": {"external_1": 96.52, "external_2": 95.76, "bar_up": 72.54, "bar_mid_up": 80.0, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.32, "motor_temp": 31.1, "lam_temp": 30.54, "motor_position": 54.02, "motor_speed": 0.54, "motor_power": 32.51, "motor_current": 1.88, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 12.08}}, {"shot": {"pressure": 5.97, "flow": 1.13, "weight": 10.1, "gravimetric_flow": 1.18, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 43475, "profile_time": 43475, "status": "Extraction", "sensors": {"external_1": 96.52, "external_2": 95.73, "bar_up": 72.38, "bar_mid_up": 80.0, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.3, "motor_temp": 31.1, "lam_temp": 30.54, "motor_position": 54.12, "motor_speed": 0.52, "motor_power": 33.14, "motor_current": 1.94, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 12.52}}, {"shot": {"pressure": 5.96, "flow": 1.15, "weight": 10.31, "gravimetric_flow": 1.18, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 43587, "profile_time": 43587, "status": "Extraction", "sensors": {"external_1": 96.48, "external_2": 95.69, "bar_up": 72.38, "bar_mid_up": 80.0, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.28, "motor_temp": 31.13, "lam_temp": 30.54, "motor_position": 54.22, "motor_speed": 0.55, "motor_power": 33.1, "motor_current": 1.86, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 12.69}}, {"shot": {"pressure": 5.96, "flow": 1.16, "weight": 10.7, "gravimetric_flow": 1.2, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 43721, "profile_time": 43721, "status": "Extraction", "sensors": {"external_1": 96.45, "external_2": 95.66, "bar_up": 72.38, "bar_mid_up": 80.0, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.27, "motor_temp": 31.13, "lam_temp": 30.54, "motor_position": 54.22, "motor_speed": 0.55, "motor_power": 32.55, "motor_current": 1.85, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 12.82}}, {"shot": {"pressure": 5.96, "flow": 1.18, "weight": 10.83, "gravimetric_flow": 1.2, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 43853, "profile_time": 43853, "status": "Extraction", "sensors": {"external_1": 96.42, "external_2": 95.66, "bar_up": 72.54, "bar_mid_up": 80.0, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.28, "motor_temp": 31.1, "lam_temp": 30.56, "motor_position": 54.32, "motor_speed": 0.58, "motor_power": 32.96, "motor_current": 1.92, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 13.14}}, {"shot": {"pressure": 5.96, "flow": 1.18, "weight": 11.03, "gravimetric_flow": 1.22, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 43984, "profile_time": 43984, "status": "Extraction", "sensors": {"external_1": 96.42, "external_2": 95.63, "bar_up": 72.38, "bar_mid_up": 79.82, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.25, "motor_temp": 31.1, "lam_temp": 30.56, "motor_position": 54.42, "motor_speed": 0.56, "motor_power": 32.72, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 13.29}}, {"shot": {"pressure": 5.96, "flow": 1.18, "weight": 11.31, "gravimetric_flow": 1.23, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 44133, "profile_time": 44133, "status": "Extraction", "sensors": {"external_1": 96.35, "external_2": 95.59, "bar_up": 72.38, "bar_mid_up": 79.82, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.24, "motor_temp": 31.1, "lam_temp": 30.56, "motor_position": 54.52, "motor_speed": 0.58, "motor_power": 32.65, "motor_current": 1.8, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 13.39}}, {"shot": {"pressure": 5.97, "flow": 1.18, "weight": 11.39, "gravimetric_flow": 1.25, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 44250, "profile_time": 44250, "status": "Extraction", "sensors": {"external_1": 96.32, "external_2": 95.56, "bar_up": 72.38, "bar_mid_up": 79.82, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.22, "motor_temp": 31.1, "lam_temp": 30.56, "motor_position": 54.52, "motor_speed": 0.58, "motor_power": 33.36, "motor_current": 1.86, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 13.43}}, {"shot": {"pressure": 5.96, "flow": 1.18, "weight": 11.44, "gravimetric_flow": 1.26, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 44377, "profile_time": 44377, "status": "Extraction", "sensors": {"external_1": 96.29, "external_2": 95.56, "bar_up": 72.38, "bar_mid_up": 79.82, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.23, "motor_temp": 31.1, "lam_temp": 30.59, "motor_position": 54.62, "motor_speed": 0.54, "motor_power": 33.08, "motor_current": 1.87, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 13.57}}, {"shot": {"pressure": 5.96, "flow": 1.19, "weight": 11.55, "gravimetric_flow": 1.26, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 44505, "profile_time": 44505, "status": "Extraction", "sensors": {"external_1": 96.29, "external_2": 95.5, "bar_up": 72.23, "bar_mid_up": 79.82, "bar_mid_down": 81.06, "bar_down": 79.48, "tube": 78.21, "motor_temp": 31.1, "lam_temp": 30.59, "motor_position": 54.72, "motor_speed": 0.6, "motor_power": 33.44, "motor_current": 1.95, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 13.82}}, {"shot": {"pressure": 5.96, "flow": 1.21, "weight": 11.8, "gravimetric_flow": 1.26, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 44635, "profile_time": 44635, "status": "Extraction", "sensors": {"external_1": 96.25, "external_2": 95.5, "bar_up": 72.38, "bar_mid_up": 79.82, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.21, "motor_temp": 31.1, "lam_temp": 30.59, "motor_position": 54.72, "motor_speed": 0.59, "motor_power": 33.87, "motor_current": 1.93, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 14.05}}, {"shot": {"pressure": 5.95, "flow": 1.22, "weight": 12.03, "gravimetric_flow": 1.27, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 44763, "profile_time": 44763, "status": "Extraction", "sensors": {"external_1": 96.22, "external_2": 95.43, "bar_up": 72.23, "bar_mid_up": 79.82, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.2, "motor_temp": 31.1, "lam_temp": 30.59, "motor_position": 54.82, "motor_speed": 0.54, "motor_power": 33.77, "motor_current": 1.9, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 14.33}}, {"shot": {"pressure": 5.94, "flow": 1.24, "weight": 12.3, "gravimetric_flow": 1.29, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 44889, "profile_time": 44889, "status": "Extraction", "sensors": {"external_1": 96.19, "external_2": 95.43, "bar_up": 72.38, "bar_mid_up": 79.82, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.2, "motor_temp": 31.13, "lam_temp": 30.61, "motor_position": 54.92, "motor_speed": 0.61, "motor_power": 33.92, "motor_current": 1.94, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 14.43}}, {"shot": {"pressure": 5.94, "flow": 1.25, "weight": 12.41, "gravimetric_flow": 1.3, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 45039, "profile_time": 45039, "status": "Extraction", "sensors": {"external_1": 96.15, "external_2": 95.46, "bar_up": 72.38, "bar_mid_up": 79.82, "bar_mid_down": 81.23, "bar_down": 79.48, "tube": 78.22, "motor_temp": 31.18, "lam_temp": 30.64, "motor_position": 55.02, "motor_speed": 0.6, "motor_power": 33.54, "motor_current": 1.89, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 14.59}}, {"shot": {"pressure": 5.94, "flow": 1.27, "weight": 12.58, "gravimetric_flow": 1.3, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 45165, "profile_time": 45165, "status": "Extraction", "sensors": {"external_1": 96.12, "external_2": 95.46, "bar_up": 72.23, "bar_mid_up": 79.82, "bar_mid_down": 81.06, "bar_down": 79.3, "tube": 78.2, "motor_temp": 31.2, "lam_temp": 30.64, "motor_position": 55.12, "motor_speed": 0.66, "motor_power": 33.0, "motor_current": 1.85, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 290.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 14.9}}, {"shot": {"pressure": 5.94, "flow": 1.28, "weight": 12.75, "gravimetric_flow": 1.3, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 45291, "profile_time": 45291, "status": "Extraction", "sensors": {"external_1": 96.12, "external_2": 95.43, "bar_up": 72.23, "bar_mid_up": 79.82, "bar_mid_down": 81.06, "bar_down": 79.48, "tube": 78.18, "motor_temp": 31.18, "lam_temp": 30.64, "motor_position": 55.12, "motor_speed": 0.66, "motor_power": 33.78, "motor_current": 1.94, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 15.06}}, {"shot": {"pressure": 5.95, "flow": 1.3, "weight": 13.03, "gravimetric_flow": 1.32, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 45408, "profile_time": 45408, "status": "Extraction", "sensors": {"external_1": 96.09, "external_2": 95.4, "bar_up": 72.23, "bar_mid_up": 79.82, "bar_mid_down": 81.06, "bar_down": 79.3, "tube": 78.15, "motor_temp": 31.15, "lam_temp": 30.64, "motor_position": 55.22, "motor_speed": 0.57, "motor_power": 33.38, "motor_current": 1.88, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 15.22}}, {"shot": {"pressure": 5.96, "flow": 1.31, "weight": 13.15, "gravimetric_flow": 1.35, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 45546, "profile_time": 45546, "status": "Extraction", "sensors": {"external_1": 96.02, "external_2": 95.3, "bar_up": 72.23, "bar_mid_up": 79.65, "bar_mid_down": 81.06, "bar_down": 79.48, "tube": 78.13, "motor_temp": 31.13, "lam_temp": 30.64, "motor_position": 55.32, "motor_speed": 0.63, "motor_power": 34.1, "motor_current": 2.01, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 15.56}}, {"shot": {"pressure": 5.96, "flow": 1.34, "weight": 13.3, "gravimetric_flow": 1.36, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 45674, "profile_time": 45674, "status": "Extraction", "sensors": {"external_1": 95.99, "external_2": 95.3, "bar_up": 72.23, "bar_mid_up": 79.65, "bar_mid_down": 81.06, "bar_down": 79.3, "tube": 78.1, "motor_temp": 31.13, "lam_temp": 30.64, "motor_position": 55.42, "motor_speed": 0.56, "motor_power": 34.29, "motor_current": 2.0, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 15.71}}, {"shot": {"pressure": 5.95, "flow": 1.36, "weight": 13.61, "gravimetric_flow": 1.37, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 45808, "profile_time": 45808, "status": "Extraction", "sensors": {"external_1": 95.99, "external_2": 95.3, "bar_up": 72.23, "bar_mid_up": 79.65, "bar_mid_down": 81.06, "bar_down": 79.48, "tube": 78.1, "motor_temp": 31.15, "lam_temp": 30.66, "motor_position": 55.52, "motor_speed": 0.7, "motor_power": 33.58, "motor_current": 1.91, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 15.83}}, {"shot": {"pressure": 5.95, "flow": 1.37, "weight": 13.72, "gravimetric_flow": 1.38, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 45957, "profile_time": 45957, "status": "Extraction", "sensors": {"external_1": 95.99, "external_2": 95.3, "bar_up": 72.23, "bar_mid_up": 79.82, "bar_mid_down": 81.06, "bar_down": 79.48, "tube": 78.11, "motor_temp": 31.18, "lam_temp": 30.71, "motor_position": 55.52, "motor_speed": 0.68, "motor_power": 34.05, "motor_current": 1.96, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 16.0}}, {"shot": {"pressure": 5.95, "flow": 1.37, "weight": 13.87, "gravimetric_flow": 1.39, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 46082, "profile_time": 46082, "status": "Extraction", "sensors": {"external_1": 95.99, "external_2": 95.27, "bar_up": 72.08, "bar_mid_up": 79.65, "bar_mid_down": 81.06, "bar_down": 79.3, "tube": 78.11, "motor_temp": 31.18, "lam_temp": 30.71, "motor_position": 55.62, "motor_speed": 0.6, "motor_power": 33.74, "motor_current": 1.87, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 16.3}}, {"shot": {"pressure": 5.95, "flow": 1.37, "weight": 14.18, "gravimetric_flow": 1.4, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 46196, "profile_time": 46196, "status": "Extraction", "sensors": {"external_1": 95.96, "external_2": 95.2, "bar_up": 72.08, "bar_mid_up": 79.65, "bar_mid_down": 81.06, "bar_down": 79.48, "tube": 78.1, "motor_temp": 31.18, "lam_temp": 30.69, "motor_position": 55.72, "motor_speed": 0.68, "motor_power": 34.06, "motor_current": 1.93, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 293.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 16.46}}, {"shot": {"pressure": 5.95, "flow": 1.37, "weight": 14.35, "gravimetric_flow": 1.41, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 46340, "profile_time": 46340, "status": "Extraction", "sensors": {"external_1": 95.92, "external_2": 95.2, "bar_up": 72.08, "bar_mid_up": 79.65, "bar_mid_down": 81.06, "bar_down": 79.3, "tube": 78.06, "motor_temp": 31.18, "lam_temp": 30.69, "motor_position": 55.82, "motor_speed": 0.58, "motor_power": 34.37, "motor_current": 1.97, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 16.57}}, {"shot": {"pressure": 5.95, "flow": 1.39, "weight": 14.47, "gravimetric_flow": 1.43, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 46468, "profile_time": 46468, "status": "Extraction", "sensors": {"external_1": 95.86, "external_2": 95.17, "bar_up": 72.08, "bar_mid_up": 79.65, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 78.03, "motor_temp": 31.2, "lam_temp": 30.69, "motor_position": 55.92, "motor_speed": 0.71, "motor_power": 33.8, "motor_current": 1.88, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 16.95}}, {"shot": {"pressure": 5.96, "flow": 1.4, "weight": 14.66, "gravimetric_flow": 1.44, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 46587, "profile_time": 46587, "status": "Extraction", "sensors": {"external_1": 95.82, "external_2": 95.17, "bar_up": 72.08, "bar_mid_up": 79.48, "bar_mid_down": 81.06, "bar_down": 79.3, "tube": 78.01, "motor_temp": 31.2, "lam_temp": 30.71, "motor_position": 56.02, "motor_speed": 0.63, "motor_power": 34.2, "motor_current": 1.91, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 17.08}}, {"shot": {"pressure": 5.95, "flow": 1.42, "weight": 14.97, "gravimetric_flow": 1.45, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 46726, "profile_time": 46726, "status": "Extraction", "sensors": {"external_1": 95.79, "external_2": 95.17, "bar_up": 72.08, "bar_mid_up": 79.65, "bar_mid_down": 81.06, "bar_down": 79.3, "tube": 78.01, "motor_temp": 31.2, "lam_temp": 30.71, "motor_position": 56.12, "motor_speed": 0.7, "motor_power": 34.13, "motor_current": 1.92, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 17.23}}, {"shot": {"pressure": 5.95, "flow": 1.43, "weight": 15.12, "gravimetric_flow": 1.44, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 46870, "profile_time": 46870, "status": "Extraction", "sensors": {"external_1": 95.76, "external_2": 95.13, "bar_up": 72.08, "bar_mid_up": 79.48, "bar_mid_down": 81.06, "bar_down": 79.3, "tube": 78.0, "motor_temp": 31.2, "lam_temp": 30.74, "motor_position": 56.12, "motor_speed": 0.7, "motor_power": 34.03, "motor_current": 1.98, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 17.6}}, {"shot": {"pressure": 5.96, "flow": 1.43, "weight": 15.34, "gravimetric_flow": 1.44, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 46995, "profile_time": 46995, "status": "Extraction", "sensors": {"external_1": 95.76, "external_2": 95.13, "bar_up": 72.08, "bar_mid_up": 79.65, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 78.0, "motor_temp": 31.2, "lam_temp": 30.74, "motor_position": 56.22, "motor_speed": 0.64, "motor_power": 34.14, "motor_current": 1.95, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 17.71}}, {"shot": {"pressure": 5.96, "flow": 1.45, "weight": 15.56, "gravimetric_flow": 1.46, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 47129, "profile_time": 47129, "status": "Extraction", "sensors": {"external_1": 95.76, "external_2": 95.13, "bar_up": 71.93, "bar_mid_up": 79.48, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.97, "motor_temp": 31.2, "lam_temp": 30.74, "motor_position": 56.33, "motor_speed": 0.74, "motor_power": 33.67, "motor_current": 1.92, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 17.86}}, {"shot": {"pressure": 5.96, "flow": 1.45, "weight": 15.69, "gravimetric_flow": 1.48, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 47256, "profile_time": 47256, "status": "Extraction", "sensors": {"external_1": 95.73, "external_2": 95.13, "bar_up": 71.93, "bar_mid_up": 79.48, "bar_mid_down": 81.06, "bar_down": 79.48, "tube": 77.97, "motor_temp": 31.18, "lam_temp": 30.74, "motor_position": 56.43, "motor_speed": 0.69, "motor_power": 34.17, "motor_current": 1.95, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 18.32}}, {"shot": {"pressure": 5.97, "flow": 1.46, "weight": 15.92, "gravimetric_flow": 1.52, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 47381, "profile_time": 47381, "status": "Extraction", "sensors": {"external_1": 95.69, "external_2": 95.07, "bar_up": 71.93, "bar_mid_up": 79.48, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.92, "motor_temp": 31.2, "lam_temp": 30.74, "motor_position": 56.53, "motor_speed": 0.72, "motor_power": 33.77, "motor_current": 1.87, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 18.55}}, {"shot": {"pressure": 5.97, "flow": 1.49, "weight": 16.31, "gravimetric_flow": 1.56, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 47509, "profile_time": 47509, "status": "Extraction", "sensors": {"external_1": 95.63, "external_2": 94.97, "bar_up": 71.93, "bar_mid_up": 79.48, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.92, "motor_temp": 31.18, "lam_temp": 30.74, "motor_position": 56.63, "motor_speed": 0.7, "motor_power": 34.3, "motor_current": 1.94, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 18.71}}, {"shot": {"pressure": 5.97, "flow": 1.52, "weight": 16.45, "gravimetric_flow": 1.59, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 47641, "profile_time": 47641, "status": "Extraction", "sensors": {"external_1": 95.63, "external_2": 94.94, "bar_up": 71.93, "bar_mid_up": 79.48, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.9, "motor_temp": 31.2, "lam_temp": 30.74, "motor_position": 56.73, "motor_speed": 0.7, "motor_power": 33.99, "motor_current": 1.9, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 19.08}}, {"shot": {"pressure": 5.98, "flow": 1.54, "weight": 16.64, "gravimetric_flow": 1.62, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 47766, "profile_time": 47766, "status": "Extraction", "sensors": {"external_1": 95.59, "external_2": 94.91, "bar_up": 71.93, "bar_mid_up": 79.3, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.88, "motor_temp": 31.18, "lam_temp": 30.74, "motor_position": 56.83, "motor_speed": 0.74, "motor_power": 34.21, "motor_current": 1.9, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 19.26}}, {"shot": {"pressure": 5.97, "flow": 1.55, "weight": 16.95, "gravimetric_flow": 1.66, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 47899, "profile_time": 47899, "status": "Extraction", "sensors": {"external_1": 95.56, "external_2": 94.87, "bar_up": 71.78, "bar_mid_up": 79.3, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.86, "motor_temp": 31.22, "lam_temp": 30.74, "motor_position": 56.93, "motor_speed": 0.69, "motor_power": 34.31, "motor_current": 1.84, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 19.42}}, {"shot": {"pressure": 5.98, "flow": 1.57, "weight": 17.1, "gravimetric_flow": 1.68, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 48046, "profile_time": 48046, "status": "Extraction", "sensors": {"external_1": 95.53, "external_2": 94.81, "bar_up": 71.78, "bar_mid_up": 79.3, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.84, "motor_temp": 31.22, "lam_temp": 30.74, "motor_position": 57.03, "motor_speed": 0.71, "motor_power": 34.38, "motor_current": 1.93, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 19.57}}, {"shot": {"pressure": 5.97, "flow": 1.58, "weight": 17.25, "gravimetric_flow": 1.7, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 48168, "profile_time": 48168, "status": "Extraction", "sensors": {"external_1": 95.5, "external_2": 94.84, "bar_up": 71.78, "bar_mid_up": 79.48, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.83, "motor_temp": 31.22, "lam_temp": 30.74, "motor_position": 57.13, "motor_speed": 0.68, "motor_power": 34.99, "motor_current": 1.96, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 291.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 19.95}}, {"shot": {"pressure": 5.97, "flow": 1.6, "weight": 17.64, "gravimetric_flow": 1.72, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 48286, "profile_time": 48286, "status": "Extraction", "sensors": {"external_1": 95.43, "external_2": 94.81, "bar_up": 71.78, "bar_mid_up": 79.3, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.83, "motor_temp": 31.2, "lam_temp": 30.74, "motor_position": 57.23, "motor_speed": 0.76, "motor_power": 34.42, "motor_current": 1.98, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 20.06}}, {"shot": {"pressure": 5.96, "flow": 1.6, "weight": 17.77, "gravimetric_flow": 1.72, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 48421, "profile_time": 48421, "status": "Extraction", "sensors": {"external_1": 95.4, "external_2": 94.84, "bar_up": 71.78, "bar_mid_up": 79.3, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.83, "motor_temp": 31.18, "lam_temp": 30.76, "motor_position": 57.33, "motor_speed": 0.74, "motor_power": 34.43, "motor_current": 1.93, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 20.23}}, {"shot": {"pressure": 5.96, "flow": 1.63, "weight": 17.95, "gravimetric_flow": 1.72, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 48569, "profile_time": 48569, "status": "Extraction", "sensors": {"external_1": 95.36, "external_2": 94.77, "bar_up": 71.78, "bar_mid_up": 79.3, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.82, "motor_temp": 31.18, "lam_temp": 30.76, "motor_position": 57.43, "motor_speed": 0.73, "motor_power": 34.2, "motor_current": 1.97, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 20.57}}, {"shot": {"pressure": 5.96, "flow": 1.64, "weight": 18.16, "gravimetric_flow": 1.72, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 48685, "profile_time": 48685, "status": "Extraction", "sensors": {"external_1": 95.36, "external_2": 94.74, "bar_up": 71.78, "bar_mid_up": 79.3, "bar_mid_down": 80.7, "bar_down": 79.3, "tube": 77.81, "motor_temp": 31.2, "lam_temp": 30.76, "motor_position": 57.43, "motor_speed": 0.71, "motor_power": 34.84, "motor_current": 1.98, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 20.72}}, {"shot": {"pressure": 5.96, "flow": 1.64, "weight": 18.47, "gravimetric_flow": 1.7, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 48826, "profile_time": 48826, "status": "Extraction", "sensors": {"external_1": 95.36, "external_2": 94.74, "bar_up": 71.78, "bar_mid_up": 79.3, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.81, "motor_temp": 31.22, "lam_temp": 30.74, "motor_position": 57.53, "motor_speed": 0.73, "motor_power": 34.26, "motor_current": 1.95, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 20.93}}, {"shot": {"pressure": 5.97, "flow": 1.64, "weight": 18.68, "gravimetric_flow": 1.69, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 48963, "profile_time": 48963, "status": "Extraction", "sensors": {"external_1": 95.33, "external_2": 94.74, "bar_up": 71.78, "bar_mid_up": 79.3, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.81, "motor_temp": 31.25, "lam_temp": 30.76, "motor_position": 57.63, "motor_speed": 0.77, "motor_power": 35.12, "motor_current": 2.0, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 21.2}}, {"shot": {"pressure": 5.97, "flow": 1.64, "weight": 18.79, "gravimetric_flow": 1.68, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 49087, "profile_time": 49087, "status": "Extraction", "sensors": {"external_1": 95.33, "external_2": 94.74, "bar_up": 71.78, "bar_mid_up": 79.3, "bar_mid_down": 80.88, "bar_down": 79.13, "tube": 77.8, "motor_temp": 31.22, "lam_temp": 30.79, "motor_position": 57.73, "motor_speed": 0.7, "motor_power": 34.54, "motor_current": 1.9, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 21.41}}, {"shot": {"pressure": 5.97, "flow": 1.64, "weight": 19.16, "gravimetric_flow": 1.67, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 49200, "profile_time": 49200, "status": "Extraction", "sensors": {"external_1": 95.23, "external_2": 94.64, "bar_up": 71.63, "bar_mid_up": 79.3, "bar_mid_down": 80.88, "bar_down": 79.3, "tube": 77.78, "motor_temp": 31.22, "lam_temp": 30.84, "motor_position": 57.83, "motor_speed": 0.8, "motor_power": 34.85, "motor_current": 2.0, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 21.62}}, {"shot": {"pressure": 5.96, "flow": 1.64, "weight": 19.36, "gravimetric_flow": 1.67, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 49336, "profile_time": 49336, "status": "Extraction", "sensors": {"external_1": 95.27, "external_2": 94.64, "bar_up": 71.63, "bar_mid_up": 79.3, "bar_mid_down": 80.88, "bar_down": 79.13, "tube": 77.76, "motor_temp": 31.22, "lam_temp": 30.84, "motor_position": 57.93, "motor_speed": 0.69, "motor_power": 34.83, "motor_current": 1.93, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 21.85}}, {"shot": {"pressure": 5.97, "flow": 1.66, "weight": 19.58, "gravimetric_flow": 1.68, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 49464, "profile_time": 49464, "status": "Extraction", "sensors": {"external_1": 95.23, "external_2": 94.61, "bar_up": 71.63, "bar_mid_up": 79.13, "bar_mid_down": 80.7, "bar_down": 79.3, "tube": 77.74, "motor_temp": 31.25, "lam_temp": 30.84, "motor_position": 58.03, "motor_speed": 0.81, "motor_power": 34.86, "motor_current": 2.01, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 22.19}}, {"shot": {"pressure": 5.96, "flow": 1.67, "weight": 19.91, "gravimetric_flow": 1.68, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 49593, "profile_time": 49593, "status": "Extraction", "sensors": {"external_1": 95.23, "external_2": 94.61, "bar_up": 71.63, "bar_mid_up": 79.13, "bar_mid_down": 80.7, "bar_down": 79.13, "tube": 77.71, "motor_temp": 31.27, "lam_temp": 30.84, "motor_position": 58.13, "motor_speed": 0.71, "motor_power": 34.67, "motor_current": 1.94, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 22.39}}, {"shot": {"pressure": 5.97, "flow": 1.67, "weight": 20.1, "gravimetric_flow": 1.68, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 49725, "profile_time": 49725, "status": "Extraction", "sensors": {"external_1": 95.17, "external_2": 94.58, "bar_up": 71.63, "bar_mid_up": 79.13, "bar_mid_down": 80.7, "bar_down": 79.13, "tube": 77.68, "motor_temp": 31.25, "lam_temp": 30.84, "motor_position": 58.23, "motor_speed": 0.81, "motor_power": 34.65, "motor_current": 1.98, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 22.53}}, {"shot": {"pressure": 5.97, "flow": 1.67, "weight": 20.24, "gravimetric_flow": 1.7, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 49854, "profile_time": 49854, "status": "Extraction", "sensors": {"external_1": 95.13, "external_2": 94.54, "bar_up": 71.63, "bar_mid_up": 79.13, "bar_mid_down": 80.7, "bar_down": 79.3, "tube": 77.68, "motor_temp": 31.25, "lam_temp": 30.84, "motor_position": 58.33, "motor_speed": 0.71, "motor_power": 35.07, "motor_current": 1.95, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 22.9}}, {"shot": {"pressure": 5.97, "flow": 1.69, "weight": 20.4, "gravimetric_flow": 1.7, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 49980, "profile_time": 49980, "status": "Extraction", "sensors": {"external_1": 95.1, "external_2": 94.54, "bar_up": 71.49, "bar_mid_up": 79.13, "bar_mid_down": 80.7, "bar_down": 79.13, "tube": 77.67, "motor_temp": 31.22, "lam_temp": 30.84, "motor_position": 58.43, "motor_speed": 0.81, "motor_power": 34.77, "motor_current": 1.95, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 23.09}}, {"shot": {"pressure": 5.97, "flow": 1.69, "weight": 20.79, "gravimetric_flow": 1.71, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 50104, "profile_time": 50104, "status": "Extraction", "sensors": {"external_1": 95.1, "external_2": 94.51, "bar_up": 71.49, "bar_mid_up": 79.13, "bar_mid_down": 80.7, "bar_down": 79.13, "tube": 77.64, "motor_temp": 31.22, "lam_temp": 30.84, "motor_position": 58.53, "motor_speed": 0.75, "motor_power": 35.07, "motor_current": 1.94, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 23.23}}, {"shot": {"pressure": 5.97, "flow": 1.7, "weight": 20.92, "gravimetric_flow": 1.72, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 50244, "profile_time": 50244, "status": "Extraction", "sensors": {"external_1": 95.04, "external_2": 94.48, "bar_up": 71.49, "bar_mid_up": 79.13, "bar_mid_down": 80.7, "bar_down": 79.13, "tube": 77.62, "motor_temp": 31.22, "lam_temp": 30.84, "motor_position": 58.63, "motor_speed": 0.82, "motor_power": 34.84, "motor_current": 1.97, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 23.45}}, {"shot": {"pressure": 5.97, "flow": 1.72, "weight": 21.14, "gravimetric_flow": 1.73, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 50380, "profile_time": 50380, "status": "Extraction", "sensors": {"external_1": 95.04, "external_2": 94.45, "bar_up": 71.49, "bar_mid_up": 79.13, "bar_mid_down": 80.7, "bar_down": 79.13, "tube": 77.62, "motor_temp": 31.25, "lam_temp": 30.84, "motor_position": 58.73, "motor_speed": 0.77, "motor_power": 35.19, "motor_current": 1.97, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 23.84}}, {"shot": {"pressure": 5.96, "flow": 1.75, "weight": 21.53, "gravimetric_flow": 1.74, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 50501, "profile_time": 50501, "status": "Extraction", "sensors": {"external_1": 95.04, "external_2": 94.41, "bar_up": 71.63, "bar_mid_up": 79.13, "bar_mid_down": 80.7, "bar_down": 79.13, "tube": 77.63, "motor_temp": 31.27, "lam_temp": 30.86, "motor_position": 58.84, "motor_speed": 0.85, "motor_power": 34.58, "motor_current": 1.91, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 24.0}}, {"shot": {"pressure": 5.97, "flow": 1.76, "weight": 21.69, "gravimetric_flow": 1.75, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 50631, "profile_time": 50631, "status": "Extraction", "sensors": {"external_1": 95.04, "external_2": 94.38, "bar_up": 71.34, "bar_mid_up": 78.96, "bar_mid_down": 80.7, "bar_down": 79.13, "tube": 77.61, "motor_temp": 31.3, "lam_temp": 30.89, "motor_position": 58.94, "motor_speed": 0.82, "motor_power": 34.77, "motor_current": 1.92, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 24.18}}, {"shot": {"pressure": 5.97, "flow": 1.76, "weight": 21.86, "gravimetric_flow": 1.74, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 50759, "profile_time": 50759, "status": "Extraction", "sensors": {"external_1": 95.0, "external_2": 94.35, "bar_up": 71.49, "bar_mid_up": 78.96, "bar_mid_down": 80.7, "bar_down": 79.3, "tube": 77.61, "motor_temp": 31.3, "lam_temp": 30.89, "motor_position": 59.04, "motor_speed": 0.81, "motor_power": 34.47, "motor_current": 1.94, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 24.58}}, {"shot": {"pressure": 5.98, "flow": 1.78, "weight": 22.26, "gravimetric_flow": 1.75, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 50889, "profile_time": 50889, "status": "Extraction", "sensors": {"external_1": 95.0, "external_2": 94.38, "bar_up": 71.49, "bar_mid_up": 78.96, "bar_mid_down": 80.7, "bar_down": 79.13, "tube": 77.59, "motor_temp": 31.32, "lam_temp": 30.89, "motor_position": 59.14, "motor_speed": 0.81, "motor_power": 34.85, "motor_current": 1.97, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 24.8}}, {"shot": {"pressure": 5.98, "flow": 1.81, "weight": 22.48, "gravimetric_flow": 1.76, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 51015, "profile_time": 51015, "status": "Extraction", "sensors": {"external_1": 94.97, "external_2": 94.35, "bar_up": 71.34, "bar_mid_up": 78.96, "bar_mid_down": 80.7, "bar_down": 79.3, "tube": 77.57, "motor_temp": 31.3, "lam_temp": 30.89, "motor_position": 59.24, "motor_speed": 0.78, "motor_power": 34.78, "motor_current": 1.9, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 25.02}}, {"shot": {"pressure": 5.99, "flow": 1.81, "weight": 22.69, "gravimetric_flow": 1.77, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 51157, "profile_time": 51157, "status": "Extraction", "sensors": {"external_1": 94.94, "external_2": 94.35, "bar_up": 71.49, "bar_mid_up": 79.13, "bar_mid_down": 80.7, "bar_down": 79.13, "tube": 77.59, "motor_temp": 31.3, "lam_temp": 30.94, "motor_position": 59.34, "motor_speed": 0.8, "motor_power": 35.16, "motor_current": 1.99, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 295.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 25.35}}, {"shot": {"pressure": 5.98, "flow": 1.82, "weight": 22.86, "gravimetric_flow": 1.79, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 51277, "profile_time": 51277, "status": "Extraction", "sensors": {"external_1": 94.91, "external_2": 94.32, "bar_up": 71.49, "bar_mid_up": 78.96, "bar_mid_down": 80.7, "bar_down": 79.13, "tube": 77.59, "motor_temp": 31.27, "lam_temp": 30.94, "motor_position": 59.44, "motor_speed": 0.82, "motor_power": 35.15, "motor_current": 2.0, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 25.5}}, {"shot": {"pressure": 5.98, "flow": 1.85, "weight": 23.15, "gravimetric_flow": 1.79, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 51403, "profile_time": 51403, "status": "Extraction", "sensors": {"external_1": 94.91, "external_2": 94.35, "bar_up": 71.49, "bar_mid_up": 78.96, "bar_mid_down": 80.52, "bar_down": 79.13, "tube": 77.58, "motor_temp": 31.32, "lam_temp": 30.94, "motor_position": 59.64, "motor_speed": 0.82, "motor_power": 34.88, "motor_current": 1.95, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 25.64}}, {"shot": {"pressure": 5.98, "flow": 1.87, "weight": 23.29, "gravimetric_flow": 1.79, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 51549, "profile_time": 51549, "status": "Extraction", "sensors": {"external_1": 94.87, "external_2": 94.35, "bar_up": 71.34, "bar_mid_up": 78.96, "bar_mid_down": 80.52, "bar_down": 79.13, "tube": 77.55, "motor_temp": 31.32, "lam_temp": 30.94, "motor_position": 59.74, "motor_speed": 0.82, "motor_power": 34.76, "motor_current": 1.92, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 26.04}}, {"shot": {"pressure": 5.98, "flow": 1.88, "weight": 23.5, "gravimetric_flow": 1.78, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 51667, "profile_time": 51667, "status": "Extraction", "sensors": {"external_1": 94.84, "external_2": 94.32, "bar_up": 71.34, "bar_mid_up": 78.79, "bar_mid_down": 80.52, "bar_down": 79.13, "tube": 77.49, "motor_temp": 31.32, "lam_temp": 30.94, "motor_position": 59.84, "motor_speed": 0.8, "motor_power": 34.98, "motor_current": 1.95, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 26.29}}, {"shot": {"pressure": 5.98, "flow": 1.88, "weight": 23.91, "gravimetric_flow": 1.81, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 51794, "profile_time": 51794, "status": "Extraction", "sensors": {"external_1": 94.81, "external_2": 94.28, "bar_up": 71.34, "bar_mid_up": 78.79, "bar_mid_down": 80.52, "bar_down": 79.13, "tube": 77.47, "motor_temp": 31.32, "lam_temp": 30.94, "motor_position": 59.94, "motor_speed": 0.79, "motor_power": 35.19, "motor_current": 1.98, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 26.54}}, {"shot": {"pressure": 5.98, "flow": 1.9, "weight": 24.14, "gravimetric_flow": 1.84, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 51925, "profile_time": 51925, "status": "Extraction", "sensors": {"external_1": 94.81, "external_2": 94.25, "bar_up": 71.34, "bar_mid_up": 78.79, "bar_mid_down": 80.52, "bar_down": 79.13, "tube": 77.46, "motor_temp": 31.34, "lam_temp": 30.94, "motor_position": 60.04, "motor_speed": 0.81, "motor_power": 35.47, "motor_current": 1.99, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 26.73}}, {"shot": {"pressure": 5.98, "flow": 1.9, "weight": 24.32, "gravimetric_flow": 1.87, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 52065, "profile_time": 52065, "status": "Extraction", "sensors": {"external_1": 94.81, "external_2": 94.22, "bar_up": 71.19, "bar_mid_up": 78.79, "bar_mid_down": 80.52, "bar_down": 79.13, "tube": 77.44, "motor_temp": 31.34, "lam_temp": 30.94, "motor_position": 60.14, "motor_speed": 0.9, "motor_power": 34.72, "motor_current": 1.92, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 27.16}}, {"shot": {"pressure": 5.98, "flow": 1.88, "weight": 24.53, "gravimetric_flow": 1.89, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 52187, "profile_time": 52187, "status": "Extraction", "sensors": {"external_1": 94.74, "external_2": 94.18, "bar_up": 71.34, "bar_mid_up": 78.79, "bar_mid_down": 80.52, "bar_down": 79.13, "tube": 77.44, "motor_temp": 31.32, "lam_temp": 30.94, "motor_position": 60.24, "motor_speed": 0.79, "motor_power": 35.04, "motor_current": 1.92, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 27.42}}, {"shot": {"pressure": 5.98, "flow": 1.88, "weight": 24.96, "gravimetric_flow": 1.91, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 52321, "profile_time": 52321, "status": "Extraction", "sensors": {"external_1": 94.71, "external_2": 94.18, "bar_up": 71.19, "bar_mid_up": 78.79, "bar_mid_down": 80.52, "bar_down": 79.13, "tube": 77.43, "motor_temp": 31.32, "lam_temp": 30.97, "motor_position": 60.34, "motor_speed": 0.87, "motor_power": 35.07, "motor_current": 1.99, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 27.65}}, {"shot": {"pressure": 5.98, "flow": 1.9, "weight": 25.16, "gravimetric_flow": 1.94, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 52453, "profile_time": 52453, "status": "Extraction", "sensors": {"external_1": 94.64, "external_2": 94.15, "bar_up": 71.19, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 79.13, "tube": 77.41, "motor_temp": 31.32, "lam_temp": 30.99, "motor_position": 60.44, "motor_speed": 0.76, "motor_power": 35.32, "motor_current": 1.94, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 28.11}}, {"shot": {"pressure": 5.98, "flow": 1.9, "weight": 25.38, "gravimetric_flow": 1.98, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 52586, "profile_time": 52586, "status": "Extraction", "sensors": {"external_1": 94.64, "external_2": 94.09, "bar_up": 71.19, "bar_mid_up": 78.79, "bar_mid_down": 80.52, "bar_down": 78.96, "tube": 77.37, "motor_temp": 31.34, "lam_temp": 31.02, "motor_position": 60.54, "motor_speed": 0.89, "motor_power": 35.63, "motor_current": 2.0, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 28.3}}, {"shot": {"pressure": 5.98, "flow": 1.9, "weight": 25.77, "gravimetric_flow": 2.0, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 52728, "profile_time": 52728, "status": "Extraction", "sensors": {"external_1": 94.61, "external_2": 94.09, "bar_up": 71.19, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.33, "motor_temp": 31.32, "lam_temp": 31.02, "motor_position": 60.64, "motor_speed": 0.79, "motor_power": 35.68, "motor_current": 1.97, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 28.48}}, {"shot": {"pressure": 5.97, "flow": 1.9, "weight": 25.95, "gravimetric_flow": 2.02, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 52854, "profile_time": 52854, "status": "Extraction", "sensors": {"external_1": 94.61, "external_2": 94.09, "bar_up": 71.19, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.32, "motor_temp": 31.34, "lam_temp": 31.02, "motor_position": 60.74, "motor_speed": 0.81, "motor_power": 35.8, "motor_current": 1.99, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 28.89}}, {"shot": {"pressure": 5.96, "flow": 1.9, "weight": 26.18, "gravimetric_flow": 2.05, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 52987, "profile_time": 52987, "status": "Extraction", "sensors": {"external_1": 94.58, "external_2": 94.09, "bar_up": 71.19, "bar_mid_up": 78.62, "bar_mid_down": 80.52, "bar_down": 78.96, "tube": 77.31, "motor_temp": 31.32, "lam_temp": 30.99, "motor_position": 60.94, "motor_speed": 0.91, "motor_power": 35.69, "motor_current": 1.98, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 29.08}}, {"shot": {"pressure": 5.96, "flow": 1.9, "weight": 26.56, "gravimetric_flow": 2.05, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 53114, "profile_time": 53114, "status": "Extraction", "sensors": {"external_1": 94.58, "external_2": 94.05, "bar_up": 71.19, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 79.13, "tube": 77.32, "motor_temp": 31.32, "lam_temp": 30.97, "motor_position": 61.04, "motor_speed": 0.84, "motor_power": 35.6, "motor_current": 1.99, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 29.27}}, {"shot": {"pressure": 5.96, "flow": 1.9, "weight": 26.76, "gravimetric_flow": 2.04, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 53259, "profile_time": 53259, "status": "Extraction", "sensors": {"external_1": 94.54, "external_2": 94.02, "bar_up": 71.19, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.32, "motor_temp": 31.32, "lam_temp": 30.97, "motor_position": 61.14, "motor_speed": 0.91, "motor_power": 35.29, "motor_current": 1.94, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 29.62}}, {"shot": {"pressure": 5.97, "flow": 1.91, "weight": 26.97, "gravimetric_flow": 2.04, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 53372, "profile_time": 53372, "status": "Extraction", "sensors": {"external_1": 94.51, "external_2": 94.02, "bar_up": 71.04, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.29, "motor_temp": 31.34, "lam_temp": 30.99, "motor_position": 61.24, "motor_speed": 0.85, "motor_power": 35.59, "motor_current": 1.93, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 29.82}}, {"shot": {"pressure": 5.97, "flow": 1.93, "weight": 27.35, "gravimetric_flow": 2.03, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 53501, "profile_time": 53501, "status": "Extraction", "sensors": {"external_1": 94.45, "external_2": 93.96, "bar_up": 71.04, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.25, "motor_temp": 31.37, "lam_temp": 31.04, "motor_position": 61.35, "motor_speed": 0.93, "motor_power": 34.86, "motor_current": 1.93, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 30.04}}, {"shot": {"pressure": 5.98, "flow": 1.94, "weight": 27.58, "gravimetric_flow": 2.02, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 53632, "profile_time": 53632, "status": "Extraction", "sensors": {"external_1": 94.45, "external_2": 93.96, "bar_up": 71.19, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.25, "motor_temp": 31.37, "lam_temp": 31.04, "motor_position": 61.45, "motor_speed": 0.91, "motor_power": 34.97, "motor_current": 1.9, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 30.29}}, {"shot": {"pressure": 5.99, "flow": 1.96, "weight": 27.83, "gravimetric_flow": 2.01, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 53764, "profile_time": 53764, "status": "Extraction", "sensors": {"external_1": 94.45, "external_2": 93.92, "bar_up": 71.04, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.25, "motor_temp": 31.37, "lam_temp": 31.04, "motor_position": 61.55, "motor_speed": 0.85, "motor_power": 35.04, "motor_current": 1.97, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 30.67}}, {"shot": {"pressure": 5.99, "flow": 1.97, "weight": 28.21, "gravimetric_flow": 2.0, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 53896, "profile_time": 53896, "status": "Extraction", "sensors": {"external_1": 94.41, "external_2": 93.96, "bar_up": 71.04, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.27, "motor_temp": 31.39, "lam_temp": 31.04, "motor_position": 61.75, "motor_speed": 0.85, "motor_power": 35.53, "motor_current": 1.96, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 30.86}}, {"shot": {"pressure": 5.99, "flow": 1.98, "weight": 28.4, "gravimetric_flow": 1.99, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 54043, "profile_time": 54043, "status": "Extraction", "sensors": {"external_1": 94.41, "external_2": 93.96, "bar_up": 71.04, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.26, "motor_temp": 31.39, "lam_temp": 31.04, "motor_position": 61.85, "motor_speed": 0.88, "motor_power": 35.35, "motor_current": 1.96, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 31.09}}, {"shot": {"pressure": 5.99, "flow": 2.0, "weight": 28.62, "gravimetric_flow": 1.99, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 54159, "profile_time": 54159, "status": "Extraction", "sensors": {"external_1": 94.38, "external_2": 93.96, "bar_up": 71.04, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.26, "motor_temp": 31.39, "lam_temp": 31.04, "motor_position": 61.95, "motor_speed": 0.9, "motor_power": 35.11, "motor_current": 1.9, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 31.58}}, {"shot": {"pressure": 5.99, "flow": 2.01, "weight": 29.1, "gravimetric_flow": 2.0, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 54288, "profile_time": 54288, "status": "Extraction", "sensors": {"external_1": 94.38, "external_2": 93.92, "bar_up": 71.04, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.24, "motor_temp": 31.39, "lam_temp": 31.04, "motor_position": 62.05, "motor_speed": 0.85, "motor_power": 35.5, "motor_current": 1.97, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 31.74}}, {"shot": {"pressure": 5.99, "flow": 2.03, "weight": 29.24, "gravimetric_flow": 2.01, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 54426, "profile_time": 54426, "status": "Extraction", "sensors": {"external_1": 94.32, "external_2": 93.86, "bar_up": 71.04, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.23, "motor_temp": 31.39, "lam_temp": 31.04, "motor_position": 62.15, "motor_speed": 0.87, "motor_power": 35.3, "motor_current": 1.96, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 31.93}}, {"shot": {"pressure": 5.99, "flow": 2.04, "weight": 29.43, "gravimetric_flow": 2.01, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 54564, "profile_time": 54564, "status": "Extraction", "sensors": {"external_1": 94.32, "external_2": 93.83, "bar_up": 71.04, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.23, "motor_temp": 31.39, "lam_temp": 31.04, "motor_position": 62.25, "motor_speed": 0.86, "motor_power": 36.04, "motor_current": 2.0, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 32.43}}, {"shot": {"pressure": 5.98, "flow": 2.06, "weight": 29.92, "gravimetric_flow": 2.02, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 54707, "profile_time": 54707, "status": "Extraction", "sensors": {"external_1": 94.32, "external_2": 93.83, "bar_up": 71.04, "bar_mid_up": 78.62, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.23, "motor_temp": 31.39, "lam_temp": 31.04, "motor_position": 62.45, "motor_speed": 0.92, "motor_power": 35.39, "motor_current": 1.95, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 32.61}}, {"shot": {"pressure": 5.98, "flow": 2.07, "weight": 30.1, "gravimetric_flow": 2.04, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 54826, "profile_time": 54826, "status": "Extraction", "sensors": {"external_1": 94.32, "external_2": 93.79, "bar_up": 71.19, "bar_mid_up": 78.45, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.24, "motor_temp": 31.41, "lam_temp": 31.07, "motor_position": 62.55, "motor_speed": 0.86, "motor_power": 35.72, "motor_current": 1.99, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 32.83}}, {"shot": {"pressure": 5.98, "flow": 2.07, "weight": 30.31, "gravimetric_flow": 2.04, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 54955, "profile_time": 54955, "status": "Extraction", "sensors": {"external_1": 94.28, "external_2": 93.79, "bar_up": 71.04, "bar_mid_up": 78.45, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.23, "motor_temp": 31.41, "lam_temp": 31.09, "motor_position": 62.65, "motor_speed": 0.95, "motor_power": 35.26, "motor_current": 1.92, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 33.23}}, {"shot": {"pressure": 5.98, "flow": 2.07, "weight": 30.54, "gravimetric_flow": 2.05, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 55083, "profile_time": 55083, "status": "Extraction", "sensors": {"external_1": 94.25, "external_2": 93.79, "bar_up": 70.9, "bar_mid_up": 78.45, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.21, "motor_temp": 31.41, "lam_temp": 31.09, "motor_position": 62.75, "motor_speed": 0.85, "motor_power": 35.49, "motor_current": 1.92, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 33.47}}, {"shot": {"pressure": 5.98, "flow": 2.07, "weight": 30.95, "gravimetric_flow": 2.05, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 55219, "profile_time": 55219, "status": "Extraction", "sensors": {"external_1": 94.22, "external_2": 93.86, "bar_up": 70.9, "bar_mid_up": 78.45, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.19, "motor_temp": 31.41, "lam_temp": 31.12, "motor_position": 62.85, "motor_speed": 0.94, "motor_power": 35.67, "motor_current": 2.0, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 33.65}}, {"shot": {"pressure": 5.98, "flow": 2.07, "weight": 31.14, "gravimetric_flow": 2.06, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 55369, "profile_time": 55369, "status": "Extraction", "sensors": {"external_1": 94.25, "external_2": 93.86, "bar_up": 71.04, "bar_mid_up": 78.45, "bar_mid_down": 80.35, "bar_down": 78.96, "tube": 77.19, "motor_temp": 31.44, "lam_temp": 31.12, "motor_position": 62.95, "motor_speed": 0.81, "motor_power": 35.81, "motor_current": 2.0, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 34.05}}, {"shot": {"pressure": 5.98, "flow": 2.07, "weight": 31.35, "gravimetric_flow": 2.06, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 55488, "profile_time": 55488, "status": "Extraction", "sensors": {"external_1": 94.25, "external_2": 93.83, "bar_up": 70.9, "bar_mid_up": 78.45, "bar_mid_down": 80.17, "bar_down": 78.96, "tube": 77.18, "motor_temp": 31.46, "lam_temp": 31.14, "motor_position": 63.15, "motor_speed": 0.86, "motor_power": 35.7, "motor_current": 1.99, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 34.28}}, {"shot": {"pressure": 5.98, "flow": 2.07, "weight": 31.77, "gravimetric_flow": 2.07, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 55625, "profile_time": 55625, "status": "Extraction", "sensors": {"external_1": 94.22, "external_2": 93.79, "bar_up": 71.04, "bar_mid_up": 78.45, "bar_mid_down": 80.17, "bar_down": 78.96, "tube": 77.16, "motor_temp": 31.46, "lam_temp": 31.14, "motor_position": 63.25, "motor_speed": 0.94, "motor_power": 35.54, "motor_current": 1.96, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 34.46}}, {"shot": {"pressure": 5.98, "flow": 2.07, "weight": 31.96, "gravimetric_flow": 2.06, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 55768, "profile_time": 55768, "status": "Extraction", "sensors": {"external_1": 94.18, "external_2": 93.73, "bar_up": 70.9, "bar_mid_up": 78.45, "bar_mid_down": 80.17, "bar_down": 78.96, "tube": 77.13, "motor_temp": 31.49, "lam_temp": 31.14, "motor_position": 63.35, "motor_speed": 0.86, "motor_power": 36.06, "motor_current": 1.99, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 34.87}}, {"shot": {"pressure": 5.97, "flow": 2.07, "weight": 32.09, "gravimetric_flow": 2.05, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 55885, "profile_time": 55885, "status": "Extraction", "sensors": {"external_1": 94.15, "external_2": 93.73, "bar_up": 70.9, "bar_mid_up": 78.45, "bar_mid_down": 80.17, "bar_down": 78.96, "tube": 77.13, "motor_temp": 31.49, "lam_temp": 31.14, "motor_position": 63.45, "motor_speed": 0.97, "motor_power": 35.62, "motor_current": 1.97, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 35.12}}, {"shot": {"pressure": 5.98, "flow": 2.07, "weight": 32.64, "gravimetric_flow": 2.04, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 56015, "profile_time": 56015, "status": "Extraction", "sensors": {"external_1": 94.15, "external_2": 93.73, "bar_up": 70.9, "bar_mid_up": 78.45, "bar_mid_down": 80.17, "bar_down": 78.96, "tube": 77.12, "motor_temp": 31.49, "lam_temp": 31.14, "motor_position": 63.55, "motor_speed": 0.91, "motor_power": 35.74, "motor_current": 1.91, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 35.3}}, {"shot": {"pressure": 5.98, "flow": 2.07, "weight": 32.82, "gravimetric_flow": 2.04, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 56151, "profile_time": 56151, "status": "Extraction", "sensors": {"external_1": 94.15, "external_2": 93.69, "bar_up": 70.75, "bar_mid_up": 78.45, "bar_mid_down": 80.17, "bar_down": 78.96, "tube": 77.11, "motor_temp": 31.49, "lam_temp": 31.14, "motor_position": 63.65, "motor_speed": 0.92, "motor_power": 35.45, "motor_current": 1.95, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 294.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 35.53}}, {"shot": {"pressure": 5.98, "flow": 2.06, "weight": 33.04, "gravimetric_flow": 2.04, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 56265, "profile_time": 56265, "status": "Extraction", "sensors": {"external_1": 94.12, "external_2": 93.73, "bar_up": 70.9, "bar_mid_up": 78.28, "bar_mid_down": 80.17, "bar_down": 78.96, "tube": 77.09, "motor_temp": 31.49, "lam_temp": 31.14, "motor_position": 63.86, "motor_speed": 0.95, "motor_power": 35.44, "motor_current": 1.83, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 35.97}}, {"shot": {"pressure": 5.99, "flow": 2.06, "weight": 33.46, "gravimetric_flow": 2.05, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 56397, "profile_time": 56397, "status": "Extraction", "sensors": {"external_1": 94.09, "external_2": 93.69, "bar_up": 70.75, "bar_mid_up": 78.45, "bar_mid_down": 80.17, "bar_down": 78.96, "tube": 77.08, "motor_temp": 31.49, "lam_temp": 31.14, "motor_position": 63.96, "motor_speed": 0.9, "motor_power": 35.56, "motor_current": 1.94, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 36.26}}, {"shot": {"pressure": 5.99, "flow": 2.08, "weight": 33.73, "gravimetric_flow": 2.08, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 56537, "profile_time": 56537, "status": "Extraction", "sensors": {"external_1": 94.05, "external_2": 93.69, "bar_up": 70.75, "bar_mid_up": 78.45, "bar_mid_down": 80.17, "bar_down": 78.96, "tube": 77.08, "motor_temp": 31.49, "lam_temp": 31.17, "motor_position": 64.06, "motor_speed": 0.91, "motor_power": 35.44, "motor_current": 1.93, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 36.47}}, {"shot": {"pressure": 6.0, "flow": 2.09, "weight": 33.91, "gravimetric_flow": 2.11, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 56678, "profile_time": 56678, "status": "Extraction", "sensors": {"external_1": 94.09, "external_2": 93.66, "bar_up": 70.75, "bar_mid_up": 78.28, "bar_mid_down": 80.17, "bar_down": 78.96, "tube": 77.07, "motor_temp": 31.51, "lam_temp": 31.17, "motor_position": 64.16, "motor_speed": 0.9, "motor_power": 35.58, "motor_current": 1.93, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 36.98}}, {"shot": {"pressure": 6.0, "flow": 2.11, "weight": 34.36, "gravimetric_flow": 2.13, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 56793, "profile_time": 56793, "status": "Extraction", "sensors": {"external_1": 94.09, "external_2": 93.66, "bar_up": 70.75, "bar_mid_up": 78.28, "bar_mid_down": 80.17, "bar_down": 78.96, "tube": 77.06, "motor_temp": 31.49, "lam_temp": 31.17, "motor_position": 64.36, "motor_speed": 0.89, "motor_power": 35.57, "motor_current": 1.96, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 37.24}}, {"shot": {"pressure": 6.0, "flow": 2.12, "weight": 34.59, "gravimetric_flow": 2.17, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 56932, "profile_time": 56932, "status": "Extraction", "sensors": {"external_1": 94.09, "external_2": 93.66, "bar_up": 70.75, "bar_mid_up": 78.28, "bar_mid_down": 80.17, "bar_down": 78.96, "tube": 77.05, "motor_temp": 31.49, "lam_temp": 31.17, "motor_position": 64.46, "motor_speed": 0.91, "motor_power": 35.67, "motor_current": 1.97, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 37.49}}, {"shot": {"pressure": 5.99, "flow": 2.12, "weight": 34.82, "gravimetric_flow": 2.21, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 57070, "profile_time": 57070, "status": "Extraction", "sensors": {"external_1": 94.05, "external_2": 93.63, "bar_up": 70.75, "bar_mid_up": 78.28, "bar_mid_down": 80.17, "bar_down": 78.96, "tube": 77.04, "motor_temp": 31.46, "lam_temp": 31.17, "motor_position": 64.56, "motor_speed": 0.85, "motor_power": 36.28, "motor_current": 2.03, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 37.7}}, {"shot": {"pressure": 5.99, "flow": 2.12, "weight": 35.01, "gravimetric_flow": 2.25, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 57184, "profile_time": 57184, "status": "Extraction", "sensors": {"external_1": 93.99, "external_2": 93.6, "bar_up": 70.6, "bar_mid_up": 78.28, "bar_mid_down": 80.0, "bar_down": 78.96, "tube": 77.01, "motor_temp": 31.49, "lam_temp": 31.19, "motor_position": 64.66, "motor_speed": 0.91, "motor_power": 36.18, "motor_current": 1.89, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 287.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 38.13}}, {"shot": {"pressure": 5.98, "flow": 2.12, "weight": 35.44, "gravimetric_flow": 2.29, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 57321, "profile_time": 57321, "status": "Extraction", "sensors": {"external_1": 93.99, "external_2": 93.56, "bar_up": 70.75, "bar_mid_up": 78.28, "bar_mid_down": 80.0, "bar_down": 78.96, "tube": 77.0, "motor_temp": 31.51, "lam_temp": 31.19, "motor_position": 64.76, "motor_speed": 0.97, "motor_power": 36.09, "motor_current": 2.02, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 38.45}}, {"shot": {"pressure": 5.98, "flow": 2.13, "weight": 35.77, "gravimetric_flow": 2.31, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 57462, "profile_time": 57462, "status": "Extraction", "sensors": {"external_1": 93.96, "external_2": 93.56, "bar_up": 70.6, "bar_mid_up": 78.28, "bar_mid_down": 80.0, "bar_down": 78.96, "tube": 76.98, "motor_temp": 31.51, "lam_temp": 31.19, "motor_position": 64.86, "motor_speed": 0.91, "motor_power": 35.83, "motor_current": 1.94, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 38.82}}, {"shot": {"pressure": 5.97, "flow": 2.14, "weight": 35.95, "gravimetric_flow": 2.32, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 57572, "profile_time": 57572, "status": "Extraction", "sensors": {"external_1": 93.96, "external_2": 93.56, "bar_up": 70.75, "bar_mid_up": 78.11, "bar_mid_down": 80.0, "bar_down": 78.96, "tube": 76.98, "motor_temp": 31.53, "lam_temp": 31.19, "motor_position": 65.06, "motor_speed": 0.88, "motor_power": 36.17, "motor_current": 2.01, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 39.04}}, {"shot": {"pressure": 5.97, "flow": 2.16, "weight": 36.39, "gravimetric_flow": 2.31, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 57708, "profile_time": 57708, "status": "Extraction", "sensors": {"external_1": 93.92, "external_2": 93.56, "bar_up": 70.6, "bar_mid_up": 78.28, "bar_mid_down": 80.0, "bar_down": 78.79, "tube": 76.96, "motor_temp": 31.51, "lam_temp": 31.17, "motor_position": 65.16, "motor_speed": 0.99, "motor_power": 36.1, "motor_current": 1.97, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 39.25}}, {"shot": {"pressure": 5.98, "flow": 2.16, "weight": 36.62, "gravimetric_flow": 2.3, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 57851, "profile_time": 57851, "status": "Extraction", "sensors": {"external_1": 93.92, "external_2": 93.56, "bar_up": 70.6, "bar_mid_up": 78.11, "bar_mid_down": 80.0, "bar_down": 78.79, "tube": 76.94, "motor_temp": 31.51, "lam_temp": 31.19, "motor_position": 65.26, "motor_speed": 0.89, "motor_power": 36.2, "motor_current": 2.01, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 39.55}}, {"shot": {"pressure": 5.98, "flow": 2.16, "weight": 36.75, "gravimetric_flow": 2.28, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 57985, "profile_time": 57985, "status": "Extraction", "sensors": {"external_1": 93.92, "external_2": 93.53, "bar_up": 70.6, "bar_mid_up": 78.11, "bar_mid_down": 80.17, "bar_down": 78.79, "tube": 76.91, "motor_temp": 31.51, "lam_temp": 31.19, "motor_position": 65.36, "motor_speed": 0.98, "motor_power": 36.35, "motor_current": 2.02, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 39.77}}, {"shot": {"pressure": 5.98, "flow": 2.15, "weight": 37.18, "gravimetric_flow": 2.26, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 58115, "profile_time": 58115, "status": "Extraction", "sensors": {"external_1": 93.92, "external_2": 93.5, "bar_up": 70.6, "bar_mid_up": 78.11, "bar_mid_down": 80.0, "bar_down": 78.96, "tube": 76.91, "motor_temp": 31.51, "lam_temp": 31.22, "motor_position": 65.56, "motor_speed": 0.98, "motor_power": 36.33, "motor_current": 2.0, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 40.08}}, {"shot": {"pressure": 5.97, "flow": 2.14, "weight": 37.48, "gravimetric_flow": 2.26, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 58257, "profile_time": 58257, "status": "Extraction", "sensors": {"external_1": 93.89, "external_2": 93.53, "bar_up": 70.6, "bar_mid_up": 78.11, "bar_mid_down": 80.0, "bar_down": 78.96, "tube": 76.92, "motor_temp": 31.51, "lam_temp": 31.22, "motor_position": 65.66, "motor_speed": 0.91, "motor_power": 36.49, "motor_current": 2.02, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 40.44}}, {"shot": {"pressure": 5.97, "flow": 2.14, "weight": 37.68, "gravimetric_flow": 2.24, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 58387, "profile_time": 58387, "status": "Extraction", "sensors": {"external_1": 93.89, "external_2": 93.53, "bar_up": 70.6, "bar_mid_up": 78.11, "bar_mid_down": 80.0, "bar_down": 78.79, "tube": 76.9, "motor_temp": 31.49, "lam_temp": 31.25, "motor_position": 65.76, "motor_speed": 1.0, "motor_power": 36.24, "motor_current": 2.03, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 40.68}}, {"shot": {"pressure": 5.97, "flow": 2.14, "weight": 38.1, "gravimetric_flow": 2.21, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 58519, "profile_time": 58519, "status": "Extraction", "sensors": {"external_1": 93.86, "external_2": 93.53, "bar_up": 70.6, "bar_mid_up": 78.11, "bar_mid_down": 80.17, "bar_down": 78.79, "tube": 76.9, "motor_temp": 31.53, "lam_temp": 31.25, "motor_position": 65.86, "motor_speed": 1.02, "motor_power": 36.22, "motor_current": 1.9, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 40.91}}, {"shot": {"pressure": 5.97, "flow": 2.15, "weight": 38.34, "gravimetric_flow": 2.19, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 58652, "profile_time": 58652, "status": "Extraction", "sensors": {"external_1": 93.89, "external_2": 93.5, "bar_up": 70.46, "bar_mid_up": 78.11, "bar_mid_down": 80.0, "bar_down": 78.79, "tube": 76.88, "motor_temp": 31.56, "lam_temp": 31.25, "motor_position": 66.06, "motor_speed": 0.95, "motor_power": 35.89, "motor_current": 1.98, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.33}}, {"shot": {"pressure": 5.98, "flow": 2.15, "weight": 38.53, "gravimetric_flow": 2.17, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 58797, "profile_time": 58797, "status": "Extraction", "sensors": {"external_1": 93.86, "external_2": 93.47, "bar_up": 70.46, "bar_mid_up": 78.11, "bar_mid_down": 80.0, "bar_down": 78.79, "tube": 76.87, "motor_temp": 31.56, "lam_temp": 31.25, "motor_position": 66.16, "motor_speed": 0.99, "motor_power": 35.51, "motor_current": 1.93, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.55}}, {"shot": {"pressure": 5.99, "flow": 2.15, "weight": 38.98, "gravimetric_flow": 2.16, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 58929, "profile_time": 58929, "status": "Extraction", "sensors": {"external_1": 93.83, "external_2": 93.47, "bar_up": 70.46, "bar_mid_up": 78.11, "bar_mid_down": 80.0, "bar_down": 78.79, "tube": 76.86, "motor_temp": 31.58, "lam_temp": 31.3, "motor_position": 66.27, "motor_speed": 0.91, "motor_power": 35.91, "motor_current": 2.0, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.82}}, {"shot": {"pressure": 5.99, "flow": 2.14, "weight": 39.25, "gravimetric_flow": 2.17, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 59044, "profile_time": 59044, "status": "Extraction", "sensors": {"external_1": 93.79, "external_2": 93.43, "bar_up": 70.6, "bar_mid_up": 78.11, "bar_mid_down": 80.0, "bar_down": 78.96, "tube": 76.88, "motor_temp": 31.58, "lam_temp": 31.32, "motor_position": 66.37, "motor_speed": 0.98, "motor_power": 35.47, "motor_current": 1.94, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.04}}, {"shot": {"pressure": 6.0, "flow": 2.14, "weight": 39.47, "gravimetric_flow": 2.17, "setpoints": {"active": "pressure", "flow": 10.8, "pressure": 6.0}}, "time": 59185, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.79, "external_2": 93.47, "bar_up": 70.46, "bar_mid_up": 78.11, "bar_mid_down": 80.0, "bar_down": 78.79, "tube": 76.88, "motor_temp": 31.63, "lam_temp": 31.32, "motor_position": 66.47, "motor_speed": 0.94, "motor_power": -14.39, "motor_current": 0.28, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 296.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.46}}, {"shot": {"pressure": 5.96, "flow": 2.04, "weight": 39.88, "gravimetric_flow": 2.17, "setpoints": {"active": null}}, "time": 59301, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.83, "external_2": 93.47, "bar_up": 70.46, "bar_mid_up": 78.11, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.86, "motor_temp": 31.58, "lam_temp": 31.27, "motor_position": 66.16, "motor_speed": -3.26, "motor_power": -40.3, "motor_current": -0.87, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 300.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.65}}, {"shot": {"pressure": 5.69, "flow": 1.82, "weight": 40.1, "gravimetric_flow": 2.18, "setpoints": {"active": null}}, "time": 59432, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.83, "external_2": 93.53, "bar_up": 70.46, "bar_mid_up": 77.94, "bar_mid_down": 80.0, "bar_down": 78.79, "tube": 76.83, "motor_temp": 31.58, "lam_temp": 31.25, "motor_position": 65.66, "motor_speed": -4.01, "motor_power": -41.45, "motor_current": -0.85, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 300.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.79}}, {"shot": {"pressure": 5.13, "flow": 1.62, "weight": 40.31, "gravimetric_flow": 2.15, "setpoints": {"active": null}}, "time": 59574, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.83, "external_2": 93.5, "bar_up": 70.46, "bar_mid_up": 77.94, "bar_mid_down": 80.0, "bar_down": 78.79, "tube": 76.82, "motor_temp": 31.53, "lam_temp": 31.25, "motor_position": 65.16, "motor_speed": -4.08, "motor_power": -40.93, "motor_current": -0.78, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 300.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 43.18}}, {"shot": {"pressure": 4.34, "flow": 1.18, "weight": 40.56, "gravimetric_flow": 2.15, "setpoints": {"active": null}}, "time": 59690, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.79, "external_2": 93.47, "bar_up": 70.46, "bar_mid_up": 77.94, "bar_mid_down": 80.0, "bar_down": 78.79, "tube": 76.82, "motor_temp": 31.56, "lam_temp": 31.25, "motor_position": 64.56, "motor_speed": -4.08, "motor_power": -40.06, "motor_current": -0.74, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 300.0, "adc_0": 48.0, "adc_1": 48.0, "adc_2": 48.0, "adc_3": 48.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 43.26}}, {"shot": {"pressure": 3.4, "flow": 0.74, "weight": 40.97, "gravimetric_flow": 2.17, "setpoints": {"active": null}}, "time": 59836, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.83, "external_2": 93.43, "bar_up": 70.6, "bar_mid_up": 78.11, "bar_mid_down": 80.0, "bar_down": 78.79, "tube": 76.81, "motor_temp": 31.58, "lam_temp": 31.3, "motor_position": 64.16, "motor_speed": -4.03, "motor_power": -40.25, "motor_current": -0.85, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 300.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 43.18}}, {"shot": {"pressure": 2.43, "flow": 0.15, "weight": 41.15, "gravimetric_flow": 2.17, "setpoints": {"active": null}}, "time": 59953, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.79, "external_2": 93.4, "bar_up": 70.46, "bar_mid_up": 77.94, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.8, "motor_temp": 31.61, "lam_temp": 31.3, "motor_position": 63.55, "motor_speed": -4.08, "motor_power": -40.22, "motor_current": -0.76, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 300.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 43.27}}, {"shot": {"pressure": 1.64, "flow": 0, "weight": 41.3, "gravimetric_flow": 2.16, "setpoints": {"active": null}}, "time": 60091, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.76, "external_2": 93.4, "bar_up": 70.31, "bar_mid_up": 77.94, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.78, "motor_temp": 31.61, "lam_temp": 31.32, "motor_position": 63.05, "motor_speed": -4.04, "motor_power": -40.13, "motor_current": -0.84, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 300.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 43.2}}, {"shot": {"pressure": 1.01, "flow": 0, "weight": 41.48, "gravimetric_flow": 2.14, "setpoints": {"active": null}}, "time": 60218, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.76, "external_2": 93.4, "bar_up": 70.46, "bar_mid_up": 77.94, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.78, "motor_temp": 31.61, "lam_temp": 31.32, "motor_position": 62.55, "motor_speed": -4.03, "motor_power": -50.06, "motor_current": -0.79, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 300.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 43.13}}, {"shot": {"pressure": 0.6, "flow": 0, "weight": 41.55, "gravimetric_flow": 2.09, "setpoints": {"active": null}}, "time": 60333, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.69, "external_2": 93.37, "bar_up": 70.31, "bar_mid_up": 77.94, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.75, "motor_temp": 31.58, "lam_temp": 31.35, "motor_position": 61.75, "motor_speed": -8.87, "motor_power": -100.0, "motor_current": -1.99, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 297.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.86}}, {"shot": {"pressure": 0.35, "flow": 0, "weight": 41.53, "gravimetric_flow": 2.03, "setpoints": {"active": null}}, "time": 60460, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.76, "external_2": 93.4, "bar_up": 70.31, "bar_mid_up": 77.94, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.75, "motor_temp": 31.56, "lam_temp": 31.35, "motor_position": 60.54, "motor_speed": -10.22, "motor_power": -100.0, "motor_current": -0.95, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 297.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.76}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.53, "gravimetric_flow": 1.94, "setpoints": {"active": null}}, "time": 60595, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.73, "external_2": 93.37, "bar_up": 70.31, "bar_mid_up": 77.94, "bar_mid_down": 80.0, "bar_down": 78.79, "tube": 76.75, "motor_temp": 31.58, "lam_temp": 31.35, "motor_position": 59.24, "motor_speed": -10.11, "motor_power": -100.0, "motor_current": -0.98, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 297.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.66}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.57, "gravimetric_flow": 1.82, "setpoints": {"active": null}}, "time": 60721, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.79, "external_2": 93.37, "bar_up": 70.31, "bar_mid_up": 77.94, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.73, "motor_temp": 31.61, "lam_temp": 31.35, "motor_position": 57.93, "motor_speed": -9.96, "motor_power": -100.0, "motor_current": -1.25, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 297.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.57}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.61, "gravimetric_flow": 1.68, "setpoints": {"active": null}}, "time": 60836, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.76, "external_2": 93.34, "bar_up": 70.31, "bar_mid_up": 77.94, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.73, "motor_temp": 31.63, "lam_temp": 31.35, "motor_position": 56.63, "motor_speed": -10.06, "motor_power": -100.0, "motor_current": -1.09, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 297.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.47}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.64, "gravimetric_flow": 1.53, "setpoints": {"active": null}}, "time": 60974, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.76, "external_2": 93.34, "bar_up": 70.31, "bar_mid_up": 77.78, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.71, "motor_temp": 31.68, "lam_temp": 31.35, "motor_position": 55.42, "motor_speed": -10.08, "motor_power": -100.0, "motor_current": -1.11, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 297.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.31}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.67, "gravimetric_flow": 1.35, "setpoints": {"active": null}}, "time": 61090, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.69, "external_2": 93.3, "bar_up": 70.31, "bar_mid_up": 77.78, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.7, "motor_temp": 31.65, "lam_temp": 31.35, "motor_position": 54.02, "motor_speed": -10.07, "motor_power": -100.0, "motor_current": -1.11, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 297.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.23}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.73, "gravimetric_flow": 1.16, "setpoints": {"active": null}}, "time": 61225, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.69, "external_2": 93.3, "bar_up": 70.31, "bar_mid_up": 77.78, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.69, "motor_temp": 31.63, "lam_temp": 31.35, "motor_position": 52.71, "motor_speed": -10.09, "motor_power": -100.0, "motor_current": -1.13, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 297.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.17}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.76, "gravimetric_flow": 0.99, "setpoints": {"active": null}}, "time": 61362, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.66, "external_2": 93.27, "bar_up": 70.31, "bar_mid_up": 77.78, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.68, "motor_temp": 31.61, "lam_temp": 31.35, "motor_position": 51.51, "motor_speed": -10.06, "motor_power": -100.0, "motor_current": -1.1, "bandheater_power": 0.0, "bandheater_current": 0.02, "pressure_sensor": 304.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.07}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.79, "gravimetric_flow": 0.82, "setpoints": {"active": null}}, "time": 61489, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.66, "external_2": 93.27, "bar_up": 70.17, "bar_mid_up": 77.78, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.67, "motor_temp": 31.65, "lam_temp": 31.37, "motor_position": 50.2, "motor_speed": -10.08, "motor_power": -100.0, "motor_current": -1.13, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 304.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.06}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.83, "gravimetric_flow": 0.67, "setpoints": {"active": null}}, "time": 61616, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.66, "external_2": 93.27, "bar_up": 70.31, "bar_mid_up": 77.78, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.67, "motor_temp": 31.68, "lam_temp": 31.4, "motor_position": 48.9, "motor_speed": -10.07, "motor_power": -100.0, "motor_current": -1.17, "bandheater_power": 0.0, "bandheater_current": 0.02, "pressure_sensor": 304.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.04}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.84, "gravimetric_flow": 0.54, "setpoints": {"active": null}}, "time": 61730, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.63, "external_2": 93.27, "bar_up": 70.31, "bar_mid_up": 77.78, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.67, "motor_temp": 31.68, "lam_temp": 31.42, "motor_position": 47.59, "motor_speed": -10.1, "motor_power": -100.0, "motor_current": -1.11, "bandheater_power": 0.0, "bandheater_current": 0.02, "pressure_sensor": 304.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.05}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.86, "gravimetric_flow": 0.45, "setpoints": {"active": null}}, "time": 61873, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.63, "external_2": 93.27, "bar_up": 70.31, "bar_mid_up": 77.78, "bar_mid_down": 79.82, "bar_down": 78.79, "tube": 76.67, "motor_temp": 31.7, "lam_temp": 31.45, "motor_position": 46.29, "motor_speed": -10.11, "motor_power": -100.0, "motor_current": -1.13, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 304.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.05}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.87, "gravimetric_flow": 0.37, "setpoints": {"active": null}}, "time": 61989, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.6, "external_2": 93.24, "bar_up": 70.17, "bar_mid_up": 77.78, "bar_mid_down": 79.65, "bar_down": 78.79, "tube": 76.65, "motor_temp": 31.7, "lam_temp": 31.45, "motor_position": 45.08, "motor_speed": -10.11, "motor_power": -100.0, "motor_current": -1.05, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 304.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.06}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.89, "gravimetric_flow": 0.32, "setpoints": {"active": null}}, "time": 62114, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.63, "external_2": 93.24, "bar_up": 70.17, "bar_mid_up": 77.61, "bar_mid_down": 79.65, "bar_down": 78.79, "tube": 76.62, "motor_temp": 31.7, "lam_temp": 31.45, "motor_position": 43.67, "motor_speed": -10.12, "motor_power": -100.0, "motor_current": -1.1, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 304.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.05}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.89, "gravimetric_flow": 0.27, "setpoints": {"active": null}}, "time": 62249, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.63, "external_2": 93.24, "bar_up": 70.17, "bar_mid_up": 77.78, "bar_mid_down": 79.65, "bar_down": 78.79, "tube": 76.61, "motor_temp": 31.72, "lam_temp": 31.45, "motor_position": 42.37, "motor_speed": -10.09, "motor_power": -100.0, "motor_current": -1.08, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 304.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.04}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.89, "gravimetric_flow": 0.25, "setpoints": {"active": null}}, "time": 62387, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.6, "external_2": 93.24, "bar_up": 70.31, "bar_mid_up": 77.78, "bar_mid_down": 79.65, "bar_down": 78.79, "tube": 76.61, "motor_temp": 31.72, "lam_temp": 31.45, "motor_position": 41.16, "motor_speed": -10.11, "motor_power": -100.0, "motor_current": -1.12, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 42.01}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.89, "gravimetric_flow": 0.23, "setpoints": {"active": null}}, "time": 62497, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.6, "external_2": 93.27, "bar_up": 70.17, "bar_mid_up": 77.78, "bar_mid_down": 79.65, "bar_down": 78.79, "tube": 76.62, "motor_temp": 31.72, "lam_temp": 31.45, "motor_position": 39.86, "motor_speed": -10.07, "motor_power": -100.0, "motor_current": -1.13, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.97}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.87, "gravimetric_flow": 0.21, "setpoints": {"active": null}}, "time": 62625, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.56, "external_2": 93.24, "bar_up": 70.17, "bar_mid_up": 77.61, "bar_mid_down": 79.82, "bar_down": 78.62, "tube": 76.61, "motor_temp": 31.7, "lam_temp": 31.45, "motor_position": 38.55, "motor_speed": -10.12, "motor_power": -100.0, "motor_current": -1.09, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.95}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.87, "gravimetric_flow": 0.19, "setpoints": {"active": null}}, "time": 62770, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.56, "external_2": 93.24, "bar_up": 70.02, "bar_mid_up": 77.61, "bar_mid_down": 79.65, "bar_down": 78.62, "tube": 76.56, "motor_temp": 31.68, "lam_temp": 31.45, "motor_position": 37.25, "motor_speed": -10.08, "motor_power": -100.0, "motor_current": -1.08, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.93}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.87, "gravimetric_flow": 0.17, "setpoints": {"active": null}}, "time": 62888, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.53, "external_2": 93.17, "bar_up": 70.02, "bar_mid_up": 77.61, "bar_mid_down": 79.65, "bar_down": 78.62, "tube": 76.51, "motor_temp": 31.68, "lam_temp": 31.45, "motor_position": 35.94, "motor_speed": -10.12, "motor_power": -100.0, "motor_current": -1.08, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.89}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.87, "gravimetric_flow": 0.14, "setpoints": {"active": null}}, "time": 63020, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.53, "external_2": 93.21, "bar_up": 70.17, "bar_mid_up": 77.61, "bar_mid_down": 79.65, "bar_down": 78.62, "tube": 76.5, "motor_temp": 31.7, "lam_temp": 31.45, "motor_position": 34.64, "motor_speed": -10.1, "motor_power": -100.0, "motor_current": -1.11, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.88}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.87, "gravimetric_flow": 0.11, "setpoints": {"active": null}}, "time": 63149, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.5, "external_2": 93.17, "bar_up": 70.02, "bar_mid_up": 77.61, "bar_mid_down": 79.65, "bar_down": 78.62, "tube": 76.5, "motor_temp": 31.7, "lam_temp": 31.45, "motor_position": 33.33, "motor_speed": -10.06, "motor_power": -100.0, "motor_current": -1.09, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 292.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.87}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.87, "gravimetric_flow": 0.09, "setpoints": {"active": null}}, "time": 63275, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.5, "external_2": 93.17, "bar_up": 70.02, "bar_mid_up": 77.61, "bar_mid_down": 79.65, "bar_down": 78.62, "tube": 76.48, "motor_temp": 31.7, "lam_temp": 31.45, "motor_position": 31.93, "motor_speed": -10.08, "motor_power": -100.0, "motor_current": -1.05, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.88}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.88, "gravimetric_flow": 0.06, "setpoints": {"active": null}}, "time": 63410, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.47, "external_2": 93.14, "bar_up": 70.02, "bar_mid_up": 77.61, "bar_mid_down": 79.65, "bar_down": 78.62, "tube": 76.48, "motor_temp": 31.68, "lam_temp": 31.45, "motor_position": 30.62, "motor_speed": -10.05, "motor_power": -100.0, "motor_current": -1.12, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.89}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.89, "gravimetric_flow": 0.05, "setpoints": {"active": null}}, "time": 63540, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.47, "external_2": 93.14, "bar_up": 70.02, "bar_mid_up": 77.61, "bar_mid_down": 79.65, "bar_down": 78.62, "tube": 76.48, "motor_temp": 31.68, "lam_temp": 31.45, "motor_position": 29.32, "motor_speed": -10.05, "motor_power": -100.0, "motor_current": -1.11, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.9}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.9, "gravimetric_flow": 0.04, "setpoints": {"active": null}}, "time": 63681, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.47, "external_2": 93.14, "bar_up": 70.02, "bar_mid_up": 77.61, "bar_mid_down": 79.65, "bar_down": 78.62, "tube": 76.48, "motor_temp": 31.7, "lam_temp": 31.45, "motor_position": 28.11, "motor_speed": -10.04, "motor_power": -100.0, "motor_current": -1.14, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 50.0, "adc_1": 50.0, "adc_2": 50.0, "adc_3": 50.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.91}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.9, "gravimetric_flow": 0.03, "setpoints": {"active": null}}, "time": 63792, "profile_time": 59185, "status": "retracting", "sensors": {"external_1": 93.43, "external_2": 93.14, "bar_up": 70.02, "bar_mid_up": 77.44, "bar_mid_down": 79.65, "bar_down": 78.62, "tube": 76.45, "motor_temp": 31.72, "lam_temp": 31.45, "motor_position": 26.81, "motor_speed": -10.03, "motor_power": -100.0, "motor_current": -1.14, "bandheater_power": 0.0, "bandheater_current": 0.03, "pressure_sensor": 288.0, "adc_0": 49.0, "adc_1": 49.0, "adc_2": 49.0, "adc_3": 49.0, "water_status": true, "motor_thermistor": "NaN", "weight_prediction": 41.91}}, {"shot": {"pressure": 0.0, "flow": 0, "weight": 41.91, "gravimetric_flow": 0.02, "setpoints": {"active": null}}, "time": 63924, "profile_time": 59185, "status": "retracting"}], "id": "8dbb4048-8141-4e82-9fa5-86f802b02096", "profile": {"name": "Slayer at Home", "id": "7646e33e-ab94-498a-9bdd-bd2be3fd081a", "author": "IronLapin", "author_id": "d9123a0a-d3d7-40fd-a548-b81376e43f23", "previous_authors": [{"name": "mimoja", "author_id": "d9123a0a-d3d7-40fd-a548-b81376e43f23", "profile_id": "0cdf18ca-d72e-4776-8e25-7b3279907dce"}], "display": {"image": "/api/v1/profile/image/c93fd8fd1cb4224fba0bf247a56cd4b7.png", "accentColor": "#A3D0B5", "shortDescription": "A slayer inspired profile", "description": "This profile is a slayer inspired profile with a long ifusion and a flat extraction modeled after the LM @ Home profile"}, "temperature": 92, "final_weight": 42, "variables": [{"name": "PreBrew Flow Rate", "key": "flow_PreBrew Flow Rate", "type": "flow", "value": 1.2}, {"name": "PreBrew Duration", "key": "time_PreBrew Duration", "type": "time", "value": 30}, {"name": "PreBrew exit weight", "key": "weight_PreBrew exit weight", "type": "weight", "value": 5}, {"name": "Max Pressure", "key": "pressure_Max Pressure", "type": "pressure", "value": 6}, {"name": "MaxFlowRate", "key": "flow_MaxFlowRate", "type": "flow", "value": 10.8}, {"name": "PreBrew pressure", "key": "pressure_PreBrew pressure", "type": "pressure", "value": 1.8}], "stages": [{"name": "PreBrew", "key": "flow_1", "type": "flow", "dynamics": {"points": [[0, "$flow_PreBrew Flow Rate"], ["$time_PreBrew Duration", "$flow_PreBrew Flow Rate"]], "over": "time", "interpolation": "linear"}, "exit_triggers": [{"type": "time", "value": "$time_PreBrew Duration", "relative": true, "comparison": ">="}, {"type": "weight", "value": "$weight_PreBrew exit weight", "relative": false, "comparison": ">="}], "limits": [{"type": "pressure", "value": "$pressure_PreBrew pressure"}]}, {"name": "Extraction", "key": "flow_2", "type": "flow", "dynamics": {"points": [[0, "$flow_MaxFlowRate"], [30, "$flow_MaxFlowRate"]], "over": "time", "interpolation": "linear"}, "exit_triggers": [], "limits": [{"type": "pressure", "value": "$pressure_Max Pressure"}]}], "last_changed": 1779584419.5591242}} \ No newline at end of file diff --git a/apps/server/utils/__init__.py b/apps/server/utils/__init__.py deleted file mode 100644 index 7a6720d3..00000000 --- a/apps/server/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Utility functions for MeticAI server.""" diff --git a/apps/server/utils/file_utils.py b/apps/server/utils/file_utils.py deleted file mode 100644 index dab7dda9..00000000 --- a/apps/server/utils/file_utils.py +++ /dev/null @@ -1,76 +0,0 @@ -"""File utility functions for MeticAI server.""" - -import json -import os -import tempfile -from pathlib import Path -from typing import Any - - -def deep_convert_to_dict(obj: Any) -> Any: - """Recursively convert an object with __dict__ to a JSON-serializable dict. - - Handles nested objects, lists, and special types that can't be directly serialized. - - Args: - obj: The object to convert - - Returns: - A JSON-serializable representation of the object - """ - if obj is None: - return None - elif isinstance(obj, (str, int, float, bool)): - return obj - elif isinstance(obj, dict): - return {k: deep_convert_to_dict(v) for k, v in obj.items()} - elif isinstance(obj, (list, tuple)): - return [deep_convert_to_dict(item) for item in obj] - elif hasattr(obj, "__dict__"): - return { - k: deep_convert_to_dict(v) - for k, v in obj.__dict__.items() - if not k.startswith("_") - } - else: - # For other types, try to convert to string as fallback - try: - return str(obj) - except Exception: - return None - - -def atomic_write_json(filepath: Path, data: Any, indent: int = 2) -> None: - """Write JSON data to a file atomically to prevent corruption. - - Writes to a temporary file first, then renames it to the target path. - This ensures the file is never left in a partially-written state. - - Args: - filepath: Path to the target file - data: Data to write (must be JSON-serializable) - indent: Number of spaces for JSON indentation - - Raises: - Exception: If the write operation fails - """ - # Serialize the data first to catch any serialization errors before writing - json_str = json.dumps(data, indent=indent, default=str) - - # Write to a temporary file in the same directory - temp_fd, temp_path = tempfile.mkstemp( - dir=filepath.parent, prefix=f".{filepath.name}.", suffix=".tmp" - ) - try: - with os.fdopen(temp_fd, "w", encoding="utf-8") as f: - f.write(json_str) - # Atomic replace - os.replace(temp_path, filepath) - except Exception: - # Clean up temp file on failure - try: - os.unlink(temp_path) - except Exception: - # Ignore errors during cleanup (temp file may already be deleted) - pass - raise diff --git a/apps/server/utils/s6_env.py b/apps/server/utils/s6_env.py deleted file mode 100644 index 0e296961..00000000 --- a/apps/server/utils/s6_env.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Helpers for interacting with the s6-overlay container environment.""" - -import logging -import os - -logger = logging.getLogger(__name__) - - -def update_s6_env(var_name: str, value: str, request_id: str = "") -> None: - """Write an environment variable to the s6 container environment directory. - - s6-overlay services started with ``#!/command/with-contenv sh`` read their - environment from ``/var/run/s6/container_environment/``. Updating a file - there ensures that the *next* service restart picks up the new value — - something that ``os.environ[…]`` alone cannot achieve because the FastAPI - process is separate from the s6 supervision tree. - - Silently skips when not running inside s6-overlay. - """ - s6_env_dir = "/var/run/s6/container_environment" - if not os.path.isdir(s6_env_dir): - logger.debug( - "s6 container environment dir not found (%s) — probably not running " - "inside s6-overlay; skipping", - s6_env_dir, - extra={"request_id": request_id}, - ) - return - try: - env_file = os.path.join(s6_env_dir, var_name) - with open(env_file, "w") as f: - f.write(value) - logger.info( - "Updated s6 container env %s", - var_name, - extra={"request_id": request_id}, - ) - except Exception as e: - logger.warning( - "Failed to update s6 container env %s: %s", - var_name, - e, - extra={"request_id": request_id}, - ) diff --git a/apps/server/utils/sanitization.py b/apps/server/utils/sanitization.py deleted file mode 100644 index 6f695e65..00000000 --- a/apps/server/utils/sanitization.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Input sanitization functions for MeticAI server.""" - -import re - - -def sanitize_profile_name_for_filename(profile_name: str) -> str: - """Safely sanitize profile name for use in filenames. - - Args: - profile_name: The profile name to sanitize - - Returns: - A safe filename string - - Note: - This prevents path traversal attacks by removing/replacing - all potentially dangerous characters. - """ - # Remove any path separators and parent directory references - safe_name = profile_name.replace("/", "_").replace("\\", "_").replace("..", "_") - # Keep only alphanumeric, spaces, hyphens, and underscores - safe_name = re.sub(r"[^a-zA-Z0-9\s\-_]", "_", safe_name) - # Replace spaces with underscores and convert to lowercase - safe_name = safe_name.replace(" ", "_").lower() - # Limit length to prevent filesystem issues - return safe_name[:200] - - -def clean_profile_name(name: str) -> str: - """Clean markdown artifacts from profile name. - - Args: - name: The profile name to clean - - Returns: - Cleaned profile name without markdown formatting - """ - # Remove leading/trailing ** or * - cleaned = re.sub(r"^[\*]+\s*", "", name) - cleaned = re.sub(r"\s*[\*]+$", "", cleaned) - # Remove any remaining ** pairs - cleaned = cleaned.replace("**", "") - return cleaned.strip() diff --git a/apps/web/android/app/src/main/AndroidManifest.xml b/apps/web/android/app/src/main/AndroidManifest.xml index cf2325ea..c34105f1 100644 --- a/apps/web/android/app/src/main/AndroidManifest.xml +++ b/apps/web/android/app/src/main/AndroidManifest.xml @@ -24,6 +24,18 @@ + + + + + + + + + + + + =8.0.0", "@mediapipe/tasks-genai": "^0.10.24" } }, "sha512-dRr3Fhwz7/g1EfnC0OXWmWYmXhugLpVt56P1/ZLxeW9EsfuE/vBG+GHxOrSpwegwhSE+0JnCKcJd4k1+4Ce/iw=="], + "@capgo/capacitor-share-target": ["@capgo/capacitor-share-target@8.0.44", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-0B2ZG2gO/KUj8lSmPhC3OfeH8/aLQGMK8ZaDARxJ++Ke2O8G8Jf48lur0O6v1AixbteqcqtGX4zPOExzWxrTsg=="], + "@chromatic-com/storybook": ["@chromatic-com/storybook@5.2.1", "", { "dependencies": { "@neoconfetti/react": "^1.0.0", "chromatic": "16.10.0", "jsonfile": "^6.1.0", "strip-ansi": "^7.1.0" }, "peerDependencies": { "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 || ^10.5.0-0 || ^10.6.0-0" } }, "sha512-z6I7NJk/0VngA64y5TNYaB4Hc2X8+90n4op6lBt9PvWk5TmIlFLDqdX33rlrwbNRkkYijVgA/wO04rVYXi5Mlg=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -450,8 +453,6 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], - "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], - "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], @@ -672,6 +673,8 @@ "@mediapipe/tasks-genai": ["@mediapipe/tasks-genai@0.10.27", "", {}, "sha512-cv69CPPAtEDBUs6dGZft2S+sBqde1XvEMST367siSyxrhffdWtm4uQIsfdedAbhJ33BwAjuMnAdxDrO9WrzIAQ=="], + "@metic/core": ["@metic/core@file:../../packages/core", { "devDependencies": { "typescript": "^6.0.3", "vitest": "4.1.5" } }], + "@meticulous-home/espresso-api": ["@meticulous-home/espresso-api@0.11.1", "", { "dependencies": { "@meticulous-home/espresso-profile": "^0.4.2", "axios": "^1.6.8", "socket.io-client": "^4.7.5" } }, "sha512-YKFOt6Ei3AlAhqfvFrIhaiuv+UBhC2uxdRcp2jDePHPm88F3o8fBxP6xre7zrgn/NYRFPbho9NzbO4dCUOyKyw=="], "@meticulous-home/espresso-profile": ["@meticulous-home/espresso-profile@0.4.2", "", {}, "sha512-VNk5X8kDVa40UcCKqATFPh0vVtcBkrXhz1Jqap/M2HBjNgGBgZ6c9Im13evQpXVqDiqFspion5mG1MXN8fZ1vw=="], @@ -1118,8 +1121,6 @@ "@swc/types": ["@swc/types@0.1.26", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw=="], - "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], - "@tailwindcss/container-queries": ["@tailwindcss/container-queries@0.1.1", "", { "peerDependencies": { "tailwindcss": ">=3.2.0" } }, "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="], @@ -1722,8 +1723,6 @@ "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], - "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], - "dateformat": ["dateformat@3.0.3", "", {}, "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q=="], "debug": ["debug@4.3.4", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="], @@ -2644,8 +2643,6 @@ "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], - "react-docgen": ["react-docgen@8.0.3", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.2", "@types/babel__core": "^7.20.5", "@types/babel__traverse": "^7.20.7", "@types/doctrine": "^0.0.9", "@types/resolve": "^1.20.2", "doctrine": "^3.0.0", "resolve": "^1.22.1", "strip-indent": "^4.0.0" } }, "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w=="], "react-docgen-typescript": ["react-docgen-typescript@2.4.0", "", { "peerDependencies": { "typescript": ">= 4.3.x" } }, "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg=="], @@ -3864,8 +3861,6 @@ "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - "react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], - "react-docgen/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], "react-docgen/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], diff --git a/apps/web/capacitor.config.ts b/apps/web/capacitor.config.ts index a420855d..b20dc412 100644 --- a/apps/web/capacitor.config.ts +++ b/apps/web/capacitor.config.ts @@ -31,6 +31,11 @@ const config: CapacitorConfig = { Preferences: { // Uses UserDefaults on iOS — no configuration needed }, + CapacitorShareTarget: { + // App Group shared between the main app and the iOS Share Extension. + // Must match the App Group capability on both targets in Xcode. + appGroupId: 'group.com.metic.app', + }, }, } diff --git a/apps/web/e2e/fullapp-sweep.spec.ts b/apps/web/e2e/fullapp-sweep.spec.ts new file mode 100644 index 00000000..65b2a407 --- /dev/null +++ b/apps/web/e2e/fullapp-sweep.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from '@playwright/test' + +/** + * Full-app sweep against the unified Bun server (3.0.0). + * + * Loads the SPA in proxy mode (served same-origin by the Bun server) and + * exercises the main views, collecting console errors and failed /api + * responses. Any response whose body contains the core notFound message + * ("No route for") is a release-blocking coverage gap. + * + * Run against a served app: + * BASE_URL=http://localhost:35590 bunx playwright test e2e/fullapp-sweep.spec.ts --project=chromium + * + * LOCAL-ONLY: this spec requires the unified Bun server (port 35590) and a + * real machine on the LAN, so it is excluded from CI (see playwright.config.ts + * testIgnore). Run it manually during server live-verification. + */ + +const BASE = process.env.BASE_URL || 'http://localhost:35590' + +interface ApiIssue { + url: string + status: number + bodySnippet: string + noRoute: boolean +} + +test.describe('Full-app sweep (unified Bun server)', () => { + test.use({ baseURL: BASE }) + + test('main views load without console errors or uncovered /api routes', async ({ page }) => { + const consoleErrors: string[] = [] + const apiIssues: ApiIssue[] = [] + + page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()) + }) + page.on('pageerror', (err) => { + consoleErrors.push(`pageerror: ${err.message}`) + }) + + page.on('response', async (res) => { + const url = res.url() + if (!/\/api\//.test(url)) return + // The transparent machine proxy (/api/v1/*) can legitimately 4xx. + if (/\/api\/v\d+\//.test(url)) return + const status = res.status() + if (status < 400) return + let body: string + try { + body = await res.text() + } catch { + body = '' + } + apiIssues.push({ + url, + status, + bodySnippet: body.slice(0, 200), + noRoute: body.includes('No route for'), + }) + }) + + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Give initial-mount hooks (settings, status, profiles, history) time to fire. + await page.waitForTimeout(2000) + + // Navigate the primary views by clicking any visible top-level nav/tab controls. + const navCandidates = [ + /History/i, + /Profiles/i, + /Catalogue/i, + /Pour.?over/i, + /Dial.?in/i, + /Control/i, + /Settings/i, + /Live/i, + ] + for (const rx of navCandidates) { + const btn = page.getByRole('button', { name: rx }).first() + if (await btn.isVisible().catch(() => false)) { + await btn.click().catch(() => {}) + await page.waitForTimeout(800) + } + } + + await page.waitForLoadState('networkidle').catch(() => {}) + await page.waitForTimeout(1000) + + // Explicitly open Settings (the update-status card + tailscale live here). + const settingsBtn = page.getByRole('button', { name: /Settings/i }).first() + if (await settingsBtn.isVisible().catch(() => false)) { + await settingsBtn.click().catch(() => {}) + await page.waitForTimeout(2500) + await page.waitForLoadState('networkidle').catch(() => {}) + } + + const noRoute = apiIssues.filter((i) => i.noRoute) + + // Report everything for diagnosis. + console.log('=== API ISSUES (status>=400, non-proxy) ===') + for (const i of apiIssues) { + console.log(`${i.status} ${i.url}${i.noRoute ? ' <<< NO ROUTE' : ''} ${i.bodySnippet}`) + } + console.log('=== CONSOLE ERRORS ===') + for (const e of consoleErrors) console.log(e) + + expect(noRoute, `Uncovered core routes: ${noRoute.map((i) => i.url).join(', ')}`).toEqual([]) + }) +}) diff --git a/apps/web/ios/App/App.xcodeproj/project.pbxproj b/apps/web/ios/App/App.xcodeproj/project.pbxproj index 17487ad3..f85e57cf 100644 --- a/apps/web/ios/App/App.xcodeproj/project.pbxproj +++ b/apps/web/ios/App/App.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -17,8 +17,33 @@ 504ED0012C0000000016851F /* MeticulousDiscoveryPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504ED0002C0000000016851F /* MeticulousDiscoveryPlugin.swift */; }; 504ED0032C0000000016851F /* MeticulousViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504ED0022C0000000016851F /* MeticulousViewController.swift */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + 9E222C543009E322009A1738 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9E222C4A3009E322009A1738 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 9E222C523009E322009A1738 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9E222C493009E322009A1738; + remoteInfo = ShareExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9E222C553009E322009A1738 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 9E222C543009E322009A1738 /* ShareExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; @@ -32,8 +57,23 @@ 504ED0022C0000000016851F /* MeticulousViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeticulousViewController.swift; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; + 9E222C4A3009E322009A1738 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 9E222C583009E322009A1738 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 9E222C493009E322009A1738 /* ShareExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 9E222C4B3009E322009A1738 /* ShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (9E222C583009E322009A1738 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = ShareExtension; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 504EC3011FED79650016851F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -43,6 +83,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 9E222C473009E322009A1738 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -51,6 +98,7 @@ children = ( 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, 504EC3061FED79650016851F /* App */, + 9E222C4B3009E322009A1738 /* ShareExtension */, 504EC3051FED79650016851F /* Products */, ); sourceTree = ""; @@ -59,6 +107,7 @@ isa = PBXGroup; children = ( 504EC3041FED79650016851F /* Metic.app */, + 9E222C4A3009E322009A1738 /* ShareExtension.appex */, ); name = Products; sourceTree = ""; @@ -90,10 +139,12 @@ 504EC3001FED79650016851F /* Sources */, 504EC3011FED79650016851F /* Frameworks */, 504EC3021FED79650016851F /* Resources */, + 9E222C553009E322009A1738 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 9E222C533009E322009A1738 /* PBXTargetDependency */, ); name = Metic; packageProductDependencies = ( @@ -103,6 +154,28 @@ productReference = 504EC3041FED79650016851F /* Metic.app */; productType = "com.apple.product-type.application"; }; + 9E222C493009E322009A1738 /* ShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9E222C593009E322009A1738 /* Build configuration list for PBXNativeTarget "ShareExtension" */; + buildPhases = ( + 9E222C463009E322009A1738 /* Sources */, + 9E222C473009E322009A1738 /* Frameworks */, + 9E222C483009E322009A1738 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 9E222C4B3009E322009A1738 /* ShareExtension */, + ); + name = ShareExtension; + packageProductDependencies = ( + ); + productName = ShareExtension; + productReference = 9E222C4A3009E322009A1738 /* ShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -110,7 +183,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 920; + LastSwiftUpdateCheck = 2660; LastUpgradeCheck = 2640; TargetAttributes = { 504EC3031FED79650016851F = { @@ -118,6 +191,9 @@ LastSwiftMigration = 1100; ProvisioningStyle = Automatic; }; + 9E222C493009E322009A1738 = { + CreatedOnToolsVersion = 26.6; + }; }; }; buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; @@ -137,6 +213,7 @@ projectRoot = ""; targets = ( 504EC3031FED79650016851F /* Metic */, + 9E222C493009E322009A1738 /* ShareExtension */, ); }; /* End PBXProject section */ @@ -155,6 +232,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 9E222C483009E322009A1738 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -168,8 +252,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 9E222C463009E322009A1738 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 9E222C533009E322009A1738 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9E222C493009E322009A1738 /* ShareExtension */; + targetProxy = 9E222C523009E322009A1738 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 504EC30B1FED79650016851F /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -320,6 +419,7 @@ baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; INFOPLIST_FILE = App/Info.plist; @@ -348,6 +448,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; INFOPLIST_FILE = App/Info.plist; @@ -371,6 +472,76 @@ }; name = Release; }; + 9E222C563009E322009A1738 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.metic.app.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9E222C573009E322009A1738 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.metic.app.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -392,6 +563,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 9E222C593009E322009A1738 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9E222C563009E322009A1738 /* Debug */, + 9E222C573009E322009A1738 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/apps/web/ios/App/App/App.entitlements b/apps/web/ios/App/App/App.entitlements new file mode 100644 index 00000000..3e5a73de --- /dev/null +++ b/apps/web/ios/App/App/App.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.metic.app + + + diff --git a/apps/web/ios/App/App/Info.plist b/apps/web/ios/App/App/Info.plist index 2d186db7..e991cf0e 100644 --- a/apps/web/ios/App/App/Info.plist +++ b/apps/web/ios/App/App/Info.plist @@ -22,6 +22,17 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + CFBundleURLTypes + + + CFBundleURLName + com.metic.app + CFBundleURLSchemes + + metic + + + ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/apps/web/ios/App/ShareExtension/Base.lproj/MainInterface.storyboard b/apps/web/ios/App/ShareExtension/Base.lproj/MainInterface.storyboard new file mode 100644 index 00000000..286a5089 --- /dev/null +++ b/apps/web/ios/App/ShareExtension/Base.lproj/MainInterface.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/ios/App/ShareExtension/Info.plist b/apps/web/ios/App/ShareExtension/Info.plist new file mode 100644 index 00000000..70a4c6ce --- /dev/null +++ b/apps/web/ios/App/ShareExtension/Info.plist @@ -0,0 +1,25 @@ + + + + + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsFileWithMaxCount + 1 + + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + diff --git a/apps/web/ios/App/ShareExtension/ShareExtension.entitlements b/apps/web/ios/App/ShareExtension/ShareExtension.entitlements new file mode 100644 index 00000000..3e5a73de --- /dev/null +++ b/apps/web/ios/App/ShareExtension/ShareExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.metic.app + + + diff --git a/apps/web/ios/App/ShareExtension/ShareViewController.swift b/apps/web/ios/App/ShareExtension/ShareViewController.swift new file mode 100644 index 00000000..5a2d7ad9 --- /dev/null +++ b/apps/web/ios/App/ShareExtension/ShareViewController.swift @@ -0,0 +1,223 @@ +import UIKit +import UniformTypeIdentifiers + +/// Share Extension for Metic. +/// +/// Collects whatever the user shares (a metprofiles link, a direct profile URL, +/// pasted profile JSON, or a shared .json / .met file), writes it into the App +/// Group container under the `share-target-data` key the +/// @capgo/capacitor-share-target plugin reads, then opens `metic://share` so the +/// host app can resolve and import the profile through the same flow as the web +/// `?import=` parameter. +/// +/// Keep `appGroupId` / `urlScheme` in sync with `capacitor.config.ts` +/// (`CapacitorShareTarget.appGroupId`), `ShareExtension.entitlements`, and the +/// app's `CFBundleURLTypes`. +class ShareViewController: UIViewController { + private let appGroupId = "group.com.metic.app" + private let sharedDataKey = "share-target-data" + private let urlScheme = "metic://share" + + override func viewDidLoad() { + super.viewDidLoad() + collectSharedContent() + } + + private func collectSharedContent() { + guard let items = extensionContext?.inputItems as? [NSExtensionItem] else { + finish() + return + } + + let group = DispatchGroup() + let lock = NSLock() + var texts: [String] = [] + var files: [[String: Any]] = [] + + for item in items { + for provider in item.attachments ?? [] { + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + group.enter() + provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { value, _ in + if let url = value as? URL { + let str = url.absoluteString + // A file URL shared as public.url is really a file. + if url.isFileURL { + if let file = self.copyToAppGroup(url) { + lock.lock(); files.append(file); lock.unlock() + } + } else { + lock.lock(); texts.append(str); lock.unlock() + } + } + group.leave() + } + } else if provider.hasItemConformingToTypeIdentifier(UTType.json.identifier) { + group.enter() + provider.loadFileRepresentation(forTypeIdentifier: UTType.json.identifier) { url, _ in + if let url = url, let file = self.copyToAppGroup(url) { + lock.lock(); files.append(file); lock.unlock() + } + group.leave() + } + } else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + group.enter() + provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { value, _ in + if let text = value as? String { + lock.lock(); texts.append(text); lock.unlock() + } + group.leave() + } + } else if provider.hasItemConformingToTypeIdentifier(UTType.item.identifier) { + group.enter() + provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, _ in + if let url = url, self.isProfileFile(url), let file = self.copyToAppGroup(url) { + lock.lock(); files.append(file); lock.unlock() + } + group.leave() + } + } + } + } + + group.notify(queue: .main) { [weak self] in + guard let self = self else { return } + self.persist(texts: texts, files: files) + if self.openHostApp() { + // Give the app-switch a beat to dispatch before we tear the + // extension down — completing too early can cancel it. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.finish() + } + } else { + // Couldn't foreground the app automatically. The share is + // already persisted to the App Group, so tell the user how to + // finish and offer a button to try opening the app themselves. + self.presentManualOpenUI() + } + } + } + + private func isProfileFile(_ url: URL) -> Bool { + let ext = url.pathExtension.lowercased() + return ext == "json" || ext == "met" + } + + /// Copy a shared file into the App Group container so the host app (a + /// separate sandbox) can read it, and describe it the way the JS + /// `SharedFile` shape expects (`uri` / `name` / `mimeType`). + private func copyToAppGroup(_ url: URL) -> [String: Any]? { + guard let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupId + ) else { return nil } + + let name = url.lastPathComponent + let dest = container.appendingPathComponent(name) + try? FileManager.default.removeItem(at: dest) + do { + try FileManager.default.copyItem(at: url, to: dest) + } catch { + return nil + } + + let mime = url.pathExtension.lowercased() == "met" + ? "application/octet-stream" + : "application/json" + return ["uri": dest.absoluteString, "name": name, "mimeType": mime] + } + + private func persist(texts: [String], files: [[String: Any]]) { + guard let defaults = UserDefaults(suiteName: appGroupId) else { return } + defaults.set( + ["title": "", "texts": texts, "files": files] as [String: Any], + forKey: sharedDataKey + ) + defaults.synchronize() + } + + /// Foreground the host app by opening `metic://share`. Share extensions + /// can't touch `UIApplication.shared`, so walk the responder chain to the + /// `UIApplication` instance and call its (deprecated) `openURL:`. Returns + /// whether the app was asked to open. + @discardableResult + private func openHostApp() -> Bool { + guard let url = URL(string: urlScheme) else { return false } + let selector = sel_registerName("openURL:") + var responder: UIResponder? = self + while let current = responder { + if let application = current as? UIApplication, application.responds(to: selector) { + _ = application.perform(selector, with: url) + return true + } + responder = current.next + } + return false + } + + /// Fallback shown when the extension can't foreground the host app on its + /// own. The share is already persisted, so the app will pick it up on next + /// launch; this just makes that obvious and offers a one-tap open. + private func presentManualOpenUI() { + view.backgroundColor = UIColor.black.withAlphaComponent(0.4) + + let card = UIView() + card.translatesAutoresizingMaskIntoConstraints = false + card.backgroundColor = .systemBackground + card.layer.cornerRadius = 16 + card.clipsToBounds = true + view.addSubview(card) + + let title = UILabel() + title.text = "Almost there" + title.font = .preferredFont(forTextStyle: .headline) + title.textAlignment = .center + title.numberOfLines = 0 + + let body = UILabel() + body.text = "Open Metic to finish importing this profile." + body.font = .preferredFont(forTextStyle: .subheadline) + body.textColor = .secondaryLabel + body.textAlignment = .center + body.numberOfLines = 0 + + let openButton = UIButton(type: .system) + openButton.setTitle("Open Metic", for: .normal) + openButton.titleLabel?.font = .preferredFont(forTextStyle: .headline) + openButton.addTarget(self, action: #selector(handleOpenTapped), for: .touchUpInside) + + let dismissButton = UIButton(type: .system) + dismissButton.setTitle("Done", for: .normal) + dismissButton.addTarget(self, action: #selector(handleDismissTapped), for: .touchUpInside) + + let stack = UIStackView(arrangedSubviews: [title, body, openButton, dismissButton]) + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .vertical + stack.spacing = 12 + stack.isLayoutMarginsRelativeArrangement = true + stack.layoutMargins = UIEdgeInsets(top: 24, left: 20, bottom: 20, right: 20) + card.addSubview(stack) + + NSLayoutConstraint.activate([ + card.centerYAnchor.constraint(equalTo: view.centerYAnchor), + card.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), + card.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), + stack.topAnchor.constraint(equalTo: card.topAnchor), + stack.bottomAnchor.constraint(equalTo: card.bottomAnchor), + stack.leadingAnchor.constraint(equalTo: card.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: card.trailingAnchor), + ]) + } + + @objc private func handleOpenTapped() { + openHostApp() + finish() + } + + @objc private func handleDismissTapped() { + finish() + } + + private func finish() { + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } +} diff --git a/apps/web/package.json b/apps/web/package.json index c1df2e06..fc12bd89 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "meticai-espresso-pro", "private": true, - "version": "2.6.0", + "version": "3.0.0-beta.1", "type": "module", "scripts": { "dev": "vite", @@ -45,9 +45,11 @@ "@capacitor/preferences": "^8.0.1", "@capacitor/share": "^8.0.1", "@capgo/capacitor-llm": "^8.1.0", + "@capgo/capacitor-share-target": "^8", "@google/genai": "^1.52.0", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^5.4.0", + "@metic/core": "file:../../packages/core", "@meticulous-home/espresso-api": "^0.11.1", "@meticulous-home/espresso-profile": "^0.4.2", "@octokit/core": "^7.0.6", @@ -107,7 +109,6 @@ "octokit": "^5.0.5", "qrcode.react": "^4.2.0", "react": "^19.2.7", - "react-day-picker": "^9.14.0", "react-dom": "^19.2.7", "react-easy-crop": "^6.0.2", "react-error-boundary": "^6.1.2", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 2debb7c4..93b9576a 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -2,6 +2,18 @@ import { defineConfig, devices } from '@playwright/test' const isCI = !!process.env.CI +/* Specs that require infrastructure CI does not provide. + - verify-tasks.spec.ts needs a running Docker container (port 3550); it is + executed separately in the docker-build job, which sets BASE_URL. + - fullapp-sweep.spec.ts is a local live-verify tool: it needs the unified + Bun server (port 35590) AND a real machine on the LAN, so it can never + run on a hosted CI runner. */ +const ciTestIgnore: string[] = [] +if (isCI) { + ciTestIgnore.push('**/fullapp-sweep.spec.ts') + if (!process.env.BASE_URL) ciTestIgnore.push('**/verify-tasks.spec.ts') +} + // In CI, only run Chromium to keep E2E fast. Locally, test all browsers. const projects = isCI ? [ @@ -33,8 +45,10 @@ export default defineConfig({ testDir: './e2e', /* verify-tasks.spec.ts needs a running Docker container (port 3550), so it's excluded from normal CI e2e runs and executed separately - in the docker-build job (which sets BASE_URL). */ - testIgnore: isCI && !process.env.BASE_URL ? ['**/verify-tasks.spec.ts'] : [], + in the docker-build job (which sets BASE_URL). fullapp-sweep.spec.ts + is a local-only live-verify tool (needs the unified Bun server + a real + machine), so it is always excluded in CI. */ + testIgnore: ciTestIgnore, fullyParallel: true, forbidOnly: isCI, retries: isCI ? 2 : 0, diff --git a/apps/web/public/locales/de/translation.json b/apps/web/public/locales/de/translation.json index f1df25ac..b852e288 100644 --- a/apps/web/public/locales/de/translation.json +++ b/apps/web/public/locales/de/translation.json @@ -512,8 +512,6 @@ "geminiModelDescriptionDynamic": "Modelle von Google AI geladen. Wählen Sie das gewünschte Modell.", "modelsLoading": "Modelle werden geladen…", "modelDefault": "Standardmodell", - "mqttEnabled": "MQTT-Brücke", - "mqttEnabledDescription": "Echtzeit-Maschinentelemetrie über MQTT. Betreibt das Live-Dashboard.", "settingsSaved": "Einstellungen erfolgreich gespeichert!", "settingsSaveFailed": "Einstellungen konnten nicht gespeichert werden", "changelog": "Änderungsprotokoll", @@ -614,9 +612,6 @@ }, "version": "Version", "footer": "Metic • Mit ❤️ für Kaffeeliebhaber entwickelt", - "addToHomeAssistant": "Add to Home Assistant", - "homeAssistantDescription": "Connect Home Assistant to Metic's MQTT broker to auto-discover your Meticulous machine (24 sensors + 11 commands). Point HA to this server's IP on port 1883.", - "homeAssistantGuide": "Home Assistant Anleitung", "poweredBy": "Powered by", "credits": { "pyMeticulous": "Official Python client for the Meticulous API", @@ -636,7 +631,6 @@ "geminiModel31FlashLite": "Gemini 3.1 Flash Lite", "aiSettings": "KI-Einstellungen", "versionAndChangelog": "Version & Änderungsprotokoll", - "mqttBridge": "MQTT-Bridge", "disclaimer": "Metic ist ein unabhängiges Open-Source-Projekt und ist nicht mit Meticulous Home, dem Hersteller der Meticulous Espressomaschine, verbunden, von diesem unterstützt oder autorisiert.", "localModelDownloadKeepOpen": "Lass die App geöffnet und bleibe auf diesem Bildschirm, bis der Download abgeschlossen ist." }, @@ -754,16 +748,10 @@ "fetchDetailsFailed": "Fehler beim Abrufen der Profildetails", "noResponseStream": "Kein Antwortstream verfügbar", "bulkImportStartFailed": "Fehler beim Starten des Massenimports", - "fromUrl": "Von URL", - "jsonOrMet": ".json / .met", - "importFromUrl": "Von URL importieren", - "urlPlaceholder": "Profil-URL einfügen...", - "urlHint": "Unterstützt .json- und .met-Profil-URLs", "back": "Zurück", "importButton": "Importieren", "fetchingUrl": "Profil von URL abrufen...", "importUrlFailed": "Import von URL fehlgeschlagen", - "invalidUrl": "Bitte geben Sie eine gültige URL ein", "decentEspresso": "Decent", "decentDescription": "Konvertieren & importieren", "decentImportHint": "Laden Sie eine Decent Espresso-Profil-JSON hoch oder fügen Sie sie ein, um sie ins Meticulous-Format zu konvertieren.", @@ -776,7 +764,24 @@ "notDecentFormat": "Kein gültiges Decent Espresso-Profilformat", "profileName": "Name", "stages": "Stufen", - "triggers": "Auslöser" + "triggers": "Auslöser", + "fromLink": "Aus Link", + "linkOrJson": "Link oder JSON", + "importFromLink": "Aus Link oder JSON importieren", + "sourcePlaceholder": "Füge einen metprofiles-Link, eine Profil-URL oder Profil-JSON ein...", + "sourceHint": "Unterstützt metprofiles.link, direkte .json/.met-Links und eingefügtes Profil-JSON", + "invalidSource": "Gib einen gültigen Link oder Profil-JSON ein", + "shareResult": { + "importingTitle": "Profil wird importiert", + "importingBody": "Das geteilte Profil wird zu deiner Bibliothek hinzugefügt...", + "successTitle": "Profil importiert", + "successBody": "\"{{name}}\" wurde erfolgreich importiert.", + "existsTitle": "Bereits importiert", + "existsBody": "\"{{name}}\" ist bereits in deiner Bibliothek.", + "errorTitle": "Import fehlgeschlagen", + "errorBody": "Das Profil konnte nicht importiert werden.", + "errorBodyWithReason": "Das Profil konnte nicht importiert werden: {{reason}}" + } }, "imageCrop": { "title": "Profilbild zuschneiden", diff --git a/apps/web/public/locales/en/translation.json b/apps/web/public/locales/en/translation.json index 04f25263..37c24882 100644 --- a/apps/web/public/locales/en/translation.json +++ b/apps/web/public/locales/en/translation.json @@ -512,11 +512,6 @@ "geminiModelDescriptionDynamic": "Models loaded from Google AI. Select which model to use.", "modelsLoading": "Loading models…", "modelDefault": "Default model", - "mqttEnabled": "MQTT Bridge", - "mqttEnabledDescription": "Real-time machine telemetry via MQTT. Powers the live Control Center dashboard.", - "addToHomeAssistant": "Add to Home Assistant", - "homeAssistantDescription": "Connect Home Assistant to Metic's MQTT broker to auto-discover your Meticulous machine (24 sensors + 11 commands). Point HA to this server's IP on port 1883.", - "homeAssistantGuide": "Home Assistant Guide", "poweredBy": "Powered by", "credits": { "pyMeticulous": "Official Python client for the Meticulous API", @@ -636,7 +631,6 @@ "geminiModel31FlashLite": "Gemini 3.1 Flash Lite", "aiSettings": "AI Settings", "versionAndChangelog": "Version & Changelog", - "mqttBridge": "MQTT Bridge", "disclaimer": "Metic is an independent, open-source project and is not affiliated with, endorsed by, or connected to Meticulous Home, the manufacturer of the Meticulous espresso machine.", "localModelDownloadKeepOpen": "Keep the app open and stay on this screen until the download finishes." }, @@ -759,16 +753,10 @@ "bulkImportStartFailed": "Failed to start bulk import", "noResponseStream": "No response stream available", "fetchDetailsFailed": "Failed to fetch profile details", - "fromUrl": "From URL", - "jsonOrMet": ".json / .met", - "importFromUrl": "Import from URL", - "urlPlaceholder": "Paste profile URL...", - "urlHint": "Supports .json and .met profile URLs", "back": "Back", "importButton": "Import", "fetchingUrl": "Fetching profile from URL...", "importUrlFailed": "Failed to import from URL", - "invalidUrl": "Please enter a valid URL", "decentEspresso": "Decent", "decentDescription": "Convert & import", "decentImportHint": "Upload or paste a Decent Espresso profile JSON to convert it to Meticulous format.", @@ -781,7 +769,24 @@ "notDecentFormat": "Not a valid Decent Espresso profile format", "profileName": "Name", "stages": "stages", - "triggers": "triggers" + "triggers": "triggers", + "fromLink": "From link", + "linkOrJson": "Link or JSON", + "importFromLink": "Import from link or JSON", + "sourcePlaceholder": "Paste a metprofiles link, a profile URL, or profile JSON...", + "sourceHint": "Supports metprofiles.link, direct .json/.met links, and pasted profile JSON", + "invalidSource": "Enter a valid link or profile JSON", + "shareResult": { + "importingTitle": "Importing profile", + "importingBody": "Adding the shared profile to your library...", + "successTitle": "Profile imported", + "successBody": "\"{{name}}\" was imported successfully.", + "existsTitle": "Already imported", + "existsBody": "\"{{name}}\" is already in your library.", + "errorTitle": "Import failed", + "errorBody": "The profile could not be imported.", + "errorBodyWithReason": "The profile could not be imported: {{reason}}" + } }, "imageCrop": { "title": "Crop Profile Image", diff --git a/apps/web/public/locales/es/translation.json b/apps/web/public/locales/es/translation.json index e46c18ba..c52e0688 100644 --- a/apps/web/public/locales/es/translation.json +++ b/apps/web/public/locales/es/translation.json @@ -512,8 +512,6 @@ "geminiModelDescriptionDynamic": "Modelos cargados desde Google AI. Seleccione qué modelo utilizar.", "modelsLoading": "Cargando modelos…", "modelDefault": "Modelo predeterminado", - "mqttEnabled": "Puente MQTT", - "mqttEnabledDescription": "Telemetría en tiempo real de la máquina vía MQTT. Alimenta el panel de control en vivo.", "settingsSaved": "¡Configuración guardada exitosamente!", "settingsSaveFailed": "Error al guardar configuración", "changelog": "Registro de cambios", @@ -614,9 +612,6 @@ }, "version": "Versión", "footer": "Metic • Construido con ❤️ para amantes del café", - "addToHomeAssistant": "Add to Home Assistant", - "homeAssistantDescription": "Connect Home Assistant to Metic's MQTT broker to auto-discover your Meticulous machine (24 sensors + 11 commands). Point HA to this server's IP on port 1883.", - "homeAssistantGuide": "Guía de Home Assistant", "poweredBy": "Powered by", "credits": { "pyMeticulous": "Official Python client for the Meticulous API", @@ -636,7 +631,6 @@ "geminiModel31FlashLite": "Gemini 3.1 Flash Lite", "aiSettings": "Ajustes de IA", "versionAndChangelog": "Versión y registro de cambios", - "mqttBridge": "Puente MQTT", "disclaimer": "Metic es un proyecto independiente de código abierto y no está afiliado, respaldado ni conectado con Meticulous Home, el fabricante de la máquina de espresso Meticulous.", "localModelDownloadKeepOpen": "Mantén la app abierta y permanece en esta pantalla hasta que finalice la descarga." }, @@ -754,16 +748,10 @@ "fetchDetailsFailed": "Error al obtener detalles del perfil", "noResponseStream": "No hay flujo de respuesta disponible", "bulkImportStartFailed": "Error al iniciar la importación masiva", - "fromUrl": "Desde URL", - "jsonOrMet": ".json / .met", - "importFromUrl": "Importar desde URL", - "urlPlaceholder": "Pegar URL del perfil...", - "urlHint": "Admite URLs de perfiles .json y .met", "back": "Atrás", "importButton": "Importar", "fetchingUrl": "Obteniendo perfil desde URL...", "importUrlFailed": "Error al importar desde URL", - "invalidUrl": "Introduce una URL válida", "decentEspresso": "Decent", "decentDescription": "Convertir e importar", "decentImportHint": "Sube o pega un JSON de perfil Decent Espresso para convertirlo al formato Meticulous.", @@ -776,7 +764,24 @@ "notDecentFormat": "No es un formato de perfil Decent Espresso válido", "profileName": "Nombre", "stages": "etapas", - "triggers": "disparadores" + "triggers": "disparadores", + "fromLink": "Desde enlace", + "linkOrJson": "Enlace o JSON", + "importFromLink": "Importar desde enlace o JSON", + "sourcePlaceholder": "Pega un enlace de metprofiles, una URL de perfil o el JSON del perfil...", + "sourceHint": "Compatible con metprofiles.link, enlaces directos .json/.met y JSON de perfil pegado", + "invalidSource": "Introduce un enlace válido o el JSON de un perfil", + "shareResult": { + "importingTitle": "Importando perfil", + "importingBody": "Añadiendo el perfil compartido a tu biblioteca...", + "successTitle": "Perfil importado", + "successBody": "\"{{name}}\" se importó correctamente.", + "existsTitle": "Ya importado", + "existsBody": "\"{{name}}\" ya está en tu biblioteca.", + "errorTitle": "Error al importar", + "errorBody": "No se pudo importar el perfil.", + "errorBodyWithReason": "No se pudo importar el perfil: {{reason}}" + } }, "imageCrop": { "title": "Recortar imagen de perfil", diff --git a/apps/web/public/locales/fr/translation.json b/apps/web/public/locales/fr/translation.json index 6b4c0ce6..9315d9b4 100644 --- a/apps/web/public/locales/fr/translation.json +++ b/apps/web/public/locales/fr/translation.json @@ -512,8 +512,6 @@ "geminiModelDescriptionDynamic": "Modèles chargés depuis Google AI. Sélectionnez le modèle à utiliser.", "modelsLoading": "Chargement des modèles…", "modelDefault": "Modèle par défaut", - "mqttEnabled": "Pont MQTT", - "mqttEnabledDescription": "Télémétrie machine en temps réel via MQTT. Alimente le tableau de bord en direct.", "settingsSaved": "Paramètres enregistrés avec succès !", "settingsSaveFailed": "Échec de l'enregistrement des paramètres", "changelog": "Journal des modifications", @@ -614,9 +612,6 @@ }, "version": "Version", "footer": "Metic • Conçu avec ❤️ pour les amateurs de café", - "addToHomeAssistant": "Add to Home Assistant", - "homeAssistantDescription": "Connect Home Assistant to Metic's MQTT broker to auto-discover your Meticulous machine (24 sensors + 11 commands). Point HA to this server's IP on port 1883.", - "homeAssistantGuide": "Guide Home Assistant", "poweredBy": "Powered by", "credits": { "pyMeticulous": "Official Python client for the Meticulous API", @@ -636,7 +631,6 @@ "geminiModel31FlashLite": "Gemini 3.1 Flash Lite", "aiSettings": "Paramètres IA", "versionAndChangelog": "Version et journal des modifications", - "mqttBridge": "Pont MQTT", "disclaimer": "Metic est un projet open source indépendant et n'est pas affilié, soutenu ou lié à Meticulous Home, le fabricant de la machine à espresso Meticulous.", "localModelDownloadKeepOpen": "Garde l'application ouverte et reste sur cet écran jusqu'à la fin du téléchargement." }, @@ -754,16 +748,10 @@ "fetchDetailsFailed": "Échec de la récupération des détails du profil", "noResponseStream": "Aucun flux de réponse disponible", "bulkImportStartFailed": "Échec du démarrage de l'importation en masse", - "fromUrl": "Depuis URL", - "jsonOrMet": ".json / .met", - "importFromUrl": "Importer depuis URL", - "urlPlaceholder": "Coller l'URL du profil...", - "urlHint": "Prend en charge les URL de profils .json et .met", "back": "Retour", "importButton": "Importer", "fetchingUrl": "Récupération du profil depuis l'URL...", "importUrlFailed": "Échec de l'importation depuis l'URL", - "invalidUrl": "Veuillez entrer une URL valide", "decentEspresso": "Decent", "decentDescription": "Convertir et importer", "decentImportHint": "Téléchargez ou collez un JSON de profil Decent Espresso pour le convertir au format Meticulous.", @@ -776,7 +764,24 @@ "notDecentFormat": "Format de profil Decent Espresso non valide", "profileName": "Nom", "stages": "étapes", - "triggers": "déclencheurs" + "triggers": "déclencheurs", + "fromLink": "Depuis un lien", + "linkOrJson": "Lien ou JSON", + "importFromLink": "Importer depuis un lien ou JSON", + "sourcePlaceholder": "Collez un lien metprofiles, une URL de profil ou le JSON du profil...", + "sourceHint": "Prend en charge metprofiles.link, les liens directs .json/.met et le JSON de profil collé", + "invalidSource": "Saisissez un lien valide ou le JSON d'un profil", + "shareResult": { + "importingTitle": "Importation du profil", + "importingBody": "Ajout du profil partagé à votre bibliothèque...", + "successTitle": "Profil importé", + "successBody": "« {{name}} » a été importé avec succès.", + "existsTitle": "Déjà importé", + "existsBody": "« {{name}} » est déjà dans votre bibliothèque.", + "errorTitle": "Échec de l'importation", + "errorBody": "Le profil n'a pas pu être importé.", + "errorBodyWithReason": "Le profil n'a pas pu être importé : {{reason}}" + } }, "imageCrop": { "title": "Recadrer l'image du profil", diff --git a/apps/web/public/locales/it/translation.json b/apps/web/public/locales/it/translation.json index c34ed55f..476c068d 100644 --- a/apps/web/public/locales/it/translation.json +++ b/apps/web/public/locales/it/translation.json @@ -512,8 +512,6 @@ "geminiModelDescriptionDynamic": "Modelli caricati da Google AI. Seleziona il modello da utilizzare.", "modelsLoading": "Caricamento modelli…", "modelDefault": "Modello predefinito", - "mqttEnabled": "Bridge MQTT", - "mqttEnabledDescription": "Telemetria macchina in tempo reale via MQTT. Alimenta la dashboard di controllo dal vivo.", "settingsSaved": "Impostazioni salvate con successo!", "settingsSaveFailed": "Impossibile salvare le impostazioni", "changelog": "Registro modifiche", @@ -614,9 +612,6 @@ }, "version": "Versione", "footer": "Metic • Realizzato con ❤️ per gli amanti del caffè", - "addToHomeAssistant": "Add to Home Assistant", - "homeAssistantDescription": "Connect Home Assistant to Metic's MQTT broker to auto-discover your Meticulous machine (24 sensors + 11 commands). Point HA to this server's IP on port 1883.", - "homeAssistantGuide": "Guida Home Assistant", "poweredBy": "Powered by", "credits": { "pyMeticulous": "Official Python client for the Meticulous API", @@ -636,7 +631,6 @@ "geminiModel31FlashLite": "Gemini 3.1 Flash Lite", "aiSettings": "Impostazioni IA", "versionAndChangelog": "Versione e registro modifiche", - "mqttBridge": "Bridge MQTT", "disclaimer": "Metic è un progetto open source indipendente e non è affiliato, approvato o collegato a Meticulous Home, il produttore della macchina espresso Meticulous.", "localModelDownloadKeepOpen": "Tieni l'app aperta e resta su questa schermata fino al termine del download." }, @@ -754,16 +748,10 @@ "fetchDetailsFailed": "Impossibile recuperare i dettagli del profilo", "noResponseStream": "Nessun flusso di risposta disponibile", "bulkImportStartFailed": "Impossibile avviare l'importazione di massa", - "fromUrl": "Da URL", - "jsonOrMet": ".json / .met", - "importFromUrl": "Importa da URL", - "urlPlaceholder": "Incolla URL del profilo...", - "urlHint": "Supporta URL di profili .json e .met", "back": "Indietro", "importButton": "Importa", "fetchingUrl": "Recupero del profilo dall'URL...", "importUrlFailed": "Importazione da URL non riuscita", - "invalidUrl": "Inserisci un URL valido", "decentEspresso": "Decent", "decentDescription": "Converti e importa", "decentImportHint": "Carica o incolla un JSON di profilo Decent Espresso per convertirlo nel formato Meticulous.", @@ -776,7 +764,24 @@ "notDecentFormat": "Formato profilo Decent Espresso non valido", "profileName": "Nome", "stages": "fasi", - "triggers": "trigger" + "triggers": "trigger", + "fromLink": "Da link", + "linkOrJson": "Link o JSON", + "importFromLink": "Importa da link o JSON", + "sourcePlaceholder": "Incolla un link metprofiles, un URL del profilo o il JSON del profilo...", + "sourceHint": "Supporta metprofiles.link, link diretti .json/.met e JSON del profilo incollato", + "invalidSource": "Inserisci un link valido o il JSON di un profilo", + "shareResult": { + "importingTitle": "Importazione profilo", + "importingBody": "Aggiunta del profilo condiviso alla tua libreria...", + "successTitle": "Profilo importato", + "successBody": "\"{{name}}\" è stato importato correttamente.", + "existsTitle": "Già importato", + "existsBody": "\"{{name}}\" è già nella tua libreria.", + "errorTitle": "Importazione non riuscita", + "errorBody": "Impossibile importare il profilo.", + "errorBodyWithReason": "Impossibile importare il profilo: {{reason}}" + } }, "imageCrop": { "title": "Ritaglia immagine profilo", diff --git a/apps/web/public/locales/sv/translation.json b/apps/web/public/locales/sv/translation.json index ff45d762..4119d1cf 100644 --- a/apps/web/public/locales/sv/translation.json +++ b/apps/web/public/locales/sv/translation.json @@ -512,8 +512,6 @@ "geminiModelDescriptionDynamic": "Modeller hämtade från Google AI. Välj vilken modell som ska användas.", "modelsLoading": "Läser in modeller…", "modelDefault": "Standardmodell", - "mqttEnabled": "MQTT-brygga", - "mqttEnabledDescription": "Realtidstelemetri från maskinen via MQTT. Driver den direkta kontrollpanelen.", "settingsSaved": "Inställningar sparade framgångsrikt!", "settingsSaveFailed": "Misslyckades spara inställningar", "changelog": "Ändringslogg", @@ -614,9 +612,6 @@ }, "version": "Version", "footer": "Metic • Byggd med ❤️ för kaffeälskare", - "addToHomeAssistant": "Add to Home Assistant", - "homeAssistantDescription": "Connect Home Assistant to Metic's MQTT broker to auto-discover your Meticulous machine (24 sensors + 11 commands). Point HA to this server's IP on port 1883.", - "homeAssistantGuide": "Home Assistant-guide", "poweredBy": "Powered by", "credits": { "pyMeticulous": "Official Python client for the Meticulous API", @@ -636,7 +631,6 @@ "geminiModel31FlashLite": "Gemini 3.1 Flash Lite", "aiSettings": "AI-inställningar", "versionAndChangelog": "Version och ändringslogg", - "mqttBridge": "MQTT-brygga", "disclaimer": "Metic är ett oberoende open source-projekt och är inte anslutet till, godkänt av eller kopplat till Meticulous Home, tillverkaren av Meticulous espressomaskinen.", "localModelDownloadKeepOpen": "Håll appen öppen och stanna på den här skärmen tills nedladdningen är klar." }, @@ -759,16 +753,10 @@ "fetchDetailsFailed": "Kunde inte hämta profildetaljer", "noResponseStream": "Ingen svarsström tillgänglig", "bulkImportStartFailed": "Kunde inte starta bulkimport", - "fromUrl": "Från URL", - "jsonOrMet": ".json / .met", - "importFromUrl": "Importera från URL", - "urlPlaceholder": "Klistra in profil-URL...", - "urlHint": "Stöder .json- och .met-profil-URL:er", "back": "Tillbaka", "importButton": "Importera", "fetchingUrl": "Hämtar profil från URL...", "importUrlFailed": "Kunde inte importera från URL", - "invalidUrl": "Ange en giltig URL", "decentEspresso": "Decent", "decentDescription": "Konvertera & importera", "decentImportHint": "Ladda upp eller klistra in en Decent Espresso-profil-JSON för att konvertera den till Meticulous-format.", @@ -781,7 +769,24 @@ "notDecentFormat": "Inte ett giltigt Decent Espresso-profilformat", "profileName": "Namn", "stages": "steg", - "triggers": "utlösare" + "triggers": "utlösare", + "fromLink": "Från länk", + "linkOrJson": "Länk eller JSON", + "importFromLink": "Importera från länk eller JSON", + "sourcePlaceholder": "Klistra in en metprofiles-länk, en profil-URL eller profil-JSON...", + "sourceHint": "Stöder metprofiles.link, direkta .json/.met-länkar och inklistrad profil-JSON", + "invalidSource": "Ange en giltig länk eller profil-JSON", + "shareResult": { + "importingTitle": "Importerar profil", + "importingBody": "Lägger till den delade profilen i ditt bibliotek...", + "successTitle": "Profil importerad", + "successBody": "\"{{name}}\" importerades.", + "existsTitle": "Redan importerad", + "existsBody": "\"{{name}}\" finns redan i ditt bibliotek.", + "errorTitle": "Importen misslyckades", + "errorBody": "Profilen kunde inte importeras.", + "errorBodyWithReason": "Profilen kunde inte importeras: {{reason}}" + } }, "imageCrop": { "title": "Beskär profilbild", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index aebc8c5e..c5caca59 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -58,6 +58,7 @@ import { BetaBanner } from '@/components/BetaBanner' import { DemoModeBanner } from '@/components/DemoModeBanner' import { FeatureErrorBoundary } from '@/components/FeatureErrorBoundary' import { ProfileImportDialog } from '@/components/ProfileImportDialog' +import { ShareImportResultDialog, type ShareImportState } from '@/components/ShareImportResultDialog' import type { ProfileData } from '@/components/ProfileBreakdown' const LiveShotView = lazy(() => import('./components/LiveShotView').then(m => ({ default: m.LiveShotView }))) @@ -71,6 +72,8 @@ const OnboardingWizard = lazy(() => import('./components/OnboardingWizard').then // Storage migration — initialises IndexedDB in direct/PWA mode import { useStorageMigration } from '@/services/storage' +import { registerShareTargetListener } from '@/services/shareImport' +import { importProfileFromSource } from '@/services/importProfile' // Capacitor plugin hooks import { useNetworkStatus } from '@/hooks/useNetworkStatus' @@ -118,7 +121,7 @@ function App() { const [shotHistoryProfileName, setShotHistoryProfileName] = useState(undefined) const [shotHistoryInitialDate, setShotHistoryInitialDate] = useState(undefined) const [shotHistoryInitialFilename, setShotHistoryInitialFilename] = useState(undefined) - const [pendingImportUrl, setPendingImportUrl] = useState(null) + const [shareImportState, setShareImportState] = useState(null) const [showAddProfileDialog, setShowAddProfileDialog] = useState(false) const fileInputRef = useRef(null) const resultsCardRef = useRef(null) @@ -494,8 +497,19 @@ function App() { } }, [islandNote]) - // Check for existing profiles on mount + // Check for existing profiles on mount. + // + // In proxy/server mode the `/api/history` endpoint is backed by the espresso + // machine's shot log, which can take 10s+ to respond (and much longer under + // concurrent load). Init must never hard-block on it, otherwise the whole app + // is stuck on the loading screen until the machine replies. Clear the + // initializing gate immediately and let the profile count populate in the + // background, guarded by a timeout so a slow or hanging machine never leaves + // the request pending indefinitely. useEffect(() => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + const checkProfiles = async () => { // In direct or demo mode, skip proxy API — default to form view if (isDemoMode() || isDirectMode()) { @@ -503,37 +517,34 @@ function App() { setIsInitializing(false) return } + + // Never block the UI on the (potentially slow) machine-backed history call. + setIsInitializing(false) + try { const serverUrl = await getServerUrl() - const response = await fetch(`${serverUrl}/api/history?limit=1&offset=0`) + const response = await fetch(`${serverUrl}/api/history?limit=1&offset=0`, { + signal: controller.signal, + }) if (response.ok) { const data = await response.json() setProfileCount(data.total || 0) } } catch (err) { - console.error('Failed to check profiles:', err) - // On error, default to form view - setProfileCount(0) + // Ignore aborts (timeout/unmount); default to form view otherwise + if (!(err instanceof DOMException && err.name === 'AbortError')) { + console.error('Failed to check profiles:', err) + setProfileCount(0) + } } finally { - setIsInitializing(false) + clearTimeout(timeout) } } checkProfiles() - }, []) - useEffect(() => { - const params = new URLSearchParams(window.location.search) - const importParam = params.get('import') - if (importParam) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time URL param init on mount - setPendingImportUrl(importParam) - // eslint-disable-next-line react-hooks/set-state-in-effect - setShowAddProfileDialog(true) - // eslint-disable-next-line react-hooks/set-state-in-effect - setViewState('profile-catalogue') - const url = new URL(window.location.href) - url.searchParams.delete('import') - window.history.replaceState({}, '', url.toString()) + return () => { + clearTimeout(timeout) + controller.abort() } }, []) @@ -552,6 +563,49 @@ function App() { } }, []) + // Automatic profile import used by the share sheet and the `?import=` deep + // link. The profile is added without any dialog interaction; a central + // popover then reports success (with the profile name) or failure (with a + // reason when the server provides one). + const runAutoImport = useCallback(async (source: string) => { + const trimmed = source.trim() + if (!trimmed) return + setShareImportState({ status: 'importing' }) + const result = await importProfileFromSource(trimmed) + setShareImportState(result) + if (result.status === 'success' || result.status === 'exists') { + refreshProfileCount() + setViewState('profile-catalogue') + } + }, [refreshProfileCount]) + + // The share sheet and the `?import=` deep link both import automatically and + // then surface the central result popover. Both run once on mount because + // runAutoImport is stable. + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const importParam = params.get('import') + if (importParam) { + const url = new URL(window.location.href) + url.searchParams.delete('import') + window.history.replaceState({}, '', url.toString()) + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time deep-link import on mount + runAutoImport(importParam) + } + }, [runAutoImport]) + + useEffect(() => { + let cleanup: (() => void) | undefined + void registerShareTargetListener((source) => { + runAutoImport(source) + }).then((fn) => { + cleanup = fn + }) + return () => { + cleanup?.() + } + }, [runAutoImport]) + const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) { @@ -931,10 +985,10 @@ function App() { if (closeTopmostOverlay()) return if (qrDialogOpen) { setQrDialogOpen(false); return } if (showAddProfileDialog) { setShowAddProfileDialog(false); return } - if (pendingImportUrl) { setPendingImportUrl(null); return } + if (shareImportState && shareImportState.status !== 'importing') { setShareImportState(null); return } if (hasUnsavedChanges() && !window.confirm(t('profileEdit.unsavedChanges'))) return navigateBack() - }, [qrDialogOpen, showAddProfileDialog, pendingImportUrl, navigateBack, t]) + }, [qrDialogOpen, showAddProfileDialog, shareImportState, navigateBack, t]) useAndroidBackButton(handleAndroidBack) @@ -1853,22 +1907,22 @@ function App() { isOpen={showAddProfileDialog} aiConfigured={aiAvailable} hideAiWhenUnavailable={hideAiWhenUnavailable} - initialUrl={pendingImportUrl ?? undefined} onClose={() => { setShowAddProfileDialog(false) - setPendingImportUrl(null) }} onImported={() => { setShowAddProfileDialog(false) - setPendingImportUrl(null) setViewState('profile-catalogue') }} onGenerateNew={() => { setShowAddProfileDialog(false) - setPendingImportUrl(null) setViewState('form') }} /> + setShareImportState(null)} + />
diff --git a/apps/web/src/components/HistoryView.tsx b/apps/web/src/components/HistoryView.tsx index 5a541e1f..0feede81 100644 --- a/apps/web/src/components/HistoryView.tsx +++ b/apps/web/src/components/HistoryView.tsx @@ -181,7 +181,9 @@ export function HistoryView({ onBack, onViewProfile, onGenerateNew, onManageMach setProfileImages(images) } - loadImages() + loadImages().catch((err) => { + console.error('Failed to load profile images', err) + }) }, [entries, fetchImagesForProfiles]) // Get all available tags from entries for filtering diff --git a/apps/web/src/components/MarkdownText.test.tsx b/apps/web/src/components/MarkdownText.test.tsx index c3ca6b38..994495a2 100644 --- a/apps/web/src/components/MarkdownText.test.tsx +++ b/apps/web/src/components/MarkdownText.test.tsx @@ -70,6 +70,18 @@ describe('MarkdownText utilities', () => { expect(result).toBe('This should be cleaned') }) + it('should not strip a valid trailing bold (no space before **)', () => { + const input = 'This is **bold**' + const result = cleanMalformedMarkdown(input) + expect(result).toBe('This is **bold**') + }) + + it('should preserve the character before a stripped trailing **', () => { + const input = 'Line one **\nLine two' + const result = cleanMalformedMarkdown(input) + expect(result).toBe('Line one\nLine two') + }) + it('should handle complex malformed markdown', () => { const input = '** Profile description\n**\nMore text\n###' const result = cleanMalformedMarkdown(input) diff --git a/apps/web/src/components/MarkdownText.tsx b/apps/web/src/components/MarkdownText.tsx index 4365320d..1c18fd68 100644 --- a/apps/web/src/components/MarkdownText.tsx +++ b/apps/web/src/components/MarkdownText.tsx @@ -20,8 +20,11 @@ export function cleanMalformedMarkdown(text: string): string { .replace(/^\s*###\s*$/gm, '') // Fix "** text" pattern at start of line -> just "text" .replace(/^\*\*\s+(?!\*)/gm, '') - // Fix "text **" pattern at end of line -> just "text" - .replace(/(? just "text". + // Avoid a lookbehind (? key + +vi.mock('react-i18next', () => ({ + initReactI18next: { type: '3rdParty', init: vi.fn() }, + useTranslation: () => ({ t: translate }), +})) + +vi.mock('framer-motion', () => ({ + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, + motion: { + div: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + }, +})) + +vi.mock('@/lib/config', () => ({ + getServerUrl: vi.fn(async () => ''), +})) + +function lastImportBody(): Record | null { + const calls = (globalThis.fetch as ReturnType).mock.calls + const call = calls.find(([url]) => String(url).endsWith('/api/import-from-url')) + if (!call) return null + return JSON.parse((call[1] as RequestInit).body as string) +} + +describe('ProfileImportDialog: link or JSON import', () => { + beforeEach(() => { + globalThis.fetch = vi.fn(async () => + new Response(JSON.stringify({ status: 'success', profile_name: 'Slay-ish' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) as unknown as typeof fetch + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + function openLinkStep() { + render( + {}} + onImported={() => {}} + onGenerateNew={() => {}} + />, + ) + fireEvent.click(screen.getByText('profileImport.fromLink')) + } + + it('posts a metprofiles link to the import endpoint', async () => { + openLinkStep() + const box = screen.getByPlaceholderText('profileImport.sourcePlaceholder') + fireEvent.change(box, { + target: { + value: 'https://metprofiles.link/profile/cd10c990-2185-4633-b883-f3fa4ed7dbfd', + }, + }) + fireEvent.click(screen.getByText('profileImport.importButton')) + await waitFor(() => { + expect(lastImportBody()).toMatchObject({ + url: 'https://metprofiles.link/profile/cd10c990-2185-4633-b883-f3fa4ed7dbfd', + }) + }) + }) + + it('accepts pasted raw JSON as a source', async () => { + openLinkStep() + const box = screen.getByPlaceholderText('profileImport.sourcePlaceholder') + fireEvent.change(box, { target: { value: '{"name":"Pasted","stages":[]}' } }) + fireEvent.click(screen.getByText('profileImport.importButton')) + await waitFor(() => { + expect(lastImportBody()).toMatchObject({ url: '{"name":"Pasted","stages":[]}' }) + }) + }) + + it('rejects non-link, non-JSON text without calling the endpoint', async () => { + openLinkStep() + const box = screen.getByPlaceholderText('profileImport.sourcePlaceholder') + fireEvent.change(box, { target: { value: 'just some words' } }) + await act(async () => { + fireEvent.click(screen.getByText('profileImport.importButton')) + }) + expect(screen.getByText('profileImport.invalidSource')).toBeTruthy() + expect(lastImportBody()).toBeNull() + }) +}) diff --git a/apps/web/src/components/ProfileImportDialog.tsx b/apps/web/src/components/ProfileImportDialog.tsx index 644e428d..10cc11c5 100644 --- a/apps/web/src/components/ProfileImportDialog.tsx +++ b/apps/web/src/components/ProfileImportDialog.tsx @@ -4,7 +4,6 @@ import { motion, AnimatePresence } from 'framer-motion' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Label } from '@/components/ui/label' -import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' @@ -26,6 +25,8 @@ import { } from '@phosphor-icons/react' import { getServerUrl } from '@/lib/config' import { detectDecentFormat, convertDecentToMeticulous, type ConversionResult } from '@/services/decentConverter' +import { classifyProfileSource } from '@/services/profileSource' +import { importProfileFromSource } from '@/services/importProfile' interface MachineProfile { id: string @@ -55,7 +56,6 @@ interface ProfileImportDialogProps { isOpen: boolean aiConfigured?: boolean hideAiWhenUnavailable?: boolean - initialUrl?: string onClose: () => void onImported: () => void onGenerateNew: () => void @@ -63,7 +63,7 @@ interface ProfileImportDialogProps { type ImportStep = 'choose' | 'file' | 'machine' | 'url' | 'decent' | 'decent-preview' | 'importing' | 'bulk-importing' | 'success' | 'bulk-success' | 'error' -export function ProfileImportDialog({ isOpen, aiConfigured = true, hideAiWhenUnavailable = false, initialUrl, onClose, onImported, onGenerateNew }: ProfileImportDialogProps) { +export function ProfileImportDialog({ isOpen, aiConfigured = true, hideAiWhenUnavailable = false, onClose, onImported, onGenerateNew }: ProfileImportDialogProps) { const { t } = useTranslation() const [step, setStep] = useState('choose') const [machineProfiles, setMachineProfiles] = useState([]) @@ -81,51 +81,30 @@ export function ProfileImportDialog({ isOpen, aiConfigured = true, hideAiWhenUna const fileInputRef = useRef(null) const decentFileInputRef = useRef(null) const abortControllerRef = useRef(null) - const autoImportTimerRef = useRef | null>(null) - const handleUrlImportRef = useRef<(urlOverride?: string) => Promise>(async () => {}) // Reset state when dialog opens useEffect(() => { if (isOpen) { // eslint-disable-next-line react-hooks/set-state-in-effect -- reset state when dialog opens - setStep(initialUrl ? 'url' : 'choose') - // eslint-disable-next-line react-hooks/set-state-in-effect - setImportUrl(initialUrl || '') - // eslint-disable-next-line react-hooks/set-state-in-effect + setStep('choose') + setImportUrl('') setMachineProfiles([]) - // eslint-disable-next-line react-hooks/set-state-in-effect setSelectedProfile(null) - // eslint-disable-next-line react-hooks/set-state-in-effect setError(null) - // eslint-disable-next-line react-hooks/set-state-in-effect setImportedProfileName(null) - // eslint-disable-next-line react-hooks/set-state-in-effect setBulkProgress(null) - // eslint-disable-next-line react-hooks/set-state-in-effect setBulkLogs([]) - // eslint-disable-next-line react-hooks/set-state-in-effect setGenerateDescriptions(aiConfigured) - // eslint-disable-next-line react-hooks/set-state-in-effect setDecentJson('') - // eslint-disable-next-line react-hooks/set-state-in-effect setDecentPreview(null) - if (initialUrl) { - autoImportTimerRef.current = setTimeout(() => handleUrlImportRef.current(initialUrl), 100) - } } else { - // eslint-disable-next-line react-hooks/set-state-in-effect setImportUrl('') - if (autoImportTimerRef.current) { - clearTimeout(autoImportTimerRef.current) - autoImportTimerRef.current = null - } if (abortControllerRef.current) { abortControllerRef.current.abort() abortControllerRef.current = null } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, aiConfigured, initialUrl]) + }, [isOpen, aiConfigured]) const fetchMachineProfiles = async () => { setLoadingMachine(true) @@ -152,54 +131,54 @@ export function ProfileImportDialog({ isOpen, aiConfigured = true, hideAiWhenUna } } - const handleUrlImport = async (urlOverride?: string) => { - const urlToImport = urlOverride || importUrl.trim() + const handleUrlImport = async () => { + const urlToImport = importUrl.trim() if (!urlToImport) return - try { - new URL(urlToImport) - } catch { - setError(t('profileImport.invalidUrl')) - setStep('error') - return + // The source may be a metprofiles link, a direct profile URL, or raw JSON + // pasted/shared as text. Classify with the shared @metic/core resolver so + // the client gives instant feedback and the server does the real work. + const kind = classifyProfileSource(urlToImport) + if (kind === 'json') { + try { + JSON.parse(urlToImport) + } catch { + setError(t('profileImport.invalidSource')) + setStep('error') + return + } + } else { + try { + new URL(urlToImport) + } catch { + setError(t('profileImport.invalidSource')) + setStep('error') + return + } } setStep('importing') setImportProgress(t('profileImport.fetchingUrl')) setError(null) - try { - const serverUrl = await getServerUrl() - const response = await fetch(`${serverUrl}/api/import-from-url`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: urlToImport, generate_description: generateDescriptions }), - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ detail: t('profileImport.importUrlFailed') })) - const errorMessage = typeof errorData.detail === 'string' - ? errorData.detail - : errorData.detail?.error || errorData.detail?.message || t('profileImport.importUrlFailed') - throw new Error(errorMessage) - } - - const result = await response.json() - - if (result.status === 'exists') { - setError(t('profileImport.profileExists', { name: result.profile_name })) - setStep('error') - return - } + const result = await importProfileFromSource(urlToImport, { + generateDescription: generateDescriptions, + }) - setImportedProfileName(result.profile_name) - setStep('success') - } catch (err) { - setError(err instanceof Error ? err.message : t('profileImport.importUrlFailed')) + if (result.status === 'exists') { + setError(t('profileImport.profileExists', { name: result.profileName })) + setStep('error') + return + } + if (result.status === 'error') { + setError(result.message || t('profileImport.importUrlFailed')) setStep('error') + return } + + setImportedProfileName(result.profileName) + setStep('success') } - useEffect(() => { handleUrlImportRef.current = handleUrlImport }) const handleDecentParse = (json: string) => { try { @@ -526,8 +505,8 @@ export function ProfileImportDialog({ isOpen, aiConfigured = true, hideAiWhenUna className="h-24 flex-col gap-2 border-border/50 hover:border-primary/50 hover:bg-primary/5" > - {t('profileImport.fromUrl')} - {t('profileImport.jsonOrMet')} + {t('profileImport.fromLink')} + {t('profileImport.linkOrJson')}