diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..1463f6b
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,8 @@
+# PlantRun agent guide
+
+- Read `PROJECT_CONTEXT.md` before planning or changing product behavior, UI, or the stored data model.
+- Read `CONTEXT.md` when work touches PlantRun terminology or domain boundaries.
+- Read `docs/PRODUCT_DISCOVERY.md` when work touches cultivation workflows, the accepted prototype direction, or V1 scope.
+- Treat `custom_components/plantrun/www/prototype/` as disposable review material. Production code lives outside that folder.
+- Keep repository checks, installation in Home Assistant, and real HACS use as separate validation states.
+- Preserve unrelated working-tree changes.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..6bb0813
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,91 @@
+# Changelog
+
+## 0.7.0
+
+- Replaced the rejected dashboard and stored run model with the accepted one-plant-per-run PlantRun application.
+- Added a persistent Tent, structured Plant and Run records, dated Stage history, sourced Strain identity, Journal Entries, and dated Tent or Plant sensor bindings.
+- Added the three-step creation flow, history-first Journal, direct Stage changes, Recorder-backed metric workspace, sensor reassignment, archive, and warned Permanent Deletion.
+- Added first-class container, soil, and light-cycle facts so the August 25 acceptance cycle can be imported without reducing those facts to prose.
+- Preserved the old v2 store in a hidden backup bucket while keeping it out of the rebuilt interface.
+- Added an atomic, idempotent import command for the acceptance ledger and compatibility handling for existing Home Assistant services.
+- Added the PlantRun favicon used by T3 Code project tabs.
+- Added domain, application, migration, websocket, and critical frontend contract tests.
+
+## 0.6.1
+
+- Keep generated stage artwork visible alongside user and SeedFinder imagery.
+- Anchor Recorder charts to the viewport so dialogs stay fully visible after scrolling.
+
+## 0.6.0
+
+- Added an in-panel Recorder chart that reads Home Assistant history on demand without storing samples in PlantRun.
+- Added run-window latest, average, low, and high statistics from transient Recorder data.
+- Added dense Main data and Grow info panels inspired by cultivation logbooks.
+- Added richer harvest summaries with start and harvest dates.
+- Added original transparent seedling, vegetative, and flowering plant assets generated for PlantRun.
+- Integrated stage artwork into run cards, plant cards, and workspaces while preserving user-provided imagery.
+
+## 0.5.0
+
+- Replaced the dashboard-style frontend with a plant-first garden and focused run workspaces.
+- Added migrations-safe structured plant records with watering logs and configurable intervals.
+- Added useful attention signals for scheduled watering, unavailable sensors, and cycle timing.
+- Added meaningful view personalization with grid/list layouts and optional overview sections.
+- Removed decorative placeholder curves and exposed truthful live/unavailable sensor states.
+- Made completed runs read-only by default with a dedicated harvest summary and correction path.
+- Fixed custom phase rendering and exact run-window history navigation in the Lovelace card.
+- Added keyboard-operable sensor history tiles and improved dialog semantics.
+
+## 0.4.2
+
+- Improved dark-theme contrast for the highlighted overview statistic and made
+ run-card progress percentages readable inside their rings.
+
+## 0.4.1
+
+- Added explicit release cache keys to every imported panel module so Home
+ Assistant cannot combine a new entry module with stale child modules.
+
+## 0.4.0
+
+- Rebuilt the PlantRun experience around a calm garden overview and a dedicated
+ run workspace instead of the rejected sidebar-and-detail dashboard pattern.
+- Added a prominent responsive lifecycle rail inspired by the supplied phase
+ references, with working canonical and custom phase transitions.
+- Added named plants and editable per-run phase plans to guided setup and run
+ customization without changing the existing storage schema.
+- Reworked live sensor bindings into Recorder-first intelligence cards and notes
+ into a lightweight chronological journal.
+- Added a four-step setup flow, polished light/dark palettes, density and optional
+ sound controls, mobile layouts, keyboard focus states, and reduced-motion support.
+- Preserved existing runs, services, SeedFinder enrichment, bindings, completion,
+ yield, and exact Home Assistant history-window navigation.
+
+## 0.3.1
+
+- Fixed light-mode text inheritance when Home Assistant itself uses a dark theme.
+- Reduced distracting low-quality cultivar imagery behind the light hero surface.
+
+## 0.3.0
+
+- Rebuilt the sidebar frontend as native ES modules with explicit API, domain,
+ view, dialog, style, and controller boundaries.
+- Removed direct Home Assistant websocket and service transport from the panel
+ controller in favor of a focused `PlantRunApi` adapter.
+- Centralized phase, target-day, progress, and run-history-window calculations.
+- Extracted Home Assistant panel registration from integration setup.
+- Added a module-set cache fingerprint so changes in imported frontend files are
+ picked up after integration reloads.
+- Preserved sound feedback, light/dark mode, density controls, stored runs, and
+ all public Home Assistant services.
+
+## 0.2.0
+
+- Redesigned the PlantRun sidebar around a smaller, responsive run workspace.
+- Added active, archive, and all-run counts with filter-aware selection.
+- Added light/dark and comfortable/compact layout preferences.
+- Reworked sensor tiles and history dialogs around Home Assistant Recorder data.
+- Added a validated three-step onboarding flow with optional cultivar and sensor setup.
+- Added user-defined phases alongside the common cultivation stages.
+- Added a finish-and-archive flow with optional dry harvest yield.
+- Fixed completed-run day/progress calculations and run-window end-time handling.
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..a06bbd8
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,55 @@
+# PlantRun
+
+PlantRun records the condition, lifecycle, and care of plants inside a controlled growing space.
+
+## Language
+
+**Tent**:
+A persistent physical enclosed growing space. A tent may contain any number of plants at any combination of lifecycle stages, and it continues to exist as plants enter and leave.
+
+**Plant**:
+An individual living plant inside a tent. Plants in the same tent may differ in variety, condition, care, and lifecycle timing.
+
+**Run**:
+A durable cultivation record of exactly one plant. A run belongs to a tent and follows that plant from planting through its individual lifecycle and later historical record.
+_Avoid_: Batch, group run
+
+**Completed Run**:
+A run whose cultivation has finished. It remains a browsable, editable historical record unless the user explicitly chooses Permanent Deletion.
+
+**Archive**:
+The historical view for runs that no longer appear among active plants. Moving a run to the Archive changes its visibility but preserves every recorded detail.
+_Avoid_: Trash, deleted runs
+
+**Permanent Deletion**:
+An explicit user action that irreversibly removes a run and every detail PlantRun owns for it. Completion and archival never trigger it.
+_Avoid_: Archive, completion
+
+**Stage**:
+A named period in one run's lifecycle. A stage belongs to the run, never to the tent.
+_Avoid_: Tent phase
+
+**Strain**:
+The named genetic variety of the plant in a run. `Strain` is the canonical product term in English and German UI.
+_Avoid_: Variety, cultivar, Sorte
+
+**Breeder**:
+The organization associated with producing or distributing a strain.
+
+**Journal Entry**:
+A dated account of an action, observation, measurement, lifecycle change, or plan. It belongs to a tent and may link to one or more runs; free text is primary and structured details are optional.
+_Avoid_: Note
+
+**Sensor Binding**:
+A dated association between a Home Assistant entity and either one run or its tent. Reassigning a sensor does not change which run owned earlier readings.
+
+**Shared Tent Metric**:
+A sensor value that describes the tent and may provide context to every run inside it, such as average temperature, average humidity, light, door state, or energy.
+_Avoid_: Plant sensor
+
+**Plant Sensor**:
+A sensor assigned to one run because it measures that individual plant or its growing medium, such as soil moisture or conductivity.
+
+**Harvest Window**:
+An editable date range estimating when one run may reach harvest. It derives from the planting date and a sourced strain-duration range without replacing either value.
+_Avoid_: Tent harvest date
diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md
index f2ec172..fce03df 100644
--- a/PROJECT_CONTEXT.md
+++ b/PROJECT_CONTEXT.md
@@ -1,135 +1,328 @@
-# PlantRun project context
+# PlantRun: product vision and rebuild brief
+
+## Status for the next implementation
+
+PlantRun needs a genuine from-scratch product and UI rebuild.
+
+The current sidebar UI, including the redesign work on
+`codex/plant-run-redesign`, was rejected by the product owner. It still looks and
+feels too much like the previous interface. **Do not use the current UI, its
+layout, visual hierarchy, cards, colors, or component composition as a design
+reference.** Existing code may be inspected only to understand behavior, stored
+data, Home Assistant contracts, migrations, and already solved technical edge
+cases.
+
+Start with product discovery, information architecture, wireframes, and a new
+visual direction before writing the replacement frontend. A new agent should
+treat the screenshots and current frontend as examples of what not to reproduce.
+
+The disposable prototype under `custom_components/plantrun/www/prototype/` is
+the current design record. Its `Clean development` plant workspace, `detail=B`
+revision 13, has been accepted as the working detail direction. The guided
+`create=A` flow is also selected: one invocation creates one plant and one run.
+Prototype code is still throwaway code and must not be promoted directly into
+the HACS integration.
+
+All remaining review screens must use the same shape language as those selected
+directions. Major work areas use a 24 px radius, grouped content uses 18 px, and
+controls use 12 px. Keep rows and charts flat inside the outer container. Do not
+switch back to joined square boxes, and do not turn every value into its own
+floating card. The Lovelace companion follows Home Assistant styling instead.
## What PlantRun is
-PlantRun is a Home Assistant custom integration for tracking cultivation runs end to end: run creation, phase changes, notes, cultivar enrichment, sensor bindings, run summaries, and persistent per-run history.
-
-The product now has two frontend entry points:
-- a dedicated sidebar app at `/plantrun-dashboard`
-- a Lovelace card: `custom:plantrun-card`
-
-## Current status: frontend rebuild branch
-
-This branch (`feature/frontend-rebuild`) is the rebuilt UI line intended to become the new frontend baseline.
-
-What is already in place here:
-- full sidebar panel rebuild in `custom_components/plantrun/www/plantrun-panel.js`
-- richer run cards with stage art, live metric chips, filters, and expanded summaries
-- detail overlay with editable run metadata, notes, yield, target days, and binding management
-- 3-step run creation wizard
-- theme + language toggles and persisted layout preferences
-- live SeedFinder cultivar search
-- Lovelace card/editor updated to use canonical PlantRun runtime data instead of brittle entity-ID guessing
-
-## Key architecture surfaces
-
-### Frontend
-
-#### `custom_components/plantrun/www/plantrun-panel.js`
-Main app surface.
-- loads runs via websocket
-- renders run grid, detail overlay, wizard, history tiles, layout/theme/lang controls
-- calls `/api/plantrun/search_cultivar` for live cultivar suggestions
-- uses HA services for mutations
-
-#### `custom_components/plantrun/www/plantrun-card.js`
-Compact Lovelace card for one selected run.
-- fetches a run with `plantrun/get_run`
-- fetches summary data with `plantrun/get_run_summary`
-- opens the sidebar dashboard for deeper interaction
-
-#### `custom_components/plantrun/www/plantrun-card-editor.js`
-Card editor for picking a run and optional title/compact mode.
-- discovers runs through `plantrun/get_runs`
-- do not regress this back to `hass.states` string matching
-
-### Backend runtime / API surface
-
-#### `custom_components/plantrun/__init__.py`
-Registers most of the runtime contract:
-- static frontend path: `/plantrun_frontend`
-- sidebar panel: `/plantrun-dashboard`
-- websocket commands:
- - `plantrun/get_runs`
- - `plantrun/get_run`
- - `plantrun/get_run_summary`
-- authenticated HTTP search endpoint:
- - `POST /api/plantrun/search_cultivar`
-- Home Assistant services for create/update/end/bind/note/image workflows
-
-### Services currently exposed
-
-- `plantrun.create_run`
-- `plantrun.add_phase`
-- `plantrun.add_note`
-- `plantrun.update_note`
-- `plantrun.delete_note`
-- `plantrun.update_run`
-- `plantrun.set_cultivar`
-- `plantrun.set_run_image`
-- `plantrun.add_binding`
-- `plantrun.update_binding`
-- `plantrun.remove_binding`
-- `plantrun.end_run`
-
-### Storage
-
-#### `custom_components/plantrun/store.py`
-- canonical source of persisted run data
-- Home Assistant `Store` backed payload with schema normalization/migration
-- maintains:
- - `runs`
- - `active_run_id`
- - `daily_rollups`
-- legacy payloads and legacy binding IDs are normalized on load
-
-### Sensors
-
-#### `custom_components/plantrun/sensor.py`
-Exposes HA entities for dashboarding and automation:
-- total runs sensor
-- per-run status / phase / cultivar / energy / energy cost sensors
-- proxy sensors per binding
-
-Important: frontend discovery should not depend on HA-generated entity IDs as the primary contract. Use storage/websocket data first; treat entities as display/automation surfaces.
-
-## Testing commands
-
-Primary gate used on this branch:
-
-```bash
-python3 -m unittest discover -s tests -p 'test_*.py'
-node --check custom_components/plantrun/www/plantrun-panel.js
-node --check custom_components/plantrun/www/plantrun-card.js
-node --check custom_components/plantrun/www/plantrun-card-editor.js
-```
-
-Useful focused suite:
-
-```bash
-python3 -m unittest tests.test_dashboard_panel_interactions -q
-```
-
-## Branch / install reality
-
-- HACS follows the repo default branch, not this feature branch.
-- If testing `feature/frontend-rebuild` before merge, use a manual checkout/copy into `custom_components/plantrun`.
-- After JS changes, hard-refresh the browser. The panel has versioned module loading, but stale frontend cache can still waste time.
-
-## Important gotchas from this sprint
-
-- **Do not reintroduce entity-ID guessing in the frontend.** Older codepaths broke when UI discovery depended on `sensor.plantrun_*` naming assumptions.
-- **Binding IDs matter.** `remove_binding` / `update_binding` should operate on stable binding IDs where possible; history is meant to stay attached to the run.
-- **Storage migration is part of the contract.** Old runs may be missing schema version, phases, active run ID, or binding IDs.
-- **Light units are intentionally normalized.** `lx` and `lux` variants should keep working.
-- **Node `--check` is syntax-only.** Passing it does not prove runtime behavior; keep the Python tests green too.
-- **Some test output warnings are expected.** The suite intentionally covers unit-drift and malformed-store scenarios.
-- **Local scratch files exist sometimes.** If `plantrun-ship-readiness-2026-03-20.md` is present locally, keep it out of commits.
-
-## If you pick this up next
-
-Good next checks before changing behavior:
-1. confirm whether the change belongs to panel, card, editor, or backend service contract
-2. prefer websocket/storage-backed data over HA entity-name inference
-3. run the full unittest + JS syntax gate before handing off
-4. test manual branch install behavior, not just assumptions about HACS
+PlantRun is a Home Assistant custom integration for documenting and following
+complete cannabis cultivation cycles ("runs"). It should feel like a focused,
+high-quality cultivation journal built naturally into Home Assistant, not like a
+generic admin dashboard or a collection of HA cards.
+
+A tent is the persistent growing space. A run represents exactly one plant from
+planting through harvest and later history. A tent may contain any number of
+overlapping runs at unrelated stages. Active and completed runs remain useful,
+browsable records.
+
+PlantRun should help users:
+
+- set up a new run without being overwhelmed;
+- see immediately where every plant/run is in its lifecycle;
+- define and advance their own phases;
+- connect existing Home Assistant sensors;
+- inspect environmental history for the exact duration of a run;
+- keep notes and important events over time;
+- complete a run with harvest information such as dry yield;
+- revisit and compare completed runs later.
+
+## Non-negotiable product principles
+
+### Home Assistant owns sensor history
+
+PlantRun must not duplicate Home Assistant time-series sensor data in its own
+storage. It stores run metadata, plant metadata, phase changes, notes, sensor
+bindings, timestamps, and harvest results. Historical values stay in Home
+Assistant Recorder.
+
+Every run defines a time window:
+
+- active run: planting/start time to now;
+- completed run: planting/start time to harvest/end time.
+
+Selecting a bound metric such as humidity, temperature, light, soil moisture,
+conductivity, or energy should open Home Assistant's native history/detail
+experience with that entity and the run's exact time window already selected.
+This behavior exists in the current project and must be preserved or improved.
+
+### Acceptance data must survive
+
+The owner's next real cultivation cycle began on August 25, 2026. Data captured
+for that cycle is relied upon and must survive the rebuild. Before replacing the
+stored schema or installed integration, create a supported export or backup and
+prove the migration or one-time import path. Older test runs may be discarded
+only after the owner identifies them as disposable. Once the rebuilt schema
+becomes the public HACS baseline, every later release must migrate real user data
+forward.
+
+### The UI is a clean-sheet design
+
+Do not reskin the existing dashboard. Do not begin by rearranging its cards. The
+new interface needs a fresh interaction model and visual system.
+
+The desired character is:
+
+- modern, friendly, calm, and tactile;
+- minimal without feeling empty or generic;
+- highly legible and intentional;
+- customizable without exposing complexity too early;
+- responsive and genuinely pleasant on desktop and mobile;
+- visually distinct from both stock Home Assistant and the rejected UI;
+- polished enough to feel like a standalone product while remaining native to
+ Home Assistant.
+
+Dark mode is the default. Use muted green-black surfaces, restrained botanical
+accents, ordinary interface typography, and plant photography for identity.
+Avoid app-store mockup styling, neon lime, glass effects, oversized marketing
+headlines, and soft gradients that make the interface look generated.
+
+Avoid dense admin-dashboard patterns, excessive bordered rectangles, repeated
+metric cards, weak visual hierarchy, decorative imagery with no purpose, and a
+layout that is merely a sidebar plus a large detail grid.
+
+The product owner liked the idea and function of a light/dark switch represented
+as light on/off, and also liked the sound on/off interaction. Keep those concepts,
+but reinterpret their visual design from scratch. Sound must remain optional,
+subtle, accessible, and easy to disable.
+
+## Core experience
+
+### Overview
+
+The opening view should answer, at a glance:
+
+- Which runs and plants are active?
+- What phase is each one in?
+- What needs attention or is coming next?
+- How far through the expected cycle is each run?
+- Which runs have recently been completed?
+
+Multiple simultaneous runs/plants are a first-class scenario, not an edge case.
+Archived runs should stay easy to discover without competing visually with active
+work.
+
+Plants dominate the overview. Shared tent values sit in one restrained strip.
+The full dashboard has its own PlantRun design, while the compact Lovelace card
+will follow Home Assistant's native visual language.
+
+### Run workspace
+
+A run needs a coherent workspace rather than an assortment of unrelated cards.
+It should combine:
+
+- identity: optional nickname, strain, breeder, and plant image;
+- current phase and phase timeline;
+- expected duration and progress;
+- bound live sensor values;
+- recorder-backed history entry points;
+- chronological notes and events;
+- editing and sensor binding;
+- completion/harvest flow and final yield;
+- useful summary for completed runs.
+
+The UI should make the distinction between live values, stored journal data, and
+Recorder history obvious without technical jargon.
+
+The accepted detail direction leads with the plant image, lifecycle progress,
+and one focused Recorder chart. Selecting an environment metric updates that
+chart and its visible target range, current status, change, latest related event,
+minimum, average, and maximum. Journal content sits below this development view.
+Permanent facts use one aligned strip beneath the chart.
+
+### Phases
+
+Common phases may be offered as helpful defaults, for example seedling,
+vegetative, flowering, drying, curing, and harvested. Users decide which phases
+they use and may add, rename, reorder, skip, or advance custom phases. Phase
+changes belong to the permanent run timeline with timestamps.
+
+Do not encode one rigid growing method into the product.
+
+The selected phase-management structure is prototype variant A. Its interaction
+contract is direct target selection, not a forced "next phase" sequence: the
+user clicks any phase in the lifecycle rail or phase plan, reviews the editable
+change timestamp, and confirms the switch. Drag-and-drop changes the displayed
+order only. It must not constrain valid transitions or rewrite recorded phase
+history.
+
+### Cultivar/strain search
+
+The partially working SeedFinder search is valuable and should be retained as a
+capability, not copied blindly as UI. It should help find cultivar and breeder
+information during setup. When reliable flowering/duration information is
+available, PlantRun may use it to suggest an expected duration and end date.
+
+The desired interaction is a search engine rather than a result-card picker: a
+prominent Strain query updates a short result list while the user types, an
+optional Breeder filter narrows it, and the highlighted result has a live detail
+preview. Clicking a result must not overwrite current run data. Import happens
+only through an explicit confirmation action.
+
+The current `plantrun/search_cultivar` websocket searches within one required
+Breeder and returns at most five matches. A global all-Breeder search is desired
+but is not implemented by that contract yet. Extend the provider boundary rather
+than faking a global result list in the production frontend. Preview fields must
+identify whether they came from the result row, a fetched detail page, or manual
+input.
+
+Suggestions must be transparent and editable. A user can always enter the data
+manually or continue without cultivar enrichment. Network/provider failure must
+never block run creation.
+
+### Notes and completed runs
+
+Notes should feel like a lightweight journal: fast to add, chronological, easy
+to edit, and visibly associated with a date/phase. Capture follows one clear
+sequence: plant, event type or free text, content, editable occurrence time, and
+save. Current Home Assistant sensor context is visibly attached rather than
+hidden. History uses readable day-grouped events with separate plant and type
+filters; do not compress the primary journal into a tiny wide table.
+
+Quick capture must stay compact. Do not rebuild it as a large setup form with
+plant cards, a row of action chips, separate date and time fields, a giant text
+area, and an always-expanded sensor grid. Plant, event type, and occurrence time
+are metadata around the text entry. Recorder context is attached automatically
+and can be expanded for inspection. The selected journal structure is prototype
+variant C, `Verlauf zuerst`: open on the chronological history and reveal the
+compact side editor only after the user requests a new entry. Do not carry
+variants A or B into implementation.
+
+Completion should record the end time and optionally harvest/yield information.
+Completed runs remain fully browsable and editable. The first version overwrites
+corrected values and does not need a correction audit trail.
+
+## Onboarding and new-run setup
+
+The first-run experience must follow progressive disclosure and current UX best
+practices. A new user should understand the value before being asked to configure
+everything.
+
+Every invocation of the creation flow creates one run for exactly one plant.
+Creating another plant means starting the same flow again. Do not add batch or
+multi-run creation.
+
+The selected structure is the guided `create=A` flow:
+
+1. breeder and Strain search with manual fallback, optional nickname, and
+ planting date;
+2. default or custom phases and optional sensor bindings;
+3. review and create the run.
+
+The production frontend must reuse the existing authenticated
+`plantrun/search_cultivar` websocket and tolerant SeedFinder matching. The fixed
+catalog in the disposable HTML is illustrative test data only. Search results,
+images, sourced duration, and derived harvest windows are optional aids and
+remain editable. Provider failure or no result must always lead to a complete
+manual path. Show progress, allow back navigation without losing input, and
+state that every value remains editable after creation.
+
+## Customization and accessibility
+
+Customization should include theme and density/layout choices where useful, but
+defaults must already look excellent. Support Home Assistant theme context while
+providing a deliberate PlantRun light and dark appearance. Respect reduced-motion
+preferences, keyboard navigation, focus visibility, contrast, touch target sizes,
+and screen-reader labels. Do not rely on color alone for phase or status.
+
+## Architecture expectations
+
+A full rebuild may replace the frontend architecture and may refactor backend
+boundaries where that makes the product easier to maintain. It should not be a
+single giant custom element or a monolithic `__init__.py`.
+
+Keep clear boundaries between:
+
+- Home Assistant transport (websocket/services);
+- the persistent tent and one-plant run domain models;
+- persistence and migrations;
+- Recorder/history link construction;
+- external cultivar providers;
+- view components and design tokens;
+- onboarding and other workflows.
+
+Prefer a small documented API contract between frontend and integration. Domain
+calculations such as phase progress, expected end date, and history window should
+be pure and testable. External provider responses must be normalized behind an
+adapter. UI state must not become the source of truth for stored run data.
+
+The implementation should follow Home Assistant custom integration conventions,
+support unload/reload cleanly, avoid unauthenticated browser fetches, and keep
+frontend cache/version handling deterministic.
+
+## Existing behavior worth studying, not visually copying
+
+The current code contains useful technical work that a rebuild should inspect:
+
+- Home Assistant `Store` schema normalization and migration;
+- services for run, phase, note, binding, cultivar, image, and completion changes;
+- websocket run/summary endpoints;
+- SeedFinder tolerant matching and flowering-window extraction;
+- stable sensor binding IDs and metric compatibility;
+- Recorder-first run-window history context and deep links;
+- legacy data compatibility;
+- HACS packaging, panel registration, Lovelace card registration, and tests.
+
+Important locations include:
+
+- `custom_components/plantrun/store.py`
+- `custom_components/plantrun/run_window.py`
+- `custom_components/plantrun/history_context.py`
+- `custom_components/plantrun/providers_seedfinder.py`
+- `custom_components/plantrun/sensor.py`
+- `custom_components/plantrun/services.yaml`
+- `tests/`
+
+The current files under `custom_components/plantrun/www/` are implementation
+reference only. They are not a design specification.
+
+## Definition of a successful rebuild
+
+Before implementation is called finished, it should demonstrate:
+
+- a clearly new visual and interaction concept approved from wireframes or
+ mockups before full build-out;
+- excellent desktop and mobile layouts;
+- easy first-run onboarding and later editing;
+- multiple active one-plant runs in one tent;
+- custom phase configuration and a durable phase timeline;
+- optional cultivar lookup with editable duration estimate;
+- sensor binding without copying recorder data;
+- exact run-window navigation into Home Assistant history;
+- notes, completion, yield, archive, and completed-run browsing;
+- light/dark and optional sound interactions;
+- a clean new baseline schema for the owner's acceptance run;
+- automated tests for domain, storage migration, APIs, and critical UI flows;
+- installation and live validation through HACS on a current Home Assistant;
+- screenshots or a short visual walkthrough for product-owner approval before a
+ final release.
+
+Most importantly: do not interpret "from scratch" as permission to discard the
+useful Home Assistant integration behavior. It means creating a new product
+experience and domain model without preserving unused test records or the
+rejected interface.
diff --git a/README.md b/README.md
index 783138f..bf30624 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,22 @@
-# PlantRun 🌱
+# PlantRun
[](https://github.com/hacs/integration)
PlantRun is a Home Assistant custom integration for tracking cultivation runs end to end.
+Version 0.7.0 is the first usable rebuild. Its production sidebar follows the
+accepted one-plant-per-run model, three-step creation flow, history-first
+Journal, direct Stage changes, Recorder workspace, Archive, and warned Permanent
+Deletion. The HTML under `custom_components/plantrun/www/prototype/` remains
+disposable design evidence.
+
It combines:
- guided run setup
- a dedicated sidebar dashboard
- a Lovelace run card
- sensor bindings and proxy entities
- SeedFinder cultivar enrichment
-- persistent per-run history and summaries
+- persistent run metadata, lifecycle, and journal history
- Home Assistant history deeplinks scoped to a PlantRun run window
---
@@ -19,23 +25,35 @@ It combines:
### Run management
- Create and manage multiple runs
+- Keep exactly one plant in each run while any number of runs overlap in one tent
- Track phases, notes, planted date, target days, status, and harvest yield
- Keep one active run for compatibility with older service flows
### Frontend
-PlantRun ships with two UI entry points:
+PlantRun has two UI entry points:
- **Sidebar dashboard** at `/plantrun-dashboard`
- **Lovelace card** via `custom:plantrun-card`
-Current UI capabilities include:
-- run cards with live metric chips and stage-aware artwork
-- detail editing for metadata, notes, bindings, cultivar, breeder, and images
-- a 3-step run creation wizard
-- theme/language/layout preferences
-- live cultivar suggestions backed by SeedFinder
-- note editing/deletion with in-panel confirmation
-- phase changes via in-panel confirmation modal
-- Home Assistant history deeplinks from bound sensor tiles
+The rebuilt sidebar currently provides:
+- a Tent-first opening view with one prominent card per active Plant
+- a focused Run workspace with lifecycle, Recorder chart, permanent facts, and latest Journal context
+- responsive dark and light layouts with a phone-width bottom navigation
+- a three-step flow that creates exactly one Plant and one Run
+- manual Strain entry plus authenticated SeedFinder suggestions when a Breeder is supplied
+- direct, timestamped Stage changes without forced transition rules
+- chronological Journal capture, filtering, editing, deletion, and attached sensor context
+- dated Tent or Plant sensor assignment with reassignment history
+- completion into a durable Archive
+- separate Permanent Deletion with an exact-name warning gate
+
+Sensor tiles are recorder-first: PlantRun stores the entity link and run time window,
+then opens Home Assistant's native History panel for the actual chart. It does not
+duplicate Home Assistant time-series data for the redesigned UI.
+
+The sidebar is shipped as dependency-free native ES modules. API transport,
+cultivation-domain calculations, views, dialogs, styles, and the custom-element
+controller are separate modules, so the integration remains easy to extend without
+a frontend build toolchain.
### Sensor and summary layer
- per-run status, phase, cultivar, energy, and energy cost sensors
@@ -46,7 +64,13 @@ Current UI capabilities include:
### Persistence
- local store-backed run history
-- migration support for older payload shapes and legacy binding IDs
+- an atomic v3 domain store with the former v2 records retained in a hidden backup bucket
+- an idempotent acceptance-ledger import command
+
+The owner's acceptance cycle began on August 25, 2026. The rebuild imports that
+cycle into the v3 store. Older v2 runs remain inside the Home Assistant backup
+and the store's hidden legacy bucket, but the rebuilt sidebar does not mix them
+into the new cultivation history.
---
@@ -158,6 +182,9 @@ python3 -m unittest tests.test_dashboard_panel_interactions -q
```
Related docs:
+- `CONTEXT.md`
+- `docs/PRODUCT_DISCOVERY.md`
+- `docs/adr/0002-one-plant-per-run.md`
- `docs/RELEASE_QA_SIGNOFF.md`
- `docs/PERFORMANCE_NOTES.md`
- `docs/RELEASE_CHECKLIST.md`
diff --git a/custom_components/plantrun/__init__.py b/custom_components/plantrun/__init__.py
index 210fa4b..c5a1df5 100644
--- a/custom_components/plantrun/__init__.py
+++ b/custom_components/plantrun/__init__.py
@@ -1,7 +1,6 @@
"""The PlantRun integration."""
import base64
import binascii
-import json
import logging
import re
from datetime import datetime, timezone
@@ -11,8 +10,7 @@
import voluptuous as vol
-from homeassistant.components import frontend, websocket_api
-from homeassistant.components.http import StaticPathConfig
+from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, ServiceCall
@@ -20,6 +18,7 @@
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers import entity_registry as er
+from .application import CommandError, PlantRunApplication
from .const import (
ACTIVE_RUN_STRATEGIES,
ACTIVE_RUN_STRATEGY_LEGACY,
@@ -35,8 +34,15 @@
UNSUPPORTED_BINDING_METRIC_TYPES,
)
from .coordinator import PlantRunCoordinator
+from .domain import DomainError
from .history_context import build_binding_history_context
from .models import Binding, CultivarSnapshot, Note, Phase, RunData
+from .panel import (
+ PANEL_JS_URL,
+ PANEL_MODULE_URL,
+ async_register_panel,
+ async_unregister_panel,
+)
from . import providers_seedfinder as _providers_seedfinder
from .retention import async_capture_daily_rollup, get_summary_with_rollup_fallback
from .run_resolution import resolve_run_or_raise
@@ -53,9 +59,6 @@
_LOGGER = logging.getLogger(__name__)
-PANEL_URL_PATH = "plantrun-dashboard"
-PANEL_TITLE = "PlantRun"
-PANEL_ICON = "mdi:sprout"
CANONICAL_PHASES = {
"seedling": "Seedling",
"vegetative": "Vegetative",
@@ -63,13 +66,6 @@
"harvest": "Harvested",
"harvested": "Harvested",
}
-_MANIFEST_VERSION = json.loads((Path(__file__).parent / "manifest.json").read_text(encoding="utf-8"))[
- "version"
-]
-_PANEL_SCRIPT_PATH = Path(__file__).parent / "www" / "plantrun-panel.js"
-_PANEL_SCRIPT_CACHE_KEY = f"{_MANIFEST_VERSION}-{int(_PANEL_SCRIPT_PATH.stat().st_mtime)}"
-PANEL_MODULE_URL = f"/plantrun_frontend/plantrun-panel.js?v={_PANEL_SCRIPT_CACHE_KEY}"
-PANEL_JS_URL = PANEL_MODULE_URL
UPLOADS_SUBDIR = "plantrun_uploads"
_OBSOLETE_LEGACY_ENTITY_IDS = {
"sensor.plantrun_active_cultivar",
@@ -108,6 +104,62 @@ def _storage_for_hass(hass: HomeAssistant) -> PlantRunStorage | None:
return None
+def _application_for_hass(hass: HomeAssistant) -> PlantRunApplication | None:
+ """Return the configured atomic PlantRun command module."""
+ domain_data = hass.data.get(DOMAIN, {})
+ for entry_data in domain_data.values():
+ if isinstance(entry_data, dict):
+ application = entry_data.get("application")
+ if isinstance(application, PlantRunApplication):
+ return application
+ return None
+
+
+@websocket_api.websocket_command({"type": "plantrun/get_state"})
+@websocket_api.async_response
+async def websocket_get_state(
+ hass: HomeAssistant,
+ connection: Any,
+ msg: dict[str, Any],
+) -> None:
+ """Return the complete v3 state used by the production frontend."""
+ application = _application_for_hass(hass)
+ if application is None:
+ connection.send_error(msg["id"], "not_loaded", "PlantRun is not loaded")
+ return
+ connection.send_result(msg["id"], application.state())
+
+
+@websocket_api.websocket_command(
+ {
+ "type": "plantrun/command",
+ "command": str,
+ vol.Optional("payload", default={}): dict,
+ }
+)
+@websocket_api.async_response
+async def websocket_command(
+ hass: HomeAssistant,
+ connection: Any,
+ msg: dict[str, Any],
+) -> None:
+ """Execute one authenticated, atomic PlantRun command."""
+ application = _application_for_hass(hass)
+ if application is None:
+ connection.send_error(msg["id"], "not_loaded", "PlantRun is not loaded")
+ return
+ try:
+ state = await application.execute(msg["command"], msg.get("payload", {}))
+ except (CommandError, DomainError) as err:
+ connection.send_error(msg["id"], "invalid_command", str(err))
+ return
+ except OSError as err:
+ _LOGGER.exception("Unable to persist PlantRun command %s", msg["command"])
+ connection.send_error(msg["id"], "persistence_failed", str(err))
+ return
+ connection.send_result(msg["id"], state)
+
+
def _summary_energy_preferences_for_hass(hass: HomeAssistant) -> dict[str, Any]:
"""Return normalized pricing preferences for the single configured entry."""
entries = hass.config_entries.async_entries(DOMAIN)
@@ -281,49 +333,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up PlantRun from a config entry."""
hass.data.setdefault(DOMAIN, {})
- if not hass.data[DOMAIN].get("_static_registered"):
- # Register frontend static path (HA API changed from sync to async registration).
- if hasattr(hass.http, "async_register_static_paths"):
- await hass.http.async_register_static_paths(
- [
- StaticPathConfig(
- "/plantrun_frontend",
- hass.config.path("custom_components/plantrun/www"),
- cache_headers=False,
- )
- ]
- )
- else:
- # Backward-compat for older HA cores.
- hass.http.register_static_path(
- "/plantrun_frontend",
- hass.config.path("custom_components/plantrun/www"),
- cache_headers=False,
- )
- hass.data[DOMAIN]["_static_registered"] = True
-
- if not hass.data[DOMAIN].get("_panel_registered"):
- frontend.async_register_built_in_panel(
- hass,
- "custom",
- sidebar_title=PANEL_TITLE,
- sidebar_icon=PANEL_ICON,
- frontend_url_path=PANEL_URL_PATH,
- config={
- "_panel_custom": {
- "name": "plantrun-dashboard-panel",
- "embed_iframe": False,
- "trust_external": False,
- # Keep both for cross-version HA compatibility.
- "module_url": PANEL_MODULE_URL,
- "js_url": PANEL_JS_URL,
- }
- },
- require_admin=False,
- )
- hass.data[DOMAIN]["_panel_registered"] = True
+ await async_register_panel(hass)
if not hass.data[DOMAIN].get("_ws_registered"):
+ websocket_api.async_register_command(hass, websocket_get_state)
+ websocket_api.async_register_command(hass, websocket_command)
websocket_api.async_register_command(hass, websocket_get_runs)
websocket_api.async_register_command(hass, websocket_get_run)
websocket_api.async_register_command(hass, websocket_get_run_summary)
@@ -333,6 +347,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
storage = PlantRunStorage(hass)
await storage.async_load()
+ application = PlantRunApplication(storage)
coordinator = PlantRunCoordinator(hass, storage)
await coordinator.async_refresh()
@@ -340,6 +355,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
runtime_data = {
"storage": storage,
"coordinator": coordinator,
+ "application": application,
}
hass.data[DOMAIN][entry.entry_id] = runtime_data
entry.runtime_data = runtime_data
@@ -405,11 +421,11 @@ async def handle_add_phase(call: ServiceCall) -> None:
"""Handle the add_phase service."""
run = resolve_target_run(call)
phase_name = str(call.data["phase_name"]).strip()
- canonical_phase = CANONICAL_PHASES.get(phase_name.lower())
- if not canonical_phase:
- raise ServiceValidationError(
- "phase_name must be one of Seedling, Vegetative, Flowering, or Harvested."
- )
+ if not phase_name:
+ raise ServiceValidationError("phase_name must not be empty.")
+ if len(phase_name) > 64:
+ raise ServiceValidationError("phase_name must be 64 characters or fewer.")
+ canonical_phase = CANONICAL_PHASES.get(phase_name.lower(), phase_name)
now = datetime.now(timezone.utc).isoformat()
current_phase = run.phases[-1].name if run.phases else None
@@ -931,9 +947,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
isinstance(value, dict) and "storage" in value
for value in hass.data.get(DOMAIN, {}).values()
)
- if not has_other_entries and hass.data.get(DOMAIN, {}).get("_panel_registered"):
- frontend.async_remove_panel(hass, PANEL_URL_PATH)
- hass.data[DOMAIN]["_panel_registered"] = False
+ if not has_other_entries:
+ async_unregister_panel(hass)
return unload_ok
diff --git a/custom_components/plantrun/application.py b/custom_components/plantrun/application.py
new file mode 100644
index 0000000..28972bd
--- /dev/null
+++ b/custom_components/plantrun/application.py
@@ -0,0 +1,442 @@
+"""Atomic command interface for PlantRun's Home Assistant adapter."""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime
+from typing import Any, Mapping, Protocol
+
+from .domain import (
+ BindingDraft,
+ JournalDraft,
+ PlantRunDomain,
+ RunDraft,
+ SourcedDuration,
+ StrainIdentity,
+)
+
+
+SCHEMA_VERSION = 3
+DEFAULT_STAGE_PLAN = (
+ "Germination",
+ "Seedling",
+ "Vegetative",
+ "Flowering",
+ "Harvested",
+)
+
+
+class CommandError(ValueError):
+ """Raised when the transport sends an unsupported or malformed command."""
+
+
+class DomainStorage(Protocol):
+ """Persistence seam required by PlantRunApplication."""
+
+ domain: PlantRunDomain
+
+ async def async_commit_domain(self, candidate: PlantRunDomain) -> None:
+ """Persist and then publish a complete candidate state."""
+
+
+def _iso_time(value: Any, field_name: str) -> datetime:
+ if not isinstance(value, str) or not value.strip():
+ raise CommandError(f"{field_name} must be an ISO timestamp")
+ normalized = value.strip().replace("Z", "+00:00")
+ try:
+ parsed = datetime.fromisoformat(normalized)
+ except ValueError as err:
+ raise CommandError(f"{field_name} must be an ISO timestamp") from err
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
+ raise CommandError(f"{field_name} must include a timezone")
+ return parsed
+
+
+def _mapping(value: Any, field_name: str) -> dict[str, Any]:
+ if value is None:
+ return {}
+ if not isinstance(value, Mapping):
+ raise CommandError(f"{field_name} must be an object")
+ return dict(value)
+
+
+def _text(value: Any, field_name: str) -> str:
+ if not isinstance(value, str) or not value.strip():
+ raise CommandError(f"{field_name} must not be empty")
+ return value.strip()
+
+
+def _optional_text(value: Any) -> str | None:
+ if value is None:
+ return None
+ cleaned = str(value).strip()
+ return cleaned or None
+
+
+def _integer(value: Any, field_name: str) -> int:
+ try:
+ return int(value)
+ except (TypeError, ValueError) as err:
+ raise CommandError(f"{field_name} must be an integer") from err
+
+
+class PlantRunApplication:
+ """Execute complete domain commands and commit each result once."""
+
+ def __init__(self, storage: DomainStorage) -> None:
+ self._storage = storage
+ self._command_lock = asyncio.Lock()
+
+ def state(self) -> dict[str, Any]:
+ """Return the complete frontend state without leaking mutable records."""
+
+ state = self._storage.domain.export_state()
+ tents = state.get("tents", [])
+ return {
+ "schema_version": SCHEMA_VERSION,
+ **state,
+ "active_tent_id": tents[0]["id"] if tents else None,
+ }
+
+ async def execute(self, command: str, payload: Mapping[str, Any] | None) -> dict[str, Any]:
+ """Apply one command to a clone, persist it, then expose it as live state."""
+
+ if not isinstance(command, str) or not command.strip():
+ raise CommandError("command must not be empty")
+ if payload is None:
+ payload = {}
+ if not isinstance(payload, Mapping):
+ raise CommandError("payload must be an object")
+
+ async with self._command_lock:
+ candidate = PlantRunDomain.from_state(self._storage.domain.export_state())
+ handler = getattr(self, f"_command_{command.strip()}", None)
+ if handler is None:
+ raise CommandError(f"Unsupported command: {command}")
+ handler(candidate, dict(payload))
+ await self._storage.async_commit_domain(candidate)
+ return self.state()
+
+ def _command_create_run(self, domain: PlantRunDomain, payload: dict[str, Any]) -> None:
+ raw_duration = _mapping(payload.get("duration"), "duration")
+ duration = None
+ minimum = raw_duration.get("minimum_days", raw_duration.get("min_days"))
+ maximum = raw_duration.get("maximum_days", raw_duration.get("max_days"))
+ duration_fields = (
+ minimum,
+ maximum,
+ raw_duration.get("meaning"),
+ raw_duration.get("start_event"),
+ raw_duration.get("source"),
+ raw_duration.get("original_wording", raw_duration.get("original_text")),
+ )
+ if any(value not in (None, "") for value in duration_fields):
+ if not all(value not in (None, "") for value in duration_fields):
+ raise CommandError("duration must include its range, meaning, start event, source, and wording")
+ duration = SourcedDuration(
+ minimum_days=_integer(minimum, "duration.minimum_days"),
+ maximum_days=_integer(maximum, "duration.maximum_days"),
+ meaning=str(duration_fields[2]),
+ start_event=str(duration_fields[3]),
+ source=str(duration_fields[4]),
+ original_wording=str(duration_fields[5]),
+ )
+
+ strain_name = _optional_text(payload.get("strain"))
+ strain = (
+ StrainIdentity(
+ name=strain_name,
+ breeder=_optional_text(payload.get("breeder")),
+ duration=duration,
+ )
+ if strain_name
+ else None
+ )
+ raw_bindings = payload.get("sensor_bindings", payload.get("bindings", [])) or []
+ if not isinstance(raw_bindings, list):
+ raise CommandError("bindings must be a list")
+ planted_at = _iso_time(payload.get("planted_at"), "planted_at")
+ bindings: list[BindingDraft] = []
+ for raw in raw_bindings:
+ item = _mapping(raw, "binding")
+ owner = str(item.get("owner", item.get("owner_type", "run")))
+ if owner == "plant":
+ owner = "run"
+ bindings.append(
+ BindingDraft(
+ owner=owner,
+ metric_type=_text(item.get("metric_type"), "binding.metric_type"),
+ entity_id=_text(
+ item.get("entity_id", item.get("sensor_id")),
+ "binding.entity_id",
+ ),
+ started_at=(
+ _iso_time(item.get("started_at"), "binding.started_at")
+ if item.get("started_at")
+ else planted_at
+ ),
+ )
+ )
+ raw_plan = payload.get("stage_plan") or DEFAULT_STAGE_PLAN
+ if not isinstance(raw_plan, (list, tuple)):
+ raise CommandError("stage_plan must be a list")
+ cultivar = _mapping(payload.get("cultivar"), "cultivar")
+ domain.create_run(
+ RunDraft(
+ run_name=_text(
+ payload.get("run_name", payload.get("friendly_name")),
+ "run_name",
+ ),
+ tent_name=_text(payload.get("tent_name", "Growzelt"), "tent_name"),
+ plant_name=_text(payload.get("plant_name"), "plant_name"),
+ planted_at=planted_at,
+ stage_plan=tuple(str(stage) for stage in raw_plan),
+ initial_stage=_text(payload.get("initial_stage", "Germination"), "initial_stage"),
+ strain=strain,
+ sensor_bindings=tuple(bindings),
+ nickname=_optional_text(payload.get("nickname")),
+ image=_optional_text(
+ payload.get("image")
+ or payload.get("image_url")
+ or cultivar.get("image_url")
+ ),
+ container=_optional_text(payload.get("container")),
+ substrate=_optional_text(payload.get("substrate", payload.get("medium"))),
+ light_schedule=_optional_text(payload.get("light_schedule")),
+ )
+ )
+
+ def _journal_draft(self, payload: dict[str, Any]) -> JournalDraft:
+ run_ids = payload.get("run_ids", []) or []
+ if not isinstance(run_ids, (list, tuple)):
+ raise CommandError("run_ids must be a list")
+ return JournalDraft(
+ tent_id=_text(payload.get("tent_id"), "tent_id"),
+ run_ids=tuple(str(run_id) for run_id in run_ids),
+ entry_type=_optional_text(payload.get("entry_type")),
+ text=_text(payload.get("text"), "text"),
+ occurred_at=_iso_time(payload.get("occurred_at"), "occurred_at"),
+ details=_mapping(payload.get("details"), "details"),
+ sensor_snapshot=_mapping(payload.get("sensor_snapshot"), "sensor_snapshot"),
+ )
+
+ def _command_create_journal_entry(self, domain: PlantRunDomain, payload: dict[str, Any]) -> None:
+ domain.add_journal_entry(self._journal_draft(payload))
+
+ def _command_update_journal_entry(self, domain: PlantRunDomain, payload: dict[str, Any]) -> None:
+ domain.edit_journal_entry(
+ _text(payload.get("entry_id"), "entry_id"),
+ self._journal_draft(payload),
+ )
+
+ def _command_delete_journal_entry(self, domain: PlantRunDomain, payload: dict[str, Any]) -> None:
+ domain.delete_journal_entry(_text(payload.get("entry_id"), "entry_id"))
+
+ def _command_change_stage(self, domain: PlantRunDomain, payload: dict[str, Any]) -> None:
+ domain.change_stage(
+ _text(payload.get("run_id"), "run_id"),
+ _text(payload.get("target_stage", payload.get("stage")), "target_stage"),
+ _iso_time(payload.get("occurred_at"), "occurred_at"),
+ )
+
+ def _command_set_stage_plan(self, domain: PlantRunDomain, payload: dict[str, Any]) -> None:
+ stages = payload.get("stage_plan")
+ if not isinstance(stages, (list, tuple)):
+ raise CommandError("stage_plan must be a list")
+ domain.set_stage_plan(
+ _text(payload.get("run_id"), "run_id"),
+ tuple(str(stage) for stage in stages),
+ )
+
+ def _command_archive_run(self, domain: PlantRunDomain, payload: dict[str, Any]) -> None:
+ domain.finish_run(
+ _text(payload.get("run_id"), "run_id"),
+ _iso_time(payload.get("occurred_at", payload.get("ended_at")), "occurred_at"),
+ harvest_details=_mapping(payload.get("harvest_details"), "harvest_details"),
+ )
+
+ def _command_delete_run(self, domain: PlantRunDomain, payload: dict[str, Any]) -> None:
+ domain.permanently_delete_run(
+ _text(payload.get("run_id"), "run_id"),
+ _text(payload.get("confirmation_name"), "confirmation_name"),
+ )
+
+ def _command_set_binding(self, domain: PlantRunDomain, payload: dict[str, Any]) -> None:
+ owner = str(payload.get("owner", payload.get("owner_type", "run")))
+ if owner == "plant":
+ owner = "run"
+ domain.set_sensor_binding(
+ owner,
+ _text(payload.get("owner_id", payload.get("run_id")), "owner_id"),
+ _text(payload.get("metric_type"), "metric_type"),
+ _text(payload.get("entity_id", payload.get("sensor_id")), "entity_id"),
+ _iso_time(payload.get("occurred_at", payload.get("started_at")), "occurred_at"),
+ )
+
+ def _command_clear_binding(self, domain: PlantRunDomain, payload: dict[str, Any]) -> None:
+ owner = str(payload.get("owner", payload.get("owner_type", "run")))
+ if owner == "plant":
+ owner = "run"
+ domain.end_sensor_binding(
+ owner,
+ _text(payload.get("owner_id", payload.get("run_id")), "owner_id"),
+ _text(payload.get("metric_type"), "metric_type"),
+ _iso_time(payload.get("occurred_at", payload.get("ended_at")), "occurred_at"),
+ )
+
+ def _command_update_run(self, domain: PlantRunDomain, payload: dict[str, Any]) -> None:
+ kwargs: dict[str, Any] = {}
+ if "run_name" in payload or "friendly_name" in payload:
+ kwargs["run_name"] = payload.get("run_name", payload.get("friendly_name"))
+ for field_name in ("nickname", "plant_name", "image", "container", "substrate", "light_schedule"):
+ if field_name in payload:
+ kwargs[field_name] = payload[field_name]
+ if "planted_at" in payload:
+ kwargs["planted_at"] = _iso_time(payload["planted_at"], "planted_at")
+ if "strain" in payload or "breeder" in payload or "duration" in payload:
+ duration_payload = _mapping(payload.get("duration"), "duration")
+ duration = None
+ if duration_payload:
+ duration = SourcedDuration(
+ minimum_days=_integer(
+ duration_payload.get("minimum_days", duration_payload.get("min_days")),
+ "duration.minimum_days",
+ ),
+ maximum_days=_integer(
+ duration_payload.get("maximum_days", duration_payload.get("max_days")),
+ "duration.maximum_days",
+ ),
+ meaning=_text(duration_payload.get("meaning"), "duration.meaning"),
+ start_event=_text(duration_payload.get("start_event"), "duration.start_event"),
+ source=_text(duration_payload.get("source"), "duration.source"),
+ original_wording=_text(
+ duration_payload.get("original_wording", duration_payload.get("original_text")),
+ "duration.original_wording",
+ ),
+ )
+ strain_name = _optional_text(payload.get("strain"))
+ kwargs["strain"] = (
+ StrainIdentity(
+ name=strain_name,
+ breeder=_optional_text(payload.get("breeder")),
+ duration=duration,
+ )
+ if strain_name
+ else None
+ )
+ domain.update_run(_text(payload.get("run_id"), "run_id"), **kwargs)
+
+ def _command_import_bundle(self, domain: PlantRunDomain, payload: dict[str, Any]) -> None:
+ """Import a small external ledger once without duplicating retry results."""
+
+ tent_name = _text(payload.get("tent_name", "Growzelt"), "tent_name")
+ raw_runs = payload.get("runs", [])
+ raw_entries = payload.get("journal_entries", [])
+ if not isinstance(raw_runs, list) or not isinstance(raw_entries, list):
+ raise CommandError("import runs and journal_entries must be lists")
+
+ for raw in raw_runs:
+ run_payload = _mapping(raw, "run")
+ run_payload["tent_name"] = tent_name
+ run_name = _text(run_payload.get("run_name"), "run_name")
+ snapshot = domain.snapshot()
+ existing = next((run for run in snapshot.runs if run.name == run_name), None)
+ if existing is None:
+ self._command_create_run(domain, run_payload)
+ continue
+
+ plant = next(item for item in snapshot.plants if item.id == existing.plant_id)
+ tent = next(item for item in snapshot.tents if item.id == existing.tent_id)
+ expected_time = _iso_time(run_payload.get("planted_at"), "planted_at")
+ raw_stage_plan = run_payload.get("stage_plan") or DEFAULT_STAGE_PLAN
+ if not isinstance(raw_stage_plan, (list, tuple)):
+ raise CommandError("stage_plan must be a list")
+ expected_stage_plan = tuple(str(stage) for stage in raw_stage_plan)
+ expected_strain = _optional_text(run_payload.get("strain"))
+ existing_strain = plant.strain
+ raw_duration = _mapping(run_payload.get("duration"), "duration")
+ expected_duration = None
+ if raw_duration:
+ expected_duration = SourcedDuration(
+ minimum_days=_integer(
+ raw_duration.get("minimum_days", raw_duration.get("min_days")),
+ "duration.minimum_days",
+ ),
+ maximum_days=_integer(
+ raw_duration.get("maximum_days", raw_duration.get("max_days")),
+ "duration.maximum_days",
+ ),
+ meaning=_text(raw_duration.get("meaning"), "duration.meaning"),
+ start_event=_text(raw_duration.get("start_event"), "duration.start_event"),
+ source=_text(raw_duration.get("source"), "duration.source"),
+ original_wording=_text(
+ raw_duration.get("original_wording", raw_duration.get("original_text")),
+ "duration.original_wording",
+ ),
+ )
+ if (
+ tent.name != tent_name
+ or plant.name != _text(run_payload.get("plant_name"), "plant_name")
+ or existing.planted_at != expected_time
+ or existing.stage_plan != expected_stage_plan
+ or existing.current_stage != _text(
+ run_payload.get("initial_stage", expected_stage_plan[0]),
+ "initial_stage",
+ )
+ or (existing_strain.name if existing_strain else None) != expected_strain
+ or (existing_strain.breeder if existing_strain else None)
+ != _optional_text(run_payload.get("breeder"))
+ or (existing_strain.duration if existing_strain else None) != expected_duration
+ or plant.container != _optional_text(run_payload.get("container"))
+ or plant.substrate != _optional_text(run_payload.get("substrate"))
+ or tent.light_schedule != _optional_text(run_payload.get("light_schedule"))
+ ):
+ raise CommandError(f"import collision for Run '{run_name}'")
+
+ snapshot = domain.snapshot()
+ tent = next((item for item in snapshot.tents if item.name == tent_name), None)
+ if tent is None and raw_entries:
+ raise CommandError("import bundle has Journal Entries but no Tent")
+ runs_by_name = {run.name: run for run in snapshot.runs}
+
+ for raw in raw_entries:
+ entry_payload = _mapping(raw, "journal_entry")
+ run_name = _optional_text(entry_payload.pop("run_name", None))
+ if run_name:
+ run = runs_by_name.get(run_name)
+ if run is None:
+ raise CommandError(f"Journal Entry references unknown Run '{run_name}'")
+ run_ids = [run.id]
+ else:
+ run_ids = []
+ occurred_at = _iso_time(entry_payload.get("occurred_at"), "occurred_at")
+ entry_type = _optional_text(entry_payload.get("entry_type"))
+ text = _text(entry_payload.get("text"), "text")
+ duplicate = next(
+ (
+ item
+ for item in domain.snapshot().journal_entries
+ if item.tent_id == tent.id
+ and item.run_ids == tuple(run_ids)
+ and item.entry_type == entry_type
+ and item.text == text
+ and item.occurred_at == occurred_at
+ ),
+ None,
+ )
+ if duplicate is not None:
+ continue
+ domain.add_journal_entry(
+ JournalDraft(
+ tent_id=tent.id,
+ run_ids=tuple(run_ids),
+ entry_type=entry_type,
+ text=text,
+ occurred_at=occurred_at,
+ details=_mapping(entry_payload.get("details"), "details"),
+ sensor_snapshot=_mapping(
+ entry_payload.get("sensor_snapshot"),
+ "sensor_snapshot",
+ ),
+ )
+ )
diff --git a/custom_components/plantrun/const.py b/custom_components/plantrun/const.py
index 199faf5..0aba9cc 100644
--- a/custom_components/plantrun/const.py
+++ b/custom_components/plantrun/const.py
@@ -12,7 +12,7 @@
# Store constants
STORE_KEY = "plantrun_store"
STORE_VERSION = 2
-STORE_SCHEMA_VERSION = 2
+STORE_SCHEMA_VERSION = 3
# Service attribute keys
ATTR_RUN_ID = "run_id"
diff --git a/custom_components/plantrun/domain.py b/custom_components/plantrun/domain.py
new file mode 100644
index 0000000..42d5a5d
--- /dev/null
+++ b/custom_components/plantrun/domain.py
@@ -0,0 +1,1064 @@
+"""Pure PlantRun v3 domain model.
+
+This module owns cultivation invariants. Home Assistant transport and storage
+code should cross this seam through ``PlantRunDomain`` instead of mutating the
+records directly.
+"""
+
+from copy import deepcopy
+from dataclasses import dataclass, field, replace
+from datetime import datetime, timezone
+import json
+from typing import Callable, Literal, Mapping
+from uuid import uuid4
+
+
+Owner = Literal["tent", "run"]
+RunStatus = Literal["active", "archived"]
+
+
+class _Unset:
+ pass
+
+
+_UNSET = _Unset()
+
+
+class DomainError(ValueError):
+ """Base error raised when a domain command cannot be applied."""
+
+
+class ValidationError(DomainError):
+ """Raised when command input violates a PlantRun invariant."""
+
+
+class NotFoundError(DomainError):
+ """Raised when a referenced PlantRun record does not exist."""
+
+
+class ConfirmationError(DomainError):
+ """Raised when permanent deletion was not confirmed exactly."""
+
+
+@dataclass(frozen=True)
+class SourcedDuration:
+ minimum_days: int
+ maximum_days: int
+ meaning: str
+ start_event: str
+ source: str
+ original_wording: str
+
+
+@dataclass(frozen=True)
+class StrainIdentity:
+ name: str
+ breeder: str | None = None
+ duration: SourcedDuration | None = None
+
+
+@dataclass(frozen=True)
+class BindingDraft:
+ owner: Owner
+ metric_type: str
+ entity_id: str
+ started_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class RunDraft:
+ run_name: str
+ tent_name: str
+ plant_name: str
+ planted_at: datetime
+ stage_plan: tuple[str, ...]
+ initial_stage: str
+ strain: StrainIdentity | None = None
+ sensor_bindings: tuple[BindingDraft, ...] = ()
+ nickname: str | None = None
+ image: str | None = None
+ container: str | None = None
+ substrate: str | None = None
+ light_schedule: str | None = None
+
+
+@dataclass(frozen=True)
+class JournalDraft:
+ tent_id: str
+ text: str
+ occurred_at: datetime
+ run_ids: tuple[str, ...] = ()
+ entry_type: str | None = None
+ details: Mapping[str, object] = field(default_factory=dict)
+ sensor_snapshot: Mapping[str, object] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class Tent:
+ id: str
+ name: str
+ created_at: datetime
+ light_schedule: str | None = None
+
+
+@dataclass(frozen=True)
+class Plant:
+ id: str
+ tent_id: str
+ name: str
+ strain: StrainIdentity | None = None
+ image: str | None = None
+ container: str | None = None
+ substrate: str | None = None
+
+
+@dataclass(frozen=True)
+class StageChange:
+ id: str
+ from_stage: str | None
+ to_stage: str
+ occurred_at: datetime
+ created_at: datetime
+
+
+@dataclass(frozen=True)
+class Run:
+ id: str
+ name: str
+ tent_id: str
+ plant_id: str
+ planted_at: datetime
+ stage_plan: tuple[str, ...]
+ current_stage: str
+ stage_history: tuple[StageChange, ...]
+ nickname: str | None = None
+ status: RunStatus = "active"
+ ended_at: datetime | None = None
+ harvest_details: Mapping[str, object] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class SensorBinding:
+ id: str
+ owner: Owner
+ owner_id: str
+ metric_type: str
+ entity_id: str
+ started_at: datetime
+ ended_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class JournalEntry:
+ id: str
+ tent_id: str
+ text: str
+ occurred_at: datetime
+ created_at: datetime
+ updated_at: datetime
+ run_ids: tuple[str, ...] = ()
+ entry_type: str | None = None
+ details: Mapping[str, object] = field(default_factory=dict)
+ sensor_snapshot: Mapping[str, object] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class DomainSnapshot:
+ tents: tuple[Tent, ...]
+ plants: tuple[Plant, ...]
+ runs: tuple[Run, ...]
+ journal_entries: tuple[JournalEntry, ...]
+ bindings: tuple[SensorBinding, ...]
+
+
+def _default_clock() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def _default_id_factory(prefix: str) -> str:
+ return f"{prefix}_{uuid4().hex}"
+
+
+class PlantRunDomain:
+ """In-memory domain model with atomic commands and immutable query results."""
+
+ def __init__(
+ self,
+ *,
+ clock: Callable[[], datetime] = _default_clock,
+ id_factory: Callable[[str], str] = _default_id_factory,
+ ) -> None:
+ self._clock = clock
+ self._id_factory = id_factory
+ self._tents: dict[str, Tent] = {}
+ self._plants: dict[str, Plant] = {}
+ self._runs: dict[str, Run] = {}
+ self._journal_entries: dict[str, JournalEntry] = {}
+ self._bindings: dict[str, SensorBinding] = {}
+
+ def create_run(self, draft: RunDraft) -> Run:
+ """Create or reuse a Tent, then create exactly one Plant and one Run."""
+
+ run_name = self._required_text(draft.run_name, "run_name")
+ tent_name = self._required_text(draft.tent_name, "tent_name")
+ plant_name = self._required_text(draft.plant_name, "plant_name")
+ planted_at = self._aware_time(draft.planted_at, "planted_at")
+ stage_plan = self._stage_plan(draft.stage_plan)
+ initial_stage = self._required_text(draft.initial_stage, "initial_stage")
+ if initial_stage not in stage_plan:
+ raise ValidationError("initial_stage must appear in stage_plan")
+ if any(run.name.casefold() == run_name.casefold() for run in self._runs.values()):
+ raise ValidationError("run_name must be unique")
+ self._validate_strain(draft.strain)
+ nickname = self._optional_text(draft.nickname, "nickname")
+ image_value = self._optional_text(draft.image, "image")
+ container = self._optional_text(draft.container, "container")
+ substrate = self._optional_text(draft.substrate, "substrate")
+ light_schedule = self._optional_text(draft.light_schedule, "light_schedule")
+ binding_keys: set[tuple[str, str]] = set()
+ for binding in draft.sensor_bindings:
+ self._validate_binding_draft(binding, planted_at)
+ binding_key = (binding.owner, binding.metric_type.strip().casefold())
+ if binding_key in binding_keys:
+ raise ValidationError("a run draft may bind each owner metric only once")
+ binding_keys.add(binding_key)
+
+ now = self._aware_time(self._clock(), "clock")
+ tent = next(
+ (item for item in self._tents.values() if item.name.casefold() == tent_name.casefold()),
+ None,
+ )
+ new_tent = tent is None
+ if tent is None:
+ tent = Tent(
+ id=self._id_factory("tent"),
+ name=tent_name,
+ created_at=now,
+ light_schedule=light_schedule,
+ )
+ elif light_schedule is not None and tent.light_schedule != light_schedule:
+ tent = replace(tent, light_schedule=light_schedule)
+
+ plant = Plant(
+ id=self._id_factory("plant"),
+ tent_id=tent.id,
+ name=plant_name,
+ strain=deepcopy(draft.strain),
+ image=image_value,
+ container=container,
+ substrate=substrate,
+ )
+ first_change = StageChange(
+ id=self._id_factory("stage_change"),
+ from_stage=None,
+ to_stage=initial_stage,
+ occurred_at=planted_at,
+ created_at=now,
+ )
+ run = Run(
+ id=self._id_factory("run"),
+ name=run_name,
+ tent_id=tent.id,
+ plant_id=plant.id,
+ planted_at=planted_at,
+ stage_plan=stage_plan,
+ current_stage=initial_stage,
+ stage_history=(first_change,),
+ nickname=nickname,
+ )
+ bindings = []
+ closed_bindings: list[SensorBinding] = []
+ for item in draft.sensor_bindings:
+ owner_id = tent.id if item.owner == "tent" else run.id
+ started_at = item.started_at or planted_at
+ active = tuple(
+ binding
+ for binding in self._bindings.values()
+ if binding.owner == item.owner
+ and binding.owner_id == owner_id
+ and binding.metric_type.casefold() == item.metric_type.strip().casefold()
+ and binding.ended_at is None
+ )
+ if len(active) > 1:
+ raise ValidationError("multiple active bindings exist for one metric")
+ if active and active[0].entity_id == item.entity_id.strip():
+ continue
+ if active:
+ if active[0].started_at > started_at:
+ raise ValidationError("binding change cannot precede the active assignment")
+ closed_bindings.append(replace(active[0], ended_at=started_at))
+ bindings.append(
+ SensorBinding(
+ id=self._id_factory("binding"),
+ owner=item.owner,
+ owner_id=owner_id,
+ metric_type=item.metric_type.strip(),
+ entity_id=item.entity_id.strip(),
+ started_at=started_at,
+ )
+ )
+
+ if new_tent or self._tents.get(tent.id) != tent:
+ self._tents[tent.id] = tent
+ self._plants[plant.id] = plant
+ self._runs[run.id] = run
+ self._bindings.update({binding.id: binding for binding in closed_bindings})
+ self._bindings.update({binding.id: binding for binding in bindings})
+ return deepcopy(run)
+
+ def update_run(
+ self,
+ run_id: str,
+ *,
+ run_name: str | _Unset = _UNSET,
+ nickname: str | None | _Unset = _UNSET,
+ plant_name: str | _Unset = _UNSET,
+ strain: StrainIdentity | None | _Unset = _UNSET,
+ planted_at: datetime | _Unset = _UNSET,
+ image: str | None | _Unset = _UNSET,
+ container: str | None | _Unset = _UNSET,
+ substrate: str | None | _Unset = _UNSET,
+ light_schedule: str | None | _Unset = _UNSET,
+ ) -> Run:
+ """Edit Run and Plant identity while retaining their stable IDs."""
+
+ run = self._run(run_id)
+ plant = self._plants[run.plant_id]
+ next_run_name = run.name
+ if not isinstance(run_name, _Unset):
+ next_run_name = self._required_text(run_name, "run_name")
+ if any(
+ item.id != run.id and item.name.casefold() == next_run_name.casefold()
+ for item in self._runs.values()
+ ):
+ raise ValidationError("run_name must be unique")
+ next_nickname = run.nickname
+ if not isinstance(nickname, _Unset):
+ next_nickname = self._optional_text(nickname, "nickname")
+ next_plant_name = plant.name
+ if not isinstance(plant_name, _Unset):
+ next_plant_name = self._required_text(plant_name, "plant_name")
+ next_strain = plant.strain
+ if not isinstance(strain, _Unset):
+ self._validate_strain(strain)
+ next_strain = strain
+ next_image = plant.image
+ if not isinstance(image, _Unset):
+ next_image = self._optional_text(image, "image")
+ next_container = plant.container
+ if not isinstance(container, _Unset):
+ next_container = self._optional_text(container, "container")
+ next_substrate = plant.substrate
+ if not isinstance(substrate, _Unset):
+ next_substrate = self._optional_text(substrate, "substrate")
+ tent = self._tents[run.tent_id]
+ next_light_schedule = tent.light_schedule
+ if not isinstance(light_schedule, _Unset):
+ next_light_schedule = self._optional_text(light_schedule, "light_schedule")
+ next_planted_at = run.planted_at
+ if not isinstance(planted_at, _Unset):
+ next_planted_at = self._aware_time(planted_at, "planted_at")
+ if run.ended_at is not None and next_planted_at > run.ended_at:
+ raise ValidationError("planted_at cannot be after ended_at")
+ if any(change.occurred_at < next_planted_at for change in run.stage_history[1:]):
+ raise ValidationError("planted_at cannot be after a recorded stage change")
+
+ stage_history = run.stage_history
+ if next_planted_at != run.planted_at:
+ stage_history = (
+ replace(stage_history[0], occurred_at=next_planted_at),
+ *stage_history[1:],
+ )
+ updated_plant = replace(
+ plant,
+ name=next_plant_name,
+ strain=deepcopy(next_strain),
+ image=next_image,
+ container=next_container,
+ substrate=next_substrate,
+ )
+ updated_run = replace(
+ run,
+ name=next_run_name,
+ nickname=next_nickname,
+ planted_at=next_planted_at,
+ stage_history=stage_history,
+ )
+ self._plants[plant.id] = updated_plant
+ self._tents[tent.id] = replace(tent, light_schedule=next_light_schedule)
+ self._runs[run.id] = updated_run
+ return deepcopy(updated_run)
+
+ def add_journal_entry(self, draft: JournalDraft) -> JournalEntry:
+ """Record a Tent entry, optionally linked to one or more Runs."""
+
+ self._validate_journal_draft(draft)
+ now = self._aware_time(self._clock(), "clock")
+ entry = JournalEntry(
+ id=self._id_factory("journal_entry"),
+ tent_id=draft.tent_id,
+ run_ids=tuple(draft.run_ids),
+ entry_type=draft.entry_type.strip() if draft.entry_type else None,
+ text=draft.text.strip(),
+ occurred_at=draft.occurred_at,
+ created_at=now,
+ updated_at=now,
+ details=self._structured_mapping(draft.details, "journal.details"),
+ sensor_snapshot=self._structured_mapping(
+ draft.sensor_snapshot,
+ "journal.sensor_snapshot",
+ ),
+ )
+ self._journal_entries[entry.id] = entry
+ return deepcopy(entry)
+
+ def edit_journal_entry(self, entry_id: str, draft: JournalDraft) -> JournalEntry:
+ """Replace editable entry content without losing its creation time."""
+
+ current = self._journal_entries.get(entry_id)
+ if current is None:
+ raise NotFoundError("journal entry not found")
+ self._validate_journal_draft(draft)
+ replacement = JournalEntry(
+ id=current.id,
+ tent_id=draft.tent_id,
+ run_ids=tuple(draft.run_ids),
+ entry_type=draft.entry_type.strip() if draft.entry_type else None,
+ text=draft.text.strip(),
+ occurred_at=draft.occurred_at,
+ created_at=current.created_at,
+ updated_at=self._aware_time(self._clock(), "clock"),
+ details=self._structured_mapping(draft.details, "journal.details"),
+ sensor_snapshot=self._structured_mapping(
+ draft.sensor_snapshot,
+ "journal.sensor_snapshot",
+ ),
+ )
+ self._journal_entries[entry_id] = replacement
+ return deepcopy(replacement)
+
+ def delete_journal_entry(self, entry_id: str) -> JournalEntry:
+ """Delete one Journal Entry and return the removed record."""
+
+ try:
+ return deepcopy(self._journal_entries.pop(entry_id))
+ except KeyError as err:
+ raise NotFoundError("journal entry not found") from err
+
+ def change_stage(self, run_id: str, target_stage: str, occurred_at: datetime) -> Run:
+ """Switch directly to any planned Stage and append permanent history."""
+
+ run = self._run(run_id)
+ target = self._required_text(target_stage, "target_stage")
+ occurred = self._aware_time(occurred_at, "occurred_at")
+ if target not in run.stage_plan:
+ raise ValidationError("target_stage must appear in stage_plan")
+ if occurred < run.planted_at:
+ raise ValidationError("stage change cannot occur before planting")
+ if run.stage_history and occurred < run.stage_history[-1].occurred_at:
+ raise ValidationError("stage change cannot precede the latest stage change")
+ if run.ended_at is not None and occurred > run.ended_at:
+ raise ValidationError("stage change cannot occur after the run ended")
+ if target == run.current_stage:
+ raise ValidationError("target_stage is already current")
+ change = StageChange(
+ id=self._id_factory("stage_change"),
+ from_stage=run.current_stage,
+ to_stage=target,
+ occurred_at=occurred,
+ created_at=self._aware_time(self._clock(), "clock"),
+ )
+ updated = replace(
+ run,
+ current_stage=target,
+ stage_history=run.stage_history + (change,),
+ )
+ self._runs[run_id] = updated
+ return deepcopy(updated)
+
+ def set_stage_plan(self, run_id: str, stages: tuple[str, ...]) -> Run:
+ """Change displayed Stage order without changing recorded history."""
+
+ run = self._run(run_id)
+ stage_plan = self._stage_plan(stages)
+ if run.current_stage not in stage_plan:
+ raise ValidationError("stage_plan must contain the current stage")
+ updated = replace(run, stage_plan=stage_plan)
+ self._runs[run_id] = updated
+ return deepcopy(updated)
+
+ def finish_run(
+ self,
+ run_id: str,
+ ended_at: datetime,
+ *,
+ harvest_details: Mapping[str, object] | None = None,
+ ) -> Run:
+ """Move a Run to the Archive without deleting or freezing its data."""
+
+ run = self._run(run_id)
+ ended = self._aware_time(ended_at, "ended_at")
+ if ended < run.planted_at:
+ raise ValidationError("ended_at cannot be before planting")
+ if run.stage_history and ended < run.stage_history[-1].occurred_at:
+ raise ValidationError("ended_at cannot precede the latest stage change")
+ active_bindings = tuple(
+ binding
+ for binding in self._bindings.values()
+ if binding.owner == "run"
+ and binding.owner_id == run.id
+ and binding.ended_at is None
+ )
+ if any(binding.started_at > ended for binding in active_bindings):
+ raise ValidationError("run cannot end before an active sensor binding starts")
+ updated = replace(
+ run,
+ status="archived",
+ ended_at=ended,
+ harvest_details=self._structured_mapping(
+ harvest_details or {},
+ "harvest_details",
+ ),
+ )
+ for binding in active_bindings:
+ self._bindings[binding.id] = replace(binding, ended_at=ended)
+ self._runs[run_id] = updated
+ return deepcopy(updated)
+
+ def update_harvest_details(
+ self,
+ run_id: str,
+ harvest_details: Mapping[str, object],
+ ) -> Run:
+ """Correct harvest facts without changing archive state or its end time."""
+
+ run = self._run(run_id)
+ updated = replace(
+ run,
+ harvest_details=self._structured_mapping(harvest_details, "harvest_details"),
+ )
+ self._runs[run_id] = updated
+ return deepcopy(updated)
+
+ def set_sensor_binding(
+ self,
+ owner: Owner,
+ owner_id: str,
+ metric_type: str,
+ entity_id: str,
+ occurred_at: datetime,
+ ) -> SensorBinding:
+ """Assign a metric and close its prior active assignment at the same time."""
+
+ if owner not in ("tent", "run"):
+ raise ValidationError("binding owner must be tent or run")
+ if owner == "tent" and owner_id not in self._tents:
+ raise NotFoundError("tent not found")
+ if owner == "run":
+ if owner_id not in self._runs:
+ raise NotFoundError("run not found")
+ run = self._runs[owner_id]
+ if run.status != "active":
+ raise ValidationError("archived runs cannot receive active sensor bindings")
+ metric = self._required_text(metric_type, "metric_type")
+ entity = self._required_text(entity_id, "entity_id")
+ occurred = self._aware_time(occurred_at, "occurred_at")
+ if owner == "run" and occurred < self._runs[owner_id].planted_at:
+ raise ValidationError("binding cannot start before planting")
+ active = tuple(
+ binding
+ for binding in self._bindings.values()
+ if binding.owner == owner
+ and binding.owner_id == owner_id
+ and binding.metric_type.casefold() == metric.casefold()
+ and binding.ended_at is None
+ )
+ if any(binding.started_at > occurred for binding in active):
+ raise ValidationError("binding change cannot precede the active assignment")
+
+ new_binding = SensorBinding(
+ id=self._id_factory("binding"),
+ owner=owner,
+ owner_id=owner_id,
+ metric_type=metric,
+ entity_id=entity,
+ started_at=occurred,
+ )
+ for binding in active:
+ self._bindings[binding.id] = replace(binding, ended_at=occurred)
+ self._bindings[new_binding.id] = new_binding
+ return deepcopy(new_binding)
+
+ def end_sensor_binding(
+ self,
+ owner: Owner,
+ owner_id: str,
+ metric_type: str,
+ ended_at: datetime,
+ ) -> tuple[SensorBinding, ...]:
+ """End the active assignment while retaining its dated history."""
+
+ if owner not in ("tent", "run"):
+ raise ValidationError("binding owner must be tent or run")
+ metric = self._required_text(metric_type, "metric_type")
+ ended = self._aware_time(ended_at, "ended_at")
+ active = tuple(
+ binding
+ for binding in self._bindings.values()
+ if binding.owner == owner
+ and binding.owner_id == owner_id
+ and binding.metric_type.casefold() == metric.casefold()
+ and binding.ended_at is None
+ )
+ if not active:
+ raise NotFoundError("active sensor binding not found")
+ if any(binding.started_at > ended for binding in active):
+ raise ValidationError("binding cannot end before it starts")
+ closed = tuple(replace(binding, ended_at=ended) for binding in active)
+ for binding in closed:
+ self._bindings[binding.id] = binding
+ return deepcopy(closed)
+
+ def permanently_delete_run(self, run_id: str, confirmation_name: str) -> Run:
+ """Irreversibly remove one Run after exact, case-sensitive confirmation."""
+
+ run = self._run(run_id)
+ if confirmation_name != run.name:
+ raise ConfirmationError("confirmation must exactly match the run name")
+
+ del self._runs[run.id]
+ del self._plants[run.plant_id]
+ self._bindings = {
+ binding_id: binding
+ for binding_id, binding in self._bindings.items()
+ if not (binding.owner == "run" and binding.owner_id == run.id)
+ }
+ remaining_entries: dict[str, JournalEntry] = {}
+ for entry_id, entry in self._journal_entries.items():
+ if run.id not in entry.run_ids:
+ remaining_entries[entry_id] = entry
+ continue
+ remaining_run_ids = tuple(item for item in entry.run_ids if item != run.id)
+ if remaining_run_ids:
+ remaining_entries[entry_id] = replace(entry, run_ids=remaining_run_ids)
+ self._journal_entries = remaining_entries
+ return deepcopy(run)
+
+ def snapshot(self) -> DomainSnapshot:
+ return deepcopy(
+ DomainSnapshot(
+ tents=tuple(self._tents.values()),
+ plants=tuple(self._plants.values()),
+ runs=tuple(self._runs.values()),
+ journal_entries=tuple(self._journal_entries.values()),
+ bindings=tuple(self._bindings.values()),
+ )
+ )
+
+ def export_state(self) -> dict[str, object]:
+ """Return the complete domain state as Home Assistant Store-safe data."""
+
+ def duration_data(value: SourcedDuration | None) -> dict[str, object] | None:
+ if value is None:
+ return None
+ return {
+ "minimum_days": value.minimum_days,
+ "maximum_days": value.maximum_days,
+ "meaning": value.meaning,
+ "start_event": value.start_event,
+ "source": value.source,
+ "original_wording": value.original_wording,
+ }
+
+ def strain_data(value: StrainIdentity | None) -> dict[str, object] | None:
+ if value is None:
+ return None
+ return {
+ "name": value.name,
+ "breeder": value.breeder,
+ "duration": duration_data(value.duration),
+ }
+
+ state: dict[str, object] = {
+ "tents": [
+ {
+ "id": item.id,
+ "name": item.name,
+ "created_at": item.created_at.isoformat(),
+ "light_schedule": item.light_schedule,
+ }
+ for item in self._tents.values()
+ ],
+ "plants": [
+ {
+ "id": item.id,
+ "tent_id": item.tent_id,
+ "name": item.name,
+ "strain": strain_data(item.strain),
+ "image": item.image,
+ "container": item.container,
+ "substrate": item.substrate,
+ }
+ for item in self._plants.values()
+ ],
+ "runs": [
+ {
+ "id": item.id,
+ "name": item.name,
+ "nickname": item.nickname,
+ "tent_id": item.tent_id,
+ "plant_id": item.plant_id,
+ "planted_at": item.planted_at.isoformat(),
+ "stage_plan": list(item.stage_plan),
+ "current_stage": item.current_stage,
+ "stage_history": [
+ {
+ "id": change.id,
+ "from_stage": change.from_stage,
+ "to_stage": change.to_stage,
+ "occurred_at": change.occurred_at.isoformat(),
+ "created_at": change.created_at.isoformat(),
+ }
+ for change in item.stage_history
+ ],
+ "status": item.status,
+ "ended_at": item.ended_at.isoformat() if item.ended_at else None,
+ "harvest_details": deepcopy(dict(item.harvest_details)),
+ }
+ for item in self._runs.values()
+ ],
+ "journal_entries": [
+ {
+ "id": item.id,
+ "tent_id": item.tent_id,
+ "run_ids": list(item.run_ids),
+ "entry_type": item.entry_type,
+ "text": item.text,
+ "occurred_at": item.occurred_at.isoformat(),
+ "created_at": item.created_at.isoformat(),
+ "updated_at": item.updated_at.isoformat(),
+ "details": deepcopy(dict(item.details)),
+ "sensor_snapshot": deepcopy(dict(item.sensor_snapshot)),
+ }
+ for item in self._journal_entries.values()
+ ],
+ "sensor_bindings": [
+ {
+ "id": item.id,
+ "owner": item.owner,
+ "owner_id": item.owner_id,
+ "metric_type": item.metric_type,
+ "entity_id": item.entity_id,
+ "started_at": item.started_at.isoformat(),
+ "ended_at": item.ended_at.isoformat() if item.ended_at else None,
+ }
+ for item in self._bindings.values()
+ ],
+ }
+ json.dumps(state)
+ return state
+
+ @classmethod
+ def from_state(
+ cls,
+ state: Mapping[str, object],
+ *,
+ clock: Callable[[], datetime] = _default_clock,
+ id_factory: Callable[[str], str] = _default_id_factory,
+ ) -> "PlantRunDomain":
+ """Restore exported state and reject broken cross-record references."""
+
+ domain = cls(clock=clock, id_factory=id_factory)
+
+ def records(key: str) -> list[Mapping[str, object]]:
+ value = state.get(key)
+ if not isinstance(value, list) or not all(isinstance(item, Mapping) for item in value):
+ raise ValidationError(f"{key} must be a list of records")
+ return value
+
+ def text_value(raw: Mapping[str, object], key: str) -> str:
+ return domain._required_text(raw.get(key), key) # type: ignore[arg-type]
+
+ def optional_text(raw: Mapping[str, object], key: str) -> str | None:
+ value = raw.get(key)
+ if value is None:
+ return None
+ return domain._required_text(value, key) # type: ignore[arg-type]
+
+ def time_value(raw: Mapping[str, object], key: str) -> datetime:
+ value = raw.get(key)
+ if not isinstance(value, str):
+ raise ValidationError(f"{key} must be an ISO timestamp")
+ try:
+ parsed = datetime.fromisoformat(value)
+ except ValueError as err:
+ raise ValidationError(f"{key} must be an ISO timestamp") from err
+ return domain._aware_time(parsed, key)
+
+ def optional_time(raw: Mapping[str, object], key: str) -> datetime | None:
+ if raw.get(key) is None:
+ return None
+ return time_value(raw, key)
+
+ def mapping_value(raw: Mapping[str, object], key: str) -> dict[str, object]:
+ value = raw.get(key, {})
+ return domain._structured_mapping(value, key)
+
+ def strain_value(raw: object) -> StrainIdentity | None:
+ if raw is None:
+ return None
+ if not isinstance(raw, Mapping):
+ raise ValidationError("strain must be a record")
+ duration_raw = raw.get("duration")
+ duration = None
+ if duration_raw is not None:
+ if not isinstance(duration_raw, Mapping):
+ raise ValidationError("strain.duration must be a record")
+ minimum = duration_raw.get("minimum_days")
+ maximum = duration_raw.get("maximum_days")
+ if not isinstance(minimum, int) or not isinstance(maximum, int):
+ raise ValidationError("duration days must be integers")
+ duration = SourcedDuration(
+ minimum_days=minimum,
+ maximum_days=maximum,
+ meaning=text_value(duration_raw, "meaning"),
+ start_event=text_value(duration_raw, "start_event"),
+ source=text_value(duration_raw, "source"),
+ original_wording=text_value(duration_raw, "original_wording"),
+ )
+ strain = StrainIdentity(
+ name=text_value(raw, "name"),
+ breeder=optional_text(raw, "breeder"),
+ duration=duration,
+ )
+ domain._validate_strain(strain)
+ return strain
+
+ def put_unique(target: dict[str, object], record_id: str, value: object) -> None:
+ if record_id in target:
+ raise ValidationError(f"duplicate id: {record_id}")
+ target[record_id] = value
+
+ for raw in records("tents"):
+ tent = Tent(
+ id=text_value(raw, "id"),
+ name=text_value(raw, "name"),
+ created_at=time_value(raw, "created_at"),
+ light_schedule=optional_text(raw, "light_schedule"),
+ )
+ put_unique(domain._tents, tent.id, tent)
+
+ for raw in records("plants"):
+ plant = Plant(
+ id=text_value(raw, "id"),
+ tent_id=text_value(raw, "tent_id"),
+ name=text_value(raw, "name"),
+ strain=strain_value(raw.get("strain")),
+ image=optional_text(raw, "image"),
+ container=optional_text(raw, "container"),
+ substrate=optional_text(raw, "substrate"),
+ )
+ if plant.tent_id not in domain._tents:
+ raise ValidationError("plant references an unknown tent")
+ put_unique(domain._plants, plant.id, plant)
+
+ for raw in records("runs"):
+ plan_raw = raw.get("stage_plan")
+ if not isinstance(plan_raw, list):
+ raise ValidationError("stage_plan must be a list")
+ stage_plan = domain._stage_plan(tuple(plan_raw)) # type: ignore[arg-type]
+ history_raw = raw.get("stage_history")
+ if not isinstance(history_raw, list) or not history_raw:
+ raise ValidationError("stage_history must be a non-empty list")
+ history: list[StageChange] = []
+ for change_raw in history_raw:
+ if not isinstance(change_raw, Mapping):
+ raise ValidationError("stage_history contains an invalid record")
+ history.append(
+ StageChange(
+ id=text_value(change_raw, "id"),
+ from_stage=optional_text(change_raw, "from_stage"),
+ to_stage=text_value(change_raw, "to_stage"),
+ occurred_at=time_value(change_raw, "occurred_at"),
+ created_at=time_value(change_raw, "created_at"),
+ )
+ )
+ if history[0].from_stage is not None:
+ raise ValidationError("initial stage history must start from no stage")
+ if any(current.from_stage != previous.to_stage for previous, current in zip(history, history[1:])):
+ raise ValidationError("stage history chain is broken")
+ current_stage = text_value(raw, "current_stage")
+ if current_stage not in stage_plan or history[-1].to_stage != current_stage:
+ raise ValidationError("current stage does not match plan and history")
+ status = raw.get("status")
+ if status not in ("active", "archived"):
+ raise ValidationError("run status must be active or archived")
+ ended_at = optional_time(raw, "ended_at")
+ if (status == "archived") != (ended_at is not None):
+ raise ValidationError("archived runs require ended_at and active runs forbid it")
+ run = Run(
+ id=text_value(raw, "id"),
+ name=text_value(raw, "name"),
+ nickname=optional_text(raw, "nickname"),
+ tent_id=text_value(raw, "tent_id"),
+ plant_id=text_value(raw, "plant_id"),
+ planted_at=time_value(raw, "planted_at"),
+ stage_plan=stage_plan,
+ current_stage=current_stage,
+ stage_history=tuple(history),
+ status=status,
+ ended_at=ended_at,
+ harvest_details=mapping_value(raw, "harvest_details"),
+ )
+ plant = domain._plants.get(run.plant_id)
+ if run.tent_id not in domain._tents or plant is None or plant.tent_id != run.tent_id:
+ raise ValidationError("run references an unknown plant or tent")
+ if run.ended_at is not None and run.ended_at < run.planted_at:
+ raise ValidationError("run ends before planting")
+ put_unique(domain._runs, run.id, run)
+
+ run_plant_ids = [run.plant_id for run in domain._runs.values()]
+ if len(set(run_plant_ids)) != len(run_plant_ids):
+ raise ValidationError("a plant cannot belong to more than one run")
+ if set(run_plant_ids) != set(domain._plants):
+ raise ValidationError("every plant must belong to exactly one run")
+
+ for raw in records("journal_entries"):
+ run_ids_raw = raw.get("run_ids", [])
+ if not isinstance(run_ids_raw, list) or not all(isinstance(item, str) for item in run_ids_raw):
+ raise ValidationError("journal run_ids must be a list of ids")
+ entry = JournalEntry(
+ id=text_value(raw, "id"),
+ tent_id=text_value(raw, "tent_id"),
+ run_ids=tuple(run_ids_raw),
+ entry_type=optional_text(raw, "entry_type"),
+ text=text_value(raw, "text"),
+ occurred_at=time_value(raw, "occurred_at"),
+ created_at=time_value(raw, "created_at"),
+ updated_at=time_value(raw, "updated_at"),
+ details=mapping_value(raw, "details"),
+ sensor_snapshot=mapping_value(raw, "sensor_snapshot"),
+ )
+ domain._validate_journal_draft(
+ JournalDraft(
+ tent_id=entry.tent_id,
+ run_ids=entry.run_ids,
+ entry_type=entry.entry_type,
+ text=entry.text,
+ occurred_at=entry.occurred_at,
+ details=entry.details,
+ sensor_snapshot=entry.sensor_snapshot,
+ )
+ )
+ put_unique(domain._journal_entries, entry.id, entry)
+
+ active_binding_keys: set[tuple[str, str, str]] = set()
+ for raw in records("sensor_bindings"):
+ owner = raw.get("owner")
+ if owner not in ("tent", "run"):
+ raise ValidationError("binding owner must be tent or run")
+ binding = SensorBinding(
+ id=text_value(raw, "id"),
+ owner=owner,
+ owner_id=text_value(raw, "owner_id"),
+ metric_type=text_value(raw, "metric_type"),
+ entity_id=text_value(raw, "entity_id"),
+ started_at=time_value(raw, "started_at"),
+ ended_at=optional_time(raw, "ended_at"),
+ )
+ owners = domain._tents if owner == "tent" else domain._runs
+ if binding.owner_id not in owners:
+ raise ValidationError("binding references an unknown owner")
+ if binding.ended_at is not None and binding.ended_at < binding.started_at:
+ raise ValidationError("binding ends before it starts")
+ active_key = (binding.owner, binding.owner_id, binding.metric_type.casefold())
+ if binding.ended_at is None:
+ if active_key in active_binding_keys:
+ raise ValidationError("multiple active bindings exist for one metric")
+ active_binding_keys.add(active_key)
+ put_unique(domain._bindings, binding.id, binding)
+
+ return domain
+
+ def _run(self, run_id: str) -> Run:
+ try:
+ return self._runs[run_id]
+ except KeyError as err:
+ raise NotFoundError("run not found") from err
+
+ @staticmethod
+ def _required_text(value: str, field_name: str) -> str:
+ if not isinstance(value, str) or not value.strip():
+ raise ValidationError(f"{field_name} must not be empty")
+ return value.strip()
+
+ @classmethod
+ def _optional_text(cls, value: str | None, field_name: str) -> str | None:
+ if value is None:
+ return None
+ return cls._required_text(value, field_name)
+
+ @staticmethod
+ def _structured_mapping(value: object, field_name: str) -> dict[str, object]:
+ if not isinstance(value, Mapping) or not all(isinstance(key, str) for key in value):
+ raise ValidationError(f"{field_name} must be a string-keyed record")
+ result = deepcopy(dict(value))
+ try:
+ json.dumps(result)
+ except (TypeError, ValueError) as err:
+ raise ValidationError(f"{field_name} must contain JSON-safe values") from err
+ return result
+
+ @classmethod
+ def _stage_plan(cls, values: tuple[str, ...]) -> tuple[str, ...]:
+ result = tuple(cls._required_text(item, "stage") for item in values)
+ if not result:
+ raise ValidationError("stage_plan must contain at least one stage")
+ if len({item.casefold() for item in result}) != len(result):
+ raise ValidationError("stage_plan stages must be unique")
+ return result
+
+ @staticmethod
+ def _aware_time(value: datetime, field_name: str) -> datetime:
+ if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
+ raise ValidationError(f"{field_name} must be timezone-aware")
+ return value
+
+ @classmethod
+ def _validate_strain(cls, strain: StrainIdentity | None) -> None:
+ if strain is None:
+ return
+ cls._required_text(strain.name, "strain.name")
+ if strain.breeder is not None:
+ cls._required_text(strain.breeder, "strain.breeder")
+ if strain.duration is None:
+ return
+ duration = strain.duration
+ if duration.minimum_days <= 0 or duration.maximum_days < duration.minimum_days:
+ raise ValidationError("duration must be a positive ordered range")
+ for field_name in ("meaning", "start_event", "source", "original_wording"):
+ cls._required_text(getattr(duration, field_name), f"duration.{field_name}")
+
+ @classmethod
+ def _validate_binding_draft(cls, binding: BindingDraft, default_start: datetime) -> None:
+ if binding.owner not in ("tent", "run"):
+ raise ValidationError("binding owner must be tent or run")
+ cls._required_text(binding.metric_type, "binding.metric_type")
+ cls._required_text(binding.entity_id, "binding.entity_id")
+ cls._aware_time(binding.started_at or default_start, "binding.started_at")
+
+ def _validate_journal_draft(self, draft: JournalDraft) -> None:
+ if draft.tent_id not in self._tents:
+ raise NotFoundError("tent not found")
+ self._required_text(draft.text, "journal.text")
+ self._aware_time(draft.occurred_at, "journal.occurred_at")
+ if draft.entry_type is not None:
+ self._required_text(draft.entry_type, "journal.entry_type")
+ if len(set(draft.run_ids)) != len(draft.run_ids):
+ raise ValidationError("journal run_ids must be unique")
+ for run_id in draft.run_ids:
+ run = self._runs.get(run_id)
+ if run is None:
+ raise NotFoundError("run not found")
+ if run.tent_id != draft.tent_id:
+ raise ValidationError("journal runs must belong to its tent")
diff --git a/custom_components/plantrun/manifest.json b/custom_components/plantrun/manifest.json
index b1ee424..d58e7ff 100644
--- a/custom_components/plantrun/manifest.json
+++ b/custom_components/plantrun/manifest.json
@@ -12,5 +12,5 @@
"requirements": [
"beautifulsoup4>=4.12.0"
],
- "version": "0.1.0"
-}
\ No newline at end of file
+ "version": "0.7.0"
+}
diff --git a/custom_components/plantrun/panel.py b/custom_components/plantrun/panel.py
new file mode 100644
index 0000000..6a6dcbe
--- /dev/null
+++ b/custom_components/plantrun/panel.py
@@ -0,0 +1,85 @@
+"""PlantRun sidebar panel registration and module cache identity."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from pathlib import Path
+from typing import Any
+
+from homeassistant.components import frontend
+from homeassistant.components.http import StaticPathConfig
+from homeassistant.core import HomeAssistant
+
+from .const import DOMAIN
+
+PANEL_URL_PATH = "plantrun-dashboard"
+PANEL_TITLE = "PlantRun"
+PANEL_ICON = "mdi:sprout"
+PANEL_ELEMENT_NAME = "plantrun-dashboard-panel"
+PANEL_STATIC_URL = "/plantrun_frontend"
+
+_COMPONENT_PATH = Path(__file__).parent
+_MANIFEST_VERSION = json.loads(
+ (_COMPONENT_PATH / "manifest.json").read_text(encoding="utf-8")
+)["version"]
+_PANEL_SCRIPT_PATH = _COMPONENT_PATH / "www" / "plantrun-panel.js"
+_PANEL_MODULE_PATHS = tuple(sorted(_PANEL_SCRIPT_PATH.parent.glob("plantrun-panel*.js")))
+_PANEL_MODULE_FINGERPRINT = hashlib.sha256(
+ "|".join(
+ f"{path.name}:{path.stat().st_mtime_ns}:{path.stat().st_size}"
+ for path in _PANEL_MODULE_PATHS
+ ).encode()
+).hexdigest()[:12]
+_PANEL_SCRIPT_CACHE_KEY = f"{_MANIFEST_VERSION}-{_PANEL_MODULE_FINGERPRINT}"
+PANEL_MODULE_URL = f"{PANEL_STATIC_URL}/plantrun-panel.js?v={_PANEL_SCRIPT_CACHE_KEY}"
+PANEL_JS_URL = PANEL_MODULE_URL
+
+
+async def async_register_panel(hass: HomeAssistant) -> None:
+ """Register the PlantRun static module directory and sidebar panel once."""
+ domain_data: dict[str, Any] = hass.data.setdefault(DOMAIN, {})
+ if not domain_data.get("_static_registered"):
+ local_path = hass.config.path("custom_components/plantrun/www")
+ if hasattr(hass.http, "async_register_static_paths"):
+ await hass.http.async_register_static_paths(
+ [StaticPathConfig(PANEL_STATIC_URL, local_path, cache_headers=False)]
+ )
+ else:
+ hass.http.register_static_path(
+ PANEL_STATIC_URL,
+ local_path,
+ cache_headers=False,
+ )
+ domain_data["_static_registered"] = True
+
+ if domain_data.get("_panel_registered"):
+ return
+
+ frontend.async_register_built_in_panel(
+ hass,
+ "custom",
+ sidebar_title=PANEL_TITLE,
+ sidebar_icon=PANEL_ICON,
+ frontend_url_path=PANEL_URL_PATH,
+ config={
+ "_panel_custom": {
+ "name": PANEL_ELEMENT_NAME,
+ "embed_iframe": False,
+ "trust_external": False,
+ "module_url": PANEL_MODULE_URL,
+ "js_url": PANEL_JS_URL,
+ }
+ },
+ require_admin=False,
+ )
+ domain_data["_panel_registered"] = True
+
+
+def async_unregister_panel(hass: HomeAssistant) -> None:
+ """Remove the sidebar panel when the final PlantRun entry unloads."""
+ domain_data = hass.data.get(DOMAIN, {})
+ if not domain_data.get("_panel_registered"):
+ return
+ frontend.async_remove_panel(hass, PANEL_URL_PATH)
+ domain_data["_panel_registered"] = False
diff --git a/custom_components/plantrun/retention.py b/custom_components/plantrun/retention.py
index 4690569..fbeb46f 100644
--- a/custom_components/plantrun/retention.py
+++ b/custom_components/plantrun/retention.py
@@ -3,16 +3,18 @@
from __future__ import annotations
from datetime import datetime, timezone
-from typing import Any
+from typing import TYPE_CHECKING, Any
from .models import RunData
-from .store import PlantRunStorage
from .summary import (
build_run_summary,
normalize_energy_currency,
normalize_energy_price_per_kwh,
)
+if TYPE_CHECKING:
+ from .store import PlantRunStorage
+
def snapshot_day(ts: datetime | None = None) -> str:
"""Return UTC day key for rollups."""
diff --git a/custom_components/plantrun/run_resolution.py b/custom_components/plantrun/run_resolution.py
index ba05304..881b001 100644
--- a/custom_components/plantrun/run_resolution.py
+++ b/custom_components/plantrun/run_resolution.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from typing import TYPE_CHECKING
+
from .const import (
ACTIVE_RUN_STRATEGIES,
ACTIVE_RUN_STRATEGY_ACTIVE_RUN_ID,
@@ -9,7 +11,9 @@
ACTIVE_RUN_STRATEGY_LEGACY,
)
from .models import RunData
-from .store import PlantRunStorage
+
+if TYPE_CHECKING:
+ from .store import PlantRunStorage
def _norm(value: str) -> str:
diff --git a/custom_components/plantrun/services.yaml b/custom_components/plantrun/services.yaml
index 3423c5d..1990ea3 100644
--- a/custom_components/plantrun/services.yaml
+++ b/custom_components/plantrun/services.yaml
@@ -61,7 +61,7 @@ add_phase:
- first_active
phase_name:
name: Phase Name
- description: The name of the phase (e.g. Seedling, Veg, Flower).
+ description: The canonical or custom phase name (for example Seedling, Vegetative, Flowering, Drying, or Curing).
required: true
example: "Seedling"
selector:
diff --git a/custom_components/plantrun/store.py b/custom_components/plantrun/store.py
index 5d6b16e..9fef18f 100644
--- a/custom_components/plantrun/store.py
+++ b/custom_components/plantrun/store.py
@@ -1,205 +1,507 @@
-"""Storage for PlantRun."""
+"""Home Assistant Store adapter for PlantRun domain state."""
+
+from __future__ import annotations
import copy
import logging
from contextlib import nullcontext
+from datetime import datetime, timezone
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from .const import DOMAIN, INITIAL_PHASE_NAME, STORE_KEY, STORE_SCHEMA_VERSION, STORE_VERSION
+from .domain import (
+ BindingDraft,
+ JournalDraft,
+ PlantRunDomain,
+ RunDraft,
+ StrainIdentity,
+)
from .instrumentation import PlantRunInstrumentation
-from .models import RunData
+from .models import Binding, CultivarSnapshot, Note, Phase, RunData
+
_LOGGER = logging.getLogger(__name__)
+def _empty_domain_state() -> dict[str, list[dict[str, Any]]]:
+ return {
+ "tents": [],
+ "plants": [],
+ "runs": [],
+ "journal_entries": [],
+ "sensor_bindings": [],
+ }
+
+
+def _empty_payload() -> dict[str, Any]:
+ return {
+ "schema_version": STORE_SCHEMA_VERSION,
+ "domain": _empty_domain_state(),
+ "legacy_v2": {"runs": [], "active_run_id": None, "daily_rollups": {}},
+ "active_run_id": None,
+ "daily_rollups": {},
+ "compatibility_runs": [],
+ }
+
+
+def _aware_time(value: str | None, fallback: datetime | None = None) -> datetime:
+ """Normalize legacy date strings for the timezone-aware v3 domain."""
+
+ if value:
+ try:
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+ except ValueError:
+ parsed = fallback or datetime.now(timezone.utc)
+ else:
+ parsed = fallback or datetime.now(timezone.utc)
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed
+
+
class PlantRunStorage:
- """Class to hold PlantRun data."""
+ """Persist the v3 domain and expose legacy RunData projections to HA sensors."""
- def __init__(self, hass: HomeAssistant, instrumentation: PlantRunInstrumentation | None = None) -> None:
- """Initialize the storage."""
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ instrumentation: PlantRunInstrumentation | None = None,
+ ) -> None:
self.hass = hass
self._store: Store[dict[str, Any]] = Store(hass, STORE_VERSION, STORE_KEY)
self._instrumentation = instrumentation
+ self._data = _empty_payload()
+ self.domain = PlantRunDomain()
self.runs: list[RunData] = []
- self._data: dict[str, Any] = {
- "schema_version": STORE_SCHEMA_VERSION,
- "runs": [],
- "active_run_id": None,
- "daily_rollups": {},
- }
@staticmethod
def _migrate_v1_to_v2(payload: dict[str, Any]) -> dict[str, Any]:
- """Migrate legacy v1 payloads to schema v2.
+ """Normalize the last public legacy payload before preserving it."""
- v1 payloads did not require `schema_version` and often omitted `active_run_id`.
- """
migrated = copy.deepcopy(payload)
migrated.setdefault("runs", [])
migrated.setdefault("active_run_id", None)
migrated.setdefault("daily_rollups", {})
-
normalized_runs: list[dict[str, Any]] = []
for run in migrated.get("runs", []):
if not isinstance(run, dict):
continue
- run_copy = copy.deepcopy(run)
- run_copy.setdefault("notes", [])
- run_copy.setdefault("phases", [])
- run_copy.setdefault("bindings", [])
- if not run_copy["phases"] and isinstance(run_copy.get("start_time"), str) and run_copy["start_time"]:
- run_copy["phases"] = [{"name": INITIAL_PHASE_NAME, "start_time": run_copy["start_time"]}]
- normalized_runs.append(run_copy)
+ item = copy.deepcopy(run)
+ item.setdefault("notes", [])
+ item.setdefault("phases", [])
+ item.setdefault("bindings", [])
+ if not item["phases"] and isinstance(item.get("start_time"), str) and item["start_time"]:
+ item["phases"] = [{"name": INITIAL_PHASE_NAME, "start_time": item["start_time"]}]
+ normalized_runs.append(item)
migrated["runs"] = normalized_runs
- migrated["schema_version"] = STORE_SCHEMA_VERSION
+ migrated["schema_version"] = 2
return migrated
@classmethod
def _normalize_payload(cls, payload: dict[str, Any] | None) -> tuple[dict[str, Any], bool]:
- """Normalize storage payload and return (payload, changed)."""
- if payload is None:
- return (
- {
- "schema_version": STORE_SCHEMA_VERSION,
- "runs": [],
- "active_run_id": None,
- "daily_rollups": {},
- },
- True,
- )
+ """Upgrade legacy runs into a preserved, non-visible backup bucket."""
- current = copy.deepcopy(payload)
+ if payload is None:
+ return _empty_payload(), True
original = copy.deepcopy(payload)
-
+ current = copy.deepcopy(payload)
schema_version = current.get("schema_version")
if not isinstance(schema_version, int):
schema_version = 1
- if schema_version < STORE_SCHEMA_VERSION:
- current = cls._migrate_v1_to_v2(current)
-
- current.setdefault("schema_version", STORE_SCHEMA_VERSION)
- current.setdefault("runs", [])
- current.setdefault("active_run_id", None)
- current.setdefault("daily_rollups", {})
-
- for run in current["runs"]:
- if not isinstance(run, dict):
- continue
- run.setdefault("notes", [])
- run.setdefault("phases", [])
- run.setdefault("bindings", [])
- if not run["phases"] and isinstance(run.get("start_time"), str) and run["start_time"]:
- run["phases"] = [{"name": INITIAL_PHASE_NAME, "start_time": run["start_time"]}]
+ if schema_version < 3:
+ legacy = cls._migrate_v1_to_v2(current)
+ current = _empty_payload()
+ current["legacy_v2"] = {
+ "runs": legacy.get("runs", []),
+ "active_run_id": legacy.get("active_run_id"),
+ "daily_rollups": legacy.get("daily_rollups", {}),
+ }
+ else:
+ current.setdefault("schema_version", STORE_SCHEMA_VERSION)
+ current.setdefault("domain", _empty_domain_state())
+ current.setdefault(
+ "legacy_v2",
+ {"runs": [], "active_run_id": None, "daily_rollups": {}},
+ )
+ current.setdefault("active_run_id", None)
+ current.setdefault("daily_rollups", {})
+ current.setdefault("compatibility_runs", [])
+ for key, default in _empty_domain_state().items():
+ current["domain"].setdefault(key, default)
+ current["schema_version"] = STORE_SCHEMA_VERSION
return current, current != original
async def async_load(self) -> None:
- """Load data from the store."""
if self._instrumentation is not None:
self._instrumentation.incr("store.load.calls")
-
- with self._instrumentation.timer("store.load.ms") if self._instrumentation is not None else nullcontext():
- data = await self._store.async_load()
- normalized, changed = self._normalize_payload(data)
-
+ timer = self._instrumentation.timer("store.load.ms") if self._instrumentation else nullcontext()
+ with timer:
+ raw = await self._store.async_load()
+ normalized, changed = self._normalize_payload(raw)
+ self.domain = PlantRunDomain.from_state(normalized["domain"])
self._data = normalized
- raw_runs = normalized.get("runs", [])
- loaded_runs: list[RunData] = []
- for raw_run in raw_runs:
+ self._refresh_projections()
+
+ compatibility_runs: list[RunData] = []
+ for item in normalized.get("compatibility_runs", []):
try:
- loaded_runs.append(RunData.from_dict(raw_run))
+ compatibility_runs.append(RunData.from_dict(item))
except Exception as err:
changed = True
- _LOGGER.warning("Skipping malformed stored PlantRun run: %s", err)
- self.runs = loaded_runs
-
- if self._instrumentation is not None:
- self._instrumentation.incr("store.load.runs_total", len(raw_runs))
- self._instrumentation.incr("store.load.runs_loaded", len(self.runs))
-
- if self.active_run_id and not any(run.id == self.active_run_id for run in self.runs):
- self._data["active_run_id"] = None
- changed = True
-
- # Persist upgraded binding IDs / schema upgrades from legacy records.
- if changed or self._bindings_need_migration(raw_runs):
+ _LOGGER.warning("Skipping malformed compatibility Run: %s", err)
+ self.runs.extend(compatibility_runs)
+ if changed:
await self.async_save()
- _LOGGER.debug("Loaded %s runs from storage", len(self.runs))
+ def public_state(self) -> dict[str, Any]:
+ state = self.domain.export_state()
+ tents = state["tents"]
+ return {
+ "schema_version": STORE_SCHEMA_VERSION,
+ **state,
+ "active_tent_id": tents[0]["id"] if tents else None,
+ }
- @staticmethod
- def _bindings_need_migration(raw_runs: list[dict[str, Any]]) -> bool:
- """Return true when one or more bindings are in legacy shape."""
- for run in raw_runs:
- ids: set[str] = set()
- for binding in run.get("bindings", []):
- binding_id = binding.get("id")
- if not isinstance(binding_id, str) or not binding_id.strip():
- return True
- if binding_id in ids:
- return True
- ids.add(binding_id)
- return False
+ async def async_commit_domain(self, candidate: PlantRunDomain) -> None:
+ """Persist a complete candidate before replacing the in-memory state."""
+
+ next_data = copy.deepcopy(self._data)
+ next_data["schema_version"] = STORE_SCHEMA_VERSION
+ next_data["domain"] = candidate.export_state()
+ active_ids = [run["id"] for run in next_data["domain"]["runs"] if run["status"] == "active"]
+ stored_active = self._data.get("active_run_id")
+ active = stored_active if stored_active in active_ids else next(iter(active_ids), None)
+ next_data["active_run_id"] = active
+ await self._store.async_save(next_data)
+ self._data = next_data
+ self.domain = candidate
+ self._refresh_projections()
+
+ def _refresh_projections(self) -> None:
+ state = self.domain.export_state()
+ plants = {item["id"]: item for item in state["plants"]}
+ bindings = state["sensor_bindings"]
+ entries = state["journal_entries"]
+ projected: list[RunData] = []
+ for raw in state["runs"]:
+ plant = plants[raw["plant_id"]]
+ run_bindings = [
+ item
+ for item in bindings
+ if (
+ item["owner"] == "run"
+ and item["owner_id"] == raw["id"]
+ and (raw["status"] == "archived" or item["ended_at"] is None)
+ )
+ or (
+ item["owner"] == "tent"
+ and item["owner_id"] == raw["tent_id"]
+ and item["ended_at"] is None
+ )
+ ]
+ stage_history = raw["stage_history"]
+ phases = []
+ for index, change in enumerate(stage_history):
+ end_time = (
+ stage_history[index + 1]["occurred_at"]
+ if index + 1 < len(stage_history)
+ else raw["ended_at"]
+ )
+ phases.append(
+ Phase(
+ id=change["id"],
+ name=change["to_stage"],
+ start_time=change["occurred_at"],
+ end_time=end_time,
+ )
+ )
+ strain = plant.get("strain") or {}
+ duration = strain.get("duration") or {}
+ minimum = duration.get("minimum_days")
+ maximum = duration.get("maximum_days")
+ flower_window = (
+ round((minimum + maximum) / 2)
+ if isinstance(minimum, int) and isinstance(maximum, int)
+ else None
+ )
+ notes = [
+ Note(id=item["id"], text=item["text"], timestamp=item["occurred_at"])
+ for item in entries
+ if raw["id"] in item.get("run_ids", [])
+ ]
+ harvest = raw.get("harvest_details") or {}
+ projected.append(
+ RunData(
+ id=raw["id"],
+ friendly_name=raw["name"],
+ start_time=raw["planted_at"],
+ planted_date=raw["planted_at"],
+ end_time=raw["ended_at"],
+ status="ended" if raw["status"] == "archived" else "active",
+ phases=phases,
+ notes=notes,
+ bindings=[
+ Binding(
+ id=item["id"],
+ metric_type=item["metric_type"],
+ sensor_id=item["entity_id"],
+ )
+ for item in run_bindings
+ ],
+ cultivar=(
+ CultivarSnapshot(
+ name=strain.get("name"),
+ breeder=strain.get("breeder"),
+ flower_window_days=flower_window,
+ image_url=plant.get("image"),
+ detail_url=duration.get("source"),
+ )
+ if strain
+ else None
+ ),
+ dry_yield_grams=harvest.get("dry_yield_g"),
+ base_config={
+ "plant_id": plant["id"],
+ "plants": [{"id": plant["id"], "name": plant["name"]}],
+ "phase_plan": raw["stage_plan"],
+ },
+ image_url=plant.get("image"),
+ image_source="plant",
+ )
+ )
+ self.runs = projected
async def async_save(self) -> None:
- """Save data to the store."""
if self._instrumentation is not None:
self._instrumentation.incr("store.save.calls")
-
- with self._instrumentation.timer("store.save.ms") if self._instrumentation is not None else nullcontext():
- self._data["schema_version"] = STORE_SCHEMA_VERSION
- self._data["runs"] = [run.to_dict() for run in self.runs]
- self._data.setdefault("active_run_id", None)
- if self._instrumentation is not None:
- self._instrumentation.incr("store.save.runs_serialized", len(self.runs))
- await self._store.async_save(self._data)
+ domain_ids = {run["id"] for run in self.domain.export_state()["runs"]}
+ self._data["compatibility_runs"] = [
+ run.to_dict() for run in self.runs if run.id not in domain_ids
+ ]
+ self._data["domain"] = self.domain.export_state()
+ self._data["schema_version"] = STORE_SCHEMA_VERSION
+ await self._store.async_save(self._data)
@property
def active_run_id(self) -> str | None:
- """Return compatibility alias for active run fallback."""
- value = self._data.get("active_run_id")
- return value if isinstance(value, str) and value else None
+ stored = self._data.get("active_run_id")
+ if isinstance(stored, str) and any(
+ run.id == stored and run.status == "active" for run in self.runs
+ ):
+ return stored
+ return None
async def async_set_active_run_id(self, run_id: str | None) -> None:
- """Persist compatibility alias for active run fallback."""
self._data["active_run_id"] = run_id
await self.async_save()
def get_run(self, run_id: str) -> RunData | None:
- """Get a run by ID."""
- for run in self.runs:
- if run.id == run_id:
- return run
- return None
+ return next((run for run in self.runs if run.id == run_id), None)
@property
def daily_rollups(self) -> dict[str, dict[str, Any]]:
- """Return persisted daily rollup snapshots."""
- rollups = self._data.get("daily_rollups")
- if isinstance(rollups, dict):
- return rollups
- return {}
+ value = self._data.get("daily_rollups")
+ return value if isinstance(value, dict) else {}
async def async_set_daily_rollup(self, run_id: str, day: str, summary: dict[str, Any]) -> None:
- """Persist one run/day summary snapshot."""
- all_rollups = self.daily_rollups
- run_rollups = all_rollups.setdefault(run_id, {})
- run_rollups[day] = summary
- self._data["daily_rollups"] = all_rollups
+ rollups = self.daily_rollups
+ rollups.setdefault(run_id, {})[day] = summary
+ self._data["daily_rollups"] = rollups
await self.async_save()
async def async_add_run(self, run: RunData) -> None:
- """Add a new run."""
- self.runs.append(run)
- await self.async_save()
+ candidate = PlantRunDomain.from_state(self.domain.export_state())
+ planted_at = _aware_time(run.planted_date or run.start_time)
+ phase_names = [phase.name for phase in run.phases if phase.name]
+ configured_plan = run.base_config.get("phase_plan") if isinstance(run.base_config, dict) else None
+ source_plan = configured_plan if isinstance(configured_plan, list) else phase_names
+ stage_plan = tuple(dict.fromkeys(str(item).strip() for item in source_plan if str(item).strip()))
+ initial_stage = phase_names[0] if phase_names else INITIAL_PHASE_NAME
+ if initial_stage not in stage_plan:
+ stage_plan = (initial_stage, *stage_plan)
+ if not stage_plan:
+ stage_plan = (initial_stage,)
+ plants = run.base_config.get("plants") if isinstance(run.base_config, dict) else None
+ raw_plant = plants[0] if isinstance(plants, list) and plants and isinstance(plants[0], dict) else {}
+ plant_name = str(raw_plant.get("name") or run.friendly_name)
+ strain = None
+ if run.cultivar and run.cultivar.name:
+ strain = StrainIdentity(name=run.cultivar.name, breeder=run.cultivar.breeder)
+ created = candidate.create_run(
+ RunDraft(
+ run_name=run.friendly_name,
+ tent_name=str(run.base_config.get("tent_name") or "Growzelt"),
+ plant_name=plant_name,
+ planted_at=planted_at,
+ stage_plan=stage_plan,
+ initial_stage=initial_stage,
+ strain=strain,
+ image=run.image_url,
+ sensor_bindings=tuple(
+ BindingDraft(
+ owner="run",
+ metric_type=binding.metric_type,
+ entity_id=binding.sensor_id,
+ started_at=planted_at,
+ )
+ for binding in run.bindings
+ ),
+ )
+ )
+ for phase in run.phases[1:]:
+ snapshot_run = next(item for item in candidate.snapshot().runs if item.id == created.id)
+ if phase.name not in snapshot_run.stage_plan:
+ candidate.set_stage_plan(created.id, (*snapshot_run.stage_plan, phase.name))
+ candidate.change_stage(created.id, phase.name, _aware_time(phase.start_time, planted_at))
+ for note in run.notes:
+ candidate.add_journal_entry(
+ JournalDraft(
+ tent_id=created.tent_id,
+ run_ids=(created.id,),
+ text=note.text,
+ occurred_at=_aware_time(note.timestamp, planted_at),
+ )
+ )
+ if run.status == "ended":
+ candidate.finish_run(
+ created.id,
+ _aware_time(run.end_time, planted_at),
+ harvest_details={
+ "dry_yield_g": run.dry_yield_grams,
+ } if run.dry_yield_grams is not None else {},
+ )
+ await self.async_commit_domain(candidate)
+ run.id = created.id
async def async_update_run(self, updated_run: RunData) -> None:
- """Update an existing run."""
- for i, run in enumerate(self.runs):
- if run.id == updated_run.id:
- self.runs[i] = updated_run
- await self.async_save()
- return
+ exported = self.domain.export_state()
+ raw_run = next((item for item in exported["runs"] if item["id"] == updated_run.id), None)
+ if raw_run is None:
+ for index, run in enumerate(self.runs):
+ if run.id == updated_run.id:
+ self.runs[index] = updated_run
+ await self.async_save()
+ return
+ return
+
+ candidate = PlantRunDomain.from_state(exported)
+ snapshot = candidate.snapshot()
+ domain_run = next(item for item in snapshot.runs if item.id == updated_run.id)
+ domain_plant = next(item for item in snapshot.plants if item.id == domain_run.plant_id)
+ plants = updated_run.base_config.get("plants") if isinstance(updated_run.base_config, dict) else None
+ raw_plant = plants[0] if isinstance(plants, list) and plants and isinstance(plants[0], dict) else {}
+ strain = domain_plant.strain
+ if updated_run.cultivar and updated_run.cultivar.name:
+ strain = StrainIdentity(
+ name=updated_run.cultivar.name,
+ breeder=updated_run.cultivar.breeder,
+ duration=domain_plant.strain.duration if domain_plant.strain else None,
+ )
+ candidate.update_run(
+ updated_run.id,
+ run_name=updated_run.friendly_name,
+ plant_name=str(raw_plant.get("name") or domain_plant.name),
+ strain=strain,
+ planted_at=_aware_time(updated_run.planted_date or updated_run.start_time, domain_run.planted_at),
+ image=updated_run.image_url,
+ )
+
+ configured_plan = updated_run.base_config.get("phase_plan") if isinstance(updated_run.base_config, dict) else None
+ if isinstance(configured_plan, list) and configured_plan:
+ plan = tuple(dict.fromkeys(str(item).strip() for item in configured_plan if str(item).strip()))
+ current = next(item for item in candidate.snapshot().runs if item.id == updated_run.id)
+ if current.current_stage not in plan:
+ plan = (*plan, current.current_stage)
+ candidate.set_stage_plan(updated_run.id, plan)
+
+ current = next(item for item in candidate.snapshot().runs if item.id == updated_run.id)
+ if updated_run.phases:
+ target = updated_run.phases[-1]
+ if target.name != current.current_stage:
+ if target.name not in current.stage_plan:
+ candidate.set_stage_plan(updated_run.id, (*current.stage_plan, target.name))
+ candidate.change_stage(
+ updated_run.id,
+ target.name,
+ _aware_time(target.start_time, domain_run.planted_at),
+ )
+
+ current_entries = {
+ entry.id: entry
+ for entry in candidate.snapshot().journal_entries
+ if updated_run.id in entry.run_ids
+ }
+ updated_notes = {note.id: note for note in updated_run.notes}
+ for entry_id in current_entries.keys() - updated_notes.keys():
+ candidate.delete_journal_entry(entry_id)
+ for note in updated_run.notes:
+ entry = current_entries.get(note.id)
+ draft = JournalDraft(
+ tent_id=domain_run.tent_id,
+ run_ids=(updated_run.id,),
+ entry_type=entry.entry_type if entry else None,
+ text=note.text,
+ occurred_at=_aware_time(note.timestamp, domain_run.planted_at),
+ details=entry.details if entry else {},
+ sensor_snapshot=entry.sensor_snapshot if entry else {},
+ )
+ if entry is None:
+ candidate.add_journal_entry(draft)
+ elif entry.text != draft.text or entry.occurred_at != draft.occurred_at:
+ candidate.edit_journal_entry(entry.id, draft)
+
+ all_bindings = {binding.id: binding for binding in candidate.snapshot().bindings}
+ active_run_bindings = {
+ binding.id: binding
+ for binding in all_bindings.values()
+ if binding.owner == "run" and binding.owner_id == updated_run.id and binding.ended_at is None
+ }
+ updated_bindings = {binding.id: binding for binding in updated_run.bindings}
+ changed_at = datetime.now(timezone.utc)
+ for binding_id, binding in active_run_bindings.items():
+ replacement = updated_bindings.get(binding_id)
+ if replacement is None or (
+ replacement.metric_type != binding.metric_type
+ or replacement.sensor_id != binding.entity_id
+ ):
+ candidate.end_sensor_binding(
+ "run",
+ updated_run.id,
+ binding.metric_type,
+ changed_at,
+ )
+ if replacement is not None:
+ candidate.set_sensor_binding(
+ "run",
+ updated_run.id,
+ replacement.metric_type,
+ replacement.sensor_id,
+ changed_at,
+ )
+ for binding_id, binding in updated_bindings.items():
+ if binding_id not in all_bindings:
+ candidate.set_sensor_binding(
+ "run",
+ updated_run.id,
+ binding.metric_type,
+ binding.sensor_id,
+ changed_at,
+ )
+
+ harvest_details = dict(domain_run.harvest_details)
+ if updated_run.dry_yield_grams is not None:
+ harvest_details["dry_yield_g"] = updated_run.dry_yield_grams
+ if updated_run.status == "ended" and domain_run.status == "active":
+ candidate.finish_run(
+ updated_run.id,
+ _aware_time(updated_run.end_time, domain_run.planted_at),
+ harvest_details=harvest_details,
+ )
+ elif harvest_details != dict(domain_run.harvest_details):
+ candidate.update_harvest_details(updated_run.id, harvest_details)
+
+ await self.async_commit_domain(candidate)
diff --git a/custom_components/plantrun/www/assets/plant-flowering-v1.png b/custom_components/plantrun/www/assets/plant-flowering-v1.png
new file mode 100644
index 0000000..7a1ad63
Binary files /dev/null and b/custom_components/plantrun/www/assets/plant-flowering-v1.png differ
diff --git a/custom_components/plantrun/www/assets/plant-seedling-v1.png b/custom_components/plantrun/www/assets/plant-seedling-v1.png
new file mode 100644
index 0000000..a8fe043
Binary files /dev/null and b/custom_components/plantrun/www/assets/plant-seedling-v1.png differ
diff --git a/custom_components/plantrun/www/assets/plant-vegetative-v1.png b/custom_components/plantrun/www/assets/plant-vegetative-v1.png
new file mode 100644
index 0000000..c254a0e
Binary files /dev/null and b/custom_components/plantrun/www/assets/plant-vegetative-v1.png differ
diff --git a/custom_components/plantrun/www/plantrun-card.js b/custom_components/plantrun/www/plantrun-card.js
index 02b6823..b6ea06a 100644
--- a/custom_components/plantrun/www/plantrun-card.js
+++ b/custom_components/plantrun/www/plantrun-card.js
@@ -36,6 +36,7 @@
this._run = null;
this._summary = {};
this._loading = false;
+ this._loadingRunId = "";
this._loadedRunId = "";
this._requestNonce = 0;
this._lastSensorSignature = "";
@@ -92,9 +93,10 @@
async _fetchRunData() {
const runId = this._normalizeRunId(this._config.run_id);
- if (!this._hass || !runId || this._loading) return;
+ if (!this._hass || !runId || (this._loading && this._loadingRunId === runId)) return;
const requestNonce = ++this._requestNonce;
this._loading = true;
+ this._loadingRunId = runId;
this.render();
try {
const [runPayload, summary] = await Promise.all([
@@ -113,6 +115,7 @@
} finally {
if (requestNonce === this._requestNonce) {
this._loading = false;
+ this._loadingRunId = "";
this.render();
}
}
@@ -171,7 +174,7 @@
}
_progress() {
- const days = S.daysBetween(this._run?.planted_date || this._run?.start_time);
+ const days = S.daysBetween(this._run?.planted_date || this._run?.start_time, this._run?.end_time || new Date());
return Math.min(100, Math.round((days / Math.max(this._targetDays(), 1)) * 100));
}
@@ -194,13 +197,15 @@
_handleClick(event) {
const sensor = event.target.closest("[data-entity-id]");
if (!sensor) return;
- this.dispatchEvent(
- new CustomEvent("hass-more-info", {
- detail: { entityId: sensor.dataset.entityId },
- bubbles: true,
- composed: true,
- })
- );
+ const start = this._run?.planted_date || this._run?.start_time;
+ const end = this._run?.end_time || new Date().toISOString();
+ if (start && end) {
+ const params = new URLSearchParams({ entity_id: sensor.dataset.entityId, start_date: start, end_date: end, back: "1" });
+ window.history.pushState(null, "", `/history?${params.toString()}`);
+ window.dispatchEvent(new CustomEvent("location-changed", { detail: { replace: false } }));
+ return;
+ }
+ this.dispatchEvent(new CustomEvent("hass-more-info", { detail: { entityId: sensor.dataset.entityId }, bubbles: true, composed: true }));
}
_refreshSensorText() {
@@ -212,7 +217,7 @@
render() {
const runId = this._normalizeRunId(this._config.run_id);
const title = this._config.title || this._run?.friendly_name || "PlantRun";
- const days = S.daysBetween(this._run?.planted_date || this._run?.start_time);
+ const days = S.daysBetween(this._run?.planted_date || this._run?.start_time, this._run?.end_time || new Date());
this.shadowRoot.innerHTML = `
-