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 [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](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 = ` -
-
-
-
PlantRunHome Assistant grow cockpit
- -
- - - -
-
-
- - ${this._renderDetail()} -
-
- - ${this._renderWizard()} - ${this._renderBindingModal()} - ${this._renderNoteModal()} - ${this._renderDeleteNoteConfirm()} - ${this._renderEditModal()} - ${this._renderHistoryInspector()} - ${this._renderPhaseConfirmModal()} -
- `; - this._hydrateHaSelectors(); + _closeDialogs() { + this._createOpen = false; + this._journalEditorOpen = false; + this._stageDraft = null; + this._bindingEditorOpen = false; + this._archiveRunId = ""; + this._deleteRunId = ""; + this._deleteConfirmation = ""; + this._deleteJournalDraft = null; + this._dialogError = ""; + this.render(); } _handleClick(event) { - const target = event.target.closest("[data-action]"); - if (!target || !this.shadowRoot.contains(target)) return; + const action = event.target.closest("[data-action]"); + if (!action || !this.shadowRoot.contains(action)) return; event.preventDefault(); - const action = target.dataset.action; - this._clickSound(); - if (action === "filter") { - this._filter = target.dataset.filter; + const name = action.dataset.action; + if (name === "navigate") { + this._screen = action.dataset.screen || "overview"; this.render(); - } else if (action === "select-run") { - this._selectedRunId = target.dataset.runId; - this._detailDraft = null; + } else if (name === "open-run") { + this._selectedRunId = action.dataset.runId; + this._screen = "run"; + this._selectedMetric = "soil_moisture"; this.render(); - } else if (action === "refresh") { - this._refreshRuns(); - } else if (action === "open-wizard") { - this._wizardOpen = true; - this._wizardStep = 1; - this._wizard = this._blankWizard(); - this._suggestions = []; + this._loadMetricHistory(); + } else if (name === "reload") { + this._loadState(); + } else if (name === "toggle-theme") { + this._theme = this._theme === "dark" ? "light" : "dark"; + localStorage.setItem(THEME_KEY, this._theme); this.render(); - this._focusWizardPrimaryField(); - } else if (action === "close-wizard") { - this._wizardOpen = false; + } else if (name === "open-create") { + this._openCreate(); + } else if (name === "close-dialog" || name === "close-journal-editor") { + this._closeDialogs(); + } else if (name === "create-next") { + this._advanceCreate(); + } else if (name === "create-back") { + this._createStep = Math.max(1, this._createStep - 1); + this._dialogError = ""; this.render(); - } else if (action === "wizard-next") { - this._wizardStep = Math.min(3, this._wizardStep + 1); + } else if (name === "submit-create") { + this._submitCreate(); + } else if (name === "preview-cultivar") { + this._previewCultivar(Number(action.dataset.index)); + } else if (name === "apply-cultivar") { + this._applyCultivarPreview(); + } else if (name === "manual-cultivar") { + this._createDraft.selected_cultivar = null; + this._cultivarPreview = null; + this._cultivarResults = []; this.render(); - } else if (action === "wizard-back") { - this._wizardStep = Math.max(1, this._wizardStep - 1); + } else if (name === "add-create-binding") { + this._createDraft.bindings.push({ metric_type: "soil_moisture", entity_id: "", owner_type: "plant" }); this.render(); - } else if (action === "add-wizard-binding") { - this._wizard.bindings = [...this._wizard.bindings, { metric_type: "temperature", sensor_id: "" }]; + } else if (name === "remove-create-binding") { + this._createDraft.bindings.splice(Number(action.dataset.index), 1); this.render(); - } else if (action === "remove-wizard-binding") { - this._wizard.bindings = this._wizard.bindings.filter((_binding, index) => index !== Number(target.dataset.index)); + } else if (name === "open-journal-editor") { + this._openJournalEditor(action.dataset.runId); + } else if (name === "set-journal-type") { + this._journalDraft.entry_type = action.dataset.entryType; this.render(); - } else if (action === "choose-cultivar") { - this._chooseCultivar(Number(target.dataset.index)); - } else if (action === "choose-detail-cultivar") { - this._chooseDetailCultivar(Number(target.dataset.index)); - } else if (action === "create-run") { - this._createRun(); - } else if (action === "open-binding") { - this._openBinding(target.dataset.runId); - } else if (action === "edit-binding") { - this._openBinding(target.dataset.runId, target.dataset.bindingId); - } else if (action === "close-binding") { - this._bindingDraft = null; + } else if (name === "save-journal-entry") { + this._saveJournalEntry(); + } else if (name === "edit-journal-entry") { + const run = this._state.runs.find((item) => item.id === action.dataset.runId); + const entry = run?.journal_entries?.find((item) => item.id === action.dataset.entryId); + if (entry) this._openJournalEditor(run.id, entry); + } else if (name === "request-delete-journal-entry") { + this._deleteJournalDraft = { run_id: action.dataset.runId, entry_id: action.dataset.entryId }; this.render(); - } else if (action === "save-binding") { - this._saveBinding(); - } else if (action === "remove-binding") { - this._removeBinding(target.dataset.runId, target.dataset.bindingId); - } else if (action === "select-phase") { - this._phaseDraft = target.dataset.phase; - this._addPhase(target.dataset.runId); - } else if (action === "close-phase-confirm") { - this._phaseConfirm = null; - this.render(); - } else if (action === "confirm-phase-change") { - this._confirmPhaseChange(); - } else if (action === "add-note") { - this._openNewNoteEditor(target.dataset.runId); - } else if (action === "edit-note") { - this._openNoteEditor(target.dataset.noteId); - } else if (action === "close-note-edit") { - this._noteEditor = null; - this.render(); - } else if (action === "save-note-edit") { - this._saveNoteEdit(); - } else if (action === "confirm-delete-note") { - this._openNoteDeleteConfirm(target.dataset.noteId); - } else if (action === "close-note-delete") { - this._noteDeleteConfirm = null; - this.render(); - } else if (action === "delete-note") { - this._deleteNote(); - } else if (action === "edit-run") { - this._openEditRun(target.dataset.runId); - } else if (action === "close-edit") { - this._detailDraft = null; + } else if (name === "confirm-delete-journal-entry") { + this._deleteJournalEntry(); + } else if (name === "select-metric") { + this._selectedMetric = action.dataset.metric; + this._loadMetricHistory(); + } else if (name === "select-stage") { + this._stageDraft = { run_id: this._selectedRunId, stage: action.dataset.stage, occurred_at: dateTimeLocal() }; this.render(); - } else if (action === "save-run") { - this._saveRun(); - } else if (action === "close-history") { - this._historyInspector = null; - this.render(); - } else if (action === "open-history-entity") { - this._openEntity(target.dataset.entityId); - } else if (action === "open-native-history") { - if (!this._openNativeHistory(this._historyInspector?.context)) this._openEntity(target.dataset.entityId); - } else if (action === "toggle-theme") { - this._theme = this._resolvedTheme() === "dark" ? "light" : "dark"; - localStorage.setItem(STORAGE.theme, this._theme); + } else if (name === "confirm-stage-change") { + this._changeStage(); + } else if (name === "open-binding-editor") { + this._openBindingEditor(action.dataset.runId); + } else if (name === "save-binding") { + this._saveBinding(); + } else if (name === "clear-binding") { + this._clearBinding(action); + } else if (name === "request-archive") { + this._archiveRunId = action.dataset.runId; this.render(); - } else if (action === "toggle-sound") { - this._sound = !this._sound; - localStorage.setItem(STORAGE.sound, this._sound ? "on" : "off"); + } else if (name === "confirm-archive") { + this._archiveRun(); + } else if (name === "request-delete-run") { + this._deleteRunId = action.dataset.runId; + this._deleteConfirmation = ""; this.render(); + } else if (name === "confirm-delete-run") { + this._deleteRun(); } } _handleInput(event) { const target = event.target; - if (target.matches("[data-wizard-field]")) { - const field = target.dataset.wizardField; - this._wizard = { ...this._wizard, [field]: target.value }; - if (field === "breeder" || field === "cultivar_name") { - this._wizard.selected_cultivar = null; - this._wizard.target_days = ""; - this._scheduleCultivarSearch(); + if (target.dataset.createField) { + this._createDraft[target.dataset.createField] = target.value; + if (target.dataset.createField === "initial_stage" && !this._createDraft.stage_plan.includes(target.value)) { + this._createDraft.stage_plan.push(target.value); + this.render(); } - } else if (target.matches("[data-note-edit-text]") && this._noteEditor) { - this._noteEditor = { ...this._noteEditor, text: target.value }; - } else if (target.matches("[data-detail-field]")) { - this._detailDraft = { ...this._detailDraft, [target.dataset.detailField]: target.value }; - if (target.dataset.detailField === "breeder" || target.dataset.detailField === "cultivar_name") { - this._detailDraft.selected_cultivar = null; - this._detailDraft.target_days = ""; - this._scheduleDetailCultivarSearch(); + if (["strain", "breeder"].includes(target.dataset.createField)) { + this._createDraft.selected_cultivar = null; + this._cultivarPreview = null; + this._scheduleCultivarSearch(); } + } else if (target.dataset.createDuration) { + this._createDraft.duration[target.dataset.createDuration] = target.value; + } else if (target.dataset.journalField) { + this._journalDraft[target.dataset.journalField] = target.value; + } else if (target.matches("[data-stage-occurred-at]")) { + this._stageDraft.occurred_at = target.value; + } else if (target.matches("[data-delete-confirmation]")) { + this._deleteConfirmation = target.value; + const run = this._state.runs.find((item) => item.id === this._deleteRunId); + const confirmation = this._deleteConfirmation; + const button = this.shadowRoot.querySelector('[data-action="confirm-delete-run"]'); + if (button) button.disabled = confirmation !== this._runName(run); } } _handleChange(event) { const target = event.target; - if (target.matches("[data-select-fallback]")) { - this._setEntitySelectorValue(target.dataset.selectFallback, target.value); - } else if (target.matches("[data-wizard-binding-metric]")) { - const index = Number(target.dataset.wizardBindingMetric); - const bindings = [...this._wizard.bindings]; - bindings[index] = { ...bindings[index], metric_type: target.value }; - this._wizard.bindings = bindings; + if (target.dataset.createStage) { + const stage = target.dataset.createStage; + if (stage === this._createDraft.initial_stage && !target.checked) { + target.checked = true; + return; + } + this._createDraft.stage_plan = target.checked + ? [...new Set([...this._createDraft.stage_plan, stage])] + : this._createDraft.stage_plan.filter((item) => item !== stage); + } else if (target.dataset.createBindingMetric !== undefined) { + const binding = this._createDraft.bindings[Number(target.dataset.createBindingMetric)]; + binding.metric_type = target.value; + binding.entity_id = ""; + this.render(); + } else if (target.dataset.createBindingEntity !== undefined) { + this._createDraft.bindings[Number(target.dataset.createBindingEntity)].entity_id = target.value; + } else if (target.dataset.createBindingOwner !== undefined) { + this._createDraft.bindings[Number(target.dataset.createBindingOwner)].owner_type = target.value; + } else if (target.dataset.bindingField) { + this._bindingDraft[target.dataset.bindingField] = target.value; + if (target.dataset.bindingField === "metric_type") this._bindingDraft.entity_id = ""; + this.render(); + } else if (target.dataset.journalFilter === "plant") { + this._journalPlantFilter = target.value; this.render(); - } else if (target.matches("[data-binding-metric]") && this._bindingDraft) { - this._bindingDraft = { ...this._bindingDraft, metric_type: target.value }; + } else if (target.dataset.journalFilter === "type") { + this._journalTypeFilter = target.value; this.render(); } } _handleKeydown(event) { - if (!event.target.matches("[data-cultivar-input], [data-detail-cultivar-input]")) return; - const detailInput = event.target.matches("[data-detail-cultivar-input]"); - const suggestions = detailInput ? this._detailDraft?.suggestions || [] : this._suggestions; - if ((event.key === "Enter" || event.key === "Tab") && suggestions.length) { + if (event.key === "Escape" && (this._createOpen || this._journalEditorOpen || this._stageDraft || this._bindingEditorOpen || this._deleteRunId)) { event.preventDefault(); - detailInput ? this._chooseDetailCultivar(0) : this._chooseCultivar(0); - } else if (event.key === "Escape") { - if (detailInput) { - this._detailDraft = { ...this._detailDraft, suggestions: [] }; - this._renderDetailSuggestionsOnly(); - } else { - this._suggestions = []; - this._renderSuggestionsOnly(); - } + this._closeDialogs(); + } + if (event.key === "Enter" && (event.ctrlKey || event.metaKey) && this._journalEditorOpen) { + event.preventDefault(); + this._saveJournalEntry(); } } - _handleMouseDown(event) { - if (!event.target.closest("[data-prevent-mousedown]")) return; - event.preventDefault(); - } - - _handlePointerDown(event) { - if (event.target.closest("button, input, select, textarea, ha-selector")) return; - const tile = event.target.closest("[data-sensor-tile]"); - if (!tile) return; - const key = `${tile.dataset.runId}:${tile.dataset.entityId}`; - const current = this._pressState[key]; - if (current?.timer) window.clearTimeout(current.timer); - this._pressState[key] = { - longPressTriggered: false, - timer: window.setTimeout(() => { - this._pressState[key].longPressTriggered = true; - this._openEntity(tile.dataset.entityId); - }, 550), - }; - } - - _handlePointerUp(event) { - if (event.target.closest("button, input, select, textarea, ha-selector")) return; - const tile = event.target.closest("[data-sensor-tile]"); - if (!tile) return; - const key = `${tile.dataset.runId}:${tile.dataset.entityId}`; - const state = this._pressState[key]; - if (!state) return; - window.clearTimeout(state.timer); - const wasLongPress = !!state.longPressTriggered; - delete this._pressState[key]; - if (!wasLongPress) this._openRunHistory(tile.dataset.runId, tile.dataset.entityId); - } - - _handlePointerCancel(event) { - if (event.target.closest("button, input, select, textarea, ha-selector")) return; - const tile = event.target.closest("[data-sensor-tile]"); - if (!tile) return; - const key = `${tile.dataset.runId}:${tile.dataset.entityId}`; - const state = this._pressState[key]; - if (state?.timer) window.clearTimeout(state.timer); - delete this._pressState[key]; - } - - _scheduleCultivarSearch() { - window.clearTimeout(this._searchTimer); - const query = this._wizard.cultivar_name.trim(); - const breeder = this._wizard.breeder.trim(); - if (query.length < 2 || breeder.length < 2) { - this._searchNonce += 1; - this._lastSearchKey = ""; - this._suggestions = []; - this._renderSuggestionsOnly(); + _advanceCreate() { + if (this._createStep === 1 && !this._createDraft.plant_name.trim()) { + this._dialogError = "Gib der Pflanze einen Namen."; + this.render(); return; } - const searchKey = `${breeder.toLowerCase()}::${query.toLowerCase()}`; - if (searchKey === this._lastSearchKey) return; - this._suggestions = [{ name: "Refreshing results…", breeder: "SeedFinder" }]; - this._renderSuggestionsOnly(); - this._searchTimer = window.setTimeout(() => this._searchCultivarSuggestions(), 260); + if (this._createStep === 2 && !this._createDraft.stage_plan.length) { + this._dialogError = "Wähle mindestens eine Phase."; + this.render(); + return; + } + this._dialogError = ""; + this._createStep = Math.min(3, this._createStep + 1); + this.render(); } - _scheduleDetailCultivarSearch() { + _scheduleCultivarSearch() { window.clearTimeout(this._searchTimer); - const query = this._detailDraft?.cultivar_name?.trim?.() || ""; - const breeder = this._detailDraft?.breeder?.trim?.() || ""; + const query = this._createDraft.strain.trim(); + const breeder = this._createDraft.breeder.trim(); if (query.length < 2 || breeder.length < 2) { - this._detailDraft = { ...this._detailDraft, suggestions: [], cultivar_searching: false }; - this._renderDetailSuggestionsOnly(); + this._cultivarResults = []; return; } - this._detailDraft = { ...this._detailDraft, cultivar_searching: true }; - this._renderDetailSuggestionsOnly(); - this._searchTimer = window.setTimeout(() => this._searchDetailCultivarSuggestions(), 180); + this._searchTimer = window.setTimeout(() => this._searchCultivar(), 320); } - async _searchCultivarSuggestions() { - const breeder = this._wizard.breeder.trim(); - const query = this._wizard.cultivar_name.trim(); - const searchKey = `${breeder.toLowerCase()}::${query.toLowerCase()}`; - this._lastSearchKey = searchKey; - if (this._suggestionCache.has(searchKey)) { - this._suggestions = this._suggestionCache.get(searchKey) || []; - this._renderSuggestionsOnly(); - return; - } - const requestNonce = ++this._searchNonce; + async _searchCultivar() { + const nonce = ++this._searchNonce; + this._cultivarSearching = true; + this.render(); try { - const payload = await this._hass.callWS({ - type: "plantrun/search_cultivar", - breeder, - query, - }); - if (requestNonce !== this._searchNonce) return; - this._suggestions = Array.isArray(payload?.results) ? payload.results : []; - this._suggestionCache.set(searchKey, this._suggestions); - } catch (_err) { - if (requestNonce !== this._searchNonce) return; - this._suggestions = []; + const result = await this._api.searchCultivar(this._createDraft.breeder, this._createDraft.strain); + if (nonce !== this._searchNonce) return; + this._cultivarResults = Array.isArray(result?.results) ? result.results : Array.isArray(result) ? result : []; + this._cultivarPreview = null; + } catch (_error) { + if (nonce !== this._searchNonce) return; + this._cultivarResults = []; + } finally { + if (nonce === this._searchNonce) { + this._cultivarSearching = false; + this.render(); + } } - this._renderSuggestionsOnly(); - } - - _renderSuggestionsOnly() { - const box = this.shadowRoot.querySelector("[data-suggestions]"); - if (box) box.innerHTML = this._suggestionMarkup(); - } - - _renderDetailSuggestionsOnly() { - const box = this.shadowRoot.querySelector("[data-detail-suggestions]"); - if (box) box.innerHTML = this._detailSuggestionMarkup(); } - _chooseCultivar(index) { - const item = this._suggestions[index]; + _previewCultivar(index) { + const item = this._cultivarResults[index]; if (!item) return; - this._wizard = { - ...this._wizard, - breeder: item.breeder || this._wizard.breeder, - cultivar_name: item.name || item.strain || this._wizard.cultivar_name, - target_days: this._derivedTargetDays(item), - selected_cultivar: item, - }; - this._suggestions = []; + this._cultivarPreview = item; this.render(); } - async _searchDetailCultivarSuggestions() { - const breeder = this._detailDraft?.breeder?.trim?.() || ""; - const query = this._detailDraft?.cultivar_name?.trim?.() || ""; - const searchKey = `${breeder.toLowerCase()}::${query.toLowerCase()}`; - if (this._suggestionCache.has(searchKey)) { - this._detailDraft = { ...this._detailDraft, suggestions: this._suggestionCache.get(searchKey) || [], cultivar_searching: false }; - this._renderDetailSuggestionsOnly(); - return; - } - try { - const payload = await this._hass.callWS({ type: "plantrun/search_cultivar", breeder, query }); - const suggestions = Array.isArray(payload?.results) ? payload.results : []; - this._suggestionCache.set(searchKey, suggestions); - this._detailDraft = { ...this._detailDraft, suggestions, cultivar_searching: false }; - } catch (_err) { - this._detailDraft = { ...this._detailDraft, suggestions: [], cultivar_searching: false }; - } - this._renderDetailSuggestionsOnly(); - } - - _chooseDetailCultivar(index) { - const item = this._detailDraft?.suggestions?.[index]; + _applyCultivarPreview() { + const item = this._cultivarPreview; if (!item) return; - this._detailDraft = { - ...this._detailDraft, - breeder: item.breeder || this._detailDraft.breeder, - cultivar_name: item.name || item.strain || this._detailDraft.cultivar_name, - target_days: this._derivedTargetDays(item), - selected_cultivar: item, - suggestions: [], - cultivar_searching: false, + this._createDraft.selected_cultivar = item; + this._createDraft.strain = item.name || item.strain || this._createDraft.strain; + this._createDraft.breeder = item.breeder || this._createDraft.breeder; + const duration = item.duration || {}; + this._createDraft.duration = { + min_days: duration.min_days ?? item.min_days ?? "", + max_days: duration.max_days ?? item.max_days ?? "", + meaning: duration.meaning || item.meaning || "", + start_event: duration.start_event || item.start_event || "", + source: duration.source || item.detail_url || item.source || "SeedFinder", + original_text: duration.original_text || item.original_text || item.original_wording || "", }; this.render(); } - async _createRun() { - if (!this._hass || !this._wizard.friendly_name.trim()) return; - const knownRunIds = new Set(this._runs.map((run) => run.id)); - const name = this._wizard.friendly_name.trim(); - await this._hass.callService(DOMAIN, "create_run", { - friendly_name: name, - planted_date: this._wizard.planted_date || undefined, - }); - await this._refreshRuns({ keepSelection: false }); - const run = this._resolveNewlyCreatedRun(name, knownRunIds); - if (!run) return; - this._selectedRunId = run.id; - const targetDays = Number(this._wizard.target_days); - if (Number.isFinite(targetDays) && targetDays > 0) { - await this._hass.callService(DOMAIN, "update_run", { - run_id: run.id, - base_config: { - target_days: targetDays, - }, - }); - } - if (this._wizard.cultivar_name.trim()) { - await this._hass.callService(DOMAIN, "set_cultivar", { - run_id: run.id, - cultivar_name: this._wizard.cultivar_name.trim(), - breeder: this._wizard.breeder.trim(), - strain: this._wizard.selected_cultivar?.name || this._wizard.cultivar_name.trim(), - }); - } - for (const binding of this._wizard.bindings) { - if (binding.metric_type && binding.sensor_id) { - await this._hass.callService(DOMAIN, "add_binding", { - run_id: run.id, - metric_type: binding.metric_type, - sensor_id: binding.sensor_id, - }); - } - } - this._wizardOpen = false; - await this._refreshRuns(); - } - - _resolveNewlyCreatedRun(name, previousRunIds = new Set()) { - const newlyDiscovered = this._runs.filter((run) => !previousRunIds.has(run.id)); - return newlyDiscovered[0] || this._runs.find((run) => run.friendly_name === name) || null; - } - - _openBinding(runId, bindingId = "") { - const run = this._runs.find((item) => item.id === runId); - const binding = run?.bindings?.find((item) => item.id === bindingId); - this._bindingDraft = binding - ? { run_id: runId, binding_id: binding.id || "", metric_type: binding.metric_type || "temperature", sensor_id: binding.sensor_id || "" } - : { run_id: runId, binding_id: "", metric_type: "temperature", sensor_id: "" }; + async _submitCreate() { + const draft = this._createDraft; + const tent = this._activeTent(); + const previousIds = new Set(this._state.runs.map((run) => run.id)); + const response = await this._command("create_run", { + tent_id: tent?.id || null, + tent_name: tent?.name || "Growzelt", + run_name: draft.nickname || draft.plant_name, + friendly_name: draft.nickname || draft.plant_name, + plant_name: draft.plant_name.trim(), + nickname: draft.nickname.trim() || null, + strain: draft.strain.trim() || null, + breeder: draft.breeder.trim() || null, + container: draft.container.trim() || null, + substrate: draft.substrate.trim() || null, + light_schedule: draft.light_schedule.trim() || null, + planted_at: toIso(draft.planted_at), + initial_stage: draft.initial_stage, + stage_plan: draft.stage_plan, + bindings: draft.bindings.filter((binding) => binding.entity_id).map((binding) => ({ ...binding, started_at: toIso(draft.planted_at) })), + duration: { + min_days: draft.duration.min_days ? Number(draft.duration.min_days) : null, + max_days: draft.duration.max_days ? Number(draft.duration.max_days) : null, + meaning: draft.duration.meaning || null, + start_event: draft.duration.start_event || null, + source: draft.duration.source || null, + original_text: draft.duration.original_text || null, + }, + cultivar: draft.selected_cultivar, + }, "Pflanze und Lauf wurden angelegt"); + if (!response) return; + const created = this._state.runs.find((run) => !previousIds.has(run.id)); + if (created) this._selectedRunId = created.id; + this._createOpen = false; + this._screen = created ? "run" : "overview"; this.render(); + if (created) this._loadMetricHistory(); } - async _saveBinding() { - if (!this._hass || !this._bindingDraft?.sensor_id) return; - await this._hass.callService(DOMAIN, this._bindingDraft.binding_id ? "update_binding" : "add_binding", { - run_id: this._bindingDraft.run_id, - binding_id: this._bindingDraft.binding_id || undefined, - metric_type: this._bindingDraft.metric_type, - sensor_id: this._bindingDraft.sensor_id, - }); - this._bindingDraft = null; - await this._refreshRuns(); + async _saveJournalEntry() { + const draft = this._journalDraft; + if (!draft.run_id || !draft.text.trim()) { + this._dialogError = "Wähle eine Pflanze und beschreibe kurz, was passiert ist."; + this.render(); + return; + } + const run = this._state.runs.find((item) => item.id === draft.run_id); + const command = draft.entry_id ? "update_journal_entry" : "create_journal_entry"; + const response = await this._command(command, { + ...(draft.entry_id ? { entry_id: draft.entry_id } : {}), + run_ids: [draft.run_id], + tent_id: run?.tent_id || this._activeTent()?.id || null, + entry_type: draft.entry_type, + text: draft.text.trim(), + occurred_at: toIso(draft.occurred_at), + details: draft.details || {}, + sensor_snapshot: draft.entry_id ? draft.sensor_snapshot : this._sensorSnapshot(run), + }, draft.entry_id ? "Eintrag aktualisiert" : "Eintrag gespeichert"); + if (!response) return; + this._journalEditorOpen = false; + this._screen = "journal"; + this.render(); } - async _removeBinding(runId, bindingId) { - if (!this._hass || !bindingId) return; - await this._hass.callService(DOMAIN, "remove_binding", { run_id: runId, binding_id: bindingId }); - await this._refreshRuns(); + _sensorSnapshot(run) { + const capturedAt = new Date().toISOString(); + return bindingsFor(run, this._activeTent()) + .filter((binding) => !binding?.ended_at) + .reduce((snapshot, binding) => { + const entityId = entityIdFor(binding); + const state = this._hass?.states?.[entityId]; + if (!entityId || !state || ["unknown", "unavailable"].includes(state.state)) return snapshot; + const unit = state.attributes?.unit_of_measurement || ""; + snapshot[entityId] = `${state.state}${unit ? ` ${unit}` : ""}`; + snapshot.captured_at = capturedAt; + return snapshot; + }, {}); } - async _addPhase(runId) { - if (!this._hass || !this._phaseDraft) return; - const run = this._runs.find((item) => item.id === runId); - const nextPhase = this._phaseDraft === "Harvest" ? "Harvested" : this._phaseDraft; - const current = run?.phases?.at?.(-1)?.name; - if (current === nextPhase) return; - this._phaseConfirm = { - run_id: runId, - run_name: run?.friendly_name || "this run", - current_phase: current || "Not started", - next_phase: nextPhase, - }; + async _saveBinding() { + const draft = this._bindingDraft; + const run = this._state.runs.find((item) => item.id === draft.run_id); + const tent = this._activeTent(); + if (!run || !draft.entity_id) { + this._dialogError = "Wähle einen Home-Assistant-Sensor."; + this.render(); + return; + } + const ownerId = draft.owner_type === "tent" ? tent?.id : run.id; + const response = await this._command("set_binding", { + owner_type: draft.owner_type, + owner_id: ownerId, + metric_type: draft.metric_type, + entity_id: draft.entity_id, + occurred_at: new Date().toISOString(), + }, "Sensorzuordnung gespeichert"); + if (!response) return; + this._bindingEditorOpen = false; this.render(); - } - - async _confirmPhaseChange() { - const pending = this._phaseConfirm; - if (!this._hass || !pending?.run_id || !pending?.next_phase) return; - this._phaseConfirm = null; + this._loadMetricHistory(); + } + + async _clearBinding(action) { + const run = this._state.runs.find((item) => item.id === this._bindingDraft.run_id); + const tent = this._activeTent(); + const ownerType = action.dataset.ownerType; + const ownerId = ownerType === "tent" ? tent?.id : run?.id; + const response = await this._command("clear_binding", { + owner_type: ownerType, + owner_id: ownerId, + metric_type: action.dataset.metric, + occurred_at: new Date().toISOString(), + }, "Sensorzuordnung beendet"); + if (!response) return; + this._bindingEditorOpen = false; this.render(); - await this._hass.callService(DOMAIN, "add_phase", { run_id: pending.run_id, phase_name: pending.next_phase }); - await this._refreshRuns(); + this._loadMetricHistory(); } - _openNewNoteEditor(runId) { - if (!runId) return; - this._noteEditor = { run_id: runId, note_id: "", text: "" }; + async _deleteJournalEntry() { + const draft = this._deleteJournalDraft; + if (!draft) return; + const response = await this._command("delete_journal_entry", { entry_id: draft.entry_id, run_id: draft.run_id }, "Eintrag gelöscht"); + if (!response) return; + this._deleteJournalDraft = null; this.render(); } - _openNoteEditor(noteId) { - const run = this._selectedRun(); - const note = run?.notes?.find((item) => item.id === noteId); - if (!run || !note) return; - this._noteEditor = { run_id: run.id, note_id: note.id, text: note.text || "" }; + async _changeStage() { + const draft = this._stageDraft; + if (!draft) return; + const response = await this._command("change_stage", { + run_id: draft.run_id, + target_stage: draft.stage, + occurred_at: toIso(draft.occurred_at), + }, `Phase zu ${draft.stage} geändert`); + if (!response) return; + this._stageDraft = null; this.render(); } - _openNoteDeleteConfirm(noteId) { - const run = this._selectedRun(); - const note = run?.notes?.find((item) => item.id === noteId); - if (!run || !note) return; - this._noteDeleteConfirm = { run_id: run.id, note_id: note.id }; + async _archiveRun() { + const runId = this._archiveRunId; + const response = await this._command("archive_run", { run_id: runId, occurred_at: new Date().toISOString() }, "Lauf archiviert"); + if (!response) return; + this._archiveRunId = ""; + this._screen = "archive"; this.render(); } - async _saveNoteEdit() { - const draft = this._noteEditor; - const text = draft?.text?.trim?.() || ""; - if (!this._hass || !draft?.run_id || !text) return; - if (draft.note_id) { - await this._hass.callService(DOMAIN, "update_note", { run_id: draft.run_id, note_id: draft.note_id, text }); - } else { - await this._hass.callService(DOMAIN, "add_note", { run_id: draft.run_id, text }); - } - this._noteEditor = null; - await this._refreshRuns(); - } - - async _deleteNote() { - const draft = this._noteDeleteConfirm; - if (!this._hass || !draft?.run_id || !draft?.note_id) return; - await this._hass.callService(DOMAIN, "delete_note", { run_id: draft.run_id, note_id: draft.note_id }); - this._noteDeleteConfirm = null; - await this._refreshRuns(); - } - - _openEditRun(runId) { - const run = this._runs.find((item) => item.id === runId); - if (!run) return; - this._detailDraft = { + async _deleteRun() { + const run = this._state.runs.find((item) => item.id === this._deleteRunId); + const confirmation = this._deleteConfirmation; + if (!run || confirmation !== this._runName(run)) return; + const response = await this._command("delete_run", { run_id: run.id, - friendly_name: run.friendly_name || "", - planted_date: run.planted_date || "", - breeder: run.cultivar?.breeder === "Unknown (Manual Entry)" ? "" : run.cultivar?.breeder || "", - cultivar_name: run.cultivar?.name || "", - target_days: this._targetDaysForRun(run), - selected_cultivar: null, - suggestions: [], - cultivar_searching: false, - dry_yield_grams: run.dry_yield_grams ?? "", - notes_summary: run.notes_summary || "", - }; - this.render(); - } - - async _saveRun() { - const draft = this._detailDraft; - if (!this._hass || !draft) return; - try { - const targetDays = Number(draft.target_days || this._derivedTargetDays(draft.selected_cultivar)); - await this._hass.callService(DOMAIN, "update_run", { - run_id: draft.run_id, - friendly_name: draft.friendly_name, - planted_date: draft.planted_date || null, - notes_summary: draft.notes_summary || null, - dry_yield_grams: draft.dry_yield_grams === "" ? null : Number(draft.dry_yield_grams), - ...(Number.isFinite(targetDays) && targetDays > 0 - ? { - base_config: { - ...(this._runs.find((item) => item.id === draft.run_id)?.base_config || {}), - target_days: targetDays, - }, - } - : {}), - }); - if (draft.cultivar_name?.trim()) { - await this._hass.callService(DOMAIN, "set_cultivar", { - run_id: draft.run_id, - cultivar_name: draft.cultivar_name.trim(), - breeder: draft.breeder?.trim?.() || "", - strain: draft.selected_cultivar?.name || draft.cultivar_name.trim(), - }); - } - this._detailDraft = null; - await this._refreshRuns(); - } catch (err) { - this._error = err?.message || "Unable to save run changes."; - this.render(); - } - } - - _openEntity(entityId) { - this.dispatchEvent(new CustomEvent("hass-more-info", { detail: { entityId }, bubbles: true, composed: true })); - } - - _openNativeHistory(context) { - if (!EXPERIMENTAL_NATIVE_HISTORY_DEEPLINK || !context?.entity_id || !context?.run_start) return false; - const end = context.run_end || context.stored_run_end || context.run_window?.effective_end; - if (!end) return false; - const params = new URLSearchParams({ - entity_id: context.entity_id, - start_date: context.run_start, - end_date: end, - back: "1", - }); - // Best-effort HA frontend hack: the history panel reads these query params - // on first render, so we navigate there directly and let the built-in page - // own the chart UI from that point. - window.history.pushState(null, "", `/history?${params.toString()}`); - window.dispatchEvent(new CustomEvent("location-changed", { detail: { replace: false } })); - return true; - } - - async _openRunHistory(runId, entityId) { - this._selectedRunId = runId; - const run = this._runs.find((item) => item.id === runId); - const binding = run?.bindings?.find((item) => item.sensor_id === entityId); - const fallbackContext = this._fallbackHistoryContext(run, binding, entityId); - const selector = `[data-sensor-tile][data-entity-id="${CSS.escape(entityId)}"]`; - const tile = this.shadowRoot.querySelector(selector); - tile?.classList.add("pulse"); - window.setTimeout(() => tile?.classList.remove("pulse"), 520); - - if (!this._hass || !binding?.id) { - this._historyInspector = { - run_id: runId, - entity_id: entityId, - binding_id: binding?.id || "", - loading: false, - error: "", - context: fallbackContext, - }; - this.render(); - return; - } - try { - const payload = await this._hass.callWS({ - type: "plantrun/get_run_binding_history_context", - run_id: runId, - binding_id: binding.id, - }); - const context = payload?.context || fallbackContext; - if (this._openNativeHistory(context)) return; - this._historyInspector = { - run_id: runId, - entity_id: entityId, - binding_id: binding.id, - loading: false, - error: "", - context, - }; - } catch (err) { - this._historyInspector = { - run_id: runId, - entity_id: entityId, - binding_id: binding.id, - loading: false, - error: err?.message || "Unable to load run-window history context.", - context: fallbackContext, - }; - } + confirmation_name: confirmation, + }, "Lauf dauerhaft gelöscht"); + if (!response) return; + this._deleteRunId = ""; + this._deleteConfirmation = ""; + this._screen = "archive"; this.render(); } - _refreshLiveSensorText() { - this.shadowRoot.querySelectorAll("[data-live-entity]").forEach((node) => { - node.textContent = this._entityState(node.dataset.liveEntity); - }); - } - - _clickSound() { - if (!this._sound) return; - try { - const Ctx = window.AudioContext || window.webkitAudioContext; - if (!Ctx) return; - this._audio = this._audio || new Ctx(); - const osc = this._audio.createOscillator(); - const gain = this._audio.createGain(); - gain.gain.setValueAtTime(0.0001, this._audio.currentTime); - gain.gain.exponentialRampToValueAtTime(0.035, this._audio.currentTime + 0.01); - gain.gain.exponentialRampToValueAtTime(0.0001, this._audio.currentTime + 0.075); - osc.frequency.value = 520; - osc.connect(gain).connect(this._audio.destination); - osc.start(); - osc.stop(this._audio.currentTime + 0.08); - } catch (_err) { - this._sound = false; - } - } - - _styles() { - return ` - :host { display:block; min-height:100%; color:var(--primary-text-color,#e8ece8); font-family:var(--primary-font-family, system-ui, sans-serif); position:relative; } - * { box-sizing:border-box; } - button, input, select, textarea { font:inherit; } - button { cursor:pointer; } - .app.theme-dark { - --primary-background-color:#111415; - --card-background-color:#1b1f20; - --primary-text-color:#edf2ec; - --secondary-text-color:#9ca69d; - --divider-color:#51605a; - --success-color:#55d66a; - --surface-strong:#232928; - --surface-soft:#1c2221; - --surface-raised:#28302e; - --border-strong:#6d7a73; - color-scheme:dark; - } - .app.theme-light { - --primary-background-color:#e6ede5; - --card-background-color:#f8fbf6; - --primary-text-color:#18211a; - --secondary-text-color:#516250; - --divider-color:#aab9aa; - --success-color:#2f9c4d; - --surface-strong:#ffffff; - --surface-soft:#eef3eb; - --surface-raised:#f3f7f1; - --border-strong:#97a997; - --hero-text:#102114; - --hero-muted:#274430; - color-scheme:light; - } - .shell { min-height:100vh; padding:18px; background: - radial-gradient(circle at 18% 0%, color-mix(in srgb, var(--success-color,#2fc46b) 18%, transparent), transparent 32%), - linear-gradient(180deg, color-mix(in srgb, var(--card-background-color,#171b1c) 92%, #123021), var(--primary-background-color,#111416)); } - .app.theme-light .shell { background: - radial-gradient(circle at 12% 0%, rgba(80, 170, 95, .18), transparent 34%), - radial-gradient(circle at 100% 0%, rgba(255,255,255,.7), transparent 28%), - linear-gradient(180deg, #f5f9f3 0%, var(--primary-background-color,#e6ede5) 56%, #dde8dc 100%); } - .topbar { display:grid; grid-template-columns:minmax(220px,1fr) auto minmax(220px,1fr); align-items:center; gap:16px; max-width:1480px; margin:0 auto 14px; } - .brand, .top-actions, nav, .hero-actions, .block-head, .inline-form, .sensor-head { display:flex; align-items:center; gap:10px; } - .brand { min-width:0; } - .brand-mark, .plant-mark { display:grid; place-items:center; width:42px; height:42px; border-radius:14px; background:color-mix(in srgb, var(--success-color,#31c76b) 18%, var(--card-background-color,#1b2020)); color:var(--success-color,#31c76b); box-shadow:inset 0 1px rgba(255,255,255,.16); overflow:hidden; } - .brand-mark svg { width:22px; height:22px; overflow:visible; } - .brand-mark .sprout-stem, .brand-mark .sprout-left, .brand-mark .sprout-right, .brand-mark .sprout-leaf { transform-origin:center; transition:transform .35s cubic-bezier(.2,.9,.2,1), opacity .25s ease; } - .brand:hover .brand-mark .sprout-left { transform:rotate(-12deg) translate(-1px, -1px); } - .brand:hover .brand-mark .sprout-right { transform:rotate(12deg) translate(1px, -1px); } - .brand:hover .brand-mark .sprout-stem { transform:translateY(-1px) scaleY(1.04); } - .brand:hover .brand-mark .sprout-leaf { transform:translateY(-1px) scale(1.04); } - .brand-mark .accent { opacity:.72; } - .brand strong { display:block; font-size:19px; } - .brand span:last-child, .hint, small, .run-row-main span, .eyebrow { color:var(--secondary-text-color,#98a29a); } - nav { justify-content:center; padding:4px; border-radius:999px; background:color-mix(in srgb, var(--card-background-color,#1f2424) 82%, transparent); border:1px solid color-mix(in srgb, var(--divider-color,#4b5551) 55%, transparent); } - nav button { border:0; border-radius:999px; padding:8px 16px; background:transparent; color:var(--secondary-text-color,#98a29a); text-transform:capitalize; } - nav button.active { color:var(--primary-text-color,#fff); background:color-mix(in srgb, var(--primary-text-color,#fff) 10%, transparent); } - .top-actions { justify-content:flex-end; } - main { max-width:1480px; margin:0 auto; display:grid; grid-template-columns:330px minmax(0,1fr); gap:14px; } - .sidebar, .detail, .panel-block, .modal { border:1px solid color-mix(in srgb, var(--divider-color,#4b5551) 55%, transparent); background:color-mix(in srgb, var(--card-background-color,#1c2121) 88%, transparent); box-shadow:0 18px 50px rgba(0,0,0,.18); backdrop-filter:blur(18px); } - .app.theme-light .sidebar, .app.theme-light .detail, .app.theme-light .panel-block, .app.theme-light .modal { background:rgba(255,255,255,.92); border-color:color-mix(in srgb, var(--border-strong,#97a997) 72%, white); } - .sidebar { min-height:calc(100vh - 100px); border-radius:26px; padding:10px; display:flex; flex-direction:column; gap:8px; } - .run-row { width:100%; border:0; display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:12px; text-align:left; padding:12px; border-radius:18px; background:transparent; color:inherit; transition:transform .18s ease, background .18s ease; } - .run-row:hover { transform:translateY(-1px); background:color-mix(in srgb, var(--primary-text-color,#fff) 7%, transparent); } - .run-row.selected { background:color-mix(in srgb, var(--success-color,#31c76b) 16%, transparent); } - .run-row-main { min-width:0; } - .run-row-main strong, .run-row-main span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; } - .stage-dot { width:12px; height:32px; border-radius:999px; background:#7fdb83; box-shadow:0 0 20px rgba(88,210,116,.45); } - .stage-dot.veg { background:#35b968; } .stage-dot.flower { background:#d9b45d; } .stage-dot.harvest { background:#b98d68; } - .ring { --progress:0; display:grid; place-items:center; width:48px; height:48px; border-radius:50%; font-size:11px; font-weight:700; background:conic-gradient(var(--success-color,#31c76b) calc(var(--progress) * 1%), color-mix(in srgb, var(--divider-color,#52605a) 50%, transparent) 0); position:relative; } - .ring:after { content:""; position:absolute; inset:5px; border-radius:inherit; background:var(--card-background-color,#1c2121); z-index:-0; } - .ring { isolation:isolate; } - .detail { min-height:calc(100vh - 100px); border-radius:30px; padding:14px; overflow:hidden; } - .hero { position:relative; min-height:245px; border-radius:24px; padding:24px; display:flex; justify-content:space-between; gap:20px; overflow:hidden; background:linear-gradient(135deg, color-mix(in srgb, var(--success-color,#31c76b) 20%, #101615), color-mix(in srgb, var(--card-background-color,#202524) 90%, #223928)); } - .hero.flower { background:linear-gradient(135deg, rgba(95,73,34,.72), color-mix(in srgb, var(--card-background-color,#202524) 92%, #2b2416)); } - .hero.has-image { background-image:linear-gradient(135deg, rgba(9,18,11,.72), rgba(12,25,15,.42) 52%, rgba(8,15,10,.78)), var(--hero-image); background-size:cover, cover; background-position:center, center; background-repeat:no-repeat, no-repeat; } - .hero.has-image.flower { background-image:linear-gradient(135deg, rgba(48,34,12,.68), rgba(67,52,23,.36) 52%, rgba(34,23,9,.74)), var(--hero-image); } - .app.theme-light .hero { background:linear-gradient(135deg, #d9efdc 0%, #edf7ee 48%, #e3efe2 100%); color:var(--hero-text,#102114); border:1px solid rgba(116, 149, 118, .28); box-shadow:inset 0 1px rgba(255,255,255,.9); } - .app.theme-light .hero.flower { background:linear-gradient(135deg, #f1e3c8 0%, #faf4e9 52%, #ecdfc1 100%); } - .app.theme-light .hero.has-image { background-image:linear-gradient(135deg, rgba(241,248,242,.72), rgba(230,241,232,.46) 48%, rgba(217,232,219,.8)), var(--hero-image); } - .app.theme-light .hero.has-image.flower { background-image:linear-gradient(135deg, rgba(248,242,233,.74), rgba(242,232,214,.48) 48%, rgba(233,220,193,.82)), var(--hero-image); } - .hero h1 { margin:8px 0; font-size:clamp(32px,4vw,64px); line-height:.95; letter-spacing:0; max-width:780px; } - .hero p { margin:0; color:color-mix(in srgb, var(--primary-text-color,#fff) 72%, transparent); font-size:16px; } - .app.theme-light .hero p, .app.theme-light .hero .eyebrow { color:var(--hero-muted,#274430); } - .hero-actions { align-self:flex-start; flex-wrap:wrap; justify-content:flex-end; z-index:1; } - .stage-glyph { position:absolute; right:18px; bottom:-34px; color:rgba(255,255,255,.09); --mdc-icon-size:210px; transform:rotate(-8deg); pointer-events:none; } - .app.theme-light .stage-glyph { color:rgba(41, 86, 51, .14); } - .stat-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin:12px 0; } - .stat-grid div { padding:15px 16px; border-radius:18px; background:color-mix(in srgb, var(--primary-text-color,#fff) 6%, transparent); } - .app.theme-light .stat-grid div { background:rgba(255,255,255,.78); border:1px solid rgba(126, 150, 127, .18); } - .stat-grid span { display:block; color:var(--secondary-text-color,#98a29a); font-size:12px; margin-bottom:4px; } - .stat-grid strong { font-size:20px; } - .content-grid { display:grid; grid-template-columns:1.15fr .85fr; gap:16px; align-items:start; margin-top:14px; } - .panel-block { border-radius:22px; padding:18px; } - .panel-block h2, .modal h2 { margin:2px 0 0; font-size:18px; } - .eyebrow { text-transform:uppercase; letter-spacing:.12em; font-size:11px; font-weight:800; } - .sensor-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(185px,1fr)); gap:10px; margin-top:10px; } - .sensor-tile { border-radius:20px; padding:14px; background:linear-gradient(180deg, color-mix(in srgb, var(--primary-text-color,#fff) 7%, transparent), color-mix(in srgb, var(--primary-text-color,#fff) 4%, transparent)); border:1px solid color-mix(in srgb, var(--divider-color,#52605a) 38%, transparent); transition:transform .18s ease, border-color .18s ease, box-shadow .18s ease; user-select:none; touch-action:manipulation; box-shadow:inset 0 1px rgba(255,255,255,.06); } - .app.theme-light .sensor-tile { background:linear-gradient(180deg, #ffffff 0%, var(--surface-raised,#f3f7f1) 100%); border-color:rgba(126, 150, 127, .26); box-shadow:0 10px 24px rgba(40, 69, 44, .08), inset 0 1px rgba(255,255,255,.95); } - .sensor-tile:hover, .sensor-tile.pulse { transform:translateY(-2px); border-color:color-mix(in srgb, var(--success-color,#31c76b) 52%, transparent); box-shadow:0 14px 28px rgba(0,0,0,.16), inset 0 1px rgba(255,255,255,.08); } - .app.theme-light .sensor-tile:hover, .app.theme-light .sensor-tile.pulse { box-shadow:0 16px 30px rgba(40, 69, 44, .13), inset 0 1px rgba(255,255,255,.95); } - .metric-badge { display:grid; place-items:center; width:34px; height:34px; border-radius:12px; background:color-mix(in srgb, var(--success-color,#31c76b) 16%, transparent); color:var(--success-color,#31c76b); } - .sensor-tile strong, .sensor-tile small, .sensor-state { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - .sensor-state { margin-top:8px; font-size:24px; font-weight:800; } - .sensor-meta { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:10px; color:var(--secondary-text-color,#98a29a); font-size:12px; } - .sensor-meta span:last-child { display:inline-flex; align-items:center; gap:5px; } - .spark { height:54px; display:flex; align-items:end; gap:4px; margin-top:12px; } - .spark span { flex:1; min-width:4px; border-radius:999px 999px 4px 4px; background:linear-gradient(180deg, var(--success-color,#31c76b), rgba(49,199,107,.22)); } - .phase-list, .binding-editor { display:grid; gap:12px; } - .note-list { display:grid; gap:14px; margin-top:10px; } - .phase-stepper { display:grid; gap:10px; margin-top:6px; } - .phase-step { width:100%; display:grid; grid-template-columns:38px minmax(0,1fr); gap:12px; align-items:center; padding:12px 14px; border-radius:18px; border:1px solid color-mix(in srgb, var(--divider-color,#52605a) 45%, transparent); background:color-mix(in srgb, var(--primary-text-color,#fff) 5%, transparent); color:inherit; text-align:left; } - .app.theme-light .phase-step, .app.theme-light .note { border-color:rgba(126, 150, 127, .24); box-shadow:0 6px 16px rgba(40, 69, 44, .05); } - .phase-step span { display:grid; place-items:center; width:38px; height:38px; border-radius:50%; font-weight:800; background:color-mix(in srgb, var(--primary-text-color,#fff) 9%, transparent); } - .app.theme-light .phase-step span { background:#eef4ec; color:#294232; } - .phase-step.done span, .phase-step.current span { background:color-mix(in srgb, var(--success-color,#31c76b) 22%, transparent); color:var(--success-color,#31c76b); } - .phase-step.current { border-color:color-mix(in srgb, var(--success-color,#31c76b) 45%, transparent); box-shadow:0 0 0 1px color-mix(in srgb, var(--success-color,#31c76b) 24%, transparent); } - .phase-step small { display:block; margin-top:3px; color:var(--secondary-text-color,#98a29a); } - .note { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:14px; align-items:start; padding:18px 18px 16px; border-radius:22px; background:color-mix(in srgb, var(--primary-text-color,#fff) 5%, transparent); } - .note-copy { display:grid; gap:10px; } - .note p { margin:0; line-height:1.55; white-space:pre-wrap; } - .note small { display:block; } - .note-actions { display:flex; gap:8px; } - .notes-block { grid-column:1 / -1; margin-top:4px; } - .empty-panel, .empty-detail, .empty-inline { display:grid; place-items:center; align-content:center; gap:12px; min-height:220px; text-align:center; color:var(--secondary-text-color,#98a29a); padding:22px; } - .empty-inline { min-height:130px; border:1px dashed color-mix(in srgb, var(--divider-color,#52605a) 60%, transparent); border-radius:18px; } - button.primary, button.ghost, .icon-button { border:1px solid color-mix(in srgb, var(--divider-color,#52605a) 55%, transparent); min-height:38px; border-radius:14px; display:inline-flex; align-items:center; justify-content:center; gap:8px; color:inherit; transition:transform .16s ease, background .16s ease, border-color .16s ease, box-shadow .16s ease; } - button.primary { background:linear-gradient(180deg, color-mix(in srgb, var(--success-color,#31c76b) 88%, white 12%), var(--success-color,#31c76b)); color:#07110b; border-color:transparent; font-weight:800; padding:0 15px; box-shadow:0 10px 24px rgba(49,199,107,.22); } - button.ghost { background:color-mix(in srgb, var(--primary-text-color,#fff) 7%, transparent); padding:0 13px; } - .icon-button { width:38px; background:color-mix(in srgb, var(--primary-text-color,#fff) 7%, transparent); padding:0; } - .app.theme-light button.ghost, .app.theme-light .icon-button, .app.theme-light input, .app.theme-light select, .app.theme-light textarea, .app.theme-light .history-row, .app.theme-light .history-window-pill, .app.theme-light .phase-step, .app.theme-light .note, .app.theme-light .run-row:hover { background:var(--surface-strong,#fff); } - .app.theme-light button.ghost, .app.theme-light .icon-button, .app.theme-light input, .app.theme-light select, .app.theme-light textarea { border-color:rgba(126, 150, 127, .32); color:var(--primary-text-color,#18211a); box-shadow:0 4px 14px rgba(40, 69, 44, .06); } - .app.theme-light .hero-actions button.ghost, .app.theme-light .hero-actions .icon-button { background:rgba(255,255,255,.86); border-color:rgba(116, 149, 118, .34); } - .app.theme-light nav { background:var(--surface-soft,#edf3ec); } - .app.theme-light nav button { color:#41523f; } - .app.theme-light nav button.active, .app.theme-light .run-row.selected { background:color-mix(in srgb, var(--success-color,#41c85f) 18%, var(--surface-strong,#fff)); color:#19341f; } - .app.theme-light .panel-block, .app.theme-light .detail, .app.theme-light .sidebar, .app.theme-light .modal { box-shadow:0 14px 34px rgba(44,70,51,.08); } - button:hover { transform:translateY(-1px); } - button:disabled { opacity:.45; cursor:not-allowed; transform:none; } - .danger { color:var(--error-color,#ef5350); } - .animated ha-icon { transition:transform .28s cubic-bezier(.2,.8,.2,1); } - .animated:hover ha-icon { transform:rotate(-18deg) scale(1.08); } - input, select, textarea { width:100%; border:1px solid color-mix(in srgb, var(--divider-color,#52605a) 55%, transparent); border-radius:14px; min-height:42px; padding:10px 12px; background:var(--surface-strong, color-mix(in srgb, var(--primary-text-color,#fff) 7%, transparent)); color:var(--primary-text-color,#fff); outline:none; } - select, option, optgroup { background:var(--surface-strong,#232928); color:var(--primary-text-color,#edf2ec); } - select:hover, textarea:hover, input:hover { border-color:color-mix(in srgb, var(--success-color,#31c76b) 28%, var(--divider-color,#52605a)); } - textarea { min-height:90px; resize:vertical; } - input:focus, select:focus, textarea:focus { border-color:var(--success-color,#31c76b); box-shadow:0 0 0 3px color-mix(in srgb, var(--success-color,#31c76b) 18%, transparent); } - .overlay { position:absolute; inset:0; z-index:20; display:grid; place-items:center; padding:22px; } - .overlay-backdrop { position:absolute; inset:0; border:0; background:rgba(0,0,0,.34); backdrop-filter:blur(8px); } - .modal { width:min(760px,100%); max-height:min(820px,calc(100vh - 44px)); overflow:auto; border-radius:26px; padding:18px; } - .modal.compact { width:min(560px,100%); } - .modal, .detail, .sidebar, .panel-block { position:relative; z-index:1; } - .modal header, .modal footer { display:flex; align-items:center; justify-content:space-between; gap:12px; } - .modal footer { margin-top:16px; justify-content:flex-end; } - .form-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; margin-top:16px; } - label { display:grid; gap:7px; color:var(--secondary-text-color,#98a29a); font-size:13px; font-weight:700; } - label input, label select, label textarea, label .ha-entity-selector { color:var(--primary-text-color,#fff); font-weight:500; } - .app.theme-light label input, .app.theme-light label select, .app.theme-light label textarea, .app.theme-light label .ha-entity-selector { color:var(--primary-text-color,#18211a); } - .field-hint { margin:6px 2px 0; color:var(--secondary-text-color,#98a29a); font-size:12px; font-weight:600; } - .field-hint.warning { color:#f4b25e; } - label.wide, .search-field { grid-column:1 / -1; } - .suggestions { display:grid; gap:6px; } - .suggestion-state { padding:10px 12px; border-radius:14px; background:color-mix(in srgb, var(--success-color,#31c76b) 10%, transparent); color:var(--secondary-text-color,#98a29a); font-size:13px; font-weight:700; } - .suggestions button { border:0; border-radius:14px; padding:10px 12px; background:color-mix(in srgb, var(--success-color,#31c76b) 12%, transparent); color:inherit; text-align:left; display:grid; gap:2px; transition:transform .14s ease, background .14s ease; } - .suggestions button:hover { transform:translateY(-1px); background:color-mix(in srgb, var(--success-color,#31c76b) 18%, transparent); } - .binding-edit-row { display:grid; grid-template-columns:160px minmax(0,1fr) 38px; gap:10px; align-items:center; } - .confirm-copy { margin:16px 0 0; color:var(--secondary-text-color,#98a29a); line-height:1.5; } - .history-modal { display:grid; gap:14px; } - .history-summary { display:grid; gap:8px; } - .phase-confirm-modal { display:grid; gap:16px; } - .history-summary p { margin:0; } - .history-window-pill { display:flex; align-items:center; flex-wrap:wrap; gap:8px; padding:10px 12px; border-radius:14px; background:color-mix(in srgb, var(--primary-text-color,#fff) 6%, transparent); } - .app.theme-light .history-window-pill { border:1px solid rgba(126, 150, 127, .22); } - .history-window-pill span { display:inline-flex; align-items:center; gap:6px; } - .history-status { display:inline-flex; align-items:center; width:max-content; max-width:100%; padding:7px 10px; border-radius:999px; font-size:12px; font-weight:700; } - .history-status.bound { background:rgba(49,199,107,.12); color:var(--success-color,#31c76b); } - .history-status.orphaned { background:rgba(255,167,38,.14); color:#f4b25e; } - .error-text { color:#f4b25e; } - .history-list { display:grid; gap:8px; max-height:280px; overflow:auto; } - .history-modal footer { display:flex; flex-wrap:wrap; gap:10px; } - .history-row { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:11px 12px; border-radius:14px; background:color-mix(in srgb, var(--primary-text-color,#fff) 5%, transparent); } - .entity-fallback { display:none; } - .entity-fallback, .ha-entity-selector { background:var(--surface-strong,#232928); color:var(--primary-text-color,#edf2ec); } - .app.theme-light .entity-fallback, .app.theme-light .ha-entity-selector, .app.theme-light select, .app.theme-light option, .app.theme-light optgroup { background:var(--surface-strong,#fff); color:var(--primary-text-color,#18211a); } - .ha-entity-selector:not(:defined) + .entity-fallback { display:block; } - .ha-entity-selector:not(:defined) { display:none; } - @media (max-width: 960px) { - .shell { padding:10px; } - .topbar, main, .content-grid, .stat-grid { grid-template-columns:1fr; } - nav { order:3; width:100%; } - .top-actions { justify-content:flex-start; flex-wrap:wrap; } - .sidebar, .detail { min-height:auto; } - .hero { min-height:220px; flex-direction:column; } - .form-grid, .binding-edit-row { grid-template-columns:1fr; } - } - `; + render() { + this.shadowRoot.innerHTML = `${this._renderShell()}`; } } + Object.assign( + PlantRunDashboardPanel.prototype, + createPanelViewMethods(), + createPanelDialogMethods(), + ); customElements.define(TAG, PlantRunDashboardPanel); })(); diff --git a/custom_components/plantrun/www/prototype/README.md b/custom_components/plantrun/www/prototype/README.md new file mode 100644 index 0000000..477932d --- /dev/null +++ b/custom_components/plantrun/www/prototype/README.md @@ -0,0 +1,96 @@ +# PlantRun UI prototype + +This folder contains throwaway UI work. It is not production PlantRun code and does not connect to Home Assistant or persist data. + +From the repository root, run: + +```powershell +python -m http.server 8766 --bind 127.0.0.1 --directory custom_components/plantrun/www/prototype +``` + +For the complete review, open: + +```text +http://127.0.0.1:8766/plantrun-review-prototype.html?view=map&variant=A&lang=de&theme=dark +``` + +The review map links back to the existing tent, plant, and creation prototypes. +It also covers every remaining product surface: + +- journal and quick capture; +- stage management; +- sensor assignment; +- strain lookup and manual fallback; +- plant editing and image ownership; +- harvest and run completion; +- archive, completed-run reading, and comparison; +- settings; +- empty, loading, partial, and error states; +- the Home Assistant-style Lovelace companion; +- later door-contact and follow-up ideas. + +Every review page has structural variants `A`, `B`, and `C`. Use the floating +arrows or change `variant=` in the URL. Start with +[REVIEW_CHECKLIST.md](./REVIEW_CHECKLIST.md) when recording feedback. + +The first journal set and its form-heavy replacement were rejected. The current +journal revision removes plant cards, action-chip rows, the oversized editor, +and the permanent sensor grid. It compares `A` Verlauf + Editor, `B` +Direkteingabe, and `C` Verlauf zuerst. `C` opens its compact editor in a side +drawer only when requested and is the selected journal direction. `A` and `B` +are retired. Stage management A is selected. Its lifecycle rail +and plan rows now select any target stage directly; the separate next-stage +dropdown is gone, and the drag handles reorder the displayed plan. + +The Strain finder was rebuilt as live search rather than a fixed card picker. +Its current variants are `A` Treffer + Vorschau, `B` Inline-Vorschau, and `C` +Suchfokus. They use a broader disposable catalog to test live filtering and +manual fallback. Production must extend the authenticated SeedFinder adapter for +search across all Breeders instead of shipping that catalog. + +Then open the tent overview: + +```text +http://127.0.0.1:8766/plantrun-ui-prototype.html?variant=A&view=overview&lang=de&theme=dark +``` + +The floating bottom control switches among the three overview structures: + +- `A`: Photo focus +- `B`: Garden workspace +- `C`: Daily garden + +Open the selected plant-workspace direction: + +```text +http://127.0.0.1:8766/plantrun-ui-prototype.html?variant=A&detail=B&view=plant&plant=tangerine&metric=moisture&lang=de&theme=dark&rev=13 +``` + +The plant workspace also has three comparison states. `detail=B`, Clean +development, revision 13 is the current working direction. Use the environment +rows to switch the main Recorder chart between soil moisture, temperature, +humidity, and light. + +Open the selected new-run flow: + +```text +http://127.0.0.1:8766/plantrun-ui-prototype.html?variant=A&detail=B&view=create&create=A&step=1&lang=de&theme=dark&rev=15 +``` + +`create=A` is selected. It creates one plant and one run in three guided steps. +To add the next plant, start the flow again. The retired one-page and two-run +variants are no longer selectable. + +The first step tests breeder and Strain search, result selection, no-result +handling, and manual fallback. Because this file is standalone, its broader +catalog is clearly marked as sample data. The HACS implementation must call the +existing authenticated `plantrun/search_cultivar` websocket instead of shipping +that catalog. Plant sensors may remain unassigned, and every imported or manual +value remains editable. + +Dark mode is the default. Add `&theme=light` to the URL or use the sun button to inspect the light version. The two cultivar images are temporary breeder references. A real user photo would replace the displayed breeder image once one exists. + +The left and right arrow keys switch variants on comparison pages unless an +input is focused. The selected creation flow has no variant switcher. The DE/EN +control changes language. Entries, creation results, sensor choices, and other +changes exist only in memory and disappear after a reload. diff --git a/custom_components/plantrun/www/prototype/REVIEW_CHECKLIST.md b/custom_components/plantrun/www/prototype/REVIEW_CHECKLIST.md new file mode 100644 index 0000000..77b3b62 --- /dev/null +++ b/custom_components/plantrun/www/prototype/REVIEW_CHECKLIST.md @@ -0,0 +1,46 @@ +# PlantRun prototype review + +Start at: + +```text +http://127.0.0.1:8766/plantrun-review-prototype.html?view=map&variant=A&lang=de&theme=dark +``` + +Use the arrows at the bottom to switch A, B, and C. Feedback can mix parts from +different variants. + +All PlantRun review screens now share the selected rounded shape system: 24 px +outer work areas, 18 px grouped content, and 12 px controls. Rows and tables stay +flat inside those containers. Flag any screen that still looks like a separate +square-box application. + +## Already prepared + +- Tent overview: Does Photo focus still work as the opening view? +- Plant workspace: Keep Clean development revision 13 or change anything? +- Creation flow: Guided A is selected. Each pass creates one plant and one run; + breeder + Strain search leads, with a complete manual fallback. +- Journal: `Verlauf zuerst` C is selected. History opens first and `Neuer + Eintrag` reveals the compact side editor. + +## Remaining flows + +1. Stages: Structure A is selected. Check the revised workflow: click any target + phase, review the switch time, confirm it, and drag phase handles to reorder + the display without limiting transitions. +2. Sensors: Which version best explains shared tent values and plant ownership? +3. Strain: Compare A `Treffer + Vorschau`, B `Inline-Vorschau`, and C + `Suchfokus`. Does typing feel like a live web search, is the selected result + preview clear, and is manual entry still easy to find? +4. Editing: Which image and base-data treatment feels least cumbersome? +5. Harvest: Should completion be guided, one page, or a focused milestone? +6. Archive: Image gallery, data table, or comparison-first selection? +7. Completed run: Which version makes old development worth revisiting? +8. Comparison: Side by side, overlaid development, or differences only? +9. Settings: Which structure stays understandable without becoming an admin page? +10. System states: Which failures need more or less explanation? +11. Lovelace: Tent glance, selected plant, or action-first card? +12. Later ideas: Which parts of the door prompt and follow-up flow should survive? + +Also note anything that still looks generated, uses too many boxes, wastes space, +or puts the wrong information first. diff --git a/custom_components/plantrun/www/prototype/assets/diesel-auto-breeder.jpg b/custom_components/plantrun/www/prototype/assets/diesel-auto-breeder.jpg new file mode 100644 index 0000000..b637f61 Binary files /dev/null and b/custom_components/plantrun/www/prototype/assets/diesel-auto-breeder.jpg differ diff --git a/custom_components/plantrun/www/prototype/assets/diesel-auto.png b/custom_components/plantrun/www/prototype/assets/diesel-auto.png new file mode 100644 index 0000000..5929331 Binary files /dev/null and b/custom_components/plantrun/www/prototype/assets/diesel-auto.png differ diff --git a/custom_components/plantrun/www/prototype/assets/tangerine-dream-auto.png b/custom_components/plantrun/www/prototype/assets/tangerine-dream-auto.png new file mode 100644 index 0000000..b0f0202 Binary files /dev/null and b/custom_components/plantrun/www/prototype/assets/tangerine-dream-auto.png differ diff --git a/custom_components/plantrun/www/prototype/assets/tangerine-dream-breeder.jpg b/custom_components/plantrun/www/prototype/assets/tangerine-dream-breeder.jpg new file mode 100644 index 0000000..57c9165 Binary files /dev/null and b/custom_components/plantrun/www/prototype/assets/tangerine-dream-breeder.jpg differ diff --git a/custom_components/plantrun/www/prototype/plantrun-review-prototype.html b/custom_components/plantrun/www/prototype/plantrun-review-prototype.html new file mode 100644 index 0000000..ff9adec --- /dev/null +++ b/custom_components/plantrun/www/prototype/plantrun-review-prototype.html @@ -0,0 +1,774 @@ + + + + + + + PlantRun Review-Prototyp + + + + +
+
+ + + diff --git a/custom_components/plantrun/www/prototype/plantrun-ui-prototype.html b/custom_components/plantrun/www/prototype/plantrun-ui-prototype.html new file mode 100644 index 0000000..a86a0fd --- /dev/null +++ b/custom_components/plantrun/www/prototype/plantrun-ui-prototype.html @@ -0,0 +1,1048 @@ + + + + + + + PlantRun UI-Prototyp + + + + +
+ + + diff --git a/docs/ACCEPTANCE_RUN_INTAKE.md b/docs/ACCEPTANCE_RUN_INTAKE.md new file mode 100644 index 0000000..1bd1bab --- /dev/null +++ b/docs/ACCEPTANCE_RUN_INTAKE.md @@ -0,0 +1,66 @@ +# Acceptance run intake + +This ledger preserves real cultivation facts until the rebuilt PlantRun can import them. Times use Europe/Berlin local time. Approximate values remain marked as approximate. + +The two Runs in this ledger are durable records. Completion moves them to the Archive without removing recorded details. Only an explicit, warned Permanent Deletion may remove them. + +## Shared Tent setup + +- Tent: Growzelt +- Cycle start date: 2026-08-25 +- Medium: All-Mix soil +- Container: 7 L final container per Plant +- Planting method: dry seeds placed directly from the package into the soil in the final container +- Initial watering: fresh soil wetted before sowing, then a small unmeasured amount sprinkled over each seed +- Light schedule: on from 20:00 until 14:00 the following day, 18 hours on and 6 hours off +- First scheduled lights-on: 2026-08-25 at 20:00 + +## Run 1 + +- Plant: Diesel Auto +- Strain: Diesel Auto +- Breeder: Royal Queen Seeds +- Planted at: approximately 2026-08-25 16:00 +- Container: 7 L final container +- Medium: All-Mix soil +- Initial Stage: Germination + +## Run 2 + +- Plant: Tangerine Dream Auto +- Strain: Tangerine Dream Auto +- Breeder: Zamnesia +- Planted at: approximately 2026-08-25 16:00 +- Container: 7 L final container +- Medium: All-Mix soil +- Initial Stage: Germination + +## Initial Journal Entries + +### 2026-08-25 at approximately 16:00 + +- Target: Diesel Auto +- Type: Planting +- Entry: Dry seed placed directly from the package into All-Mix soil in a 7 L final container. + +- Target: Diesel Auto +- Type: Water +- Entry: Wetted the fresh soil, then sprinkled a little additional water over the seed. Amount not measured. + +- Target: Tangerine Dream Auto +- Type: Planting +- Entry: Dry seed placed directly from the package into All-Mix soil in a 7 L final container. + +- Target: Tangerine Dream Auto +- Type: Water +- Entry: Wetted the fresh soil, then sprinkled a little additional water over the seed. Amount not measured. + +### 2026-08-25 at 20:00 + +- Target: shared Tent +- Type: Lighting +- Entry: First scheduled lights-on. Light schedule is 20:00 to 14:00, 18/6. + +## Legacy data decision + +- The existing Amnesia and Purple Cookie Kush records will be backed up but left out of the rebuilt PlantRun. diff --git a/docs/PRODUCT_DISCOVERY.md b/docs/PRODUCT_DISCOVERY.md new file mode 100644 index 0000000..afcf48e --- /dev/null +++ b/docs/PRODUCT_DISCOVERY.md @@ -0,0 +1,388 @@ +# PlantRun product discovery + +## Status + +Discovery is active. This document records evidence, settled product direction, and ideas that should survive the interview. It is not an implementation plan. + +The acceptance cultivation cycle began on August 25, 2026. The earlier +Tangerine Dream Auto and Diesel Auto scenario below was prototype input, not a +confirmed description of the new live cycle. Capture the current plants and +events before replacing the installed integration. + +## Why the current product failed + +PlantRun has repeatedly changed its interface without settling the daily habit it must support. The current product is better at maintaining run metadata than recording or understanding what happened around the plants. + +The existing Home Assistant integration work remains useful, especially sensor bindings, Recorder history, cultivar lookup, and storage conventions. The current frontend and stored test data do not need to survive the rebuild. + +## Observed record history + +The user's RemNote history contains dated cultivation entries across several years. Earlier entries contain much more operational detail than recent entries. + +Observed entry types include: + +- planting named varieties; +- watering multiple plants with a quantity and named product; +- spraying for pests and defoliating; +- changing the light schedule to begin flowering; +- recording temperature and humidity conditions; +- noting plant appearance, smell, growth, and water demand; +- comparing plants and their positions under different lights; +- inspecting trichomes and using the result to plan darkness and harvest; +- beginning a darkness period; +- harvesting and hanging plants to dry; +- removing dried plants and checking remaining moisture. + +The records mix quick actions, observations, measurements, plans, and lifecycle changes. Some entries apply to the whole tent, some to several plants, and some to one named variety or plant. + +## Settled direction + +- The tent is the user's main mental starting point. +- A tent persists across an unlimited number of growing records. PlantRun must allow any combination of plant lifecycle stages inside it without imposing a synchronized tent stage. +- A tent contains individual plants whose variety, condition, care, appearance, and lifecycle may need separate tracking. +- One run always records exactly one plant. A tent may contain any number of overlapping runs at unrelated stages. +- Each run owns its stage history and estimated harvest window. The tent has no stage of its own. +- A run becomes a durable historical record when cultivation finishes. Finishing never deletes the run. +- Archiving changes where a run appears but preserves its stages, Journal Entries, sensor bindings, images, sourced Strain data, and harvest details. +- A user may permanently delete a run from a separate danger area. PlantRun must explain that the action cannot be undone, identify the PlantRun-owned data that will disappear, require the exact run name as confirmation, and use an explicit `Delete permanently` action. Home Assistant Recorder data remains owned by Home Assistant and is not deleted with the run. +- Harvest ends the cultivation sensor window. Later drying or curing entries may remain attached as post-harvest history without extending that window. +- Completed runs remain editable. PlantRun should not impose a read-only archive state. +- Journal entries start as free text and may carry optional structured details such as targets, quantities, products, measurements, or follow-up plans. +- Existing Home Assistant sensor values and Recorder history are the strongest reason for PlantRun to live in Home Assistant. +- The primary access point should work wherever the user's Home Assistant is available. Desktop is the current common capture device. +- The product should eventually be publishable through HACS, but the first success test is whether its owner uses it for a real cultivation cycle. +- The first version is cannabis-capable and may use cannabis-specific data providers. Its visual language should still look like a refined plant application rather than explicit cannabis branding. +- Existing PlantRun records may be overwritten. The rejected UI and its data model must not constrain the rebuild. +- Harvest estimates belong to individual plants. Plants in the same tent may have different estimates and finish at different times. +- The initial fast journal actions are Water, Change stage, Inspect, and Harvest. General free text remains equally prominent. +- Water entries must work without a measured amount. Quantity and product are optional details, and a qualitative entry such as lightly sprinkling a seed is complete on its own. +- PlantRun should show phase-specific target ranges beside sensor values and Recorder history. Every assessment must pair color with text, expose the target range, and remain editable rather than presenting a hidden universal rule. +- New runs start with Seedling, Vegetative, Flowering, and Harvested selected. PlantRun also presents other available stages and permits custom stages. +- The owner's established method for automatic plants is to sow each dry seed directly from the package into soil in its final container. PlantRun must support Germination as the initial Stage and must not require a separate germination workflow. +- The first usable version should provide a full sidebar application and a compact Lovelace companion card. Other entry points can follow later. +- The current real cultivation cycle is the acceptance run. The user intends to record it in the rebuilt PlantRun and refine the product from that use. + +## Current Home Assistant setup + +The existing manual tent dashboard already separates shared conditions from plant-specific readings. + +Tent-level information includes: + +- average temperature derived from multiple temperature sensors; +- average relative humidity derived from multiple humidity sensors; +- illuminance in lux; +- grow-light state; +- tent-door contact state; +- accumulated energy use. + +Plant-level information currently comes from two soil sensors: + +- soil moisture for each plant; +- temperature readings; +- conductivity for one plant sensor; +- another humidity reading. + +PlantRun should use the Home Assistant aggregate entities the user already created for the primary tent temperature and humidity. Individual source entities may remain available for diagnosis without competing with the aggregate on the opening view. + +## First real test scenario + +The next cultivation starts on August 21, 2026, with two independent runs in the same tent: + +- Tangerine Dream Auto, breeder Zamnesia; +- Diesel Auto, breeder Royal Queen Seeds. + +Both runs use an 18/6 light schedule throughout. Soil-sensor assignment is not decided yet. + +Official breeder timing for the prototype: + +- Tangerine Dream Autoflower: 70 to 77 days from seed to harvest, described by Zamnesia as about 11 weeks after germination; +- Diesel Auto: 90 to 95 days after germination, with a separate flowering duration of 55 to 65 days. + +PlantRun must preserve a duration's range, meaning, start event, source, and original wording. It must not treat every provider number as flowering time or reduce a range to one midpoint. + +The user will use PlantRun as the only cultivation journal during this test and refine the product from real use. + +## Prototype decision + +Before rebuilding the HACS integration, create a disposable read-only HTML prototype to answer the UI design question. It should contain three structurally different variants that use the first real test scenario and can be switched in place. + +The three revised prototype directions are Photo focus, Garden workspace, and Daily garden. The prototype is German-first and includes English from the start through a separate translation catalog rather than embedded labels. + +The interface includes a visible DE/EN flag switch. Language support is part of the prototype rather than a later retrofit. + +Desktop controls the first design. The same prototype must remain usable at phone width without reducing the mobile experience to one long stack. + +The prototype should feel organized and clean while retaining substantial cultivation detail. Original plant photography may be generated where real images are unavailable. + +### First visual review + +The first three variants were rejected on August 20, 2026 because they looked AI-generated. The problem was the overall visual grammar, not one color or component. It combined oversized rounded panels, pill controls, marketing-style headlines, soft decorative charts, excessive empty space, and generated plant photography presented as if it were current run evidence. + +The next pass must be practical and specific to PlantRun: + +- prefer flat lists, tables, definition rows, and explicit timestamps; +- use ordinary interface typography instead of large editorial headlines; +- show the hierarchy through borders, alignment, and spacing rather than nested cards; +- avoid decorative charts and invented status copy; +- do not show a mature generated plant as the photo of a run that starts today; +- reserve photography for real photos the user adds, with an honest empty state beforehand; +- keep controls rectangular and compact where that suits the Home Assistant context. + +The generated prototype images remain disposable assets and are not approved product art. + +### Approved visual contract + +The user approved the following contract on August 20, 2026: + +- the full PlantRun dashboard has its own design and does not imitate Home Assistant; +- the future compact Lovelace card follows Home Assistant's native visual language; +- plants dominate the opening view; +- dark mode is the default, with an in-app light switch and comfortable green-black surfaces; +- desktop uses a narrow navigation rail or a transferred desktop equivalent; +- mobile uses the visual language of the supplied plant-care references, including photo-led cards and bottom navigation; +- a fetched breeder image represents the cultivar until the user adds a real photo; +- once a user photo exists, it becomes the displayed plant image while the breeder image remains stored and deletable; +- the opening view shows only status, attention, and the next estimate; detailed sensor and journal data belongs in the plant view; +- every plant opens into its own full workspace; +- the direct card action defaults to creating a journal entry. + +The disposable prototype tests three structures rather than three color skins: + +- Photo focus gives both runs equal, image-led cards; +- Garden workspace makes one selected run dominant and keeps the other close for switching; +- Daily garden pairs a plant gallery with today's work feed. + +The locally cached Zamnesia and Royal Queen Seeds product images are prototype references. They are not approved distributable HACS assets. + +### Photo focus review + +The user accepted the information order of Photo focus but rejected its first visual treatment. Large enclosed cards, bright app-green, dark elevated surfaces, and dashboard-style sensor pills still looked AI-generated and did not match the supplied references. + +Photo focus therefore uses the reference's open composition more literally. Plant columns sit directly on the page, sensor values share one restrained strip, details use rules instead of nested tiles, and the light design is the source for the darker adaptation. The default dark theme uses muted botanical green rather than neon lime. + +### Plant workspace review + +The user selected the `Clean development` plant workspace as the working direction on August 20, 2026. The accepted revision is `detail=B`, revision 13 in the disposable prototype. + +The workspace puts recorded facts and sensor development ahead of prose notes: + +- the breeder image and plant identity anchor the top of the page; +- one lifecycle line shows the current day, current stage, future stages, and estimated harvest window; +- one large Recorder chart explains the selected metric with its current value, change, target range, latest related event, minimum, average, and maximum; +- the environment panel switches the chart between soil moisture, temperature, humidity, and light; +- segmented indicators use green or amber together with status text, never color alone; +- permanent facts form one aligned strip beneath the chart rather than a shorter unmatched side column; +- the latest journal entry stays below the visual development data. + +The chart must earn its space. A line without a target band, event context, summary values, or comparison controls was rejected as too empty and uninformative. + +### Cross-screen shape language + +The review on August 21, 2026 exposed a mismatch between the selected tent, +plant, and creation directions and the remaining review deck. The selected +screens use rounded outer working surfaces, while the deck still used joined, +square table boxes. + +The disposable prototypes now share one shape system: + +- major panels and visual work areas use a 24 px radius; +- grouped cards and image containers use an 18 px radius; +- buttons, fields, compact controls, and small images use a 12 px radius; +- rows, timelines, tables, and metric divisions stay flat inside those rounded + containers instead of becoming separate floating cards. + +Variants may disagree about layout and information order. They must not look as +if they belong to different applications. The Lovelace preview remains an +exception and follows Home Assistant card styling. + +### New-run flow review + +On August 21, 2026, the guided `create=A` flow was selected. The one-page and +two-run alternatives are retired. Opening the flow always creates exactly one +plant and one independent run. A second plant is created by starting the same +flow again; there is no batch creation path. + +The selected flow has three steps: + +1. search for a breeder and Strain, or enter both manually; set the optional + nickname and planting date; +2. review or change the default phases and optionally assign plant and shared + tent sensors; +3. review and create the single run. + +The production build must reuse the authenticated Home Assistant websocket +contract `plantrun/search_cultivar` and its existing SeedFinder matching rather +than shipping a fixed strain list. The disposable HTML contains a broader sample +catalog only so the search, selection, empty-result, and manual-fallback +interactions can be reviewed without Home Assistant. Those entries are not a +production strain database. + +The selected result may provide a breeder image and sourced duration range. The +image remains the visual fallback until the user adds a plant photo. Duration +and the derived harvest window remain editable. Missing results, missing timing +data, or provider failure never block creation. + +### Strain-finder review + +The card-style Strain finder was rejected on August 21, 2026. The desired +interaction is closer to a web search: one prominent query, live results while +typing, an optional Breeder filter, and a detailed preview of the highlighted +result. Selecting a result only changes the preview. PlantRun imports the data +only after explicit confirmation. + +The replacement prototype compares three search-led structures: + +- variant A keeps a Google-like result list beside a persistent detail preview; +- variant B expands the selected preview inside the result stream; +- variant C leaves the result list unobstructed and opens the preview in a side + drawer. + +The result list is not a fixed production catalog. It is disposable sample data +used to test typing, filtering, selection, no-result handling, and manual +fallback. The current authenticated `plantrun/search_cultivar` websocket already +returns up to five scored SeedFinder matches, but it requires a Breeder before +searching. Supporting the desired search across all Breeders therefore needs a +backend/provider extension or index. The production preview must distinguish +fields returned by the search from detail-page enrichment and must never invent +an image or duration meaning. + +### Journal review + +The first journal alternatives were rejected on August 21, 2026. Their main +problems were not missing decoration but weak task flow: the capture control was +too small, the table text was difficult to scan, filters mixed plant and event +type into one strip, panel padding left large dead areas, and the relationship +between the entry, its timestamp, and sensor context was unclear. + +The following form-heavy revision was also rejected. It exposed plant cards, +five action chips, a large textarea, separate date and time fields, and all +attached sensor values at once. The result felt elementary, badly spaced, and +overloaded. This establishes a stronger rule: quick capture must not resemble a +setup form. Plant, event type, and occurrence time are compact metadata controls +around the text entry. Sensor context is attached by default but collapsed until +the user asks to inspect it. + +The replacement prototypes share one capture contract: + +1. choose exactly one plant; +2. choose an event type or leave the entry as free text; +3. write what happened; +4. review the editable occurrence date and time; +5. save with the current Home Assistant sensor context visibly attached. + +History is chronological and readable rather than a compressed data table. +Plant and event-type filters are separate compact controls. The current revision +tests three quieter structures: + +- variant A gives the history most of the width and keeps a compact editor in a + fixed right column; +- variant B puts a compact inline editor above a day-grouped log; +- variant C opens on the history alone and moves capture into an on-demand side + drawer. + +All three support keyboard saving and show attached Home Assistant values without +turning them into a permanent metric grid. Variant C, `Verlauf zuerst`, was +selected on August 21, 2026. The journal opens on the chronological history and +shows the compact side editor only after `Neuer Eintrag` is requested. Variants +A and B are retired. + +### Phase workflow review + +Variant A was selected as the phase-management direction on August 21, 2026, +but its first interaction model was rejected. Drag handles were decorative, and +the page showed a phase rail while requiring the same target to be chosen again +from a separate "next phase" dropdown. + +The revised contract is direct: + +- every phase in the lifecycle rail and phase plan is an explicit target; +- selecting a phase updates one clearly labeled target in the confirmation + panel; +- the user reviews the editable change timestamp and confirms the switch; +- any phase may be selected, including an earlier or custom phase; +- drag-and-drop changes only the displayed order and imposes no transition + rules. + +Every confirmed switch remains a timestamped event in the permanent run +history. Reordering the plan never rewrites that history. + +### Remaining-flow review deck + +On August 20, 2026, the disposable review deck was expanded to cover the +remaining product surfaces before the next feedback session. The entry point is +`plantrun-review-prototype.html?view=map`. + +The deck links to the existing tent, plant, and creation prototypes and adds +three structural alternatives for journal capture, phases, sensor ownership, +strain lookup and manual fallback, editing, completion, archive, completed-run +reading, comparison, settings, failure states, and the Lovelace companion card. +It also keeps door-contact prompts and scheduled follow-ups in a visibly separate +later-ideas section. + +The creation flow and phase-management structure A are selected. The revised +journal alternatives and the other remaining surfaces are still review +material and must not be treated as approved implementation requirements. + +## Visual-reference findings + +The supplied inspiration set contains several recurring ideas worth testing: + +- compact icon-and-value rows for dense cultivation facts; +- a prominent lifecycle rail that distinguishes completed, current, and future stages; +- small, restrained navigation rather than a large administrative sidebar; +- plant imagery used for identity and recognition; +- focused charts that explain one metric with a current value and trend; +- light surfaces, generous spacing, restrained green accents, and occasional stage colors; +- desktop information density paired with purpose-built mobile plant browsing. + +The prototype must not reproduce the screenshots. Each structural variant should interpret these ideas differently. Avoid returning to repeated generic metric cards, oversized decorative plant art, or an enterprise batch-management layout. + +## Historical sensor policy + +PlantRun uses Home Assistant Recorder as the source of sensor data: + +- recent detail comes from raw Recorder history while it remains available; +- permanent run development uses hourly long-term statistics, including minimum, average, and maximum; +- bindings must warn when an entity cannot produce long-term statistics; +- PlantRun does not duplicate raw time-series samples; +- binary door and lamp history is not promised permanently; +- plant-level sensor assignments have start and end timestamps so reassigning a sensor does not rewrite earlier ownership. + +Completed-run edits overwrite the corrected fields in the first version. A visible correction history remains a later feature idea. + +## Discovery outcome + +Shared product understanding was confirmed on August 19, 2026, visual review +continued on August 20, and the guided single-plant creation flow plus the phase +management A structure were selected on August 21. The disposable prototype under +`custom_components/plantrun/www/prototype/` now covers the tent overview, the +selected plant workspace direction, the selected creation flow, and the +remaining review states. Production HACS implementation remains a separate +decision until those remaining states are reviewed. + +The prototype is evidence, not production code. After the user approves a direction, rewrite the chosen design as a maintained Home Assistant integration rather than promoting prototype code directly. + +## Idea to preserve + +### Tent-door capture prompt + +When a Home Assistant contact sensor detects that the tent was opened, an automation could wait until the work session is likely finished and send a phone notification. Opening the notification would start a context-aware PlantRun entry or offer common changes for that tent. + +This is a later workflow, not a prototype requirement. The product and event model should leave room for it. + +### Scheduled follow-up from a journal entry + +A journal entry may contain a future action, such as ending a darkness period and harvesting after three days. PlantRun could turn that part into a scheduled action and later record its completion. This remains a feature candidate until the first-version scope is settled. + +## Current product priority signal + +The user rated the desired Home Assistant capabilities as follows: + +- existing sensor values and Recorder history: 10/10; +- dashboard access: 8/10; +- reminders and notifications: 6/10; +- recording events triggered by automations: 5/10; +- automations reacting to cultivation events: 4/10; +- camera snapshots: 4/10, currently unavailable for testing; +- voice or Assist capture: 2/10 for now; +- NFC tags or physical buttons: 2/10 for now. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..62797d1 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,179 @@ +# PlantRun roadmap + +## Direction + +PlantRun will not wait for every screen and later feature before it becomes useful. The rebuild will ship as a sequence of production-quality vertical slices. Each slice must work inside Home Assistant, persist real data, and use the accepted visual direction. + +The first target is deliberately narrow: record the cultivation cycle that has already started without depending on the rejected PlantRun interface. + +## Current truth + +- Home Assistant currently runs the HACS release `v0.4.2`. +- The repository branch declares `0.6.1`, but that code is the rejected earlier overhaul rather than the accepted rebuild. +- The accepted `Clean development` workspace, guided creation flow, journal, and phase interactions exist only as disposable prototypes. +- The live `v0.4.2` store contains two legacy runs. One of them stores multiple plants inside one run, which conflicts with the settled one-plant-per-run model. +- The current cultivation cycle has begun. New acceptance data is real data and must survive every later update. + +## Working rule + +Build the smallest honest PlantRun first, then use it during the current cycle. Do not polish all review screens before the core capture loop works. Do not publish the old `0.6.1` overhaul as if it were the rebuild. + +## Phase 0: protect the current cycle + +Goal: make the gap before the first usable rebuild short and recoverable. + +Work: + +- Record the planting date, plants, strains, breeders, and any events that have already happened in a temporary import ledger. +- Export the current PlantRun runs through its supported websocket contract and take a Home Assistant backup before changing the installed integration. +- Classify the existing Amnesia and Purple Cookie Kush records as either keep or discard. No migration may silently decide this. +- Define a small import format for the temporary ledger so the first rebuilt version can ingest it once. +- Freeze HACS updates and Home Assistant restarts related to PlantRun until the backup and import path have been tested. + +Gate: + +- Every fact recorded since the current cycle began exists in a readable export or ledger with an explicit timestamp and plant target. +- The owner has decided which legacy runs matter. + +## Phase 1: usable capture slice + +Goal: replace the temporary ledger with PlantRun as soon as possible. + +Production scope: + +- one persistent Tent; +- one Plant and one Run per creation flow; +- durable Runs that move to the Archive automatically and are deleted only through an explicit permanent-deletion flow; +- the selected three-step creation flow with manual Strain entry; +- Germination as a valid initial Stage for seeds sown directly into their final container; +- a chronological Journal using the selected `Verlauf zuerst` interaction; +- free-text entries with an editable occurrence time; +- the first structured event types: Water, Change stage, Inspect, and Harvest; +- optional structured details without making them required; +- edit and delete for Journal Entries; +- persistence across integration reload, Home Assistant restart, and browser reload; +- desktop and mobile layouts built from the accepted PlantRun visual system; +- one supported import of the Phase 0 ledger. + +Architecture: + +- Put Tent, Plant, Run, Stage, and Journal Entry rules behind a small domain interface. +- Put storage versioning and migration behind one persistence module. +- Expose commands and queries through a documented Home Assistant websocket interface. +- Keep Home Assistant transport out of domain calculations. +- Build the frontend as focused workflow modules rather than another single large custom element. + +Gate: + +- Create the current real plant and run inside Home Assistant. +- Add, edit, and delete a real Journal Entry. +- Reload the integration, restart Home Assistant, and reopen the browser. The Run and Journal remain correct. +- Import the temporary ledger and verify every imported timestamp and target. +- Complete keyboard and phone-width checks for the creation and Journal paths. + +Explicitly excluded: + +- Recorder charts; +- global Strain search; +- comparison and archive analytics; +- reminders, notifications, camera, voice, and NFC; +- the full Lovelace companion card. + +## Phase 2: daily cultivation workspace + +Goal: make PlantRun useful for daily work, not only data entry. + +Scope: + +- the accepted plant workspace with lifecycle progress and latest Journal context; +- a Tent overview that supports multiple active one-plant Runs at unrelated Stages; +- direct Stage selection with an editable timestamp; +- custom Stage creation and display reordering without transition restrictions; +- plant photos and honest image fallbacks; +- separate Tent and Plant sensor ownership; +- fast capture from the Tent overview and plant workspace; +- clear empty, offline, loading, and error states. + +Gate: + +- Log real entries for more than one Plant without target ambiguity. +- Move a Run to another Stage, including a backward or custom Stage, without rewriting its history. +- Reassign a Plant Sensor while retaining the earlier binding period. +- Validate the full daily loop on desktop and mobile. + +## Phase 3: Recorder and environmental context + +Goal: make Home Assistant history a reason to use PlantRun instead of a separate journal. + +Scope: + +- Recorder-backed raw history for recent detail; +- long-term statistics for permanent Run history; +- one focused metric chart with target range, current value, change, latest related event, minimum, average, and maximum; +- dated Sensor Bindings for the Tent and individual Runs; +- compatibility checks for metric units and long-term statistics; +- exact Run-window history navigation; +- correction of the current power-versus-energy binding warning. + +Gate: + +- A temperature or humidity chart uses the configured Tent aggregate entity. +- A Plant Sensor reassignment shows the correct entity for each historical period. +- A completed Run retains its long-term view after raw Recorder data expires. +- Unit mismatches fail visibly and never present power as accumulated energy. + +## Phase 4: finish and revisit a cycle + +Goal: cover the complete cultivation lifecycle. + +Scope: + +- Harvest and completion flow; +- end time, dry yield, and optional harvest details; +- post-harvest Journal Entries without extending the cultivation sensor window; +- browsable and editable completed Runs; +- archive filtering and a useful completed-Run summary; +- full retention of Stages, Journal Entries, Sensor Bindings, images, sourced Strain data, and harvest details after archival; +- a separate danger area for Permanent Deletion that explains the lost PlantRun data, requires the exact Run name, and has no in-app undo; +- replacement Lovelace card using native Home Assistant styling. + +Gate: + +- Complete a real or rehearsed Run and reopen it after a Home Assistant restart. +- Verify the Recorder window ends at harvest while later Journal Entries remain visible. +- Correct a completed Run and confirm the updated values everywhere they are shown. + +## Phase 5: HACS baseline release + +Goal: turn the owner-tested rebuild into the maintained HACS version. + +Requirements: + +- migration or explicit import for every data shape the owner chose to preserve; +- automated tests for the domain, storage, websocket contracts, and critical frontend flows; +- clean installation and update checks on the current supported Home Assistant release; +- deterministic frontend cache busting; +- unload, reload, restart, backup, restore, and rollback checks; +- final desktop and mobile screenshots approved by the owner; +- a release checklist that distinguishes repository checks, installed files, loaded integration state, and functional live proof. + +The version number is chosen only after deciding whether the old unreleased `0.6.1` history will be replaced, retained, or skipped. + +## Later work + +These features stay outside the first HACS baseline unless real use proves one is needed sooner: + +- reminders and tent-door capture prompts; +- scheduled follow-ups from Journal Entries; +- automation-originated Journal Entries and cultivation events; +- Run comparison; +- global all-Breeder Strain search and richer provider adapters; +- camera snapshots; +- voice, Assist, NFC, and physical-button capture; +- optional sound and deeper appearance customization. + +## Execution order + +The next implementation work starts with Phase 0 and Phase 1. Remaining prototype review must not block them because the selected creation, Journal, phase, and plant-workspace directions already define the first usable paths. Review of archive, comparison, settings, and later states can continue while the capture slice is built. + +No phase is complete because its files exist or its tests pass. A phase ends only after the listed Home Assistant live gate passes with the current cultivation data. diff --git a/docs/adr/0001-recorder-backed-historical-sensor-data.md b/docs/adr/0001-recorder-backed-historical-sensor-data.md new file mode 100644 index 0000000..3d0d12e --- /dev/null +++ b/docs/adr/0001-recorder-backed-historical-sensor-data.md @@ -0,0 +1,3 @@ +# Use Recorder statistics for permanent sensor history + +PlantRun will not store copies of Home Assistant sensor samples. Recent detailed charts query raw Recorder history, while full-run and completed-run charts query Recorder's hourly long-term minimum, average, and maximum statistics. Sensor binding must warn when an entity cannot produce long-term statistics, and PlantRun will not promise permanent history for binary entities such as doors or lamps. This avoids a second time-series store inside PlantRun at the cost of losing raw detail after the user's Recorder retention period. diff --git a/docs/adr/0002-one-plant-per-run.md b/docs/adr/0002-one-plant-per-run.md new file mode 100644 index 0000000..9fddf75 --- /dev/null +++ b/docs/adr/0002-one-plant-per-run.md @@ -0,0 +1,3 @@ +# Keep one plant per run + +Each run records exactly one plant, while a tent persists across any number of overlapping runs at unrelated stages. A setup flow may prepare several plants together, but it must create separate runs and may share only tent-level context such as climate, light, door, and energy. This keeps lifecycle stages, sensor ownership, harvest estimates, journal history, and completion dates unambiguous when plants in the same tent develop differently. diff --git a/docs/adr/0003-explicit-permanent-run-deletion.md b/docs/adr/0003-explicit-permanent-run-deletion.md new file mode 100644 index 0000000..fc037d7 --- /dev/null +++ b/docs/adr/0003-explicit-permanent-run-deletion.md @@ -0,0 +1,3 @@ +# Require explicit confirmation for permanent Run deletion + +PlantRun archives completed Runs without removing data, but the user retains the right to permanently delete any Run. Permanent Deletion lives in a separate danger area, names the PlantRun-owned details that will be lost, requires the exact Run name, and has no in-app undo; Home Assistant Recorder data remains outside PlantRun and is not deleted. diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..b0e9c67 --- /dev/null +++ b/favicon.svg @@ -0,0 +1,15 @@ + + PlantRun + + + + diff --git a/t3.json b/t3.json new file mode 100644 index 0000000..ed71639 --- /dev/null +++ b/t3.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://t3.codes/schema/t3.json", + "iconPath": "favicon.svg" +} diff --git a/tests/dashboard_js_test_utils.py b/tests/dashboard_js_test_utils.py index f5c9f53..703f3ba 100644 --- a/tests/dashboard_js_test_utils.py +++ b/tests/dashboard_js_test_utils.py @@ -2,10 +2,25 @@ ROOT = Path(__file__).resolve().parents[1] PANEL_JS = ROOT / "custom_components" / "plantrun" / "www" / "plantrun-panel.js" +PANEL_API_JS = ROOT / "custom_components" / "plantrun" / "www" / "plantrun-panel-api.js" +PANEL_DIALOGS_JS = ROOT / "custom_components" / "plantrun" / "www" / "plantrun-panel-dialogs.js" +PANEL_DOMAIN_JS = ROOT / "custom_components" / "plantrun" / "www" / "plantrun-panel-domain.js" +PANEL_STYLES_JS = ROOT / "custom_components" / "plantrun" / "www" / "plantrun-panel-styles.js" +PANEL_VIEWS_JS = ROOT / "custom_components" / "plantrun" / "www" / "plantrun-panel-views.js" def load_panel_source() -> str: - return PANEL_JS.read_text(encoding="utf-8") + return "\n".join( + path.read_text(encoding="utf-8") + for path in ( + PANEL_JS, + PANEL_API_JS, + PANEL_DIALOGS_JS, + PANEL_DOMAIN_JS, + PANEL_STYLES_JS, + PANEL_VIEWS_JS, + ) + ) def assert_has_snippets(testcase, source: str, snippets: list[str]) -> None: diff --git a/tests/frontend_harness.html b/tests/frontend_harness.html new file mode 100644 index 0000000..ac8d4a1 --- /dev/null +++ b/tests/frontend_harness.html @@ -0,0 +1,61 @@ + + + + + + PlantRun production frontend harness + + + + + + + diff --git a/tests/test_application_v3.py b/tests/test_application_v3.py new file mode 100644 index 0000000..184ed11 --- /dev/null +++ b/tests/test_application_v3.py @@ -0,0 +1,412 @@ +import asyncio +import importlib.util +import sys +import types +import unittest +from datetime import datetime, timezone +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PLANTRUN_DIR = ROOT / "custom_components" / "plantrun" + +custom_components = types.ModuleType("custom_components") +custom_components.__path__ = [str(ROOT / "custom_components")] +sys.modules.setdefault("custom_components", custom_components) +plantrun_pkg = types.ModuleType("custom_components.plantrun") +plantrun_pkg.__path__ = [str(PLANTRUN_DIR)] +sys.modules["custom_components.plantrun"] = plantrun_pkg + + +def _load(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +domain_module = _load("custom_components.plantrun.domain", PLANTRUN_DIR / "domain.py") +application_module = _load( + "custom_components.plantrun.application", PLANTRUN_DIR / "application.py" +) +CommandError = application_module.CommandError +PlantRunApplication = application_module.PlantRunApplication +PlantRunDomain = domain_module.PlantRunDomain + + +class RecordingStorage: + def __init__(self) -> None: + self.domain = PlantRunDomain() + self.commits: list[dict] = [] + self.fail_next_commit = False + + async def async_commit_domain(self, candidate: PlantRunDomain) -> None: + if self.fail_next_commit: + self.fail_next_commit = False + raise OSError("disk full") + self.commits.append(candidate.export_state()) + self.domain = candidate + + +class YieldingStorage(RecordingStorage): + async def async_commit_domain(self, candidate: PlantRunDomain) -> None: + await asyncio.sleep(0) + await super().async_commit_domain(candidate) + + +class TestPlantRunApplication(unittest.TestCase): + def test_create_run_is_one_atomic_command_and_returns_complete_state(self) -> None: + storage = RecordingStorage() + app = PlantRunApplication(storage) + + state = asyncio.run( + app.execute( + "create_run", + { + "run_name": "Diesel Auto RQS", + "tent_name": "Growzelt", + "plant_name": "Diesel Auto", + "strain": "Diesel Auto", + "breeder": "Royal Queen Seeds", + "container": "7 L final container", + "substrate": "All-Mix soil", + "light_schedule": "20:00 to 14:00, 18/6", + "planted_at": "2026-08-25T16:00:00+02:00", + "initial_stage": "Germination", + "stage_plan": [ + "Germination", + "Seedling", + "Vegetative", + "Flowering", + "Harvested", + ], + }, + ) + ) + + self.assertEqual(len(storage.commits), 1) + self.assertEqual(state["schema_version"], 3) + self.assertEqual(state["tents"][0]["name"], "Growzelt") + self.assertEqual(state["plants"][0]["name"], "Diesel Auto") + self.assertEqual(state["plants"][0]["container"], "7 L final container") + self.assertEqual(state["plants"][0]["substrate"], "All-Mix soil") + self.assertEqual(state["tents"][0]["light_schedule"], "20:00 to 14:00, 18/6") + self.assertEqual(state["runs"][0]["name"], "Diesel Auto RQS") + self.assertEqual(state["runs"][0]["current_stage"], "Germination") + self.assertEqual(state["active_tent_id"], state["tents"][0]["id"]) + + def test_failed_persistence_does_not_replace_the_live_domain(self) -> None: + storage = RecordingStorage() + app = PlantRunApplication(storage) + original_state = storage.domain.export_state() + storage.fail_next_commit = True + + with self.assertRaisesRegex(OSError, "disk full"): + asyncio.run( + app.execute( + "create_run", + { + "run_name": "Tangerine Dream Auto Zamnesia", + "tent_name": "Growzelt", + "plant_name": "Tangerine Dream Auto", + "strain": "Tangerine Dream Auto", + "breeder": "Zamnesia", + "planted_at": "2026-08-25T16:00:00+02:00", + "initial_stage": "Germination", + "stage_plan": ["Germination", "Seedling", "Harvested"], + }, + ) + ) + + self.assertEqual(storage.domain.export_state(), original_state) + self.assertEqual(storage.commits, []) + + def test_journal_command_preserves_unmeasured_watering_as_optional_detail(self) -> None: + storage = RecordingStorage() + app = PlantRunApplication(storage) + created = asyncio.run( + app.execute( + "create_run", + { + "run_name": "Diesel Auto RQS", + "tent_name": "Growzelt", + "plant_name": "Diesel Auto", + "planted_at": "2026-08-25T16:00:00+02:00", + "initial_stage": "Germination", + "stage_plan": ["Germination", "Harvested"], + }, + ) + ) + run_id = created["runs"][0]["id"] + tent_id = created["tents"][0]["id"] + + state = asyncio.run( + app.execute( + "create_journal_entry", + { + "tent_id": tent_id, + "run_ids": [run_id], + "entry_type": "Water", + "text": "Wetted the fresh soil, then sprinkled a little water over the seed. Amount not measured.", + "occurred_at": "2026-08-25T16:00:00+02:00", + "details": {"amount": None, "measured": False}, + }, + ) + ) + + entry = state["journal_entries"][0] + self.assertEqual(entry["entry_type"], "Water") + self.assertEqual(entry["details"], {"amount": None, "measured": False}) + self.assertEqual(entry["run_ids"], [run_id]) + + def test_unknown_command_is_rejected_without_persistence(self) -> None: + storage = RecordingStorage() + app = PlantRunApplication(storage) + + with self.assertRaisesRegex(CommandError, "Unsupported command"): + asyncio.run(app.execute("invent_data", {})) + + self.assertEqual(storage.commits, []) + + def test_concurrent_commands_are_serialized_without_losing_an_entry(self) -> None: + storage = YieldingStorage() + app = PlantRunApplication(storage) + + async def scenario() -> dict: + created = await app.execute( + "create_run", + { + "run_name": "Diesel Auto RQS", + "tent_name": "Growzelt", + "plant_name": "Diesel Auto", + "planted_at": "2026-08-25T16:00:00+02:00", + }, + ) + run_id = created["runs"][0]["id"] + tent_id = created["tents"][0]["id"] + payload = { + "tent_id": tent_id, + "run_ids": [run_id], + "entry_type": "Inspect", + "occurred_at": "2026-08-26T10:00:00+02:00", + } + await asyncio.gather( + app.execute("create_journal_entry", {**payload, "text": "First check"}), + app.execute("create_journal_entry", {**payload, "text": "Second check"}), + ) + return app.state() + + state = asyncio.run(scenario()) + + self.assertEqual( + {entry["text"] for entry in state["journal_entries"]}, + {"First check", "Second check"}, + ) + + def test_frontend_plant_binding_maps_to_run_ownership(self) -> None: + storage = RecordingStorage() + app = PlantRunApplication(storage) + + state = asyncio.run( + app.execute( + "create_run", + { + "run_name": "Bound plant", + "tent_name": "Growzelt", + "plant_name": "Bound plant", + "planted_at": "2026-08-25T16:00:00+02:00", + "bindings": [ + { + "owner_type": "plant", + "metric_type": "soil_moisture", + "entity_id": "sensor.bound_soil", + } + ], + }, + ) + ) + + binding = state["sensor_bindings"][0] + self.assertEqual(binding["owner"], "run") + self.assertEqual(binding["owner_id"], state["runs"][0]["id"]) + + def test_frontend_can_clear_a_binding_without_deleting_its_history(self) -> None: + storage = RecordingStorage() + app = PlantRunApplication(storage) + created = asyncio.run( + app.execute( + "create_run", + { + "run_name": "Bound plant", + "tent_name": "Growzelt", + "plant_name": "Bound plant", + "planted_at": "2026-08-25T16:00:00+02:00", + "bindings": [ + { + "owner_type": "plant", + "metric_type": "soil_moisture", + "entity_id": "sensor.bound_soil", + } + ], + }, + ) + ) + + state = asyncio.run( + app.execute( + "clear_binding", + { + "owner_type": "plant", + "owner_id": created["runs"][0]["id"], + "metric_type": "soil_moisture", + "occurred_at": "2026-08-26T12:00:00+02:00", + }, + ) + ) + + self.assertEqual( + state["sensor_bindings"][0]["ended_at"], + "2026-08-26T12:00:00+02:00", + ) + + def test_import_bundle_is_atomic_and_idempotent(self) -> None: + storage = RecordingStorage() + app = PlantRunApplication(storage) + payload = { + "tent_name": "Growzelt", + "runs": [ + { + "run_name": "Diesel Auto RQS", + "plant_name": "Diesel Auto", + "strain": "Diesel Auto", + "breeder": "Royal Queen Seeds", + "container": "7 L final container", + "substrate": "All-Mix soil", + "light_schedule": "20:00 to 14:00, 18/6", + "planted_at": "2026-08-25T16:00:00+02:00", + "initial_stage": "Germination", + "stage_plan": ["Germination", "Seedling", "Vegetative", "Flowering", "Harvested"], + }, + { + "run_name": "Tangerine Dream Auto Zamnesia", + "plant_name": "Tangerine Dream Auto", + "strain": "Tangerine Dream Auto", + "breeder": "Zamnesia", + "container": "7 L final container", + "substrate": "All-Mix soil", + "light_schedule": "20:00 to 14:00, 18/6", + "planted_at": "2026-08-25T16:00:00+02:00", + "initial_stage": "Germination", + "stage_plan": ["Germination", "Seedling", "Vegetative", "Flowering", "Harvested"], + }, + ], + "journal_entries": [ + { + "run_name": "Diesel Auto RQS", + "entry_type": "Planting", + "occurred_at": "2026-08-25T16:00:00+02:00", + "text": "Dry seed placed directly into All-Mix soil.", + }, + { + "run_name": "Diesel Auto RQS", + "entry_type": "Water", + "occurred_at": "2026-08-25T16:00:00+02:00", + "text": "Fresh soil wetted. Amount not measured.", + "details": {"amount": None, "measured": False}, + }, + { + "run_name": "Tangerine Dream Auto Zamnesia", + "entry_type": "Planting", + "occurred_at": "2026-08-25T16:00:00+02:00", + "text": "Dry seed placed directly into All-Mix soil.", + }, + { + "run_name": "Tangerine Dream Auto Zamnesia", + "entry_type": "Water", + "occurred_at": "2026-08-25T16:00:00+02:00", + "text": "Fresh soil wetted. Amount not measured.", + "details": {"amount": None, "measured": False}, + }, + { + "run_name": None, + "entry_type": "Lighting", + "occurred_at": "2026-08-25T20:00:00+02:00", + "text": "First scheduled lights-on. Light schedule is 20:00 to 14:00, 18/6.", + }, + ], + } + + first = asyncio.run(app.execute("import_bundle", payload)) + second = asyncio.run(app.execute("import_bundle", payload)) + + self.assertEqual(len(first["tents"]), 1) + self.assertEqual(len(second["runs"]), 2) + self.assertEqual(len(second["plants"]), 2) + self.assertEqual(len(second["journal_entries"]), 5) + self.assertEqual({run["current_stage"] for run in second["runs"]}, {"Germination"}) + self.assertEqual({plant["container"] for plant in second["plants"]}, {"7 L final container"}) + self.assertEqual(second["tents"][0]["light_schedule"], "20:00 to 14:00, 18/6") + + def test_invalid_duration_number_is_reported_as_a_command_error(self) -> None: + storage = RecordingStorage() + app = PlantRunApplication(storage) + + with self.assertRaisesRegex(CommandError, "duration.minimum_days must be an integer"): + asyncio.run( + app.execute( + "create_run", + { + "run_name": "Broken duration", + "tent_name": "Growzelt", + "plant_name": "Test plant", + "planted_at": "2026-08-25T16:00:00+02:00", + "duration": { + "minimum_days": "unknown", + "maximum_days": 80, + "meaning": "breeder estimate", + "start_event": "seed", + "source": "example.invalid", + "original_wording": "70 to 80 days", + }, + }, + ) + ) + + self.assertEqual(storage.commits, []) + + def test_import_retry_rejects_conflicting_permanent_facts(self) -> None: + storage = RecordingStorage() + app = PlantRunApplication(storage) + base = { + "tent_name": "Growzelt", + "runs": [ + { + "run_name": "Diesel Auto RQS", + "plant_name": "Diesel Auto", + "strain": "Diesel Auto", + "breeder": "Royal Queen Seeds", + "container": "7 L final container", + "substrate": "All-Mix soil", + "light_schedule": "20:00 to 14:00, 18/6", + "planted_at": "2026-08-25T16:00:00+02:00", + "initial_stage": "Germination", + } + ], + "journal_entries": [], + } + asyncio.run(app.execute("import_bundle", base)) + conflicting = { + **base, + "runs": [{**base["runs"][0], "container": "11 L final container"}], + } + + with self.assertRaisesRegex(CommandError, "import collision"): + asyncio.run(app.execute("import_bundle", conflicting)) + + self.assertEqual(storage.domain.snapshot().plants[0].container, "7 L final container") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_backend_history_contracts.py b/tests/test_backend_history_contracts.py index ee3b013..654befa 100644 --- a/tests/test_backend_history_contracts.py +++ b/tests/test_backend_history_contracts.py @@ -8,6 +8,13 @@ class BackendHistoryContractTests(unittest.TestCase): + def test_backend_exposes_authenticated_atomic_v3_commands(self): + source = INIT_PY.read_text(encoding="utf-8") + self.assertIn('{"type": "plantrun/get_state"}', source) + self.assertIn('"type": "plantrun/command"', source) + self.assertIn('state = await application.execute(msg["command"]', source) + self.assertIn('connection.send_error(msg["id"], "invalid_command"', source) + def test_backend_exposes_binding_history_context_websocket(self): source = INIT_PY.read_text(encoding="utf-8") self.assertIn('plantrun/get_run_binding_history_context', source) diff --git a/tests/test_dashboard_js_contracts.py b/tests/test_dashboard_js_contracts.py index 913d90a..2c1964b 100644 --- a/tests/test_dashboard_js_contracts.py +++ b/tests/test_dashboard_js_contracts.py @@ -3,129 +3,166 @@ ROOT = Path(__file__).resolve().parents[1] -CARD_JS = ROOT / "custom_components" / "plantrun" / "www" / "plantrun-card.js" -EDITOR_JS = ROOT / "custom_components" / "plantrun" / "www" / "plantrun-card-editor.js" -PANEL_JS = ROOT / "custom_components" / "plantrun" / "www" / "plantrun-panel.js" +WWW = ROOT / "custom_components" / "plantrun" / "www" +CARD_JS = WWW / "plantrun-card.js" +EDITOR_JS = WWW / "plantrun-card-editor.js" +PANEL_ENTRY_JS = WWW / "plantrun-panel.js" +PANEL_API_JS = WWW / "plantrun-panel-api.js" +PANEL_DIALOGS_JS = WWW / "plantrun-panel-dialogs.js" +PANEL_DOMAIN_JS = WWW / "plantrun-panel-domain.js" +PANEL_STYLES_JS = WWW / "plantrun-panel-styles.js" +PANEL_VIEWS_JS = WWW / "plantrun-panel-views.js" + + +def panel_source() -> str: + return "\n".join( + path.read_text(encoding="utf-8") + for path in (PANEL_ENTRY_JS, PANEL_API_JS, PANEL_DIALOGS_JS, PANEL_DOMAIN_JS, PANEL_STYLES_JS, PANEL_VIEWS_JS) + ) class DashboardJsContractsTests(unittest.TestCase): - def test_card_treats_common_placeholder_run_ids_as_unset(self): - source = CARD_JS.read_text(encoding="utf-8") - self.assertIn('""', source) - self.assertIn('"your_run_id"', source) - self.assertIn('normalized.includes(" 0)", source) - - def test_panel_sensor_bindings_use_ha_entity_selector_and_sensor_fallback(self): - source = PANEL_JS.read_text(encoding="utf-8") - self.assertIn(" run.id));", source) - self.assertIn("_resolveNewlyCreatedRun(name, knownRunIds)", source) - self.assertIn("_resolveNewlyCreatedRun(name, previousRunIds = new Set())", source) - self.assertIn("const newlyDiscovered = this._runs.filter((run) => !previousRunIds.has(run.id));", source) - - def test_panel_cultivar_search_clears_stale_selection_and_ignores_old_responses(self): - source = PANEL_JS.read_text(encoding="utf-8") - self.assertIn("this._wizard.selected_cultivar = null;", source) - self.assertIn('type: "plantrun/search_cultivar"', source) - self.assertIn("this._suggestionCache = new Map();", source) - self.assertIn("if (searchKey === this._lastSearchKey) return;", source) - self.assertIn("const requestNonce = ++this._searchNonce;", source) - self.assertIn("if (requestNonce !== this._searchNonce) return;", source) - self.assertIn("this._renderSuggestionsOnly();", source) - - def test_panel_wizard_input_updates_do_not_render_while_typing(self): - source = PANEL_JS.read_text(encoding="utf-8") - self.assertIn("_handleInput(event)", source) - self.assertIn("this._wizard = { ...this._wizard, [field]: target.value };", source) - self.assertIn('this._wizard.target_days = "";', source) - handle_input = source[source.index("_handleInput(event)") : source.index("_handleChange(event)")] - self.assertNotIn("this.render()", handle_input) - - def test_panel_sensor_tap_attempts_native_history_deeplink_before_modal_fallback(self): - source = PANEL_JS.read_text(encoding="utf-8") - self.assertIn("Tap a sensor to inspect its run window.", source) - self.assertIn("_renderHistoryInspector()", source) - self.assertIn('type: "plantrun/get_run_binding_history_context"', source) - self.assertIn("const EXPERIMENTAL_NATIVE_HISTORY_DEEPLINK = true;", source) - self.assertIn("window.history.pushState(null, \"\", `/history?${params.toString()}`);", source) - self.assertIn('data-action="open-native-history"', source) - - def test_panel_phase_control_is_canonical_timeline_with_confirmation(self): - source = PANEL_JS.read_text(encoding="utf-8") - self.assertIn('const CANONICAL_STAGES = ["Seedling", "Vegetative", "Flowering", "Harvested"]', source) - self.assertIn('

Phase

', source) - self.assertIn('data-action="select-phase"', source) - self.assertIn("_renderPhaseConfirmModal()", source) - self.assertIn('data-action="confirm-phase-change"', source) - self.assertNotIn("window.confirm(", source) - self.assertNotIn("minus the cursed browser popup", source) - - def test_panel_binding_metric_changes_force_picker_refresh_and_edit_existing_bindings(self): - source = PANEL_JS.read_text(encoding="utf-8") - self.assertIn('data-action="edit-binding"', source) - self.assertIn('this._openBinding(target.dataset.runId, target.dataset.bindingId);', source) - self.assertIn('this._bindingDraft = binding', source) - self.assertIn('this.render();', source) - - def test_panel_detail_editor_sends_explicit_nulls_when_fields_are_cleared(self): - source = PANEL_JS.read_text(encoding="utf-8") - self.assertIn("planted_date: draft.planted_date || null,", source) - self.assertIn("notes_summary: draft.notes_summary || null,", source) - self.assertIn('dry_yield_grams: draft.dry_yield_grams === "" ? null : Number(draft.dry_yield_grams),', source) - - def test_panel_detail_editor_persists_target_days_and_keeps_dialog_open_on_error(self): - source = PANEL_JS.read_text(encoding="utf-8") - self.assertIn("const targetDays = Number(draft.target_days || this._derivedTargetDays(draft.selected_cultivar));", source) - self.assertIn("...(this._runs.find((item) => item.id === draft.run_id)?.base_config || {}),", source) - self.assertIn("target_days: targetDays,", source) - self.assertIn("this._detailDraft = null;", source) - self.assertIn("this._error = err?.message || \"Unable to save run changes.\";", source) - - def test_panel_hero_uses_seedfinder_image_when_available(self): - source = PANEL_JS.read_text(encoding="utf-8") - self.assertIn("_heroMediaStyle(run)", source) - self.assertIn('run.image_url ? " has-image" : ""', source) - self.assertIn(".hero.has-image", source) - self.assertIn("var(--hero-image)", source) + def test_panel_keeps_a_small_authenticated_transport_interface(self): + source = PANEL_API_JS.read_text(encoding="utf-8") + self.assertIn('type: "plantrun/get_state"', source) + self.assertIn('type: "plantrun/command"', source) + self.assertIn("async getState()", source) + self.assertIn("async command(command, payload = {})", source) + self.assertIn("async searchCultivar(breeder, query)", source) + self.assertIn("async getRecorderHistory(entityId, start, end)", source) + self.assertNotIn("callService", source) + + def test_panel_uses_production_modules_and_no_prototype_fixtures(self): + source = PANEL_ENTRY_JS.read_text(encoding="utf-8") + for module in ("api", "dialogs", "domain", "styles", "views"): + self.assertIn(f'from "./plantrun-panel-{module}.js?v=0.7.0"', source) + self.assertNotIn('from "./prototype/', source.lower()) + self.assertNotIn("Tangerine Dream Auto", source) + self.assertNotIn("Diesel Auto", source) + + def test_state_normalizer_supports_first_class_and_embedded_records(self): + source = PANEL_DOMAIN_JS.read_text(encoding="utf-8") + self.assertIn("export function normalizeState(payload)", source) + self.assertIn("source.plants", source) + self.assertIn("run?.plant", source) + self.assertIn("source.journal_entries", source) + self.assertIn("run?.journal_entries", source) + self.assertIn("one plant", source.lower()) + self.assertIn("source.sensor_bindings", source) + self.assertIn('owner === "run" ? "plant"', source) + + def test_overview_is_tent_first_and_plant_dominant(self): + source = PANEL_VIEWS_JS.read_text(encoding="utf-8") + self.assertIn('class="tent-overview"', source) + self.assertIn('class="plant-gallery"', source) + self.assertIn('data-action="open-run"', source) + self.assertIn("Nächste Schätzung", source) + self.assertIn("Noch kein Pflanzenfoto", source) + + def test_workspace_matches_accepted_information_order(self): + source = panel_source() + for text in ( + "Lebenszyklus", + "Recorder-Verlauf", + "Zielbereich", + "Minimum", + "Durchschnitt", + "Maximum", + "Feste Daten", + "Letzter Journaleintrag", + ): + self.assertIn(text, source) + self.assertIn('data-action="select-metric"', source) + self.assertIn('data-action="select-stage"', source) + self.assertIn("PlantRun zeichnet keine erfundenen Messwerte.", source) + + def test_frontend_uses_recorded_stage_history_and_real_archive_end(self): + domain = PANEL_DOMAIN_JS.read_text(encoding="utf-8") + views = PANEL_VIEWS_JS.read_text(encoding="utf-8") + self.assertIn("export function recordedStages(run)", domain) + self.assertIn("run?.stage_history", domain) + self.assertIn("recorded.has(stage.toLowerCase())", views) + self.assertIn("run?.ended_at || run?.archived_at", domain) + self.assertIn("runEnd(b) || 0", views) + self.assertIn("daysSince(runStart(run), runEnd(run))", views) + + def test_energy_is_bindable_and_unbound_metrics_have_an_honest_owner_label(self): + domain = PANEL_DOMAIN_JS.read_text(encoding="utf-8") + panel = PANEL_ENTRY_JS.read_text(encoding="utf-8") + views = PANEL_VIEWS_JS.read_text(encoding="utf-8") + self.assertIn('{ key: "energy", label: "Energie"', domain) + self.assertIn('energy: ["energy", "kwh", "wh"]', panel.lower()) + self.assertIn('itemBinding ? (itemBinding.owner_type === "tent" ? "Zelt" : "Pflanze") : "Nicht zugeordnet"', views) + + def test_create_flow_is_three_steps_for_one_plant_and_one_run(self): + source = panel_source() + self.assertIn("1. Sorte und Pflanze", source) + self.assertIn("2. Ablauf und Sensoren", source) + self.assertIn("3. Prüfen und anlegen", source) + self.assertIn('command("create_run"', source) + self.assertIn("plant_name:", source) + self.assertIn("initial_stage:", source) + self.assertIn("stage_plan:", source) + self.assertIn("container:", source) + self.assertIn("substrate:", source) + self.assertIn("light_schedule:", source) + self.assertNotIn("Add plant", source) + + def test_seedfinder_preview_does_not_import_until_confirmed(self): + panel = PANEL_ENTRY_JS.read_text(encoding="utf-8") + dialogs = PANEL_DIALOGS_JS.read_text(encoding="utf-8") + preview_start = panel.index("_previewCultivar(index)") + apply_start = panel.index("_applyCultivarPreview()") + self.assertNotIn("this._createDraft.strain =", panel[preview_start:apply_start]) + self.assertIn("this._createDraft.strain =", panel[apply_start:]) + self.assertIn("this._cultivarPreview", dialogs) + self.assertIn("Angaben übernehmen", dialogs) + + def test_journal_is_history_first_with_on_demand_editor(self): + source = panel_source() + self.assertIn("Verlauf zuerst", source) + self.assertIn('data-action="open-journal-editor"', source) + self.assertIn('class="journal-drawer"', source) + self.assertIn('"create_journal_entry"', source) + self.assertIn('this._command(command, {', source) + self.assertIn("occurred_at:", source) + self.assertIn("run_ids:", source) + self.assertIn("const tentEntries", source) + self.assertIn("Sensorkontext wird beim Speichern angehängt", source) + + def test_stage_change_is_direct_and_timestamped(self): + source = panel_source() + self.assertIn('data-action="select-stage"', source) + self.assertIn('data-stage-occurred-at', source) + self.assertIn('command("change_stage"', source) + self.assertIn("occurred_at:", source) + + def test_archive_and_permanent_deletion_are_separate(self): + source = panel_source() + self.assertIn('command("archive_run"', source) + self.assertIn('command("delete_run"', source) + self.assertIn("Dauerhaft löschen", source) + self.assertIn("Diese Aktion kann nicht rückgängig gemacht werden.", source) + self.assertIn("confirmation_name:", source) + self.assertIn('data-delete-confirmation', source) + self.assertNotIn("vollständig lesbar und bearbeitbar", source) + + def test_styles_follow_shape_mobile_focus_and_motion_contracts(self): + styles = PANEL_STYLES_JS.read_text(encoding="utf-8") + for token in ("--radius-work:24px", "--radius-group:18px", "--radius-control:12px"): + self.assertIn(token, styles) + self.assertIn("@media(max-width:720px)", styles) + self.assertIn("@media(prefers-reduced-motion:reduce)", styles) + self.assertIn(":focus-visible", styles) + self.assertIn(".mobile-nav", styles) + + def test_lovelace_card_and_editor_remain_registered(self): + card = CARD_JS.read_text(encoding="utf-8") + editor = EDITOR_JS.read_text(encoding="utf-8") + self.assertIn('const TAG = "plantrun-card"', card) + self.assertIn("customElements.define(TAG, PlantRunCard)", card) + self.assertIn('const TAG = "plantrun-card-editor"', editor) + self.assertIn("customElements.define(TAG, PlantRunCardEditor)", editor) if __name__ == "__main__": diff --git a/tests/test_dashboard_panel_interactions.py b/tests/test_dashboard_panel_interactions.py index 06fd28d..6eb4183 100644 --- a/tests/test_dashboard_panel_interactions.py +++ b/tests/test_dashboard_panel_interactions.py @@ -7,122 +7,97 @@ class DashboardPanelInteractionRegressionTests(unittest.TestCase): def setUp(self): self.source = load_panel_source() - def test_short_tap_and_long_press_are_single_fire_paths(self): - assert_has_snippets( - self, - self.source, - [ - "this._pressState[key].longPressTriggered = true;", - "this._openEntity(tile.dataset.entityId);", - "const wasLongPress = !!state.longPressTriggered;", - "if (!wasLongPress) this._openRunHistory(tile.dataset.runId, tile.dataset.entityId);", - "binding_id: binding?.id || \"\"", - 'type: "plantrun/get_run_binding_history_context"', - "if (this._openNativeHistory(context)) return;", - "delete this._pressState[key];", - ], - ) - - def test_rapid_sensor_interactions_clear_prior_timer(self): - assert_has_snippets( - self, - self.source, - [ - 'if (event.target.closest("button, input, select, textarea, ha-selector")) return;', - "const current = this._pressState[key];", - "if (current?.timer) window.clearTimeout(current.timer);", - "_handlePointerCancel(event)", - "if (state?.timer) window.clearTimeout(state.timer);", - ], - ) - - def test_delegated_clicks_are_scoped_to_shadow_root_actions(self): - assert_has_snippets( - self, - self.source, - [ - 'this.shadowRoot.addEventListener("click", this._boundClick);', - 'const target = event.target.closest("[data-action]");', - "if (!target || !this.shadowRoot.contains(target)) return;", - "event.preventDefault();", - ], - ) - - def test_modal_backdrops_close_dialogs_without_inputs_triggering_overlay_close(self): - assert_has_snippets( - self, - self.source, - [ - '
', - 'class="overlay-backdrop" data-action="close-wizard"', - 'class="overlay-backdrop" data-action="close-binding"', - 'class="overlay-backdrop" data-action="close-note-edit"', - 'class="overlay-backdrop" data-action="close-note-delete"', - 'class="overlay-backdrop" data-action="close-edit"', - 'class="overlay-backdrop" data-action="close-history"', - ], - ) - - def test_binding_tiles_expose_explicit_edit_action_and_filtered_picker_states(self): - assert_has_snippets( - self, - self.source, - [ - 'data-action="edit-binding"', - 'No compatible Home Assistant sensors found', - 'This sensor no longer matches the selected metric.', - 'this._openBinding(target.dataset.runId, target.dataset.bindingId);', - ], - ) - - def test_modal_overlays_are_scoped_to_panel_host(self): - assert_has_snippets( - self, - self.source, - [ - ":host { display:block; min-height:100%;", - ".overlay { position:absolute; inset:0; z-index:20;", - ".overlay-backdrop { position:absolute; inset:0;", - "position:relative;", - ], - ) - - def test_decorative_stage_art_does_not_steal_pointer_input(self): - assert_has_snippets( - self, - self.source, - [ - ".stage-glyph { position:absolute;", - "pointer-events:none;", - ".sensor-tile {", - "touch-action:manipulation;", - ".metric-badge { display:grid; place-items:center;", - ], - ) - - def test_sound_feedback_is_user_gated_and_disableable(self): - assert_has_snippets( - self, - self.source, - [ - 'localStorage.getItem(STORAGE.sound) === "on"', - 'data-action="toggle-sound"', - "_clickSound()", - "if (!this._sound) return;", - ], - ) - - def test_theme_toggle_is_binary_and_has_local_light_dark_tokens(self): - assert_has_snippets( - self, - self.source, - [ - 'this._theme = localStorage.getItem(STORAGE.theme) || (window.matchMedia?.(THEME_QUERY).matches ? "light" : "dark")', - 'this._theme = this._resolvedTheme() === "dark" ? "light" : "dark";', - '.app.theme-light {', - '.app.theme-dark {', - ], - ) + def test_delegated_events_stay_inside_shadow_root(self): + assert_has_snippets(self, self.source, [ + 'this.shadowRoot.addEventListener("click", this._boundClick);', + 'const action = event.target.closest("[data-action]");', + "if (!action || !this.shadowRoot.contains(action)) return;", + 'this.shadowRoot.addEventListener("keydown", this._boundKeydown);', + ]) + + def test_command_busy_state_and_mutation_errors_stay_recoverable(self): + assert_has_snippets(self, self.source, [ + "this._busy = true;", + "this._busy = false;", + 'role="alert"', + "const message = error?.message", + "this._dialogError = message;", + "this._toast = message;", + ]) + self.assertIn('this._error = error?.message || "Home Assistant hat keinen PlantRun-Zustand geliefert.";', self.source) + + def test_create_flow_keeps_initial_stage_in_the_plan(self): + assert_has_snippets(self, self.source, [ + 'target.dataset.createField === "initial_stage"', + "this._createDraft.stage_plan.includes(target.value)", + "stage === draft.initial_stage ? \"disabled\" : \"\"", + "if (stage === this._createDraft.initial_stage && !target.checked)", + ]) + + def test_cultivar_result_preview_requires_explicit_import(self): + assert_has_snippets(self, self.source, [ + 'data-action="preview-cultivar"', + 'data-action="apply-cultivar"', + "this._previewCultivar(Number(action.dataset.index));", + "this._applyCultivarPreview();", + "_previewCultivar(index)", + "_applyCultivarPreview()", + "Angaben übernehmen", + ]) + + def test_recorder_fetch_ignores_stale_metric_requests(self): + assert_has_snippets(self, self.source, [ + "const nonce = ++this._historyNonce;", + "if (nonce !== this._historyNonce) return;", + "this._api.getRecorderHistory(", + "this._historyPoints = points;", + ]) + + def test_dialogs_use_real_modal_semantics_and_backdrops(self): + assert_has_snippets(self, self.source, [ + 'role="dialog"', + 'aria-modal="true"', + 'class="modal-backdrop"', + 'data-action="close-dialog"', + 'data-action="close-journal-editor"', + ]) + + def test_keyboard_shortcut_saves_journal_only_in_editor(self): + assert_has_snippets(self, self.source, [ + 'event.key === "Enter"', + "event.ctrlKey || event.metaKey", + 'this._journalEditorOpen', + 'this._saveJournalEntry();', + ]) + + def test_sensor_bindings_can_be_reassigned_or_ended(self): + assert_has_snippets(self, self.source, [ + 'data-action="open-binding-editor"', + 'this._command("set_binding"', + 'this._command("clear_binding"', + 'data-action="clear-binding"', + ]) + + def test_new_journal_entry_captures_current_bound_sensor_values(self): + assert_has_snippets(self, self.source, [ + "_sensorSnapshot(run)", + "sensor_snapshot:", + "snapshot.captured_at = capturedAt;", + "entry?.sensor_snapshot", + ]) + + def test_snapshot_capture_time_is_metadata_not_a_sensor_value(self): + assert_has_snippets(self, self.source, [ + 'key !== "captured_at"', + "contextRows.length", + ]) + + def test_delete_button_requires_exact_run_name(self): + assert_has_snippets(self, self.source, [ + "confirmation !== this._runName(run)", + 'data-action="confirm-delete-run"', + "confirmation_name: confirmation", + ]) if __name__ == "__main__": diff --git a/tests/test_domain_v3.py b/tests/test_domain_v3.py new file mode 100644 index 0000000..7b863bf --- /dev/null +++ b/tests/test_domain_v3.py @@ -0,0 +1,555 @@ +import unittest +import json +from datetime import datetime, timedelta, timezone +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + + +DOMAIN_PATH = ( + Path(__file__).resolve().parents[1] + / "custom_components" + / "plantrun" + / "domain.py" +) +SPEC = spec_from_file_location("plantrun_domain", DOMAIN_PATH) +assert SPEC and SPEC.loader +DOMAIN = module_from_spec(SPEC) +SPEC.loader.exec_module(DOMAIN) + +BindingDraft = DOMAIN.BindingDraft +JournalDraft = DOMAIN.JournalDraft +PlantRunDomain = DOMAIN.PlantRunDomain +RunDraft = DOMAIN.RunDraft +SourcedDuration = DOMAIN.SourcedDuration +StrainIdentity = DOMAIN.StrainIdentity + + +UTC = timezone.utc +PLANTED_AT = datetime(2026, 8, 25, 14, 0, tzinfo=UTC) +NOW = datetime(2026, 8, 25, 18, 0, tzinfo=UTC) + + +class PrefixIds: + def __init__(self) -> None: + self._counts: dict[str, int] = {} + + def __call__(self, prefix: str) -> str: + count = self._counts.get(prefix, 0) + 1 + self._counts[prefix] = count + return f"{prefix}-{count}" + + +def make_domain() -> PlantRunDomain: + return PlantRunDomain(clock=lambda: NOW, id_factory=PrefixIds()) + + +def create_minimal_run(domain: PlantRunDomain, name: str = "Diesel Auto RQS"): + return domain.create_run( + RunDraft( + run_name=name, + tent_name="Growzelt", + plant_name=name, + planted_at=PLANTED_AT, + stage_plan=("Germination", "Seedling", "Flowering", "Harvested"), + initial_stage="Germination", + ) + ) + + +class TestRunCreation(unittest.TestCase): + def test_creates_one_plant_run_with_sourced_duration_and_dated_bindings(self) -> None: + domain = make_domain() + duration = SourcedDuration( + minimum_days=90, + maximum_days=95, + meaning="seed to harvest", + start_event="germination", + source="https://example.test/diesel-auto", + original_wording="90-95 days after germination", + ) + + run = domain.create_run( + RunDraft( + run_name="Diesel Auto RQS", + tent_name="Growzelt", + plant_name="Diesel Auto", + planted_at=PLANTED_AT, + stage_plan=("Germination", "Seedling", "Vegetative", "Flowering", "Harvested"), + initial_stage="Germination", + strain=StrainIdentity( + name="Diesel Auto", + breeder="Royal Queen Seeds", + duration=duration, + ), + container="7 L final container", + substrate="All-Mix soil", + light_schedule="20:00 to 14:00, 18/6", + sensor_bindings=( + BindingDraft( + owner="tent", + metric_type="temperature", + entity_id="sensor.growzelt_temperature", + ), + BindingDraft( + owner="run", + metric_type="soil_moisture", + entity_id="sensor.diesel_soil_moisture", + ), + ), + ) + ) + + snapshot = domain.snapshot() + self.assertEqual(len(snapshot.tents), 1) + self.assertEqual(len(snapshot.plants), 1) + self.assertEqual(len(snapshot.runs), 1) + self.assertEqual(run.plant_id, snapshot.plants[0].id) + self.assertEqual(run.tent_id, snapshot.tents[0].id) + self.assertEqual(run.current_stage, "Germination") + self.assertEqual(run.stage_history[0].occurred_at, PLANTED_AT) + self.assertEqual(snapshot.plants[0].strain.duration, duration) + self.assertEqual(snapshot.plants[0].container, "7 L final container") + self.assertEqual(snapshot.plants[0].substrate, "All-Mix soil") + self.assertEqual(snapshot.tents[0].light_schedule, "20:00 to 14:00, 18/6") + self.assertEqual( + [(binding.owner, binding.started_at) for binding in snapshot.bindings], + [("tent", PLANTED_AT), ("run", PLANTED_AT)], + ) + + def test_invalid_run_creation_is_atomic(self) -> None: + domain = make_domain() + + with self.assertRaisesRegex(DOMAIN.ValidationError, "initial_stage"): + domain.create_run( + RunDraft( + run_name="Broken run", + tent_name="Growzelt", + plant_name="Broken plant", + planted_at=PLANTED_AT, + stage_plan=("Seedling", "Flowering"), + initial_stage="Germination", + ) + ) + + snapshot = domain.snapshot() + self.assertEqual(snapshot.tents, ()) + self.assertEqual(snapshot.plants, ()) + self.assertEqual(snapshot.runs, ()) + + def test_reuses_persistent_tent_and_does_not_duplicate_shared_binding(self) -> None: + domain = make_domain() + shared = BindingDraft( + owner="tent", + metric_type="temperature", + entity_id="sensor.growzelt_temperature", + ) + first = domain.create_run( + RunDraft( + run_name="Diesel Auto RQS", + tent_name="Growzelt", + plant_name="Diesel Auto", + planted_at=PLANTED_AT, + stage_plan=("Germination", "Harvested"), + initial_stage="Germination", + sensor_bindings=(shared,), + ) + ) + second = domain.create_run( + RunDraft( + run_name="Tangerine Dream Auto Zamnesia", + tent_name="growzelt", + plant_name="Tangerine Dream Auto", + planted_at=PLANTED_AT, + stage_plan=("Germination", "Harvested"), + initial_stage="Germination", + sensor_bindings=(shared,), + ) + ) + + snapshot = domain.snapshot() + self.assertEqual(first.tent_id, second.tent_id) + self.assertEqual(len(snapshot.tents), 1) + self.assertEqual(len(snapshot.runs), 2) + self.assertEqual(len({run.plant_id for run in snapshot.runs}), 2) + self.assertEqual(len(snapshot.bindings), 1) + + +class TestJournal(unittest.TestCase): + def test_adds_dated_entry_with_optional_details_and_sensor_context(self) -> None: + domain = make_domain() + run = create_minimal_run(domain) + + entry = domain.add_journal_entry( + JournalDraft( + tent_id=run.tent_id, + run_ids=(run.id,), + entry_type="Water", + text="Soil wetted and a small unmeasured sprinkle over the seed.", + occurred_at=PLANTED_AT, + details={"amount_ml": None}, + sensor_snapshot={"sensor.growzelt_temperature": 24.1}, + ) + ) + + self.assertEqual(entry.created_at, NOW) + self.assertEqual(entry.updated_at, NOW) + self.assertEqual(entry.occurred_at, PLANTED_AT) + self.assertEqual(entry.run_ids, (run.id,)) + self.assertEqual(entry.details, {"amount_ml": None}) + self.assertEqual( + entry.sensor_snapshot, + {"sensor.growzelt_temperature": 24.1}, + ) + self.assertEqual(domain.snapshot().journal_entries, (entry,)) + + def test_edit_preserves_creation_time_and_delete_removes_the_entry(self) -> None: + edited_at = NOW + timedelta(hours=1) + moments = iter((NOW, NOW, edited_at)) + domain = PlantRunDomain(clock=lambda: next(moments), id_factory=PrefixIds()) + run = create_minimal_run(domain) + entry = domain.add_journal_entry( + JournalDraft( + tent_id=run.tent_id, + run_ids=(run.id,), + entry_type="Inspect", + text="Seed checked.", + occurred_at=PLANTED_AT, + ) + ) + + replacement = domain.edit_journal_entry( + entry.id, + JournalDraft( + tent_id=run.tent_id, + run_ids=(run.id,), + entry_type="Inspect", + text="Seed checked, soil still moist.", + occurred_at=PLANTED_AT + timedelta(minutes=15), + details={"follow_up": "tomorrow"}, + ), + ) + + self.assertEqual(replacement.id, entry.id) + self.assertEqual(replacement.created_at, entry.created_at) + self.assertEqual(replacement.updated_at, edited_at) + self.assertEqual(replacement.occurred_at, PLANTED_AT + timedelta(minutes=15)) + self.assertEqual(replacement.details, {"follow_up": "tomorrow"}) + self.assertEqual(domain.delete_journal_entry(entry.id), replacement) + self.assertEqual(domain.snapshot().journal_entries, ()) + + +class TestStages(unittest.TestCase): + def test_can_switch_directly_to_any_planned_stage_and_reorder_without_rewriting_history(self) -> None: + domain = make_domain() + run = create_minimal_run(domain) + flowering_at = PLANTED_AT + timedelta(days=25) + correction_at = PLANTED_AT + timedelta(days=26) + + flowering = domain.change_stage(run.id, "Flowering", flowering_at) + corrected = domain.change_stage(run.id, "Germination", correction_at) + history_before_reorder = corrected.stage_history + reordered = domain.set_stage_plan( + run.id, + ("Germination", "Flowering", "Seedling", "Harvested"), + ) + + self.assertEqual(flowering.current_stage, "Flowering") + self.assertEqual(corrected.current_stage, "Germination") + self.assertEqual(corrected.stage_history[-1].from_stage, "Flowering") + self.assertEqual(corrected.stage_history[-1].occurred_at, correction_at) + self.assertEqual(reordered.stage_history, history_before_reorder) + self.assertEqual( + reordered.stage_plan, + ("Germination", "Flowering", "Seedling", "Harvested"), + ) + + def test_stage_changes_and_run_end_cannot_break_timeline_order(self) -> None: + domain = make_domain() + run = create_minimal_run(domain) + flowering_at = PLANTED_AT + timedelta(days=25) + domain.change_stage(run.id, "Flowering", flowering_at) + + with self.assertRaisesRegex(DOMAIN.ValidationError, "latest stage change"): + domain.change_stage(run.id, "Germination", flowering_at - timedelta(days=1)) + with self.assertRaisesRegex(DOMAIN.ValidationError, "latest stage change"): + domain.finish_run(run.id, flowering_at - timedelta(hours=1)) + + +class TestRunEditingAndBindings(unittest.TestCase): + def test_updates_run_and_plant_identity_without_replacing_their_ids(self) -> None: + domain = make_domain() + run = create_minimal_run(domain) + corrected_planting = PLANTED_AT - timedelta(hours=1) + strain = StrainIdentity(name="Diesel Auto", breeder="Royal Queen Seeds") + + updated = domain.update_run( + run.id, + run_name="Diesel Auto 2026", + nickname="Diesel", + plant_name="Diesel Auto", + strain=strain, + planted_at=corrected_planting, + image="/local/plantrun/diesel.jpg", + ) + + snapshot = domain.snapshot() + plant = snapshot.plants[0] + self.assertEqual(updated.id, run.id) + self.assertEqual(updated.plant_id, run.plant_id) + self.assertEqual(updated.name, "Diesel Auto 2026") + self.assertEqual(updated.nickname, "Diesel") + self.assertEqual(updated.planted_at, corrected_planting) + self.assertEqual(plant.id, run.plant_id) + self.assertEqual(plant.name, "Diesel Auto") + self.assertEqual(plant.strain, strain) + self.assertEqual(plant.image, "/local/plantrun/diesel.jpg") + + def test_rebinding_a_metric_closes_the_old_assignment_at_the_change_time(self) -> None: + domain = make_domain() + run = domain.create_run( + RunDraft( + run_name="Diesel Auto RQS", + tent_name="Growzelt", + plant_name="Diesel Auto", + planted_at=PLANTED_AT, + stage_plan=("Germination", "Harvested"), + initial_stage="Germination", + sensor_bindings=( + BindingDraft( + owner="run", + metric_type="soil_moisture", + entity_id="sensor.soil_probe_1", + ), + ), + ) + ) + reassigned_at = PLANTED_AT + timedelta(days=7) + + new_binding = domain.set_sensor_binding( + "run", + run.id, + "soil_moisture", + "sensor.soil_probe_2", + reassigned_at, + ) + + bindings = domain.snapshot().bindings + self.assertEqual(len(bindings), 2) + self.assertEqual(bindings[0].entity_id, "sensor.soil_probe_1") + self.assertEqual(bindings[0].ended_at, reassigned_at) + self.assertEqual(new_binding.entity_id, "sensor.soil_probe_2") + self.assertEqual(new_binding.started_at, reassigned_at) + self.assertIsNone(new_binding.ended_at) + + def test_clearing_a_binding_ends_ownership_without_erasing_history(self) -> None: + domain = make_domain() + run = create_minimal_run(domain) + binding = domain.set_sensor_binding( + "run", + run.id, + "soil_moisture", + "sensor.diesel_soil", + PLANTED_AT, + ) + cleared_at = PLANTED_AT + timedelta(days=2) + + closed = domain.end_sensor_binding( + "run", + run.id, + "soil_moisture", + cleared_at, + ) + + self.assertEqual([item.id for item in closed], [binding.id]) + self.assertEqual(domain.snapshot().bindings[0].ended_at, cleared_at) + + def test_archived_run_cannot_receive_an_active_binding(self) -> None: + domain = make_domain() + run = create_minimal_run(domain) + ended_at = PLANTED_AT + timedelta(days=80) + domain.finish_run(run.id, ended_at) + + with self.assertRaisesRegex(DOMAIN.ValidationError, "archived runs"): + domain.set_sensor_binding( + "run", + run.id, + "soil_moisture", + "sensor.late_probe", + ended_at, + ) + + +class TestArchiveAndDeletion(unittest.TestCase): + def test_finishing_archives_the_run_and_preserves_every_record(self) -> None: + domain = make_domain() + run = domain.create_run( + RunDraft( + run_name="Diesel Auto RQS", + tent_name="Growzelt", + plant_name="Diesel Auto", + planted_at=PLANTED_AT, + stage_plan=("Germination", "Harvested"), + initial_stage="Germination", + sensor_bindings=( + BindingDraft( + owner="run", + metric_type="soil_moisture", + entity_id="sensor.diesel_soil", + ), + ), + ) + ) + entry = domain.add_journal_entry( + JournalDraft( + tent_id=run.tent_id, + run_ids=(run.id,), + entry_type="Harvest", + text="Harvested and hung to dry.", + occurred_at=PLANTED_AT + timedelta(days=90), + ) + ) + + archived = domain.finish_run( + run.id, + PLANTED_AT + timedelta(days=90), + harvest_details={"dry_yield_g": 48.5}, + ) + + snapshot = domain.snapshot() + self.assertEqual(archived.status, "archived") + self.assertEqual(archived.ended_at, PLANTED_AT + timedelta(days=90)) + self.assertEqual(archived.harvest_details, {"dry_yield_g": 48.5}) + self.assertEqual(snapshot.journal_entries, (entry,)) + self.assertEqual(len(snapshot.plants), 1) + self.assertEqual(len(snapshot.bindings), 1) + self.assertEqual(snapshot.bindings[0].ended_at, archived.ended_at) + + def test_permanent_deletion_requires_exact_name_and_removes_only_run_owned_data(self) -> None: + domain = make_domain() + first = domain.create_run( + RunDraft( + run_name="Diesel Auto RQS", + tent_name="Growzelt", + plant_name="Diesel Auto", + planted_at=PLANTED_AT, + stage_plan=("Germination", "Harvested"), + initial_stage="Germination", + sensor_bindings=( + BindingDraft( + owner="tent", + metric_type="temperature", + entity_id="sensor.growzelt_temperature", + ), + BindingDraft( + owner="run", + metric_type="soil_moisture", + entity_id="sensor.diesel_soil", + ), + ), + ) + ) + second = create_minimal_run(domain, "Tangerine Dream Auto Zamnesia") + shared_entry = domain.add_journal_entry( + JournalDraft( + tent_id=first.tent_id, + run_ids=(first.id, second.id), + text="Both seeds planted directly into final pots.", + occurred_at=PLANTED_AT, + ) + ) + only_first_entry = domain.add_journal_entry( + JournalDraft( + tent_id=first.tent_id, + run_ids=(first.id,), + text="Only belongs to Diesel Auto RQS.", + occurred_at=PLANTED_AT, + ) + ) + before = domain.snapshot() + + with self.assertRaises(DOMAIN.ConfirmationError): + domain.permanently_delete_run(first.id, "diesel auto rqs") + self.assertEqual(domain.snapshot(), before) + + deleted = domain.permanently_delete_run(first.id, "Diesel Auto RQS") + snapshot = domain.snapshot() + self.assertEqual(deleted, first) + self.assertEqual([run.id for run in snapshot.runs], [second.id]) + self.assertEqual([plant.id for plant in snapshot.plants], [second.plant_id]) + self.assertEqual(len(snapshot.tents), 1) + self.assertEqual( + [(binding.owner, binding.entity_id) for binding in snapshot.bindings], + [("tent", "sensor.growzelt_temperature")], + ) + self.assertEqual(snapshot.journal_entries[0].id, shared_entry.id) + self.assertEqual(snapshot.journal_entries[0].run_ids, (second.id,)) + self.assertNotIn(only_first_entry.id, {entry.id for entry in snapshot.journal_entries}) + + +class TestPersistence(unittest.TestCase): + def test_exported_state_is_json_safe_and_round_trips_without_losing_meaning(self) -> None: + domain = make_domain() + duration = SourcedDuration( + minimum_days=70, + maximum_days=77, + meaning="seed to harvest", + start_event="germination", + source="https://example.test/tangerine-dream-auto", + original_wording="about 11 weeks after germination", + ) + run = domain.create_run( + RunDraft( + run_name="Tangerine Dream Auto Zamnesia", + nickname="Tangerine", + tent_name="Growzelt", + plant_name="Tangerine Dream Auto", + planted_at=PLANTED_AT, + stage_plan=("Germination", "Seedling", "Flowering", "Harvested"), + initial_stage="Germination", + strain=StrainIdentity( + name="Tangerine Dream Auto", + breeder="Zamnesia", + duration=duration, + ), + image="https://example.test/tangerine.jpg", + container="7 L final container", + substrate="All-Mix soil", + light_schedule="20:00 to 14:00, 18/6", + ) + ) + domain.add_journal_entry( + JournalDraft( + tent_id=run.tent_id, + run_ids=(run.id,), + entry_type="Water", + text="Small unmeasured sprinkle over the seed.", + occurred_at=PLANTED_AT, + details={"amount_ml": None}, + ) + ) + domain.change_stage(run.id, "Seedling", PLANTED_AT + timedelta(days=3)) + + state = domain.export_state() + encoded = json.dumps(state) + restored = PlantRunDomain.from_state( + json.loads(encoded), + clock=lambda: NOW, + id_factory=PrefixIds(), + ) + + self.assertEqual( + set(state), + {"tents", "plants", "runs", "journal_entries", "sensor_bindings"}, + ) + self.assertEqual(restored.snapshot(), domain.snapshot()) + self.assertEqual( + restored.snapshot().plants[0].strain.duration.original_wording, + "about 11 weeks after germination", + ) + self.assertEqual(restored.snapshot().plants[0].container, "7 L final container") + self.assertEqual(restored.snapshot().plants[0].substrate, "All-Mix soil") + self.assertEqual(restored.snapshot().tents[0].light_schedule, "20:00 to 14:00, 18/6") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_panel_autocomplete.py b/tests/test_panel_autocomplete.py index dc97778..053510f 100644 --- a/tests/test_panel_autocomplete.py +++ b/tests/test_panel_autocomplete.py @@ -1,26 +1,19 @@ import unittest -from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] -PANEL_PATH = ROOT / "custom_components" / "plantrun" / "www" / "plantrun-panel.js" +from tests.dashboard_js_test_utils import load_panel_source class PanelAutocompleteTest(unittest.TestCase): - def test_cultivar_input_supports_keyboard_click_and_stable_async_selection(self): - source = PANEL_PATH.read_text(encoding="utf-8") - - self.assertIn("_handleKeydown(event)", source) - self.assertIn('event.key === "Enter" || event.key === "Tab"', source) - self.assertIn('event.key === "Escape"', source) - self.assertIn('data-action="choose-cultivar"', source) - self.assertIn('data-prevent-mousedown', source) - self.assertIn('_handleMouseDown(event)', source) - self.assertIn("_scheduleCultivarSearch()", source) - self.assertIn("_scheduleDetailCultivarSearch()", source) - self.assertIn('data-detail-cultivar-input', source) - self.assertIn('data-action="choose-detail-cultivar"', source) - self.assertIn("Refreshing results…", source) - self.assertIn("_renderSuggestionsOnly()", source) + def test_cultivar_search_is_async_stable_and_manual_entry_remains_available(self): + source = load_panel_source() + self.assertIn("const nonce = ++this._searchNonce;", source) + self.assertIn("if (nonce !== this._searchNonce) return;", source) + self.assertIn("this._api.searchCultivar(", source) + self.assertIn('data-action="preview-cultivar"', source) + self.assertIn('data-action="apply-cultivar"', source) + self.assertIn("Manuell fortfahren", source) + self.assertIn("Die Suche blockiert das Anlegen nie.", source) + self.assertIn('["strain", "breeder"].includes', source) if __name__ == "__main__": diff --git a/tests/test_panel_notes_editing.py b/tests/test_panel_notes_editing.py index 4e60c87..0446bcb 100644 --- a/tests/test_panel_notes_editing.py +++ b/tests/test_panel_notes_editing.py @@ -1,29 +1,19 @@ -from pathlib import Path import unittest +from tests.dashboard_js_test_utils import load_panel_source -ROOT = Path(__file__).resolve().parents[1] -PANEL_PATH = ROOT / "custom_components" / "plantrun" / "www" / "plantrun-panel.js" - -class PanelNotesEditingTests(unittest.TestCase): - def test_notes_support_create_edit_delete_and_datetime_display(self): - source = PANEL_PATH.read_text(encoding="utf-8") - - self.assertIn("formatDateTime", source) - self.assertIn('data-action="add-note"', source) - self.assertIn('data-action="edit-note"', source) - self.assertIn('data-action="confirm-delete-note"', source) - self.assertIn('data-action="save-note-edit"', source) - self.assertIn('data-action="delete-note"', source) - self.assertIn('data-note-edit-text', source) - self.assertIn('S.formatDateTime(note.timestamp)', source) - self.assertIn('_openNewNoteEditor(runId)', source) - self.assertIn('

${isNew ? "New note" : "Edit note"}

', source) - self.assertIn('this._hass.callService(DOMAIN, "add_note"', source) - self.assertIn('this._hass.callService(DOMAIN, "update_note"', source) - self.assertIn('this._hass.callService(DOMAIN, "delete_note"', source) - self.assertNotIn('data-note-draft', source) +class PanelJournalEditingTests(unittest.TestCase): + def test_journal_supports_create_edit_delete_and_occurrence_time(self): + source = load_panel_source() + self.assertIn('"create_journal_entry"', source) + self.assertIn('"update_journal_entry"', source) + self.assertIn('command("delete_journal_entry"', source) + self.assertIn('data-journal-occurred-at', source) + self.assertIn('data-action="edit-journal-entry"', source) + self.assertIn('data-action="request-delete-journal-entry"', source) + self.assertIn("occurred_at:", source) + self.assertIn("entry_id: entry.id", source) if __name__ == "__main__": diff --git a/tests/test_stability_lifecycle.py b/tests/test_stability_lifecycle.py index 2a240a8..20c6dcb 100644 --- a/tests/test_stability_lifecycle.py +++ b/tests/test_stability_lifecycle.py @@ -1,5 +1,6 @@ import asyncio import base64 +import hashlib import importlib.util import json import shutil @@ -527,10 +528,17 @@ def test_panel_script_is_loaded_as_a_versioned_module(self): manifest_version = json.loads((PLANTRUN_DIR / "manifest.json").read_text(encoding="utf-8"))[ "version" ] - panel_mtime = int((PLANTRUN_DIR / "www" / "plantrun-panel.js").stat().st_mtime) - expected_url = f"/plantrun_frontend/plantrun-panel.js?v={manifest_version}-{panel_mtime}" - - self.assertNotIn('import {', panel_script) + module_paths = tuple(sorted((PLANTRUN_DIR / "www").glob("plantrun-panel*.js"))) + expected_fingerprint = hashlib.sha256( + "|".join( + f"{path.name}:{path.stat().st_mtime_ns}:{path.stat().st_size}" + for path in module_paths + ).encode() + ).hexdigest()[:12] + expected_url = f"/plantrun_frontend/plantrun-panel.js?v={manifest_version}-{expected_fingerprint}" + + self.assertIn('import { PlantRunApi }', panel_script) + self.assertIn('import { panelStyles }', panel_script) self.assertIn('customElements.get("ha-panel-lovelace")', panel_script) self.assertEqual(self.integration.PANEL_MODULE_URL, expected_url) self.assertEqual(self.integration.PANEL_JS_URL, expected_url) @@ -712,6 +720,27 @@ def test_create_run_service_initializes_default_phase(self): self.assertEqual(len(storage.runs), 1) self.assertEqual(storage.runs[0].phases[0].name, "Seedling") + def test_add_phase_service_accepts_a_custom_phase(self): + hass = self._build_hass() + entry = sys.modules["homeassistant.config_entries"].ConfigEntry("entry-custom-phase") + asyncio.run(self.integration.async_setup_entry(hass, entry)) + + storage = FakeStorage.instances[-1] + run = self.models.RunData( + id="run-custom-phase", + friendly_name="Tent Custom", + start_time="2026-03-10T00:00:00+00:00", + phases=[self.models.Phase(name="Vegetative", start_time="2026-03-10T00:00:00+00:00")], + ) + storage.runs.append(run) + storage.active_run_id = run.id + handler = hass.services.get_handler(self.integration.DOMAIN, "add_phase") + + asyncio.run(handler(sys.modules["homeassistant.core"].ServiceCall({"run_id": run.id, "phase_name": "Drying"}))) + + self.assertEqual(run.phases[-1].name, "Drying") + self.assertEqual(run.status, "active") + def test_set_cultivar_service_uses_shared_session(self): hass = self._build_hass() entry = sys.modules["homeassistant.config_entries"].ConfigEntry("entry-service") diff --git a/tests/test_store_migration.py b/tests/test_store_migration.py index b91020a..bbfde28 100644 --- a/tests/test_store_migration.py +++ b/tests/test_store_migration.py @@ -70,27 +70,29 @@ def test_migrates_v1_payload(self) -> None: } migrated, changed = PlantRunStorage._normalize_payload(payload) self.assertTrue(changed) - self.assertEqual(migrated["schema_version"], 2) - self.assertIn("active_run_id", migrated) - self.assertEqual(migrated["active_run_id"], None) - self.assertEqual(migrated["runs"][0]["notes"], []) - self.assertEqual(migrated["runs"][0]["phases"][0]["name"], "Seedling") + self.assertEqual(migrated["schema_version"], 3) + self.assertEqual(migrated["domain"]["runs"], []) + self.assertEqual(migrated["legacy_v2"]["active_run_id"], None) + self.assertEqual(migrated["legacy_v2"]["runs"][0]["notes"], []) + self.assertEqual( + migrated["legacy_v2"]["runs"][0]["phases"][0]["name"], + "Seedling", + ) def test_migration_is_idempotent(self) -> None: payload = { - "schema_version": 2, + "schema_version": 3, "active_run_id": None, "daily_rollups": {}, - "runs": [ - { - "id": "run1", - "friendly_name": "Run A", - "start_time": "2026-03-01T00:00:00", - "notes": [], - "phases": [{"name": "Seedling", "start_time": "2026-03-01T00:00:00"}], - "bindings": [], - } - ], + "domain": { + "tents": [], + "plants": [], + "runs": [], + "journal_entries": [], + "sensor_bindings": [], + }, + "legacy_v2": {"runs": [], "active_run_id": None, "daily_rollups": {}}, + "compatibility_runs": [], } first, first_changed = PlantRunStorage._normalize_payload(payload) second, second_changed = PlantRunStorage._normalize_payload(first) @@ -116,9 +118,12 @@ def test_schema_v2_run_without_phases_gets_default_phase(self) -> None: } migrated, changed = PlantRunStorage._normalize_payload(payload) self.assertTrue(changed) - self.assertEqual(migrated["runs"][0]["phases"][0]["name"], "Seedling") + self.assertEqual( + migrated["legacy_v2"]["runs"][0]["phases"][0]["name"], + "Seedling", + ) - def test_async_load_skips_malformed_runs_and_clears_invalid_active_run(self) -> None: + def test_async_load_preserves_legacy_runs_without_exposing_them(self) -> None: storage = PlantRunStorage(object()) storage._store.saved = { "schema_version": 2, @@ -147,10 +152,163 @@ def test_async_load_skips_malformed_runs_and_clears_invalid_active_run(self) -> asyncio.run(storage.async_load()) - self.assertEqual(len(storage.runs), 1) - self.assertEqual(storage.runs[0].id, "run1") + self.assertEqual(storage.runs, []) + self.assertEqual(len(storage._data["legacy_v2"]["runs"]), 2) self.assertIsNone(storage.active_run_id) + def test_domain_commit_round_trips_through_home_assistant_store(self) -> None: + import asyncio + from datetime import datetime, timezone + + domain_module = _load_module( + "custom_components.plantrun.domain", + PLANTRUN_DIR / "domain.py", + ) + domain = domain_module.PlantRunDomain() + domain.create_run( + domain_module.RunDraft( + run_name="Diesel Auto RQS", + tent_name="Growzelt", + plant_name="Diesel Auto", + planted_at=datetime(2026, 8, 25, 16, 0, tzinfo=timezone.utc), + stage_plan=("Germination", "Harvested"), + initial_stage="Germination", + ) + ) + storage = PlantRunStorage(object()) + + asyncio.run(storage.async_commit_domain(domain)) + saved = storage._store.saved + restored = PlantRunStorage(object()) + restored._store.saved = saved + asyncio.run(restored.async_load()) + + self.assertEqual(restored.public_state()["runs"][0]["name"], "Diesel Auto RQS") + self.assertEqual(restored.runs[0].phases[0].name, "Germination") + + def test_legacy_service_run_creation_enters_the_visible_domain(self) -> None: + import asyncio + + models = sys.modules["custom_components.plantrun.models"] + storage = PlantRunStorage(object()) + run = models.RunData( + friendly_name="Service Run", + start_time="2026-08-25T16:00:00+02:00", + planted_date="2026-08-25T16:00:00+02:00", + phases=[models.Phase(name="Germination", start_time="2026-08-25T16:00:00+02:00")], + base_config={"plants": [{"name": "Service Plant"}]}, + ) + + asyncio.run(storage.async_add_run(run)) + + state = storage.public_state() + self.assertEqual(state["runs"][0]["name"], "Service Run") + self.assertEqual(state["plants"][0]["name"], "Service Plant") + self.assertEqual(run.id, state["runs"][0]["id"]) + self.assertEqual(storage._data["compatibility_runs"], []) + + def test_legacy_service_updates_write_stage_and_journal_to_the_domain(self) -> None: + import asyncio + + models = sys.modules["custom_components.plantrun.models"] + storage = PlantRunStorage(object()) + run = models.RunData( + friendly_name="Service Run", + start_time="2026-08-25T16:00:00+02:00", + planted_date="2026-08-25T16:00:00+02:00", + phases=[models.Phase(name="Germination", start_time="2026-08-25T16:00:00+02:00")], + ) + asyncio.run(storage.async_add_run(run)) + projected = storage.get_run(run.id) + projected.phases.append( + models.Phase(name="Seedling", start_time="2026-08-28T12:00:00+02:00") + ) + projected.notes.append( + models.Note(text="First leaves visible.", timestamp="2026-08-28T12:05:00+02:00") + ) + + asyncio.run(storage.async_update_run(projected)) + + state = storage.public_state() + self.assertEqual(state["runs"][0]["current_stage"], "Seedling") + self.assertEqual(state["journal_entries"][0]["text"], "First leaves visible.") + + def test_domain_commit_preserves_an_explicit_active_run_selection(self) -> None: + import asyncio + from datetime import datetime, timezone + + domain_module = sys.modules["custom_components.plantrun.domain"] + domain = domain_module.PlantRunDomain() + first = domain.create_run( + domain_module.RunDraft( + run_name="Diesel Auto RQS", + tent_name="Growzelt", + plant_name="Diesel Auto", + planted_at=datetime(2026, 8, 25, 16, 0, tzinfo=timezone.utc), + stage_plan=("Germination", "Harvested"), + initial_stage="Germination", + ) + ) + second = domain.create_run( + domain_module.RunDraft( + run_name="Tangerine Dream Auto Zamnesia", + tent_name="Growzelt", + plant_name="Tangerine Dream Auto", + planted_at=datetime(2026, 8, 25, 16, 0, tzinfo=timezone.utc), + stage_plan=("Germination", "Harvested"), + initial_stage="Germination", + ) + ) + storage = PlantRunStorage(object()) + asyncio.run(storage.async_commit_domain(domain)) + asyncio.run(storage.async_set_active_run_id(second.id)) + + candidate = domain_module.PlantRunDomain.from_state(storage.domain.export_state()) + candidate.add_journal_entry( + domain_module.JournalDraft( + tent_id=first.tent_id, + run_ids=(first.id,), + text="Soil checked.", + occurred_at=datetime(2026, 8, 26, 10, 0, tzinfo=timezone.utc), + ) + ) + asyncio.run(storage.async_commit_domain(candidate)) + + self.assertEqual(storage.active_run_id, second.id) + + def test_archived_binding_remains_in_compatibility_projection(self) -> None: + import asyncio + from datetime import datetime, timedelta, timezone + + domain_module = sys.modules["custom_components.plantrun.domain"] + planted_at = datetime(2026, 8, 25, 16, 0, tzinfo=timezone.utc) + domain = domain_module.PlantRunDomain() + run = domain.create_run( + domain_module.RunDraft( + run_name="Diesel Auto RQS", + tent_name="Growzelt", + plant_name="Diesel Auto", + planted_at=planted_at, + stage_plan=("Germination", "Harvested"), + initial_stage="Germination", + sensor_bindings=( + domain_module.BindingDraft( + owner="run", + metric_type="soil_moisture", + entity_id="sensor.diesel_soil", + ), + ), + ) + ) + domain.finish_run(run.id, planted_at + timedelta(days=80)) + storage = PlantRunStorage(object()) + + asyncio.run(storage.async_commit_domain(domain)) + + projected = storage.get_run(run.id) + self.assertEqual(len(projected.bindings), 1) + self.assertEqual(projected.bindings[0].sensor_id, "sensor.diesel_soil") + if __name__ == "__main__": unittest.main()