Skip to content
Ihsen6 min readPart 2 of 3Build a GlassHome widget
  • Widgets
  • Developers
GlassHome dashboards for Home Assistant across themes, on a monitor and phone

Widgets that show data over time

Reading Home Assistant history and statistics from a widget, and rendering a different tile at 2x1 than at 3x2.

Part 1 built a tile that answers “what is happening now”. This one answers “what has been happening”:

A GlassHome tile titled Solar reading 5.0 kW, with an orange sparkline underneath showing a day of solar production rising through the morning.
One widget, 3x2. The same widget at 2x1 drops the chart and keeps the number.

That curve is a real day of solar production, from Home Assistant’s recorder.

Three sources, not one

You want Use Comes from
The value now useEntity, useEntities Dash’s live store, already in memory
The last hours or days trackEntityHistory + useEntityHistory HA’s recorder, fetched on demand
Weeks, months, or sums per bucket useEntityStatistics HA’s long-term statistics

The first is free. The other two are fetches you ask for explicitly. Most widgets want the middle row; reach for statistics when the window would run to thousands of state changes, or when you want HA to do the bucketing.

Step 1: ask for history, then read it

useEntityHistory does not fetch. It reads a slice of Dash’s store that exists only if something asked for it, so track on mount and untrack on cleanup:

import {
  trackEntityHistory,
  untrackEntityHistory,
  useEntityHistory,
} from "@glasshome/widget-sdk";
import { createMemo, onCleanup, onMount } from "solid-js";

const entityId = () => props.config.entityIds[0] ?? "";
const history = useEntityHistory(entityId);

onMount(() => {
  const id = entityId();
  if (id) trackEntityHistory(id, { startTime: new Date(Date.now() - 24 * 3_600_000) });
});
onCleanup(() => {
  const id = entityId();
  if (id) untrackEntityHistory(id);
});

const points = createMemo(() =>
  (history()?.timeline ?? [])
    .map((entry) => ({ value: Number(entry.state), timestamp: entry.timestamp }))
    .filter((p) => !Number.isNaN(p.value)),
);

Skip the onCleanup and the entity stays tracked for the life of the page. history() gives you { timeline, loading, error, lastFetched }. The timeline is state changes, not evenly spaced samples, so plot against the timestamps.

The Number(...) and filter matter: entity states are strings, and a briefly unavailable sensor puts NaN in your series, which turns an SVG path into nothing.

Step 2: turn the series into a line

monotoneCubicPath takes points and returns an SVG path, with no overshoot between samples. Scale your values into the box first:

import { monotoneCubicPath } from "@glasshome/widget-sdk";

const path = createMemo(() => {
  const pts = props.points;
  const w = size().width - 24;
  const h = 46;
  if (pts.length < 2 || w <= 0) return "";

  const values = pts.map((p) => p.value);
  const min = Math.min(...values);
  const max = Math.max(...values);
  const span = max - min || 1;            // a flat series would divide by zero
  const t0 = pts[0]?.timestamp ?? 0;
  const tSpan = (pts[pts.length - 1]?.timestamp ?? t0) - t0 || 1;

  return monotoneCubicPath(
    pts.map((p) => ({
      x: ((p.timestamp - t0) / tSpan) * w,
      y: h - ((p.value - min) / span) * h,
    })),
  );
});
<svg class="mt-1 w-full" height="46" aria-hidden="true">
  <path d={path()} fill="none" stroke="var(--widget-color)" stroke-width="2.5" stroke-linecap="round" />
</svg>

stroke="var(--widget-color)" takes the tile’s tone, so the line matches the widget in both themes without you picking a colour.

Step 3: one component, two sizes

A 1x1 tile has no room for a chart. Measure the shell instead of shipping two widgets:

import { useWidgetDimensions } from "@glasshome/widget-sdk";

const size = useWidgetDimensions();
const roomForChart = () => size().height >= 140 && points().length > 1;
<Widget.Value value={Math.round(latest())} unit={unit()} />
<Show when={roomForChart()}>
  <Sparkline points={points()} />
</Show>
The same Solar widget at two by one: icon, title and the value 5.0 kW, with no chart.
2x1: the number earns its space, the chart doesn't.

Same code, same config, different tile. size() is an accessor, so resizing re-decides.

Call it from inside `<Widget>`

useWidgetDimensions throws if you call it in the widget function itself. Put it in a child component that renders inside <Widget>, like PowerContent below.

Longer windows: statistics

For a month of data, ask Home Assistant for pre-bucketed values instead of every state change:

const stats = useEntityStatistics(
  () => props.config.entityIds[0] ?? "",
  () => ({ startTime: new Date(Date.now() - 30 * 24 * 3_600_000), period: "day" }),
);

You get buckets with start, end and the statistics that entity carries. It’s a Solid resource, so stats.loading and stats.error are there, and it re-fetches when the id or options change.

Rule of thumb: sum for metered things (energy, water), mean for sampled ones (temperature, power draw).

The whole widget

// src/my-power/index.tsx
import {
  defineConfig,
  defineWidget,
  field,
  type Infer,
  monotoneCubicPath,
  trackEntityHistory,
  untrackEntityHistory,
  useEntities,
  useEntityHistory,
  useWidgetDimensions,
  useWidgetEntityGroup,
  Widget,
} from "@glasshome/widget-sdk";
import { Icon } from "@iconify-icon/solid";
import { createMemo, onCleanup, onMount, Show } from "solid-js";

const configSchema = defineConfig({
  title: field.title(),
  entityIds: field.entity("sensor"),
  hours: field.number({ title: "Hours of history", default: 24, min: 1, max: 72 }),
});

type Config = Infer<typeof configSchema>;

function Sparkline(props: { points: { value: number; timestamp: number }[] }) {
  const size = useWidgetDimensions();

  const path = createMemo(() => {
    const pts = props.points;
    const w = size().width - 24;
    const h = 46;
    if (pts.length < 2 || w <= 0) return "";
    const values = pts.map((p) => p.value);
    const min = Math.min(...values);
    const max = Math.max(...values);
    const span = max - min || 1;
    const t0 = pts[0]?.timestamp ?? 0;
    const tSpan = (pts[pts.length - 1]?.timestamp ?? t0) - t0 || 1;
    return monotoneCubicPath(
      pts.map((p) => ({
        x: ((p.timestamp - t0) / tSpan) * w,
        y: h - ((p.value - min) / span) * h,
      })),
    );
  });

  return (
    <svg class="mt-1 w-full" height="46" aria-hidden="true">
      <path d={path()} fill="none" stroke="var(--widget-color)" stroke-width="2.5" stroke-linecap="round" />
    </svg>
  );
}

// Inside <Widget>, so it may measure the shell.
function PowerContent(props: { config: Config; latest: () => number; unit: () => string }) {
  const size = useWidgetDimensions();
  const entityId = () => props.config.entityIds[0] ?? "";
  const history = useEntityHistory(entityId);

  onMount(() => {
    const id = entityId();
    if (id)
      trackEntityHistory(id, {
        startTime: new Date(Date.now() - props.config.hours * 3_600_000),
      });
  });
  onCleanup(() => {
    const id = entityId();
    if (id) untrackEntityHistory(id);
  });

  const points = createMemo(() =>
    (history()?.timeline ?? [])
      .map((entry) => ({ value: Number(entry.state), timestamp: entry.timestamp }))
      .filter((p) => !Number.isNaN(p.value)),
  );

  const roomForChart = () => size().height >= 140 && points().length > 1;

  return (
    <Widget.Content>
      <Widget.Icon icon={<Icon icon="mdi:solar-power-variant" />} />
      <div class="flex min-w-0 flex-col gap-1">
        <Widget.Title>{props.config.title || "Power"}</Widget.Title>
        <Widget.Value value={Math.round(props.latest())} unit={props.unit()} />
        <Show when={roomForChart()}>
          <Sparkline points={points()} />
        </Show>
      </div>
    </Widget.Content>
  );
}

function MyPowerWidget(props: { config: Config }) {
  const entities = useEntities(() => props.config.entityIds);

  const { aggregatedData, emptyState, hasEntities } = useWidgetEntityGroup({
    entities,
    aggregationMode: () => "sensor",
    emptyStateConfig: {
      icon: <Icon icon="mdi:flash" width={32} />,
      title: "No sensor yet",
      message: "Hold to pick one",
    },
  });

  const sensor = createMemo(() => {
    const data = aggregatedData();
    return data && "numericValue" in data ? data : undefined;
  });

  return (
    <Widget variant="classic-glass" tone="accent" emptyState={emptyState()} loading={!hasEntities()}>
      <Show when={hasEntities()}>
        <PowerContent
          config={props.config}
          latest={() => sensor()?.numericValue ?? 0}
          unit={() => sensor()?.unit ?? ""}
        />
      </Show>
    </Widget>
  );
}

export default defineWidget<Config>({
  manifest: {
    name: "My Power",
    description: "A sensor value with its recent history",
    icon: "mdi:solar-power-variant",
    minSize: { w: 1, h: 1 },
    maxSize: { w: 4, h: 4 },
    defaultSize: { w: 2, h: 2 },
    sdkVersion: "^1.11.2",
    capabilities: [{ domain: "sensor", access: "read" }],
  },
  configSchema,
  component: MyPowerWidget,
});

Note the capability: read on sensor, because this widget changes nothing.

A trap when you render previews

The demo home only has history for its energy entities, so point your examples at sensor.solar_power or sensor.home_power. Anything else renders a tile with no line.