Help

Generated reference for the classes and methods available when writing the Ruby that drives dashboards, widgets, and automations (see the AI Chat’s own system prompt for the same material, also generated from these class/method docs). Regenerated automatically whenever the underlying source changes — see CodeDocs.

Widget

A widget is responsible for all of a home’s automation tasks. An automation can be triggered:

  • On a fixed schedule (defined in natural language, e.g. "every 30 minutes", in the schedule field on the widget form)

  • When one or more of its monitored states change (states, managed from the widget’s Edit page)

  • Scheduled dynamically as the automation runs, via set_schedule(schedule_string) and clear_schedule — a bare duration ("5 minutes", as opposed to a recurring pattern like "every 30 minutes") is a one-shot: it fires once and re-triggers action_code with @event == :delay rather than :schedule, so a widget can tell "my own one-shot countdown elapsed" apart from a genuine recurring firing. See TimedControl::Service for a ready-made "on now, off after a delay unless retriggered" wrapper around this. A one-shot delay can carry a key: (see set_delay_schedule) so a single widget can run several independent countdowns at once instead of needing one widget per thing being timed — see @delay_key below.

  • Whenever this widget’s own configuration is saved from the Configure page (@event == :config_changed) — so a config field acting as an override (e.g. "force this light on") takes effect immediately, rather than sitting unnoticed until the next unrelated state change or schedule tick happens to run action_code and discover it.

Facilities — a widget provides:

  • Automation code (action_code) — runs whenever one of the monitored states changes, a scheduled timer fires, or this widget’s own config is saved; should preferably use helpers to access/set state and dynamic schedules. @event (:schedule, :delay, :state_change, or :config_changed) and @trigger_state hold the details of what triggered it, or nil; @delay_key/@delay_payload carry through whatever was passed to a keyed set_delay_schedule's key:/extra keywords, when @event == :delay — see @!method action_code below.

  • A configuration form (config_view_code), if the widget needs some configuration — e.g. a thermostat’s setpoint temperature. Its data is available to action_code via the config helper (a Hash, [] or dot notation to read); action_code can write to it too, though that isn’t recommended.

  • A status view (status_view_code) — runtime information about the widget, e.g. how far above or below its setpoint a thermostat currently is.

  • Persistent data storage — the data helper (a Hash, [] or dot notation, any Ruby object). Useful for e.g. which phase action_code is in, or when a state last changed so a derivative can be calculated (works even when no state recorder is present).

  • Global data — the get_setting(name) helper reads the app-wide Settings table, for data shared cleanly across multiple widgets, e.g. the start and end of a holiday period.

Scheduling — schedules are natural-language strings, parsed by the fugit gem; see fugit’s nat documentation for what’s supported.

Widget#action_code

Evaluated via safe_instance_eval whenever this widget’s action is triggered — a monitored state changing, if states is non-empty (@event == :state_change); a recurring schedule firing, including a manual "Trigger with Schedule" (both set @event == :schedule — there’s no way to tell a real scheduled firing apart from a manual trigger from inside action_code); a one-shot delay set via set_schedule/set_delay_schedule elapsing (@event == :delay); or this widget’s own configuration being saved from the Configure page (@event == :config_changed, queued through solid_queue same as every other trigger — see Widget#update_config — so it can’t run concurrently with, say, a state change already in progress for this same widget) — see Widget#run_action.

self (Widget)

this Widget instance. Available:

  • states — this widget’s own configured States (a has_and_belongs_to_many association). Only useful when this widget actually has some configured — a schedule-triggered widget typically has none, so this is often empty.

  • StateHelper (get_state(device_name:, state_name:), get_states(device_names:, state_names:)) — the preferred way to find any State, not just this widget’s own, especially for a schedule-triggered widget with nothing in states to fall back on. Plain ActiveRecord (Device.find_by, State.find_by, …​) also works but isn’t the preferred way.

  • To make something happen in the real world, once you have the State: call State#set(value) on it — not writing to a State’s value column directly. set triggers that State’s own action_code, which is what actually knows how to reach the device (MQTT, WiFi, whatever it takes) — a widget’s action_code stays hardware-agnostic.

  • data — an OpenHash (dot-and-bracket access) persisted across every action_code run for this widget, backed by the action_data column. Use it to remember something between runs.

  • @event (:schedule, :delay, :state_change, or :config_changed), @trigger_state — set just before this code runs: what triggered it, and the State whose change triggered it (a State, not just its id — nil unless @event == :state_change).

  • @delay_key, @delay_payload — only meaningful when @event == :delay: whatever key:/extra keywords were passed to the set_delay_schedule call that scheduled this firing (nil/{} for an unkeyed one-shot, or for any other @event). key: is what makes several independent one-shot countdowns possible on the same widget at once — e.g. several fans, each started/stopped by the same widget but timed out independently — see the example below; @delay_payload is a plain Hash for anything else you want carried alongside.

  • This widget’s own attributes/associations: name, enable, states.

Toggle a light found via StateHelper, based on the widget’s own remembered count
count = data[:press_count].to_i + 1
data[:press_count] = count
get_state(device_name: "kitchen", state_name: "light")&.set(count.even? ? "ON" : "OFF")
Several humidity-driven fans, one widget instead of one-per-fan: each fan gets its own
keyed countdown (`key: fan_state.id`), started/restarted whenever that fan's own humidity
derivative crosses its threshold, and turned off either when humidity returns to baseline
(found via `@trigger_state`, a `:state_change`) or the countdown elapses first (`:delay`,
identified by `@delay_key` rather than `@trigger_state`, which is nil here)
fans = {"bathroom_humidity" => "bathroom_fan", "kitchen_humidity" => "kitchen_fan"}
case @event
when :state_change
  fan_name = fans[@trigger_state.name]
  return unless fan_name
  baseline = data["baseline_#{@trigger_state.id}"] ||= @trigger_state.value
  if @trigger_state.value > baseline + 5   # derivative threshold
    get_state(device_name: @trigger_state.device.name, state_name: fan_name)&.set("ON")
    set_delay_schedule(1800, key: @trigger_state.id)   # this fan's own 30-minute timeout
  elsif @trigger_state.value <= baseline
    get_state(device_name: @trigger_state.device.name, state_name: fan_name)&.set("OFF")
    discard_delayed_schedule(key: @trigger_state.id)   # cancel that fan's own countdown
  end
when :delay
  trigger_state = State.find(@delay_key)
  fan_name = FANS[trigger_state.name]
  get_state(device_name: trigger_state.device.name, state_name: fan_name)&.set("OFF") if fan_name
end

Widget#config_view_code

Evaluated via safe_instance_eval from the Configure page (Views::Widgets::ConfigForm) — a completely different self than action_code: this runs inside a Phlex view, not on the widget.

self (Views::Widgets::ConfigForm)

a Components::Form (Superform) instance, bound to this widget’s own config (an OpenHashForm over its config_data column) — so plain Phlex tag calls (div, p, …​) and field(:name).input(…​)-style Superform calls both work directly. The widget record itself is reachable via @widget, not self. Also has StateHelper (get_state(device_name:, state_name:)) and SettingsHelper (get_setting, get_setting!) — same StateHelper as action_code, but SettingsHelper is not available there.

A single text field saved into config_data
row field(:label).input(class: "input")

Widget#status_view_code

Evaluated via safe_instance_eval from the Status page (Views::Widgets::StatusView) — same shape as config_view_code: a Phlex view context, not the widget.

self (Views::Widgets::StatusView)

a Views::Base instance — plain Phlex tag calls and Components::* renders work directly; the widget record is @widget, not self. Also has StateHelper and SettingsHelper, same as config_view_code — see there.

Show the widget’s current data
p { "Last run: #{@widget.data[:last_run_at]}" }

Components::SensorCard

A dashboard card showing one State’s current value, unit, icon, and last-updated timestamp — live via Turbo Streams — with a click-through to that State’s history page, if it exists. The appearance of every part of the card can be changed by passing in a set of DaisyUI or Tailwind attributes for that part of the card as a hash. For example card_title: {class: 'text-red-500 text-8xl'} to change the size and colour of the title. Any attributes you set will take precedence over the default values and you replace all the attributes for a card part by adding a ! suffix, card_title!: {…​}, for example.

title (String, nil)

card heading; defaults to the state’s device name, humanized

device_name (String, nil)

looked up via StateHelper#get_state; ignored if state: given

state_name (String, nil)

looked up via StateHelper#get_state; ignored if state: given

state (State, nil)

pass an already-loaded State directly instead of device_name:/state_name:

attributes (Hash)

per-element class/attribute overrides. There is no attributes: keyword to pass a hash to — this represents every other keyword argument the call receives (Ruby’s **attributes), so each part name goes directly at the top level of the .new(…​) call, e.g. card:, not attributes: {card: …​}. Recognized keys: :card, :card_header, :card_title, :card_body, :value, :unit, :icon, :timestamp; :icon_name overrides which icon is shown (defaults to the state’s own icon column). Each value is a Hash merged onto that part’s own default attributes/classes.

A card for a specific device/state, by name
SensorCard(device_name: "kitchen", state_name: "temperature")
A card for a State you already have, with a custom title
SensorCard(state: some_state, title: "Kitchen Temp")
Colour the value and card border based on the state’s value
SensorCard(
  state: some_state,
  card: {class: some_state.value.to_f < 20 ? "border-4 border-red-500" : "border-4 border-green-500"},
  value: {class: "font-bold"}
)

Components::Chart

Renders a single Apache ECharts chart (via the rails_charts gem) for one or more State data sources over a user-selectable time range. If you want several charts on a page, make several Chart instances (or a higher-level component that does) — this component deliberately never emits more than one chart.

Data sources — pass either data_source: (one) or data_sources: (an array). Each entry may be:

  • a State object — default drawing style

  • {device:, state:} — resolved via Device.get_state

  • {source: <State>, style: {...}} — a State plus per-source overrides

  • {device:, state:, style: {...}} — resolved, plus overrides

  • {numeric_data: [[timestamp, value], ...], units:, name:, style: {}} — arbitrary numeric data not backed by any State (e.g. a computed/derived series). Goes through the same downsampling (Downsampling.downsample, honouring method:) and envelope handling a State’s own #history gets, so it behaves identically once rendered.

  • {segment_data: [[start_timestamp, end_timestamp, value], ...], name:, labels: {}, style: {}} — arbitrary segment (discrete) data, same shape State#segments itself returns — spans, not point samples, so build them yourself rather than passing raw value-change samples.

style: is a hash of native ECharts series options (type:, symbol:, color:, areaStyle:, lineStyle:, …​) deep-merged over the defaults — a light escape hatch, not a full pass-through; for anything exotic, roll your own.

Raw sources (numeric_data:/segment_data:) have no persisted identity — unlike a State, there’s nothing ChartsController could re-fetch by id on a later request. So if any source in the chart is raw, ChartControls (the range/offset picker, the ◀/▶ steppers, the custom from/to form) is not rendered at all — the chart shows exactly the window range:/offset:/from:/to: resolved to at render time (still used to frame the initial dataZoom window) and nothing more; there is no way to switch ranges afterwards without changing the calling code and re-rendering. A lambda/proc as a data source was considered and rejected for this reason — a closure can’t survive the round-trip to a fresh request either, so it wouldn’t actually fix this.

Classification — each source is one of:

  • segment — discrete state (boolean / enum), drawn as coloured spans

  • numeric — has units, drawn as a line/bar/area on a value axis

Layout rules:

  • All numeric, one unit — single grid, one left value axis

  • All segments — single grid, category rows

  • Numeric, several units — single grid; first unit on the left axis, each further unit on its own right axis

  • Segments and numerics — two stacked grids sharing the time axis: segments on top, numerics below

Overloading a chart with many different units is the caller’s responsibility.

Time range — driven by range: + offset: (stepped by ChartControls), or an explicit Custom from:/to:. Sub-day ranges frame the view via the dataZoom window; History itself is queried by the covering dates. range: must be one of (there is no other way to discover these short of reading Chart.ranges in the source):

  • "Today", "Yesterday" — that calendar day

  • "This week", "This month", "This year" — calendar-aligned, snapped to that boundary

  • "Last hour", "Last 12 hours", "Last 24 hours", "Last 7 days", "Last 30 days" — rolling windows ending now

  • "All" — full extent, no window

  • "Custom" — requires from:/to: instead of offset:

offset: steps whichever named range backwards by that many of its own units (e.g. range: "This week", offset: -1 is last week; offset: is ignored for "All"/"Custom").

Customisation hooks:

  • chart: — a hash deep-ish merged over the whole ECharts option tree. Because the merge is Phlex #mix, use a trailing ! on a key to REPLACE a default rather than combine it, e.g. chart: {yAxis: {min: 10, max: 30}} or chart: {tooltip: {formatter!: RailsCharts.js("(p) => ...")}}

  • controls: — a hash forwarded to Components::ChartControls (class:, etc.)

    data_source (State, Hash)

    one data source — a State, {device:, state:} / {source:, style:} / {device:, state:, style:}, or {numeric_data:, …​} / {segment_data:, …​}; see the source comment above for every shape

    data_sources (Array<State, Hash>)

    several data sources, same shapes as data_source

    range (String)

    one of the named ranges listed above (default "Last 24 hours")

    offset (Integer)

    steps range backwards this many of its own units (0 = current); see above

    from (String)

    with to: and range: "Custom", an explicit window instead of a named range

    to (String)

    see from:

    height (String, Symbol)

    a CSS length, or :fill to expand into a flex parent (e.g. a modal)

    title (String)

    optional heading above the chart

    method (Symbol)

    :bucket (averaged line, default) or :lttb (peak-preserving line)

    envelope (Boolean)

    add a min/max band behind numeric lines

    colours (Hash)

    override the segment value ⇒ colour map, e.g. {true ⇒ "gold"}

    band (Float)

    segment bar thickness, as a fraction of its category row

    band_max (Integer)

    segment bar thickness cap, in pixels

A single temperature sensor over the last day
Chart(data_source: some_temperature_state)
Two sensors sharing a card, one with a colour override
Chart(data_sources: [state_a, {source: state_b, style: {color: "red"}}])
A computed series with no backing State — no range controls, per the note above
Chart(data_source: {numeric_data: [[1.hour.ago.to_i, 3], [Time.current.to_i, 5]],
                     units: "kWh", name: "Projected usage"})

StateHelper

Read access to device state, mixed into Components::Base (so available in any Phlex view/component, including eval-target view fields like Widget#config_view_code) and into Widget itself (so action_code has it too — the preferred way to find a State there, since a schedule-triggered widget has no monitored states of its own to fall back on; see Widget). Deliberately named get_state/get_states, not aliased to the bare state/states — Widget already has its own states (a has_and_belongs_to_many association), and a short alias here would silently shadow it wherever this module is mixed in.

StateHelper#get_state

The single State matching both names exactly, or nil.

device_name (String)

exact device name

state_name (String)

exact state name, scoped to that device

Example
get_state(device_name: "kitchen", state_name: "temperature")

StateHelper#get_state!

Same as get_state, but raises ActiveRecord::RecordNotFound instead of returning nil when no device or state matches — use this when a missing state means something is actually misconfigured, not "not set up yet".

device_name (String)

exact device name

state_name (String)

exact state name, scoped to that device

Example
get_state!(device_name: "kitchen", state_name: "temperature")

StateHelper#set_state

Requests a value change on the single State matching both names exactly — a thin wrapper over get_state! + State#set (the way to make something happen in the real world, not writing value directly). Raises ActiveRecord::RecordNotFound if no device or state matches, or whatever State#set itself raises (e.g. no action_code).

device_name (String)

exact device name

state_name (String)

exact state name, scoped to that device

value (Object)

the new value to push out and record

Example
set_state(device_name: "kitchen", state_name: "light", value: "ON")

StateHelper#get_states

Every State matching both patterns — for "all the upstairs temperature sensors"-style queries where you don’t know the exact names.

device_names (String, Regexp, Array<String>, nil)

matched case-insensitively; nil means any device

state_names (String, Regexp, Array<String>, nil)

matched case-insensitively; nil means any state name

Every state on any device with "upstairs" in its name
get_states(device_names: /upstairs/)

SettingsHelper

Read access to the app-wide Setting table (see Settings in the sidebar) — the two differ only in what happens when the name doesn’t exist. Mixed into Components::Base (available in any Phlex view/component) and a few specific models/jobs that need config outside a request (Chat, DatabaseBackupJob) — check the including class before assuming it’s available anywhere in particular.

SettingsHelper#get_setting

The setting’s value (already cast to its declared type — string/integer/boolean/etc, not always a String), or nil if no setting has that name.

name (String)

the setting’s name, e.g. "backup_retention_days"

Example
get_setting("backup_retention_days")

SettingsHelper#get_setting!

Same as get_setting, but raises ActiveRecord::RecordNotFound instead of returning nil when the name doesn’t exist — use this when a missing setting means something is actually misconfigured, not "not set yet".

name (String)

the setting’s name

Example
get_setting!("ai_api_key")

SettingsHelper#set_setting

Writes a new value for the Setting matching name exactly. Never creates a new row — raises ActiveRecord::RecordNotFound if no setting has that name.

name (String)

the setting’s name

value (Object)

the new value to store

Example
set_setting("holiday_mode", true)

Alarm::Service

A configurable intrusion-alarm state machine - disarmed -> arming -> armed -> pending -> triggered, back to disarmed only via a valid disarm code. Runs as a widget’s action_code, one Alarm::Service instance constructed fresh on every trigger (mirroring how the rest of this app’s automations work), seeding/persisting its phase through the widget’s own data store so it picks up where it left off between runs.

Two things get monitored: an alarm control panel (a virtual device with a mode state - set by Alarm::ControlPanel when the arm/disarm buttons or code keypad are used - and a code state holding whatever’s currently been typed) and any number of sensors (door/window/ motion states, each with its own trigger rules - see add_sensor). Sensors can belong to zero or more numbered groups (a bitmask): every sensor in the same group must trigger within group_trigger_interval seconds of each other for that to count as a real trigger rather than one sensor alone (guards against a single sensor - a curtain moving, a spider on a PIR - causing a false alarm). Configure sensors and the alarm’s own settings via Alarm::SensorConfigEditor from a widget’s config_view_code.

Only the alarm panel’s mode state should be added to this widget’s monitored states - not its code state. code is read on demand (@widget.get_state(...)), not monitored; if it were also monitored, every keystroke on the keypad would trigger run with the code digits themselves as alarm_control_state.value, which control_panel_input doesn’t expect and would raise on.

A widget’s action_code
Alarm::Service.from_config(
  widget: self,
  alarm_sounders: ->(mode) { get_state(device_name: "hall", state_name: "siren")&.set(mode) },
  notifier: ->(state) { Notification::Service.notify("Alarm triggered by #{state.fully_qualified_name}") }
).run(event: @event, state: @trigger_state)

Alarm::Service.from_config

Builds an {Alarm::Service} straight from a widget’s own config_data["alarm"] - what {Alarm::SensorConfigEditor} writes there - instead of passing every setting by hand.

widget (Widget)

see {#initialize}.

alarm_sounders (Proc)

see {#initialize}.

notifier (Proc)

see {#initialize}.

code (String, Proc, nil)

see {#initialize}. Overrides whatever config_data["alarm"]["code"] holds, if given.

Alarm::Service#initialize

widget (Widget)

the widget this alarm’s phase/period persist against (via its data store) and whose set_schedule/clear_schedule/get_state this delegates to.

alarm_sounders (Proc)

called with :on/:off to ring/silence physical sirens/bells.

notifier (Proc)

called with the triggering State when the alarm actually triggers.

alarm_panel_device_name (String)

the virtual alarm-panel device’s name - its mode and code states are what Alarm::ControlPanel writes to.

code (String, Proc, nil)

the disarm code - a literal string, a Proc called for a fresh value each check, or nil for no code required at all.

return (Service)

a new instance of Service

Alarm::Service#add_sensor

state (State)

the sensor’s own State - becomes this sensor’s key.

groups (Integer)

a bitmask - 0 (default) means this sensor isn’t grouped with any other.

triggered_value (String)

the State#value that counts as "triggered" for this sensor.

ignore_on_exit (Boolean)

if true, this sensor being already triggered doesn’t block arming.

entry_route (Boolean)

if true, triggering this sensor while armed starts the entry countdown (pending) instead of triggering immediately.

omit_at_night (Boolean)

if true, this sensor is ignored while armed for the night - regardless of groups; a grouped sensor with this set can’t trigger overnight even if another member of its group also fires within group_trigger_interval.

Alarm::Service#run

event (Symbol)

:state_change, :delay, or :schedule - same shape as Widget#action_code’s own `@event. Only :delay (this alarm’s own exit/entry/trigger countdown elapsing - see set_schedule below) advances the arming/pending/triggered timers; a genuine recurring :schedule firing, if the widget happens to have one configured for something unrelated, is simply ignored.

state (State, nil)

the State that changed, if event == :state_change.

Alarm::ControlPanel

A numeric keypad + Arm Day/Arm Night/Disarm buttons for an Alarm::Service-backed alarm - reads and writes the alarm panel device’s mode/code states directly (via POST /states/:id/set), which is what Alarm::Service#control_panel_input watches for. Updates live if the alarm’s phase changes from anywhere else, not just this panel (Alarm::Service#run broadcasts here after every run) - not tied to Components::StateValue's own per-state broadcast, since what this shows (the alarm’s overall phase - arming/armed/pending/triggered) lives on the widget’s data, not on either individual state.

There’s no separate "enter"/OK key - whatever’s on the keypad display gets submitted as part of the same click that presses Arm Day, Arm Night, or Disarm (alarm_control_panel_controller.js), since a typed code with no following arm/disarm command is meaningless on its own.

Not for direct use from a widget’s action_code - this is a dashboard-facing display/control component, rendered from view_code/status_view_code.

The panel’s own native size (card width, button size, status/display text) is deliberately on the large side - meant to be comfortably tappable on a wall-mounted tablet - and scales as one unit via scale:, a percentage (100, the default, is that native size). Tried-and-tested values: 50%, 100%, 200%, 300% - see Content::CARD_CLASS/BUTTON_CLASS/TEXT_CLASS/DISPLAY_CLASS for the exact native classes scale: scales from. scale: is a plain CSS transform (transform: scale(...), not a Tailwind class) - not because Tailwind classes only used in user-authored view code don’t work generally (they do: DynamicTailwind - see lib/dynamic_tailwind.rb - regex-scans Dashboard#view_ code/Widget#config_view_code/Widget#status_view_code for literal class: "..." text and compiles a supplementary stylesheet from whatever it finds), but because that scan only ever sees literal source text. A class built here, inside this file, by interpolating an arbitrary caller- supplied number ("scale-[#{n}]") doesn’t help even if this exact file were added to the scan list - the regex would capture the literal, unevaluated #{n} as "the class", never the number a caller actually passed at render time. That means a transform-scaled panel doesn’t reflow: its layout footprint stays its native size even though it visually grows/shrinks, so a large scale: can overlap neighboring dashboard content if there isn’t room around it - transform-origin: top keeps growth downward-only (not upward too, the CSS default), so scaling up doesn’t creep into whatever’s above the panel on the page (a dashboard’s own tab bar, say). Clicking still works correctly regardless of scale - browsers hit-test the transformed visual, not the original box.

For finer control than scale: gives you, the appearance of every part can also be changed individually by passing in a set of DaisyUI/Tailwind attributes for that part as a hash, same convention as Components::SensorCard - e.g. card: {class: 'w-96'} for a wider card, or disarm_button: {class: 'btn-lg'} to enlarge just the disarm button. Any attributes you set take precedence over the native defaults; replace one specific attribute entirely instead of appending to it (the default for class) by suffixing that attribute’s own key with !, e.g. card: {class!: 'my-card-only'}. Only the panel’s very first render (from view_code) sees scale:/attributes - a live update via .broadcast (triggered by Alarm::Service#run, which has no idea what dashboard is showing it or how it’s styled) always re-renders at the native size with no overrides, same limitation Components::StateValue's own broadcast has.

From a dashboard’s view_code, at native size
render Alarm::ControlPanel.new(Device.find_by!(name: "front_hall_alarm_panel"))
Scaled down to half size
render Alarm::ControlPanel.new(Device.find_by!(name: "front_hall_alarm_panel"), scale: 50)
Overriding just the disarm button’s size
render Alarm::ControlPanel.new(Device.find_by!(name: "front_hall_alarm_panel"), disarm_button: {class: "btn-lg"})

Alarm::ControlPanel#initialize

device (Device)

the alarm panel’s virtual device - must have mode and code States.

scale (Integer, Float)

a percentage applied via transform: scale(…​) - 100 (the default) is the panel’s own native size.

attributes (Hash)

per-element class/attribute overrides. There is no attributes: keyword to pass a hash to — this represents every other keyword argument the call receives (Ruby’s **attributes), so each part name goes directly at the top level of the .new(…​) call, e.g. card:, not attributes: {card: …​}. Recognized keys: :card, :card_body, :status, :display, :keypad, :keypad_button (every digit/clear key), :actions, :arm_button (Arm Day/Arm Night), :disarm_button.

return (ControlPanel)

a new instance of ControlPanel

Alarm::SensorConfigEditor

The full configuration editor for an Alarm::Service-backed widget - a per-sensor table (group membership, exit/entry/night-time rules) plus the alarm’s own settings (durations, messages, the disarm code, which device is the control panel), matching exactly what Alarm::Service.from_config/#add_sensor read back out of config_data["alarm"].

Meant to be the entire body of a widget’s config_view_code - a single render call, same as Scheduler::ScheduleEditor. Unlike ScheduleEditor's dynamic, freely-add/removable rows, the sensor table has exactly one row per state already in the widget’s own monitored states (added via the widget’s usual "States to monitor" chooser, Views::MonitoredStates::Modal) - not a separate state picker or add/remove UI here, since a sensor’s State has to be in states anyway for the widget to ever be re-triggered when that sensor changes (Widget#run_action only fires on a monitored state’s own change). The alarm panel’s own mode/code states are excluded from the table - those are the control panel itself, not a sensor, even though mode also has to be in states for Alarm::Service#control_panel_input to ever run. Each of the 10 possible groups is its own checkbox (group1..group10), not a precomputed bitmask - Alarm::Service.from_config reassembles it from whichever are checked.

A widget’s config_view_code
render Alarm::SensorConfigEditor.new(@widget)

Audio::Service

Plays a short sound (an alert chime, a spoken alert from TextToSpeech::Service, …​) on the dashboard tablet — no player controls or UI ever appear anywhere. Not for music. Broadcasts to every page currently open (via Components::AudioPlayer), so this works from a widget’s action_code, another service, or anywhere else in the app.

A sound hosted elsewhere entirely is fetched once and cached (Rails.cache, keyed by the URL, no expiry) rather than re-fetched from the origin server on every single play — served back at a local, content-addressed path by CachedAudioController, the same shape TextToSpeech::Service already uses for generated speech. This assumes the URL’s own content doesn’t change over time (reasonable for a specific hosted sound file, but worth being explicit about - unlike TextToSpeech::Service's cache key, which is a hash of the actual text and so is safe by construction, this one is only as safe as the assumption that the URL itself is stable).

A named system sound (from public/sounds/arm_exit.mp3)
Audio::Service.play(:arm_exit)
A spoken alert, generated on the fly
Audio::Service.play(TextToSpeech::Service.speak("Front door is open"))
A sound hosted elsewhere entirely - fetched once, cached locally after that
Audio::Service.play("https://example.com/chime.mp3")

Audio::Service.play

sound (Symbol, String)

a short name (looked up as public/sounds/<name>.mp3 — drop a file there and reference it by that name), an already-fully-qualified absolute path (e.g. TextToSpeech::Service.speak’s return value) used as-is, or a full `http(s):// URL, which gets fetched once and cached rather than re-fetched on every play.

DynamicDns::Service

Keeps a dynamic-DNS hostname pointed at this network’s current public IP, so a home connection with no static IP (the normal case) can still be reached from the internet under a stable name. Run automatically on a fixed schedule by DynamicDnsUpdateJob (see config/recurring.yml) — frequent enough that a changed IP is caught quickly, infrequent enough not to hammer the provider.

Which service actually gets updated is chosen via the dynamic_dns_provider Setting (see Settings in the sidebar) — currently only "duckdns" — so the same call works regardless of which one is configured. Configuration is generic across providers rather than one Setting pair per provider: dynamic_dns_domain (the hostname/subdomain to update) and dynamic_dns_token (the provider’s credential). Adding another provider (No-IP, Dynu, …​) means adding a case branch here, reusing the same generic dynamic_dns_domain/dynamic_dns_token Settings unless a provider’s credential shape genuinely doesn’t fit — not rearchitecting the caller.

Example
DynamicDns::Service.update!

Notification::Service

Sends a push notification to the user wherever they are (their phone) - as opposed to Audio::Service, which plays a sound on a tablet already showing this app’s dashboard in the room.

Which service actually delivers it is chosen via the notification_provider Setting (see Settings in the sidebar) - currently "pushover" or "ntfy" - so the same call works regardless of which one is configured. Configuration is generic across providers rather than one Setting pair per provider: notification_url (the endpoint - fixed for Pushover, self-hosted/variable for ntfy), notification_api_key (the provider’s primary credential - Pushover’s app token, ntfy’s optional auth token), and notification_recipient (a secondary identifier only some providers need - Pushover’s user key; unused by ntfy). Switching notification_provider means updating notification_url/notification_api_key/notification_recipient to that provider’s own values.

Considered (and rejected) as providers here: OneSignal, Firebase Cloud Messaging, Pushwoosh, Airship, and Braze all require building and publishing your own mobile app with their SDK embedded before they can deliver anything - fundamentally different from Pushover/ntfy’s "send to an app the user already has installed" model. Novu/Courier are notification orchestration platforms for companies with many customers - for push specifically they still delegate to FCM/APNs underneath, so they don’t remove that requirement either. Gotify/Telegram/Discord fit the same model as Pushover/ntfy and would be natural, contained future additions here, reusing the same generic notification_url/notification_api_key/notification_recipient Settings.

Example
Notification::Service.notify("Front door has been open for 10 minutes", title: "Front door")
Example
Notification::Service.notify("Alarm triggered!", priority: :high, sound: "siren")

Notification::Service.notify

message (String)

the notification body

title (String, nil)

an optional title line - omit to fall back to the provider’s own default

priority (Symbol)

:low, :normal (default), or :high - mapped to whichever scale the configured provider actually uses. Deliberately not the full range either provider supports: Pushover’s emergency tier needs extra retry/expire params this doesn’t send, and ntfy’s min/max aren’t meaningfully different from low/high for this app’s purposes - a known, deliberate scope cut, not an oversight.

sound (String, nil)

provider-specific notification sound name - only Pushover supports this; silently ignored when the active provider is ntfy, which has no per-message sound concept at all (only priority-driven vibration/sound tiers). One of Pushover’s built-in names (pushover (default), bike, bugle, cashregister, classical, cosmic, falling, gamelan, incoming, intermission, magic, mechanical, pianobar, siren, spacealarm, tugboat, alien, climb, persistent, echo, updown, vibrate, none) or a custom sound the user has uploaded to their own Pushover account. Not validated client-side - an invalid name is rejected by Pushover itself.

return (true)

always - a delivery failure raises rather than returning falsy

Scheduler::Service

A small always-available helper for widgets whose action_code needs to answer "is it currently within one of these named time windows".

A Service holds one or more Periods (see Scheduler::Service::Period) and is evaluated against Time.current (or an explicit time) via #value/#run. Periods are checked in the order they were added; mode: :all runs every period’s in:/out: action for its side effect (e.g. switching a zone on or off), while mode: :first stops at the first period time_now falls inside.

For each period, the in:/out: value or lambda is used depending on whether time_now falls inside it; either can be nil, in which case the Service-level in:/out: (from .new) is used instead. A lambda is called, anything else returned as-is. If no period matches, the Service-level out: is used.

An irrigation system where only one zone should be on at a time (water pressure) — every
period runs its own `in:`/`out:` lambda for its side effect, nothing is returned
s = Scheduler::Service.new(mode: :all) do |s|
  s.period(day: :odd, time: '17h00', duration: '1h30', in: -> { set_zone(1, :on) }, out: -> { set_zone(1, :off) })
  s.period(day: :odd, time: '18h30', duration: '1h30', in: -> { set_zone(2, :on) }, out: -> { set_zone(2, :off) })
end
s.run
A thermostat’s target temperature by time of day — out: on the constructor is the frost
guard used whenever no period matches
sch = Scheduler::Service.new(mode: :first, out: 10) do |s|
  s.period(day: :weekday, time: '6h', duration: '2h', in: 21)
  s.period(day: :weekday, time: '17h', duration: '5h', in: 21)
  s.period(day: :weekend, time: '8h', duration: '14h', in: 21)
end
target_temperature = sch.value

Scheduler::Service::Period

One named time window within a Scheduler::Service — a day-of-week selector, a start time, and a duration. Normally the start time is a fixed clock time (mode: :time, the default); it can instead be anchored to that day’s sunrise or sunset (before sunrise/after sunrise/ before sunset/after sunset), in which case time: is read as an offset from that solar event rather than from midnight. Sunrise/sunset modes need the latitude/longitude settings configured (see Settings in the sidebar) — see Scheduler::SolarCalculator for how they’re computed and cached.

Constructed via Service#period, not directly — see that method for the parameter reference.

Scheduler::Service#period

Adds one time period to this schedule (see Scheduler::Service::Period) and returns self, so calls can be chained or used as s << period(...).

mode (Symbol, String)

:time for a fixed clock time (default), or "before sunrise"/"after sunrise"/"before sunset"/"after sunset" (case-insensitive, space or underscore) to anchor time: as an offset from that day’s solar event instead — requires the latitude/longitude settings to be configured (see Settings in the sidebar).

day (Symbol, String)

which day(s) this period applies on — everyday, weekday, weekend, a day name (monday, tuesday, …​), even/odd (day-of-year parity, for alternating things like irrigation zones), or default (matches every day, ignoring time/duration entirely — always "in").

time (String, Integer)

the period’s start, or (in a sunrise/sunset mode) its offset from that event — "17h00", "6h", "H:MM"-style "90:30", a plain integer of minutes, or a bare digit string ("90"). Optional — a period with no time:/duration: (e.g. day: :default, or a template meant purely for #configure) is simply never "in".

duration (String, Integer)

how long the period lasts, same formats as time. Optional, see time: above.

value (Object)

a per-period value, independent of in:/out: — see #configure and Scheduler::ScheduleEditor for the intended use: a non-technical user edits this (and day:/time:/duration:) through a table UI, while in:/out: (often a shared Proc) stay defined in code.

in (Object, Proc)

value returned, or lambda called, while time_now is inside the period. nil defers to this Service’s own `in:, then to this period’s own value:.

out (Object, Proc)

same, for while time_now is outside the period. nil defers to this Service’s own `out:.

Example
s.period(day: :weekday, time: '6h', duration: '2h', in: 21)

Scheduler::Service#configure

Rebuilds this schedule’s periods entirely from plain data — typically what Scheduler::ScheduleEditor stored in a widget’s config_data, read back via the config helper. The first period ever added (usually the only one, defined once in action_code purely to establish the mode:/in:/out: "shape") becomes the template: every row in rows produces one new Period via Period#from_config, and @periods is replaced wholesale — the template itself is discarded, not kept alongside the configured rows.

rows (Array<Hash>, nil)

each a day:/time:/duration:/value: row (see Period#from_config) — typically config.some_schedule_name

return (Scheduler::Service)

self

A widget author defines the shape once in code; the actual periods come from config
sch = Scheduler::Service.new(out: :off) do |s|
  s.period(in: ->(p) { p.value })
end
sch.configure(config.irrigation_schedule)
sch.run

Scheduler::ScheduleEditor

An editable table of Scheduler::Service periods (mode/day/time/duration/value), meant to be rendered from inside a widget’s config_view_code — so a non-technical user can add/remove/edit periods without touching code — and read back in action_code via Scheduler::Service#configure.

Rows are stored directly under config_data[name] as an Array of {"mode", "day", "time", "duration", "value"} Hashes, submitted through plain name="widget[<name>][][field]" inputs (no Superform binding — the row count is dynamic, added/removed client-side by dynamic_rows_controller.js before any server round-trip). Pass a different name: for each schedule a widget needs; the same name is what #configure reads back via config.<name>.

Mode is per-row, so one schedule/table can freely mix e.g. a before-sunrise period with an after-sunset one (irrigate 1h before sunrise, then again 1h after sunset) — what’s not supported per row is multiple values or custom validation; anything needing those is better served by hand-writing the config view directly against config and reading it back manually in action_code, same as any other config field.

The appearance of every part of the table can be changed by passing in a set of DaisyUI/Tailwind attributes for that part as a hash — e.g. table: {class: 'table-zebra'} for zebra-striped rows, or row: {class: 'text-lg'} to enlarge every row’s text. Any attributes you set take precedence over the defaults; replace one specific attribute entirely instead of appending to it (the default for class) by suffixing that attribute’s own key with ! inside the hash, e.g. table: {class!: 'my-table-only'}.

Inside a widget’s config_view_code, restricting the value column to a fixed choice
render Scheduler::ScheduleEditor.new(@widget, name: "irrigation_schedule", value_label: "Zone state",
                                      value_choices: {"On" => "on", "Off" => "off"},
                                      template: {mode: "time", day: "odd", time: "17:00", duration: 90, value: "on"})
Inside that widget’s action_code
sch = Scheduler::Service.new(out: "off") { |s| s.period(in: ->(p) { p.value }) }
sch.configure(config.irrigation_schedule)
get_state(device_name: "irrigation-controller", state_name: "zone_1").set(sch.run)

Scheduler::ScheduleEditor#initialize

widget (Widget)

whose config_data[name] holds this schedule’s rows

name (String, Symbol)

the config_data key this schedule is stored/read under — also read back via Scheduler::Service#configure(config.<name>) in action_code. Use a different name: per schedule if a widget needs more than one.

template (Hash)

default values for a newly-added row, and what’s shown when config_data has nothing yet — time: must already be "HH:MM" (e.g. "17:00", not "17h00") to pre-fill the time picker correctly. mode: defaults to "time" if omitted.

value_label (String)

heading for the value column — e.g. "Temperature" for a thermostat schedule, "Zone state" for irrigation. Defaults to "Value".

value_choices (Array, Hash)

restricts the value column to a fixed set of choices, rendered as a <select> instead of a free-text input. An Array of plain values (["on", "off"]) uses a humanized version of each as its own label; a Hash ({"On" ⇒ "on", "Off" ⇒ "off"}) uses its keys as the displayed labels and its values as what’s actually stored. nil (the default) keeps the value column as free text.

attributes (Hash)

per-element class/attribute overrides. There is no attributes: keyword to pass a hash to — this represents every other keyword argument the call receives (Ruby’s **attributes), so each part name goes directly at the top level of the .new(…​) call, e.g. table:, not attributes: {table: …​}. Recognized keys: :container (the outer div), :table, :header_row, :header_cell (every <th>), :row (every <tr>, including the clonable template row new rows are copied from), :cell (every <td>), :add_button, :delete_button. Each value is a Hash merged onto that part’s own default attributes/classes.

return (ScheduleEditor)

a new instance of ScheduleEditor

Scheduler::SolarCalculator

Sunrise/sunset for a given date and location, computed locally with the "Sunrise Equation" (the same algorithm — from the Almanac for Computers, 1990 — behind most small sunrise/sunset libraries). No network call or API key: it’s ~40 lines of trig, accurate to within about a minute for any latitude this app is realistically deployed at. Used by Scheduler::Service::Period to resolve before_sunrise/after_sunset/etc modes; see that class’s own docs for the caching layer built on top of this.

Scheduler::SolarCalculator.sunrise_sunset

Sunrise and sunset for one date/location, as minutes after local midnight in Time.zone (DST-aware — the same local clock reading a Time.current on that date would show).

date (Date)

the calendar date to compute for

latitude (Numeric)

decimal degrees, positive = north

longitude (Numeric)

decimal degrees, positive = east

return (Array(Integer, Integer))

[sunrise_minutes, sunset_minutes] after local midnight

Example
Scheduler::SolarCalculator.sunrise_sunset(date: Date.today, latitude: 51.5074, longitude: -0.1278)
# => [304, 1195]

TextToSpeech::Service

Synthesizes speech (an mp3) from short text, for Audio::Service to then play — the two are meant to be used together: Audio::Service.play(TextToSpeech::Service.speak("Front door is open")).

Uses the same free, unofficial Google Translate TTS engine Home Assistant’s built-in google_translate integration uses (via the Python gTTS library) — there’s no official API for this, it’s a reverse-engineered internal Google endpoint, so treat it as best-effort: it has a real user base and a maintained upstream that tends to track breakage, but Google could change it without notice at any time.

Limited to 100 characters per call (MAX_CHARS), matching that endpoint’s own hard per-request limit — comfortably enough for a short spoken alert ("front door is open"), not enough for anything long-form. Longer text raises rather than silently truncating.

Results are cached (Rails.cache, keyed by the exact text+language, no expiry — the same input always produces the same audio, so there’s nothing to invalidate on a timer) and served back at a plain path by TextToSpeechController, so repeating an identical phrase is a cache hit, not a fresh request to Google.

Example
Audio::Service.play(TextToSpeech::Service.speak("Front door is open"))

TextToSpeech::Service.speak

text (String)

the words to speak — up to MAX_CHARS (100) characters

lang (String)

a language code, e.g. "en", "fr", "de"

return (String)

a path (not a full URL — the only intended consumer is a browser tab already on this app’s own origin) that serves the generated mp3, suitable for Audio::Service.play

Thermostat::Service

A simple on/off thermostat: compares a measured temperature against a target and switches an actuator (e.g. a valve or heat pump relay) on or off accordingly, with a hysteresis gap around the target so it doesn’t cycle right at the setpoint.

thermometer:/actuator: each accept either a State or a Proc, for cases needing custom logic (e.g. averaging several sensors, or driving more than one appliance) instead of a single plain State. A thermometer lambda is called with no arguments and should return the current reading; an actuator lambda is called with the desired state (:on/:off) as its one argument. target_temperature: is a plain number or a zero-arg lambda — e.g. reading a Scheduler::Service#value for a time-of-day setpoint.

mode: controls the behavior: :off always turns the actuator off; :heating turns it on once the reading drops to target - heating_hysteresis or below, off again once it’s back above that; :cooling is the mirror image, turning on at target + cooling_hysteresis or above. Call #action each time this thermostat should re-evaluate and act — nothing runs automatically on its own.

thermometer (State, Proc)

where the current reading comes from

actuator (State, Proc)

what gets switched :on/:off

target_temperature (Numeric, Proc)

the setpoint, or a zero-arg lambda returning one

heating_hysteresis (Float)

in :heating mode, how far below the target the reading must drop before switching on

cooling_hysteresis (Float)

in :cooling mode, how far above the target the reading must rise before switching on

mode (Symbol)

:off, :heating, or :cooling

A heating thermostat using a schedule for its setpoint
sch = Scheduler::Service.new { |s| s.period(day: :everyday, time: '0h', duration: '6h', in: 21, out: 20) }
Thermostat::Service.new(thermometer: get_state(device_name: 'Lounge Temperature', state_name: 'temperature'),
                        actuator: get_state(device_name: 'heat-pump-controller', state_name: 'lounge'),
                        target_temperature: -> { sch.value },
                        mode: :heating).action

TimedControl::Service

A one-shot "on now, off after a delay unless retriggered" automation - the common motion-light shape: something triggers, an action runs immediately, and a second action undoes it after delay seconds, extending the delay instead if retriggered while still running. Built on Widget/Interface's own one-shot schedule (see set_delay_schedule/discard_delayed_schedule in lib/scheduler.rb) - without a key: (see below), only one TimedControl::Service (or any other unkeyed one-shot schedule use on the same record, including Alarm::Service's own countdowns) can be outstanding at a time.

condition:, on_action:, and off_action: are called with no arguments - normally defined inline in a widget’s action_code, so they can close over @trigger_state, get_state, data, etc. directly, the same idiom Alarm::Service's own alarm_sounders:/notifier: procs use.

widget (Widget, Interface)

the record this timer’s one-shot schedule runs against - needs set_delay_schedule/discard_delayed_schedule (from lib/scheduler.rb’s `Scheduler module, included by both).

condition (Proc, nil)

called (no arguments) on a :state_change event to decide whether to proceed at all. A falsy return value (false or nil) stops here without running anything. Not called at all when nil - no condition means always proceed.

on_action (Proc)

called whenever this fires "on" - the first trigger, and every subsequent one while already in progress if restart_on_new_event: is true.

off_action (Proc)

called when the delay elapses with no further retrigger.

restart_on_new_event (Boolean)

while already in progress, whether a new triggering event re-runs on_action and restarts the delay from now (true, default), or is ignored outright (false - the original delay keeps counting down unaffected).

delay (Integer, Float)

how many seconds after the most recent on_action before off_action runs.

A motion-triggered porch light, on for 5 minutes (longer if retriggered), skipped in daylight
TimedControl::Service.new(
  widget: self,
  condition: -> { get_state(device_name: "porch", state_name: "daylight")&.value != "true" },
  on_action: -> { set_state(device_name: "porch", state_name: "light", value: "ON") },
  off_action: -> { set_state(device_name: "porch", state_name: "light", value: "OFF") },
  delay: 300
).run(event: @event, state: @trigger_state)
Independent per-room timers from one widget, via key: - each room’s own State id as
`key:` gives it its own outstanding countdown, so motion in the kitchen doesn't cut the
bathroom's timer short (the restriction the plain example above still has - no `key:` there
means only one countdown at a time for the whole widget). `motion` is fixed by the loop, not
read off `@trigger_state` (nil on the `:delay` firing), so `off_action` still knows which room's
light to turn off no matter which of the two events actually reached it.
get_states(device_names: ["bathroom", "kitchen"], state_names: "motion").each do |motion|
  next unless @trigger_state == motion || @delay_key == motion.id
  TimedControl::Service.new(
    widget: self,
    on_action: -> { get_state(device_name: motion.device.name, state_name: "light")&.set("ON") },
    off_action: -> { get_state(device_name: motion.device.name, state_name: "light")&.set("OFF") },
    delay: 300
  ).run(event: @event, state: @trigger_state, key: motion.id)
end

TimedControl::Service#run

event (Symbol)

:state_change, :delay, or :schedule - same shape as Widget#action_code’s own `@event. :schedule is ignored outright - a widget can have a genuine recurring schedule configured for something unrelated alongside a TimedControl::Service.

state (State, nil)

accepted but unused directly, only so the same event:, state: @trigger_state call this app’s other services take (Alarm::Service#run) works here too - condition:/on_action: read @trigger_state themselves via closure if they need it.

key (Object, nil)

identifies which outstanding countdown this run is for, so several independent TimedControl::Service timers can run off the same widget at once - see the per-room example above. Forwarded as-is to set_delay_schedule/discard_delayed_schedule (`to_s’d there); pass the same key every time for a given "thing being timed" (e.g. the monitored State’s own id), and omit it (the default, nil) for the original single-countdown behavior.

UtilityMeter::Service

Totals how much of something (energy, water, gas, …​) a State has recorded as used over a date range - and its cost, if a unit price is given. A thin wrapper around State#value_delta (recorder day-boundaries and any periodic meter reset are already handled correctly there) that adds the optional peak/off-peak-style time-of-day windowing and the price multiplication on top.

Two tariff rates on the same meter (e.g. peak/off-peak electricity) are two separate UtilityMeter::Service instances against the same state:, each with its own unit_price:/ time_range: - not one instance juggling both. Import vs export on a net energy meter works the same way - two instances, one direction: :positive, one direction: :negative.

state (State)

the recorded, numeric State to total - must be record: true and not #discrete?, same requirement as State#value_delta.

unit_price (Numeric, nil)

cost per unit of usage (i.e. per whatever state.units is) - when given, #usage returns {usage:, cost:} instead of a bare number.

time_range (Range, nil)

restricts every day in #usage’s date range to just this local time-of-day window before summing - seconds (or any `ActiveSupport::Duration, e.g. 7.hours) since that day’s midnight. The end can exceed 24.hours to reach into the next calendar day (e.g. 23.hours..31.hours for 23:00-07:00) rather than needing special "wraps around" handling. nil (default) uses each whole day.

direction (Symbol)

:all (default) sums every recorded change, same as before this param existed; :positive sums only the rises (e.g. import), :negative only the falls (e.g. export, kept negative rather than made a magnitude) - see Recorder#value_delta for the mechanics.

A simple monthly gas bill, no time-of-day tariff
UtilityMeter::Service.new(
  state: get_state(device_name: "gas", state_name: "usage"), unit_price: 0.07
).usage(1.month.ago.to_date..Date.today)
# => {usage: 142.3, cost: 9.96}
Peak/off-peak electricity, two meters against the same State
electricity = get_state(device_name: "meter", state_name: "electricity")
peak     = UtilityMeter::Service.new(state: electricity, unit_price: 0.30, time_range: 7.hours..23.hours)
off_peak = UtilityMeter::Service.new(state: electricity, unit_price: 0.15, time_range: 23.hours..31.hours) # 23:00-07:00, wraps past midnight
peak.usage(date_range)[:cost] + off_peak.usage(date_range)[:cost]
Import vs export on a net meter (negative value == exporting, e.g. solar)
electricity = get_state(device_name: "meter", state_name: "electricity")
imported = UtilityMeter::Service.new(state: electricity, direction: :positive).usage(date_range)
exported = UtilityMeter::Service.new(state: electricity, direction: :negative).usage(date_range)
# exported is <= 0 - it's the sum of only the falls, sign kept as-is; negate it for a plain magnitude

UtilityMeter::Service#usage

date_range (Date, Range)

the date range to total usage over - a single Date, or a Date Range.

return (Numeric, Hash)

the summed usage - a Hash {usage:, cost:} instead if unit_price: was given to .new. Either usage (or usage:) is nil if the State has no recorded data in range at all.