Skip to content

Fan

One fan.* entity: a tower or desk fan, a ceiling fan, an extractor. On and off, its speed, and the presets, oscillation and direction it declares.

import { useHaFan } from "@dashhaus/core";
/** On and off, and its speed when it has speeds: a slider with one stop per speed. */
export function Speed({ entityId }: { entityId: string }) {
const { entity, actions } = useHaFan(entityId);
const count = entity.speedCount ?? 0;
const step = 100 / Math.max(1, count);
return (
<fieldset disabled={entity.state === "unavailable"}>
<legend>{entity.name}: {entity.state === "on" ? `${entity.percentage ?? "—"}%` : entity.state}</legend>
<button type="button" aria-pressed={entity.state === "on"} onClick={actions.onToggle}>
{entity.state === "on" ? "On" : "Off"}
</button>
{entity.supportsSpeed && count >= 2 && (
<label>
Speed
<input
type="range"
min={step}
max={100}
step={step}
value={entity.percentage ?? step}
onChange={(event) => actions.onPercentageChange(event.currentTarget.valueAsNumber)}
/>
</label>
)}
</fieldset>
);
}

speedCount is how many speeds the fan has, from Home Assistant’s percentage_step: 3 for a three-speed fan, 100 for a stepless one. Its speed is always a percentage, so a three-speed fan runs at 33, 67 or 100.

<HaFan> is useHaFan as a component that draws nothing of its own. It takes every option of the hook as a prop, and hands the hook’s result to its child function. Use it where hooks can’t go, such as one per item of a list.

entityIdstring
The Home Assistant entity id.
children(live: HaFanResult) => ReactNode
Draws the hook's result. The component itself draws nothing.
useHaFan(entityId: string, options?: HaFanOptions): HaFanResult

One live fan for any UI: the mapped entity, today's on/off history and its five capability-gated service actions.

It reads the fan, its area and today’s history.

name?string
Overrides Home Assistant's friendly name.
area?string
Overrides the area Home Assistant's registries resolve for the entity.
lastSeen?string
Overrides when the entity was last reachable, shown while it is unavailable.
history?FanHistory
Host-owned history override; when omitted, today's history is read from Home Assistant.
loadHistory?boolean
Set false to skip the Home Assistant history read.
onActionError?(error: HaActionError) => void
Called with an HaActionError when an action is refused or fails.
entityFanEntity
historyFanHistory
actionsFanHandlers

Each supports… flag comes from what the fan declares to Home Assistant. Its speed and preset are known only while it is on; a fan that is off reports neither.

entityIdstring
The Home Assistant entity id.
namestring
Its name: the `name` option, else Home Assistant's friendly name, else its entity id.
area?string
Where it is: "Bedroom".
state"unavailable" | "off" | "on"
supportsSpeed?boolean
True when the fan takes a speed (`FanEntityFeature.SET_SPEED`).
percentage?number
Its speed in percent, 1-100, while it is on and reports one. A fan that is off reports none.
speedCount?number
How many speeds it has, from HA `percentage_step`: 3 for a three-speed fan, 100 for a stepless one.
supportsPreset?boolean
True when it takes a preset (`FanEntityFeature.PRESET_MODE`) and lists at least one.
presetModes?string[]
Its presets, as Home Assistant names them ("sleep", "nature").
presetMode?string
The preset it runs, while on.
supportsOscillate?boolean
True when it oscillates on request (`FanEntityFeature.OSCILLATE`).
oscillating?boolean
supportsDirection?boolean
True when it reverses (`FanEntityFeature.DIRECTION`): a ceiling fan's summer and winter direction.
direction?FanDirection
lastChanged?string
lastSeen?string
While it is unavailable, when it was last reachable: the `lastSeen` option, else what Home Assistant reports.

Today, from Home Assistant’s recorder, kept up to date by every change since. Pass history to supply your own, or loadHistory={false} to skip the read.

samplesHourSample[]
1 for hours it was on, 0 off, "unavailable", null for hours ahead. Fractions for part of an hour.
currentHournumber
onMinutesnumber
Minutes on today, including the current hour.
onToggle() => void
Calls `fan.toggle`.
onPercentageChange(percent: number) => void
Calls `fan.set_percentage`, rounded to a whole percent from 1 to 100, so it never switches the fan off; setting a speed while it is off turns it on. A fan without speeds reports `unsupported`.
onPresetChange(preset: string) => void
Calls `fan.set_preset_mode`, which turns the fan on. A fan without presets reports `unsupported`; a preset it doesn't list reports `invalid_input`.
onOscillateChange(oscillating: boolean) => void
Calls `fan.oscillate`. A fan that doesn't oscillate reports `unsupported`.
onDirectionChange(direction: FanDirection) => void
Calls `fan.set_direction` with `forward` or `reverse`. A fan that doesn't reverse reports `unsupported`.

Every action on an unavailable entity is reported to onActionError as unsupported and never sent.

Hooks can’t be called in a loop, so a list renders one headless component per entity. The selector returns a string: useHaSelector re-renders only when its value changes, and a string of ids changes only when an entity comes or goes. Lists skip each item’s history read with loadHistory={false}.

import { HaFan, useHaSelector } from "@dashhaus/core";
import type { HaSnapshot } from "@dashhaus/core";
const fanIds = (snapshot: HaSnapshot) =>
Object.keys(snapshot.entities).filter((id) => id.startsWith("fan.")).sort().join(" ");
export function AllFans() {
const ids = useHaSelector(fanIds).split(" ").filter(Boolean);
return (
<ul>
{ids.map((id) => (
<li key={id}>
<HaFan entityId={id} loadHistory={false}>
{({ entity }) => (
<>
{entity.name}: {entity.state === "on" && entity.percentage != null ? `${entity.percentage}%` : entity.state}
</>
)}
</HaFan>
</li>
))}
</ul>
);
}
import { useHaFan } from "@dashhaus/core";
/** Its presets, its sweep and a ceiling fan's direction: each only when the fan declares it. */
export function Extras({ entityId }: { entityId: string }) {
const { entity, actions } = useHaFan(entityId, { loadHistory: false });
return (
<>
{entity.supportsPreset && (
<div role="group" aria-label={`${entity.name} preset`}>
{entity.presetModes?.map((preset) => (
<button key={preset} type="button" aria-pressed={entity.presetMode === preset} onClick={() => actions.onPresetChange(preset)}>
{preset}
</button>
))}
</div>
)}
{entity.supportsOscillate && (
<button type="button" aria-pressed={entity.oscillating === true} onClick={() => actions.onOscillateChange(!entity.oscillating)}>
Oscillate
</button>
)}
{entity.supportsDirection && (
<button type="button" onClick={() => actions.onDirectionChange(entity.direction === "forward" ? "reverse" : "forward")}>
{entity.direction === "reverse" ? "Reverse" : "Forward"}
</button>
)}
</>
);
}

presetModes is Home Assistant’s preset_modes: normal, sleep, nature, whatever the fan has. Direction is a ceiling fan’s: forward for a summer downdraft on most, reverse for winter.

Orb’s HaFanTile and Field’s HaFanModule are useHaFan above each skin’s card. Both set the speed on the light’s groove or fader, one stop per speed, and keep presets, oscillation and direction in its details. They take the hook’s options as props, plus their own:

import { HaFanTile } from "@dashhaus/orb";
import { HaFanModule } from "@dashhaus/field";
export function OrbFan() {
return <HaFanTile entityId="fan.bedroom" />;
}
export function FieldFan() {
return <HaFanModule entityId="fan.living_room_ceiling" footprint="compact" />;
}
entityIdstring
The Home Assistant entity id.
showControl?boolean
Compact only. false removes the inline control, shows the value in its place, and makes the row open the details. Default true.
footprint?CardFootprint
Card footprint. The skin's stylesheet sets the heights; the three names are the shared vocabulary.
defaultOpen?boolean
defaultAction?undefined

Every state of both is in Storybook: Orb and Field.