count = data[:press_count].to_i + 1
data[:press_count] = count
get_state(device_name: "kitchen", state_name: "light")&.set(count.even? ? "ON" : "OFF")
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)andclear_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-triggersaction_codewith@event == :delayrather than:schedule, so a widget can tell "my own one-shot countdown elapsed" apart from a genuine recurring firing. SeeTimedControl::Servicefor a ready-made "on now, off after a delay unless retriggered" wrapper around this. A one-shot delay can carry akey:(seeset_delay_schedule) so a single widget can run several independent countdowns at once instead of needing one widget per thing being timed — see@delay_keybelow. -
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 runaction_codeand 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_statehold the details of what triggered it, or nil;@delay_key/@delay_payloadcarry through whatever was passed to a keyedset_delay_schedule'skey:/extra keywords, when@event == :delay— see@!method action_codebelow. -
A configuration form (
config_view_code), if the widget needs some configuration — e.g. a thermostat’s setpoint temperature. Its data is available toaction_codevia theconfighelper (a Hash,[]or dot notation to read);action_codecan 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
datahelper (a Hash,[]or dot notation, any Ruby object). Useful for e.g. which phaseaction_codeis 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 (ahas_and_belongs_to_manyassociation). 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 instatesto 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’svaluecolumn directly.settriggers that State’s ownaction_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 everyaction_coderun for this widget, backed by theaction_datacolumn. 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: whateverkey:/extra keywords were passed to theset_delay_schedulecall 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_payloadis a plain Hash for anything else you want carried alongside. -
This widget’s own attributes/associations:
name,enable,states.
-
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 itsconfig_datacolumn) — so plain Phlex tag calls (div,p, …) andfield(:name).input(…)-style Superform calls both work directly. The widget record itself is reachable via@widget, notself. Also hasStateHelper(get_state(device_name:, state_name:)) andSettingsHelper(get_setting,get_setting!) — sameStateHelperasaction_code, butSettingsHelperis not available there.
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::Baseinstance — plain Phlex tag calls andComponents::*renders work directly; the widget record is@widget, notself. Also hasStateHelperandSettingsHelper, same asconfig_view_code— see there.
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:, notattributes: {card: …}. Recognized keys::card,:card_header,:card_title,:card_body,:value,:unit,:icon,:timestamp;:icon_nameoverrides which icon is shown (defaults to the state’s owniconcolumn). Each value is a Hash merged onto that part’s own default attributes/classes.
SensorCard(device_name: "kitchen", state_name: "temperature")
SensorCard(state: some_state, title: "Kitchen Temp")
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 viaDevice.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, honouringmethod:) and envelope handling a State’s own#historygets, so it behaves identically once rendered. -
{segment_data: [[start_timestamp, end_timestamp, value], ...], name:, labels: {}, style: {}}— arbitrary segment (discrete) data, same shapeState#segmentsitself 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"— requiresfrom:/to:instead ofoffset:
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}}orchart: {tooltip: {formatter!: RailsCharts.js("(p) => ...")}} -
controls:— a hash forwarded toComponents::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
rangebackwards this many of its own units (0 = current); see above - from (String)
-
with
to:andrange: "Custom", an explicit window instead of a named range - to (String)
-
see
from: - height (String, Symbol)
-
a CSS length, or
:fillto 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
Chart(data_source: some_temperature_state)
Chart(data_sources: [state_a, {source: state_b, style: {color: "red"}}])
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
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
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
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
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"
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
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
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.
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
datastore) and whoseset_schedule/clear_schedule/get_statethis delegates to. - alarm_sounders (Proc)
-
called with
:on/:offto 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
modeandcodestates are whatAlarm::ControlPanelwrites 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 withingroup_trigger_interval.
Alarm::Service#run
- event (Symbol)
-
:state_change,:delay, or:schedule- same shape asWidget#action_code’s own `@event. Only:delay(this alarm’s own exit/entry/trigger countdown elapsing - seeset_schedulebelow) advances the arming/pending/triggered timers; a genuine recurring:schedulefiring, 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.
render Alarm::ControlPanel.new(Device.find_by!(name: "front_hall_alarm_panel"))
render Alarm::ControlPanel.new(Device.find_by!(name: "front_hall_alarm_panel"), scale: 50)
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
modeandcodeStates. - 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:, notattributes: {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.
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).
public/sounds/arm_exit.mp3)Audio::Service.play(:arm_exit)
Audio::Service.play(TextToSpeech::Service.speak("Front door is open"))
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.
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.
Notification::Service.notify("Front door has been open for 10 minutes", title: "Front door")
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 extraretry/expireparams this doesn’t send, and ntfy’smin/maxaren’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.
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
out: on the constructor is the frostguard 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)
-
:timefor a fixed clock time (default), or"before sunrise"/"after sunrise"/"before sunset"/"after sunset"(case-insensitive, space or underscore) to anchortime:as an offset from that day’s solar event instead — requires thelatitude/longitudesettings 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), ordefault(matches every day, ignoringtime/durationentirely — 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 notime:/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, seetime:above. - value (Object)
-
a per-period value, independent of
in:/out:— see#configureandScheduler::ScheduleEditorfor the intended use: a non-technical user edits this (andday:/time:/duration:) through a table UI, whilein:/out:(often a shared Proc) stay defined in code. - in (Object, Proc)
-
value returned, or lambda called, while
time_nowis inside the period.nildefers to thisService’s own `in:, then to this period’s ownvalue:. - out (Object, Proc)
-
same, for while
time_nowis outside the period.nildefers to thisService’s own `out:.
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 (seePeriod#from_config) — typicallyconfig.some_schedule_name - return (Scheduler::Service)
-
self
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'}.
config_view_code, restricting the value column to a fixed choicerender 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"})
action_codesch = 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_datakey this schedule is stored/read under — also read back viaScheduler::Service#configure(config.<name>)inaction_code. Use a differentname:per schedule if a widget needs more than one. - template (Hash)
-
default values for a newly-added row, and what’s shown when
config_datahas 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:, notattributes: {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
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.
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
:heatingmode, how far below the target the reading must drop before switching on - cooling_hysteresis (Float)
-
in
:coolingmode, how far above the target the reading must rise before switching on - mode (Symbol)
-
:off,:heating, or:cooling
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(fromlib/scheduler.rb’s `Schedulermodule, included by both). - condition (Proc, nil)
-
called (no arguments) on a
:state_changeevent to decide whether to proceed at all. A falsy return value (falseornil) 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_actionand 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_actionbeforeoff_actionruns.
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)
`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 asWidget#action_code’s own `@event.:scheduleis ignored outright - a widget can have a genuine recurring schedule configured for something unrelated alongside aTimedControl::Service. - state (State, nil)
-
accepted but unused directly, only so the same
event:, state: @trigger_statecall this app’s other services take (Alarm::Service#run) works here too -condition:/on_action:read@trigger_statethemselves via closure if they need it. - key (Object, nil)
-
identifies which outstanding countdown this run is for, so several independent
TimedControl::Servicetimers can run off the same widget at once - see the per-room example above. Forwarded as-is toset_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: trueand not#discrete?, same requirement asState#value_delta. - unit_price (Numeric, nil)
-
cost per unit of usage (i.e. per whatever
state.unitsis) - when given,#usagereturns{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 exceed24.hoursto reach into the next calendar day (e.g.23.hours..31.hoursfor 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;:positivesums only the rises (e.g. import),:negativeonly the falls (e.g. export, kept negative rather than made a magnitude) - seeRecorder#value_deltafor the mechanics.
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}
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]
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 aDateRange. - return (Numeric, Hash)
-
the summed usage - a Hash
{usage:, cost:}instead ifunit_price:was given to.new. Eitherusage(orusage:) isnilif the State has no recorded data in range at all.