5a6f34a4676bc52d6a228ddd4e539fcd5b1dd845
16
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5a6f34a467 |
Analysis: bound the freshness query, let window sums skip chunks, share the Overview's catalog
ci / build-test (push) Successful in 2m34s
Three things the 1,000-meter x 10-year measurement found, each proved by EXPLAIN or a statement count before and after. No tally moves. Freshness had two jobs in one unbounded query. The mark -- when a meter last delivered -- is now stored on meter_rollup_state and maintained by every recompute, with a one-pass backfill in the migration, so an import-only meter keeps its years-old last activity without reading a single raw row. The rhythm that decides stale versus live is sampled inside a 90-day window and only for meters that actually have a live source; a source silent for longer than that is re-read unbounded, so it is still called stale by its own rhythm rather than by a default. The portfolio query went from 13.8 ms planning plus 36.1 ms execution across all 123 reading chunks to 0.58 plus 0.44 ms across four. Window sums took their time bounds only from the unnest join, so the planner could not exclude chunks: a 1,960-window case scanned 1.39 M rows in parallel and spilled a 45 MB sort. Repeating the overall min and max as constants makes it five chunks and nested-loop index scans, 121.5 ms to 8.7 ms. The Overview read the catalog three times, once for the quantities and once for each of its two bills. One context and one catalog snapshot now feed all three: 31 statements per load to 23. The final timings on an idle machine are in docs/ANALYSIS_REPORT.md: the brief's target request (100 meters, ten years, monthly) is 286 ms against two seconds, and a startup rebuild of 1,000 meters is 279 s. |
||
|
|
8940ef25c3 |
Analysis: one selected period, one set of numbers, on every page
ci / build-test (push) Successful in 2m31s
The dashboards told several stories at once. Overview asked for full calendar years, meter detail for a fixed 12-month window that was really 13, Trends for 24 months with an Apply button, and the energy pages for 60. Each page derived "today" from UTC, so the first hours of a local day belonged to yesterday. A missing tariff, a month nobody measured and a genuine zero all rendered as 0. And a virtual meter -- the one thing the spreadsheet leans on hardest -- was excluded from analysis outright: MeterPeriodService returned null for it and the page offered a flow diagram instead. docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58 plus amendments A-01..A-30; code, tests and release notes cite those ids. The analysis layer Core/Analysis holds the pure rules: period presets resolved once in the instance zone into a local date range and a half-open UTC range, bucket plans, calendar-unit comparisons, coverage runs with a resolution class, normalized quantities and units, the totals policy, the virtual formula parser/validator/evaluator, and the cost calculator. "Now" comes from TimeProvider; services never read the clock. Normalization now writes, in the same transaction as consumption and by diff, per-meter rollups by local day and month plus coverage runs and a rollup state (AnalysisDataWriter). AnalysisReader answers a request from those tables -- month rollups for month and year buckets, day rollups otherwise, at most two partial edge days from consumption -- and CostReader prices the result month by month. Pages, /api/v1 and the CSV export read nothing else. The unused continuous aggregates are dropped. The reader's statement count per request is constant whether it covers one meter or a thousand. On a synthetic 1,000-meter, ten-year instance the brief's target request (100 meters, ten years, monthly) takes 374 ms against a two-second target, and the Overview went from 48,244 SQL statements per load to 205. Missing is not zero Every bucket carries a status -- available, partial, missing, unresolved, invalid, pending -- derived from coverage, never from the amount, with provenance and a reason code beside it. A true zero is a number and a bar on the baseline; an unknown bucket is a gap that says why; a month whose data only exists monthly says so instead of inventing daily detail; a scope with no tariff says "not priced" instead of 0. Rows whose interval closes after now are reported separately rather than counted. Virtual meters are analysis subjects A virtual meter stores a canonical definition -- expression over m<id> references, result kind, unit and cost rule -- validated on save and on read for syntax, unknown or self references, loops and unit/kind rules. It is evaluated on read from its sources' rollups over their joint coverage: a missing source makes the bucket missing, an observed zero is a valid input, a non-finite result is invalid with its dependency path, and the page lists each source's contribution. Topology links are topology only and never rewrite a saved calculation; expression-less meters from older installs are converted once at startup. The editor has Sum, Difference and Advanced modes with a live preview. Totals and the bill Per energy type the totals policy separates use, grid import, export, generation and runtime, marks breakdown meters as breakdowns and virtual meters as views, and never adds across units. The bill follows it: grid import where there is one, separately priced subsections at their own price, feed-in only on export meters, standing charges once per scope per local day, manual costs once on their start day, categories as non-overlapping covers whose composition reconciles to the bill. The seeded demo's yearly totals now match the spreadsheet. Pages and navigation The period lives in the URL and every page reads the same contract, so a link, a reload and the browser's Back button keep it. Shared components carry it: page header with breadcrumbs, period toolbar, theme-aware chart with an accessible table beside it, metric cards, comparison and availability states, attention items that each link to the one action that fixes them. Meter detail leads with an Analysis tab and resolves its tabs by key; the energy page has Overview, History, Flow and Meters; the old cost-only Trends page is a general Analysis page over portfolio, type, category, meter or a meter comparison. Records tabs are paged server-side instead of showing the latest 200. Everything is English and German, light and dark, down to 360px. Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and what the first start after the update does (it rebuilds all analysis data before the web server listens). docs/SDD.md and CLAUDE.md describe the system as it now is. Tests: 1,733 Core and 746 integration, all green, plus an opt-in performance suite with a synthetic 1,000-meter generator. |
||
|
|
aacdc28d70 |
Meters: record events from the UI, and book consumption in the months it accrued in
Two threads that ended up in the same files. One is navigation: a meter
swap that happened today had no click path at all, and most per-meter
tasks were reachable only by knowing which admin page owned them. The
other is attribution: readings on 1 August and 16 September showed six
weeks of water under September and nothing under August.
Meter events from the UI
Swap, counter reset, tank level, delivery and note are recorded through
MeterEventService rather than ad-hoc inserts, so the dialog's verdict and
the saved result come from the same Validate call, and every record or
delete recomputes the meter inside one transaction. MeterEventRules
decides which events a mode offers -- a tank has no register to swap, and
Correction is offered nowhere because nothing reads it.
A swap is stored as the event at T plus a manual reading of the new
register's start value at exactly T. That pairing is the whole trick: the
boundary window is (previousReading, reading], so the old register's tail
books at T and every later reading counts from the new start. Writing the
old final value as the reading at T instead -- the obvious thing -- double
counts the tail and then rejects every reading the new register produces.
Deleting a swap removes that start reading only while it is still the
untouched start value, and only Manual readings can be deleted at all.
Navigation
The meter page is now the hub: primary entry by mode, a "Record event"
menu, and Edit through a shared MeterEditor that also owns tank setup.
Other pages link into it with MeterLinks (/meters/{id}?tab=...&action=...),
whose action is consumed once after the interactive render and dropped
from the address -- the reverse order flashes the dialog and closes it,
because a circuit's first location change dismisses every open dialog.
The app bar gains a "Find a meter" dialog with the same quick entry.
A source that has no usable connector now links to creating (or enabling)
one and comes back to the same source dialog with the connector picked
and everything typed still there; the draft survives in a circuit-scoped
DraftStore, and the way back is a meter id rather than a URL, so the page
cannot be made to redirect anywhere else. The connector list shows which
meters use each connector, import batches list the meters and categories
they wrote to, the meter editor owns the meter's own cost categories, and
the dashboard's empty cost panel names the first missing step instead of
listing every admin page.
Months
A reading is an instant, and what it measures accrued over the time since
the previous one. Booking the whole delta at the closing reading misfiles
it whenever the interval crosses a month boundary, so a plain increase is
now divided at local month boundaries in proportion to elapsed time, each
share stamped inside its month and marked estimated: the meter recorded a
total, not a shape. The parts always sum to the original.
Imported monthly tables are the exception that keeps the golden fixtures
reconciling. "Mai 2026" carries the register at the end of May but is
stamped on the 1st, so the importer -- the only place that still knows
whether the date cell named a month or a day -- flags it MonthLabel, and
the engine reads it as the end of its month. Inferring that from the
stamp instead would catch day-dated rows: a sheet with "01.08.2026" in it
is not a monthly table, and reading it as one moves two thirds of July
into August.
ReadingTimeline is the single ordering built on that: effective time,
then stamp. The register normalizers walk it, and so do the decrease
guard and the event dialog, which is what stops them disagreeing about
which reading is "previous" -- a sheet imported after live readings of the
same month used to count that month twice, and a mid-month reading below
the month's end value was rejected as a drop. A swap detected in a
monthly table applies from the start of that local month, i.e. to the
first reading in it, and a recorded start value never counts above the
reading it lands on.
Every reader buckets in the configured timezone rather than a hardcoded
one, and turns a requested date into that zone's local midnight, so the
divided shares are read back under the months they were stamped in. The
zone id is normalised to its IANA form, because .NET accepts a Windows id
that PostgreSQL will not bucket by, and both are checked at startup.
Stored consumption is derived, so a rule change reaches a meter only at
its next reading -- weeks, for a meter read monthly. NormalizationUpgrade
records the revision and zone the stored series was built with and
rebuilds everything once at startup when either differs, each meter in
its own transaction. A meter that fails is logged, kept in
normalization_pending and retried at the next start: one bad series must
never keep the application down.
What an operator sees once
Existing charts change on the first start after the update: months that
carried a neighbour's use give it back. Rows of earlier imports from
monthly tables are marked as such before anything is recomputed, and if
that marking fails nothing is rebuilt or recorded, so the upgrade simply
runs again next time rather than shifting every imported month by one. A
wizard import whose date format was left on auto-detect is treated as a
monthly table when all of its rows sit on the 1st across at least two
months -- exactly how those rows were attributed before -- and each such
batch is named in the log, because a day-dated sheet always read on the
1st looks identical; revert and re-import it with the day format if that
is what it was.
Tests: 120 unit and 230 integration, including the reference fixtures,
which still reconcile month for month.
|
||
|
|
bfa0b537ee |
i18n: ship the UI in English and German
ci / build-test (push) Failing after 35s
The last open item on the M7 list. Number and currency formatting was already locale-aware, but every string in the UI was an English literal, so a German instance read half in each language -- German data, English chrome. This translates all of it and adds the machinery to keep it translated. Strings live in Localization/Strings.resx (English, neutral) and Strings.de.resx. The neutral file generates a strongly-typed accessor at build time, aliased as S in _Imports.razor, so components reference compiled properties -- @S.Common_Save, not a string key. That choice is the point: across 4,500 lines of markup, a key lookup that silently falls back to its own name is a defect you find in production, while a renamed property is a build error. Generation runs in MSBuild rather than the IDE designer, so dotnet build alone reproduces it anywhere. Resource fallback is the hazard here. Ask for a key the German satellite lacks and ResourceManager quietly serves the English one -- correct at runtime, disastrous at release time, because a half-translated build looks perfectly healthy. StringResourceTests reads each satellite with tryParents: false, which is the only way to see what one actually contains, and fails on a missing or blank translation, a placeholder that changed arity, an orphan, or a key nothing references. Three things needed more than substitution: - Domain enums reached the screen as bare identifiers. They stay bare in the model -- they are persisted as text and appear in the REST API, so their names are part of the data contract -- and DisplayNames is now the single place that decides how each value is spoken. Every arm ends in a fallback returning the identifier, so a value added later cannot throw mid-render; EnumDisplayNameTests is what stops that safety net quietly becoming the shipping behaviour. - Infrastructure was writing display text: FlowService's "Other (X)", MeterPeriodView's "Generation"/"Consumption", the HA connection-test verdicts, the updater's snackbar, the CSV importer's row warnings. Each now returns an outcome value and the UI supplies the words, which is where the reader's language is known. Diagnostics that are not ours -- an HTTP status, systemd's stderr, an exception message -- are passed through untranslated, and every English summary is kept alongside the outcome so log lines never move with the UI language. The UpdateRunner change is additive only; no gate was touched. - Importer warnings carry their arguments rather than a finished sentence, so the numbers inside them pick up the reader's grouping. A register that reads 2.940,19 everywhere else must not read 2940.19 only inside a warning. Switching language is a redirect through /culture/set followed by a full reload, not an interactive state change: a Blazor Server circuit is fixed to the culture of the request that opened it. That makes the endpoint a redirector taking its target from the query string, so anything but a local path is refused rather than followed. Preference order is the cookie, then Accept-Language, then MeterVault__Locale -- an instance can be pinned to one language and a reader can still switch. Locale keeps its documented default of "en". Format now follows CurrentCulture instead of a hardcoded de-DE, so an instance with nothing configured and a browser asking for English will show English number formatting where it previously showed German; set MeterVault__Locale=de to pin the old behaviour. The importer's de-DE parsing is untouched and stays that way -- that dialect is a property of the spreadsheets, not of whoever is looking at the dashboard. Anything that comes from the database -- meter names, energy-type display names, category names -- is user data and is never translated. Claude-Session: https://claude.ai/code/session_0112ezeWqaZ85kTj5bYu9JHx |
||
|
|
af786c7b28 |
Meters: add manual reading entry from the meter-detail Readings tab
ci / build-test (push) Successful in 1m37s
Entering a reading by hand previously meant POST /api/v1/readings with an API key, or a one-row CSV through the import wizard. SourceType.Manual existed in the enum but nothing was behind it. This adds the click path, built for the case it is actually used in: walking to each manual meter with a phone in hand. "Add reading" on the Readings tab opens a dialog prefilled with the meter's last register value and the current local time, both editable: - An on-screen keypad, because a register is read standing at the meter. It behaves like a calculator against the prefill - the first digit replaces it (a fresh register), while backspace edits it in place, which is the common case since only a register's last digits move. - Typed input accepts both separators (last one wins), so a German and an English phone keyboard both do the right thing. ReadingEntry owns that rule and is unit-tested; it deliberately differs from GermanNumber, where a lone dot really is a thousands separator. - A live parsed-value echo plus delta-since-last, which is the net that catches a mistyped digit before it is committed. - Decrease / replaces-existing / future / backdated surfaced before saving, and DST spring-forward gaps refused rather than shifted. The verdict line sits in a fixed-height, no-wrap slot above the keypad. That is load-bearing, not cosmetic: an alert that appears there when the value dips below the last reading moves the keys out from under the user's thumb mid-entry, which is a guaranteed mistype on a phone. The long-form explanation goes below the keypad, where reflow is harmless. Saving goes through IngestionService.IngestByMeterAsync, so the monotonic-decrease guard and inline renormalization apply exactly as for any other ingest. A new optional quality parameter stamps the row ReadingQuality.Manual; null preserves today's behaviour, so a source re-reporting the same timestamp updates the value without silently relabelling a hand-entered or imported reading. Also: the meter-detail tabs now render times in the instance timezone per SDD section 10, instead of raw UTC. Without it a reading entered at 18:00 reads back as 16:00. Side effect is that historic imported monthly rows show 01:00/02:00 rather than 00:00 - correct, if noisier. Claude-Session: https://claude.ai/code/session_01D4x3JbNKCSV4cBR9s7bJmX |
||
|
|
1f575c9da2 |
Update: drop the API-key requirement from the update trigger
ci / build-test (push) Successful in 1m14s
Owner's call: MeterVault__AllowInAppUpdate is now the whole gate. One click on the banner, no key, no prompt, and the REST endpoint no longer asks for one either. What that means, recorded so it is not rediscovered later: with the flag on, anything that can reach MeterVault can trigger a rebuild and restart. On the realistic threat model that is a repeatable denial of service — minutes of downtime and a pegged CPU per request — rather than code injection, because the build comes from the owner's own repository. It becomes remote code execution if that repository is ever compromised. The flag still defaults off, and that default is now the only thing between an upgrade and an open trigger, so UpdateRunnerTests pins it along with the fact that configuring API keys does not imply consent to rebuild the host. Kept one guard, which is not authentication: the REST endpoint requires an X-MeterVault-Update header. Without it any website could POST to the endpoint through the browser of someone on the network — a plain HTML form is enough, and no key means nothing else would stop it. A form cannot set a custom header and a cross-origin fetch that tries is stopped by a preflight nothing here answers, so this costs a deliberate caller one flag and costs the button nothing, since it runs over the Blazor circuit rather than HTTP. The confirmation dialog stays, now purely as a guard against a stray click costing several minutes of downtime. Every triggered update is logged as a warning: with no key there is no caller to attribute it to, and the restart discards anything held in memory. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd |
||
|
|
9eb3f7d53c |
Update: add an opt-in "Update now" button, gated on an API key
ci / build-test (push) Successful in 1m9s
Adds the button, plus POST /api/v1/system/update for driving it from Home Assistant or curl. Both pull the latest source, rebuild and restart the service. The gating is the substance of this change. The updater builds whatever is on the branch and the LXC runs MeterVault as root, so triggering it is root-equivalent on that host, and the web UI has no login — "reachable from the dashboard" alone would mean any device on the network could take the machine. Three independent conditions must hold before anything runs: the operator set MeterVault__AllowInAppUpdate, at least one API key is configured, and the caller presented one, compared in constant time so retries cannot time out the key. AllowAnonymousApi cannot reach it. That flag opens reads, and opening reads must not open root, so the endpoint checks the presented key itself rather than relying on the shared group filter that honours it. Availability is re-checked inside LaunchAsync rather than trusting the caller to have done so. The UI button asks for the key every time instead of remembering it: with no login, a browser left open on the dashboard would otherwise be a standing permission to execute code on the host. The key is cleared from component state immediately, and a wrong key and a keyless deployment give the same message so an unauthenticated caller cannot tell them apart. Launched detached through systemd-run: the updater restarts the service, so a child process would be killed part-way through, leaving the app down with a half-published build. --collect reaps the transient unit so a later update is not blocked by the remains of the previous one. Off by default, and where there is no /usr/bin/update — a container, a dev box — it reports that rather than half-running something. Tests pin every refusal, including through the real HTTP pipeline; none of them launch anything. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd |
||
|
|
ad896db051 |
Normalization: apportion a long unread gap across the months it covers
A counter delta is booked at the reading that closes it. That is right at the reporting cadence -- a monthly series books December against the 1 January reading, which is what the reference sheet does -- and wrong after an outage. Observed on Solar 1: 78 days of generation arrived as one July row, leaving June looking like the array was switched off. An interval containing two or more complete calendar months is now divided across them in proportion to elapsed time. The meter recorded a total, not a shape, so every row a split produces is marked Estimated. The sum is exact: the final segment absorbs the rounding remainder, so a split never creates or destroys energy. Counting whole months *contained* rather than boundaries *crossed* is what makes the rule safe. A monthly series contains exactly one whole month per interval and is untouched, so the golden fixtures keep measuring the normalizer rather than the splitter; and a reading landing hours late cannot tip the rule and hand the new month a sliver. GapSplittingIsInertOnFixturesTests asserts the rule declines to fire on every reference interval, so this cannot drift into the oracle unnoticed. Not apportioned: swap and reset amounts (explicit corrections booked at their event -- apportioning one would rewrite a number the operator supplied), a rejected decrease, and a zero delta, which would otherwise fan out into rows that say nothing. Segments are stamped at their end, keeping the existing convention that a row records the period ending at its timestamp -- so nothing shifts relative to how unsplit intervals are already labelled. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd |
||
|
|
e23df37a3f |
Connectors: allow secrets to be entered in the UI, encrypted at rest
ci / build-test (push) Successful in 1m12s
Reference-only secrets (SDD §6.4) meant adding a connector required editing a file on the server and restarting the service. In practice that leads to the token being pasted into the env-var *name* field, which fails with "environment variable '<token>' is not set" and gives no hint what went wrong. Add a second storage form, chosen per connector: type the secret in and it is encrypted via ASP.NET Core data protection before it is stored. The env-var reference stays as an equal alternative — this widens the choice rather than replacing it. Exactly one form survives a save, so a stale secret cannot linger and silently win; EndpointSecret.Resolve is the single resolution path. The guarantee that matters is preserved: no plaintext in the database, so pg_dump and JSON exports carry nothing usable. The trust boundary is stated plainly in §6.4 — the key ring is on disk, so this protects against leaked database content, not an attacker who already has the host, which is the same boundary an env var has. Details worth noting: - Key ring defaults to /var/lib/metervault/keys, outside the app directory, because the LXC updater republishes /opt/metervault on every update. Docker gets a named volume. Overridable via MeterVault__DataProtectionKeyPath. - Undecryptable ciphertext (key ring lost) falls back rather than throwing: an ingestion worker on a timer should degrade, not crash. - The stored secret is never sent to the browser; a blank field means "unchanged", not "cleared". - MQTT usernames are stored as-is — §6.4 covers tokens and passwords, and encrypting a username would only blank the field on every edit. - ExportService drops *_enc values: bound to the originating key ring, so useless where an export would be restored. Expect to re-enter after a restore. - HaConnectionTester now takes a resolved token, so the admin UI can test a token that has been typed but not yet saved. SDD §6.4 and §9 updated to describe both forms rather than contradict the code. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd |
||
|
|
8550ed8d9e |
Ingestion: Home Assistant WebSocket push path
ci / build-test (push) Successful in 1m16s
HomeAssistantWebSocketWorker holds a persistent state_changed subscription per HA endpoint that opts in via the connector's WebSocket toggle (HaEndpointConfig.UseWebSocket): auth handshake, subscribe, ingest in real time, capped-backoff reconnect. The REST poll worker skips WS endpoints so each is served once. HaWebSocketProtocol holds the pure handshake/parse logic. Verified by 11 protocol unit tests + a live integration test against an in-process fake HA server. CLAUDE.md updated. Claude-Session: https://claude.ai/code/session_01Kib2MniVFbD95fkgLgBBnB |
||
|
|
7e34aeccc8 |
Meter chain topology + per-energy-type flow (Sankey) pages
ci / build-test (push) Successful in 1m24s
Adds a meter hierarchy and a flow view: a downstream meter is a *subsection*
of an upstream one (not an addition), so you can see where a main meter's flow
divides — e.g. official water → garden, pool, other; grid/battery → all → car.
- MeterLink (schema + migration AddMeterLinks): a directed from→to flow edge.
Multi-parent allowed (a merge, e.g. grid + solar → house); multi-child is a
split. Cascade-deletes with either endpoint; unique + distinct-endpoint checks.
- FlowService: per energy type + period, builds a Sankey graph — nodes = meters
sized by consumption; link value = downstream meter's consumption, split
proportionally across multiple upstreams; unaccounted remainder under a meter
becomes a synthetic "Other" node; depth via topological longest-path.
- SankeyChart.razor: hand-rolled inline-SVG Sankey (ApexCharts has no Sankey
type) — columns by depth, nodes stacked by value, bezier ribbons sized by flow,
left→right, theme-aware, HTML-encoded labels, tooltips. Built as a MarkupString
to sidestep Razor's <text> element clash.
- /energy/{id} page (one per energy type): KPIs (consumption + cost), the flow
Sankey, and the meter list. NavMenu now lists a link per energy type
(Electricity, Water, Gas, …) loaded from the DB.
- Meters admin: cycle-safe "Sub-meter of (upstream meters)" multi-select
(descendants excluded to prevent cycles); reconciles meter_link rows on save.
- Reference data seeds a demo chain (Haus → Auto) so electricity flow shows
Haus dividing into Auto + Other.
Tests: FlowServiceTests (single-parent remainder; two-parent proportional
split); render test now asserts the flow chain + covers /energy/{id}. 69 Core +
47 Integration = 116 green. Live-verified: Haus 95,450 kWh → Auto 51,909 +
Other 43,541 (flow conserved), all 5 energy-type pages render.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
|
||
|
|
09cd435c2b |
Admin write-CRUD, Home Assistant connector config, wiring audit
ci / build-test (push) Successful in 1m19s
Three requested phases.
1) Admin section (SDD §8.7) — MudBlazor inline-dialog CRUD, consistent pattern,
delete guards, snackbar feedback, shared Confirm helper:
- Energy types: create/edit/delete (blocks delete when meters reference it).
- Meters: create/edit/delete; recomputes consumption when mode/baseline
changes (NormalizationService over a fresh factory context, in a tx);
delete cascades data (consumption+readings are Restrict → removed first).
- A meter's ingest sources: manage on the meter-detail Sources tab
(add/edit/delete MQTT/Tasmota/HA sources with typed config).
- Tariffs: full CRUD (scope/component/value/validity).
- Cost categories: CRUD + member management (meter or energy-type members).
- Connectors: ingestion_endpoint CRUD (MQTT broker + Home Assistant);
secrets referenced by env-var name only, never stored.
- Settings: read-only effective-config view (settings are env-driven and
reproducible, so an editable form would change nothing — kept honest).
PV role is now editable on meters (MeterMeta.SetRole can clear a role).
2) Read Home Assistant — extracted a shared public HaEndpointConfig (was a
private record in the worker), added HaConnectionTester (powers the connector
"Test connection": checks base URL + env-resolved token, optionally reads one
entity). Configuring an HA connector + an HA source on a meter drives the
existing REST-poll worker end to end. (WebSocket push stays a future
optimization; REST poll already reads HA.)
3) Wiring/placeholder audit — swept every OnClick/Href: all handlers are real,
all internal links resolve to real routes, no TODO/stub/placeholder code.
Fixed one genuine gap: MainLayout had no drawer toggle, so the nav was
unreachable on narrow screens — added a hamburger button.
Tests: +6 (MeterMeta.SetRole role-removal; HaConnectionTester fail-closed
guard branches with a throwing HttpClientFactory proving no network on bad
config); render test now covers all admin routes. 69 Core + 45 Integration =
114 green. Live-verified in Docker: all admin pages 200, drawer toggle present,
Settings shows real effective config.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
|
||
|
|
1282acf82c |
SDD §8 panels (PV/oil/meter-detail) + fix reference-data-in-Docker
Complete the SDD §8 dashboard views that were deferred at the M5 boundary,
and fix a shipping bug that left the Docker demo empty.
Bug: "Load reference data" created meters/tank/tariffs but imported zero
readings in Docker. Root cause: sampledata/ was excluded by .dockerignore and
never copied into the build stage, so the App csproj's linked Content glob
resolved to nothing at publish time; ReferenceDataImporter then silently
skipped the missing CSVs after already writing its marker meter, leaving the
DB permanently "loaded" but empty.
- .dockerignore: stop excluding sampledata/
- Dockerfile: COPY sampledata/ into the build stage
- ReferenceDataImporter: fail-fast (validate CSVs exist before the marker
meter) and throw instead of silently skipping a missing file
- Program.cs + MeterVaultOptions: opt-in MeterVault__SeedReferenceData
(compose METERVAULT_SEED=true) for a one-command populated demo
New SDD §8 panels (read models in Infrastructure/Dashboard, Blazor pages):
- §8.4 Solar/PV (/solar): generation from GenerationCounter meters;
self-consumption / autarky % / self-consumption % / savings derived from
meters tagged total_load & grid_import via Meter.Meta role config
(MeterRoles/MeterMeta) — nothing hardcoded by name.
- §8.5 Oil/consumable (/consumables): tank level (cm→L calibrated), fill
gauge, deliveries log, burner runtime, effective L/h (fixed/empirical),
forecast-to-empty, tariff cost, monthly series.
- §8.6 Meter detail (/meters/{id}): raw readings, normalized consumption,
source status, tariff timeline, events, measured-vs-estimated markers.
- Reusable SeriesChart component; nav links; Meters list rows link to detail.
Tests: MeterMetaTests (Core, +10); DashboardRenderTests extended to assert the
three panel services compute real figures and the new routes render (108 total,
all green). Live-verified in Docker: seed imports 302 readings / 347 consumption
rows; panels render (generation 16,481 kWh, oil 3,967 L) cross-checking the DB.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
|
||
|
|
39da00d486 |
Switch to master branch + Gitea Actions
- Move CI/release workflows from .github/workflows to .gitea/workflows (Gitea Actions), targeting the master branch. - docker-publish: push to the Gitea container registry (git.finalfactory.de) with a lowercased image name; login via github.token or a PACKAGES_TOKEN secret. - version-tag: gitea-actions bot identity; optional RELEASE_TOKEN to re-trigger the image build. - Update README/CLAUDE.md/Unraid template/build-and-push.ps1 to the Gitea registry + master. Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr |
||
|
|
e223278771 |
M7: release polish — export/import, docs, easy docker push
- Easy docker push (MQTTower ergonomics + the image push it lacks): VERSION file → version-tag.yml (semver-guard auto-tag) → docker-publish.yml (buildx multi-arch → GHCR, registry centralized for one-line retarget to git.finalfactory.de) + ci.yml + build-and-push.ps1. - Verified end to end: deploy/Dockerfile builds; docker compose stack (app + timescaledb) comes up healthy; /healthz and the dashboard respond in-container. - JSON config export/import (ExportService) with id remapping on restore + GET /export, POST /import endpoints; round-trip test preserves meter→type, meter-scoped tariff, category links. - README, HA/Tasmota/MQTT wiring guide (docs/wiring.md), Unraid template. - CLAUDE.md updated to reflect the built codebase. - i18n: locale-aware number/currency formatting (de-DE); full de UI string localization deferred. 95 tests green (56 Core + 39 integration). Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr |
||
|
|
48a7f5a825 |
M0: scaffold solution, EF+Timescale schema, /healthz
Five-project Clean Architecture solution (Core/Infrastructure/App + Core.Tests/ Integration.Tests) on .NET 10 with central package management, snake_case EF mapping, and shared build/style config. - Full domain entity set + EF DbContext for the SDD §5.3 schema (singular table names). - InitialSchema migration (relational) + TimescaleHypertables migration (raw SQL: create_hypertable + compression on reading, hypertable on consumption). - App wiring: Serilog (actually wired, unlike MQTTower), DbContext, migrate-on-startup, /healthz. Serves plain HTTP behind a reverse proxy (no HTTPS redirect). - deploy/Dockerfile (2-stage, ICU-capable) + docker-compose (app + timescaledb). - Integration.Tests: shared TimescaleFixture (Testcontainers) — migrations, hypertables, compression policy, and /healthz all verified green (4/4). Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr |