Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions dist/plotly-graph-card.js

Large diffs are not rendered by default.

32 changes: 29 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,24 @@ entities:

Fetch and plot long-term statistics of an entity

#### for entities with state_class=measurement (normal sensors, like temperature)

```yaml
type: custom:plotly-graph
entities:
- entity: sensor.temperature
statistic: max # `min`, `mean` of `max`
period: 5minute # `5minute`, `hour`, `day`, `week`, `month`, `auto` # `auto` varies the period depending on the zoom level
```

The option `auto` makes the period relative to the currently visible time range. It picks the longest period, such that there are at least 100 datapoints in screen.

#### for entities with state_class=measurement (normal sensors, like temperature)

```yaml
type: custom:plotly-graph
entities:
- entity: sensor.temperature
# for entities with state_class=measurement (normal sensors, like temperature):
statistic: max # `min`, `mean` of `max`
# for entities with state_class=total (such as utility meters):
statistic: state # `state` or `sum`
Expand All @@ -193,9 +206,22 @@ entities:

```

Note that `5minute` period statistics are limited in time as normal recorder history is, contrary to other periods which keep data for years.
#### step function for auto period

The option `auto` makes the period relative to the currently visible time range. It picks the longest period, such that there are at least 500 datapoints in screen.
```yaml
type: custom:plotly-graph
entities:
- entity: sensor.temperature
statistic: mean
period:
0: 5minute
24h: hour # when the visible range is ≥ 1 day, use the `hour` period
7d: day # from 7 days on, use `day`
# 6M: week # not yet supported in HA
1y: month # from 1 year on, use `month
```

Note that `5minute` period statistics are limited in time as normal recorder history is, contrary to other periods which keep data for years.

## Extra entity attributes:

Expand Down
44 changes: 44 additions & 0 deletions src/duration/duration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { parseTimeDuration } from "./duration";

describe("data-ranges", () => {
const ms = 1;
const s = ms * 1000;
const m = s * 60;
const h = m * 60;
const d = h * 24;
const w = d * 7;
const M = d * 30;
const y = d * 365;
it("Should parse all units", () => {
expect(parseTimeDuration("1ms")).toBe(1 * ms);
expect(parseTimeDuration("1s")).toBe(1 * s);
expect(parseTimeDuration("1m")).toBe(1 * m);
expect(parseTimeDuration("1h")).toBe(1 * h);
expect(parseTimeDuration("1d")).toBe(1 * d);
expect(parseTimeDuration("1w")).toBe(1 * w);
expect(parseTimeDuration("1M")).toBe(1 * M);
expect(parseTimeDuration("1y")).toBe(1 * y);
});
it("Should parse all signs", () => {
expect(parseTimeDuration("1ms")).toBe(1 * ms);
expect(parseTimeDuration("+1ms")).toBe(1 * ms);
expect(parseTimeDuration("-1ms")).toBe(-1 * ms);
});
it("Should parse all numbers", () => {
expect(parseTimeDuration("1s")).toBe(1 * s);
expect(parseTimeDuration("1.5s")).toBe(1.5 * s);
});
it("Should parse undefined", () => {
expect(parseTimeDuration(undefined)).toBe(0);
});
it("Should throw when it can't parse", () => {
// @ts-expect-error
expect(() => parseTimeDuration("1")).toThrow();
// @ts-expect-error
expect(() => parseTimeDuration("s")).toThrow();
// @ts-expect-error
expect(() => parseTimeDuration("--1s")).toThrow();
// @ts-expect-error
expect(() => parseTimeDuration("-1.1.1s")).toThrow();
});
});
38 changes: 38 additions & 0 deletions src/duration/duration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const timeUnits = {
ms: 1,
s: 1000,
m: 1000 * 60,
h: 1000 * 60 * 60,
d: 1000 * 60 * 60 * 24,
w: 1000 * 60 * 60 * 24 * 7,
M: 1000 * 60 * 60 * 24 * 30,
y: 1000 * 60 * 60 * 24 * 365,
};
type TimeUnit = keyof typeof timeUnits;
export type TimeDurationStr = `${number}${TimeUnit}` | `0`;

/**
*
* @param str 1.5s, -2m, 1h, 1d, 1w, 1M, 1.5y
* @returns duration in milliseconds
*/
export const parseTimeDuration = (str: TimeDurationStr | undefined): number => {
if (!str) return 0;
if (str === "0") return 0;
if (!str.match) return 0;
const match = str.match(
/^(?<sign>[+-])?(?<number>\d*(\.\d)?)(?<unit>(ms|s|m|h|d|w|M|y))$/
);
if (!match || !match.groups)
throw new Error(`Cannot parse "${str}" as a duration`);
const g = match.groups;
const sign = g.sign === "-" ? -1 : 1;
const number = parseFloat(g.number);
if (Number.isNaN(number))
throw new Error(`Cannot parse "${str}" as a duration`);
const unit = timeUnits[g.unit as TimeUnit];
if (unit === undefined)
throw new Error(`Cannot parse "${str}" as a duration`);

return sign * number * unit;
};
49 changes: 32 additions & 17 deletions src/plotly-graph-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import {
STATISTIC_PERIODS,
STATISTIC_TYPES,
StatisticPeriod,
isAutoPeriodConfig as getIsAutoPeriodConfig,
} from "./recorder-types";
import { parseTimeDuration } from "./duration/duration";

const componentName = isProduction ? "plotly-graph" : "plotly-graph-dev";

Expand Down Expand Up @@ -237,10 +239,23 @@ export class PlotlyGraph extends HTMLElement {
if ("statistic" in entity || "period" in entity) {
const validStatistic = STATISTIC_TYPES.includes(entity.statistic!);
if (!validStatistic) entity.statistic = "mean";
const validPeriod = STATISTIC_PERIODS.includes(entity.period);
console.log(entity.period);
// @TODO: cleanup how this is done
if (entity.period === "auto") {
entity.autoPeriod = true;
entity.autoPeriod = {
"0": "5minute",
"1d": "hour",
"7d": "day",
// "28d": "week",
"12M": "month",
};
}
const isAutoPeriodConfig = getIsAutoPeriodConfig(entity.period);

if (isAutoPeriodConfig) {
entity.autoPeriod = entity.period;
}
const validPeriod = STATISTIC_PERIODS.includes(entity.period);
if (!validPeriod) entity.period = "hour";
}
const [oldAPI_entity, oldAPI_attribute] = entity.entity.split("::");
Expand Down Expand Up @@ -307,23 +322,23 @@ export class PlotlyGraph extends HTMLElement {
for (const entity of this.config.entities) {
if ((entity as any).autoPeriod) {
if (isEntityIdStatisticsConfig(entity) && entity.autoPeriod) {
const spanInMinutes = (range[1] - range[0]) / 1000 / 60;
const MIN_POINTS_PER_RANGE = 100;
let period: StatisticPeriod = "5minute";
const period2minutes: [StatisticPeriod, number][] = [
// needs to be sorted in ascending order
["hour", 60],
["day", 60 * 24],
// ["week", 60 * 24 * 7], not supported yet in HA
["month", 60 * 24 * 30],
];
for (const [aPeriod, minutesPerPoint] of period2minutes) {
const pointsInSpan = spanInMinutes / minutesPerPoint;
if (pointsInSpan > MIN_POINTS_PER_RANGE) period = aPeriod;
entity.period = "5minute";
const timeSpan = range[1] - range[0];
const mapping = Object.entries(entity.autoPeriod)
.map(
([duration, period]) =>
[parseTimeDuration(duration as any), period] as [
number,
StatisticPeriod
]
)
.sort(([durationA], [durationB]) => durationA - durationB);

for (const [fromMS, aPeriod] of mapping) {
if (timeSpan >= fromMS) entity.period = aPeriod;
}
entity.period = period;
this.config.layout = merge(this.config.layout, {
xaxis: { title: `Period: ${period}` },
xaxis: { title: `Period: ${entity.period}` },
});
}
}
Expand Down
21 changes: 21 additions & 0 deletions src/recorder-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
// https://github.com/home-assistant/frontend/blob/dev/src/data/recorder.ts
import { keys } from "lodash";
import { parseTimeDuration, TimeDurationStr } from "./duration/duration";

export interface StatisticValue {
statistic_id: string;
start: string;
Expand All @@ -25,3 +28,21 @@ export const STATISTIC_PERIODS = [
"month",
] as const;
export type StatisticPeriod = typeof STATISTIC_PERIODS[number];
export type AutoPeriodConfig = Record<TimeDurationStr, StatisticPeriod>;

export function isAutoPeriodConfig(val: any): val is AutoPeriodConfig {
const isObject =
typeof val === "object" && val !== null && !Array.isArray(val);
if (!isObject) return false;
const entries = Object.entries(val);
if (entries.length === 0) return false;
return entries.every(([duration, period]) => {
if (!STATISTIC_PERIODS.includes(period as any)) return false;
try {
parseTimeDuration(duration as any);
} catch (e) {
return false;
}
return true;
});
}
5 changes: 3 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Datum } from "plotly.js";
import { ColorSchemeArray, ColorSchemeNames } from "./color-schemes";
import {
AutoPeriodConfig,
StatisticPeriod,
StatisticType,
StatisticValue,
Expand All @@ -16,7 +17,7 @@ export type InputConfig = {
entity: string;
attribute?: string;
statistic?: StatisticType;
period?: StatisticPeriod | "auto";
period?: StatisticPeriod | "auto" | AutoPeriodConfig;
unit_of_measurement?: string;
lambda?: string;
show_value?:
Expand Down Expand Up @@ -74,7 +75,7 @@ export type EntityIdStatisticsConfig = {
entity: string;
statistic: StatisticType;
period: StatisticPeriod;
autoPeriod: boolean;
autoPeriod: AutoPeriodConfig;
};
export type EntityIdConfig =
| EntityIdStateConfig
Expand Down
3 changes: 2 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));