@hashsome/core 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/client.d.ts +10 -6
- package/dist/client.js +84 -3
- package/dist/integration.d.ts +22 -1
- package/dist/local-client.d.ts +4 -1
- package/dist/local-client.js +33 -0
- package/dist/mock-forecast.d.ts +8 -0
- package/dist/mock-forecast.js +136 -0
- package/dist/mock-history.d.ts +9 -0
- package/dist/mock-history.js +65 -0
- package/dist/mock-logbook.d.ts +8 -0
- package/dist/mock-logbook.js +43 -0
- package/dist/mock-queue.d.ts +9 -0
- package/dist/mock-queue.js +37 -0
- package/dist/mock.d.ts +9 -1
- package/dist/mock.js +56 -0
- package/dist/model/index.d.ts +2 -0
- package/dist/model/index.js +2 -0
- package/dist/model/logbook.d.ts +26 -0
- package/dist/model/logbook.js +1 -0
- package/dist/model/media-player.d.ts +26 -1
- package/dist/model/queue.d.ts +25 -0
- package/dist/model/queue.js +1 -0
- package/dist/model/weather.d.ts +72 -0
- package/dist/model/weather.js +3 -0
- package/dist/protocol.d.ts +2 -2
- package/dist/protocol.js +5 -1
- package/package.json +1 -1
- package/dist/base-integration.js.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/codegen.js.map +0 -1
- package/dist/conformance.js.map +0 -1
- package/dist/emitter.js.map +0 -1
- package/dist/entity.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/integration.js.map +0 -1
- package/dist/local-client.js.map +0 -1
- package/dist/mock.js.map +0 -1
- package/dist/model/action.js.map +0 -1
- package/dist/model/base.js.map +0 -1
- package/dist/model/browse.js.map +0 -1
- package/dist/model/climate.js.map +0 -1
- package/dist/model/generic.js.map +0 -1
- package/dist/model/history.js.map +0 -1
- package/dist/model/index.js.map +0 -1
- package/dist/model/light.js.map +0 -1
- package/dist/model/media-player.js.map +0 -1
- package/dist/model/person.js.map +0 -1
- package/dist/model/sensor.js.map +0 -1
- package/dist/model/switch.js.map +0 -1
- package/dist/model/weather.js.map +0 -1
- package/dist/protocol.js.map +0 -1
package/dist/client.js
CHANGED
|
@@ -4,6 +4,8 @@ import { encodeMessage, parseServerMessage } from './protocol.js';
|
|
|
4
4
|
* subscriptions, resubscribes and reconnects with backoff. Listeners receive
|
|
5
5
|
* `undefined` until the server has answered, then an entity or `null` (unknown entity).
|
|
6
6
|
*/
|
|
7
|
+
/** How long a request waits for the connection to open before it fails, in ms. */
|
|
8
|
+
const WAIT_MS = 10_000;
|
|
7
9
|
export class RemoteClient {
|
|
8
10
|
#options;
|
|
9
11
|
#socket;
|
|
@@ -16,6 +18,9 @@ export class RemoteClient {
|
|
|
16
18
|
#listeners = new Map();
|
|
17
19
|
#pending = new Map();
|
|
18
20
|
#nextId = 1;
|
|
21
|
+
/** Requests made while the connection was not open yet (opening, or back after a drop): each is
|
|
22
|
+
* sent when it opens, or given up on after a while. */
|
|
23
|
+
#waiting = new Set();
|
|
19
24
|
#retry = 0;
|
|
20
25
|
#timer;
|
|
21
26
|
#stopped = true;
|
|
@@ -35,6 +40,7 @@ export class RemoteClient {
|
|
|
35
40
|
close() {
|
|
36
41
|
this.#stopped = true;
|
|
37
42
|
clearTimeout(this.#timer);
|
|
43
|
+
this.#giveUpWaiting(new Error('Connection closed'));
|
|
38
44
|
this.#socket?.close();
|
|
39
45
|
this.#socket = undefined;
|
|
40
46
|
this.#setLink('closed');
|
|
@@ -90,7 +96,7 @@ export class RemoteClient {
|
|
|
90
96
|
ref,
|
|
91
97
|
query: 'browse',
|
|
92
98
|
args: { ...query },
|
|
93
|
-
})));
|
|
99
|
+
}), true));
|
|
94
100
|
}
|
|
95
101
|
async history(ref, query) {
|
|
96
102
|
return (await this.#request((id) => ({
|
|
@@ -99,12 +105,83 @@ export class RemoteClient {
|
|
|
99
105
|
ref,
|
|
100
106
|
query: 'history',
|
|
101
107
|
args: { ...query },
|
|
102
|
-
})));
|
|
108
|
+
}), true));
|
|
109
|
+
}
|
|
110
|
+
async forecast(ref, query) {
|
|
111
|
+
return (await this.#request((id) => ({
|
|
112
|
+
type: 'query',
|
|
113
|
+
id,
|
|
114
|
+
ref,
|
|
115
|
+
query: 'forecast',
|
|
116
|
+
args: { ...query },
|
|
117
|
+
}), true));
|
|
118
|
+
}
|
|
119
|
+
async logbook(ref, query) {
|
|
120
|
+
return (await this.#request((id) => ({
|
|
121
|
+
type: 'query',
|
|
122
|
+
id,
|
|
123
|
+
ref,
|
|
124
|
+
query: 'logbook',
|
|
125
|
+
args: { ...query },
|
|
126
|
+
}), true));
|
|
127
|
+
}
|
|
128
|
+
async queue(ref, query) {
|
|
129
|
+
return (await this.#request((id) => ({
|
|
130
|
+
type: 'query',
|
|
131
|
+
id,
|
|
132
|
+
ref,
|
|
133
|
+
query: 'queue',
|
|
134
|
+
args: { ...query },
|
|
135
|
+
}), true));
|
|
103
136
|
}
|
|
104
137
|
callRaw(integration, request) {
|
|
105
138
|
return this.#request((id) => ({ type: 'raw', id, integration, request }));
|
|
106
139
|
}
|
|
107
|
-
|
|
140
|
+
/** Resolves once the connection is open: now if it is, else when it opens. The page asks for things
|
|
141
|
+
* the moment it appears, which can be before the socket has finished opening, and a request made
|
|
142
|
+
* then waits for it instead of failing. It fails if the connection does not come within
|
|
143
|
+
* `WAIT_MS` (or the client was closed), and the caller can ask again later. */
|
|
144
|
+
#whenOpen() {
|
|
145
|
+
if (this.#socket?.readyState === 1) {
|
|
146
|
+
return Promise.resolve();
|
|
147
|
+
}
|
|
148
|
+
if (this.#stopped) {
|
|
149
|
+
return Promise.reject(new Error('Not connected to runtime'));
|
|
150
|
+
}
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const waiter = {
|
|
153
|
+
open: () => {
|
|
154
|
+
clearTimeout(waiter.timer);
|
|
155
|
+
resolve();
|
|
156
|
+
},
|
|
157
|
+
fail: (error) => {
|
|
158
|
+
clearTimeout(waiter.timer);
|
|
159
|
+
reject(error);
|
|
160
|
+
},
|
|
161
|
+
timer: setTimeout(() => {
|
|
162
|
+
this.#waiting.delete(waiter);
|
|
163
|
+
reject(new Error('Not connected to runtime'));
|
|
164
|
+
}, WAIT_MS),
|
|
165
|
+
};
|
|
166
|
+
this.#waiting.add(waiter);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
#giveUpWaiting(error) {
|
|
170
|
+
for (const waiter of Array.from(this.#waiting)) {
|
|
171
|
+
waiter.fail(error);
|
|
172
|
+
}
|
|
173
|
+
this.#waiting.clear();
|
|
174
|
+
}
|
|
175
|
+
/** Sends a request and resolves with its answer. A read (`wait`) made before the connection is open
|
|
176
|
+
* waits for it; a command or a raw request never does, since doing it late, after the person has
|
|
177
|
+
* seen it fail, would be a surprise. */
|
|
178
|
+
#request(build, wait = false) {
|
|
179
|
+
if (!wait || this.#socket?.readyState === 1) {
|
|
180
|
+
return this.#sendRequest(build);
|
|
181
|
+
}
|
|
182
|
+
return this.#whenOpen().then(() => this.#sendRequest(build));
|
|
183
|
+
}
|
|
184
|
+
#sendRequest(build) {
|
|
108
185
|
const id = this.#nextId++;
|
|
109
186
|
return new Promise((resolve, reject) => {
|
|
110
187
|
if (this.#socket?.readyState !== 1) {
|
|
@@ -136,6 +213,10 @@ export class RemoteClient {
|
|
|
136
213
|
socket.addEventListener('open', () => {
|
|
137
214
|
this.#retry = 0;
|
|
138
215
|
this.#setLink('open');
|
|
216
|
+
for (const waiter of Array.from(this.#waiting)) {
|
|
217
|
+
waiter.open();
|
|
218
|
+
}
|
|
219
|
+
this.#waiting.clear();
|
|
139
220
|
for (const ref of this.#listeners.keys()) {
|
|
140
221
|
this.#send({ type: 'subscribe', ref });
|
|
141
222
|
}
|
package/dist/integration.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EntityRef } from './entity.ts';
|
|
2
|
-
import type { BrowseQuery, BrowseResult, Entity, HistoryQuery, HistoryResult } from './model/index.ts';
|
|
2
|
+
import type { BrowseQuery, BrowseResult, Entity, ForecastQuery, ForecastResult, HistoryQuery, HistoryResult, LogbookQuery, LogbookResult, QueueQuery, QueueResult } from './model/index.ts';
|
|
3
3
|
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
|
|
4
4
|
export type Unsubscribe = () => void;
|
|
5
5
|
/**
|
|
@@ -48,6 +48,27 @@ export interface Integration {
|
|
|
48
48
|
* Arguments come from a browser: validate them.
|
|
49
49
|
*/
|
|
50
50
|
history?(entityId: string, query: HistoryQuery): Promise<HistoryResult>;
|
|
51
|
+
/**
|
|
52
|
+
* A weather entity's forecast of the asked kind (days, hours, half-days), soonest first. Optional:
|
|
53
|
+
* only for backends that have forecasts. Resolves with no points when the entity cannot give that
|
|
54
|
+
* kind, and rejects with a displayable `Error` for an unknown entity. Arguments come from a
|
|
55
|
+
* browser: validate them.
|
|
56
|
+
*/
|
|
57
|
+
forecast?(entityId: string, query: ForecastQuery): Promise<ForecastResult>;
|
|
58
|
+
/**
|
|
59
|
+
* Recent activity of an entity: what happened to it (turned on, went offline) and who or what
|
|
60
|
+
* caused it, newest first. Optional: only for backends that keep such a record (Home Assistant's
|
|
61
|
+
* logbook). Resolves with no entries for an entity with none, and rejects with a displayable
|
|
62
|
+
* `Error` for an unknown entity. Arguments come from a browser: validate them.
|
|
63
|
+
*/
|
|
64
|
+
logbook?(entityId: string, query: LogbookQuery): Promise<LogbookResult>;
|
|
65
|
+
/**
|
|
66
|
+
* A media player's queue: the tracks that play, in order, starting a little before the current one.
|
|
67
|
+
* Optional: only for players that report `capabilities.queue`. Resolves with no items for a player
|
|
68
|
+
* with an empty queue, and rejects with a displayable `Error` for an unknown entity. Arguments come
|
|
69
|
+
* from a browser: validate them.
|
|
70
|
+
*/
|
|
71
|
+
queue?(entityId: string, query: QueueQuery): Promise<QueueResult>;
|
|
51
72
|
/**
|
|
52
73
|
* Fetches a file the backend serves for its entities, such as artwork, with the integration's own
|
|
53
74
|
* credentials. `path` is the backend's own path and starts with `/`; it comes from a browser, so
|
package/dist/local-client.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Client, EntityListener, LinkStatus } from './client.ts';
|
|
2
2
|
import { type EntityRef } from './entity.ts';
|
|
3
3
|
import type { ConnectionStatus, Integration, Unsubscribe } from './integration.ts';
|
|
4
|
-
import type { BrowseQuery, BrowseResult, Entity, HistoryQuery, HistoryResult } from './model/index.ts';
|
|
4
|
+
import type { BrowseQuery, BrowseResult, Entity, ForecastQuery, ForecastResult, HistoryQuery, HistoryResult, LogbookQuery, LogbookResult, QueueQuery, QueueResult } from './model/index.ts';
|
|
5
5
|
/** In-process `Client` over integrations, with no runtime server. Used by the gallery and tests. */
|
|
6
6
|
export declare class LocalClient implements Client {
|
|
7
7
|
#private;
|
|
@@ -18,5 +18,8 @@ export declare class LocalClient implements Client {
|
|
|
18
18
|
command(ref: EntityRef, name: string, args?: Record<string, unknown>): Promise<void>;
|
|
19
19
|
browse(ref: EntityRef, query: BrowseQuery): Promise<BrowseResult>;
|
|
20
20
|
history(ref: EntityRef, query: HistoryQuery): Promise<HistoryResult>;
|
|
21
|
+
forecast(ref: EntityRef, query: ForecastQuery): Promise<ForecastResult>;
|
|
22
|
+
logbook(ref: EntityRef, query: LogbookQuery): Promise<LogbookResult>;
|
|
23
|
+
queue(ref: EntityRef, query: QueueQuery): Promise<QueueResult>;
|
|
21
24
|
callRaw(integration: string, request: Record<string, unknown>): Promise<unknown>;
|
|
22
25
|
}
|
package/dist/local-client.js
CHANGED
|
@@ -99,6 +99,39 @@ export class LocalClient {
|
|
|
99
99
|
}
|
|
100
100
|
return source.history(id, query);
|
|
101
101
|
}
|
|
102
|
+
forecast(ref, query) {
|
|
103
|
+
const { integration, id } = parseEntityRef(ref);
|
|
104
|
+
const source = this.#integrations.get(integration);
|
|
105
|
+
if (!source) {
|
|
106
|
+
return Promise.reject(new Error(`Unknown integration "${integration}"`));
|
|
107
|
+
}
|
|
108
|
+
if (!source.forecast) {
|
|
109
|
+
return Promise.reject(new Error(`"${integration}" has no forecasts`));
|
|
110
|
+
}
|
|
111
|
+
return source.forecast(id, query);
|
|
112
|
+
}
|
|
113
|
+
logbook(ref, query) {
|
|
114
|
+
const { integration, id } = parseEntityRef(ref);
|
|
115
|
+
const source = this.#integrations.get(integration);
|
|
116
|
+
if (!source) {
|
|
117
|
+
return Promise.reject(new Error(`Unknown integration "${integration}"`));
|
|
118
|
+
}
|
|
119
|
+
if (!source.logbook) {
|
|
120
|
+
return Promise.reject(new Error(`"${integration}" keeps no activity`));
|
|
121
|
+
}
|
|
122
|
+
return source.logbook(id, query);
|
|
123
|
+
}
|
|
124
|
+
queue(ref, query) {
|
|
125
|
+
const { integration, id } = parseEntityRef(ref);
|
|
126
|
+
const source = this.#integrations.get(integration);
|
|
127
|
+
if (!source) {
|
|
128
|
+
return Promise.reject(new Error(`Unknown integration "${integration}"`));
|
|
129
|
+
}
|
|
130
|
+
if (!source.queue) {
|
|
131
|
+
return Promise.reject(new Error(`"${integration}" has no queue`));
|
|
132
|
+
}
|
|
133
|
+
return source.queue(id, query);
|
|
134
|
+
}
|
|
102
135
|
callRaw(integration, request) {
|
|
103
136
|
const source = this.#integrations.get(integration);
|
|
104
137
|
if (!source) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Entity, ForecastQuery, ForecastResult } from './model/index.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A made-up but believable forecast for a mock weather entity, so the UI that a real source feeds
|
|
4
|
+
* has something to show: a spell of weather moving through, warmer afternoons and colder nights
|
|
5
|
+
* around the entity's current temperature. The same every time for the same entity and moment (`now`
|
|
6
|
+
* is a parameter so a test can pin it). Anything that is not a weather entity has none.
|
|
7
|
+
*/
|
|
8
|
+
export declare function mockForecast(entity: Entity | undefined, query: ForecastQuery, now?: number): ForecastResult;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const HOUR = 3_600_000;
|
|
2
|
+
const DAY = 24 * HOUR;
|
|
3
|
+
/** A spell of weather the made-up days run through, so a forecast is not one flat condition. */
|
|
4
|
+
const SPELL = [
|
|
5
|
+
'sunny',
|
|
6
|
+
'partlycloudy',
|
|
7
|
+
'cloudy',
|
|
8
|
+
'rainy',
|
|
9
|
+
'pouring',
|
|
10
|
+
'partlycloudy',
|
|
11
|
+
'sunny',
|
|
12
|
+
'sunny',
|
|
13
|
+
'cloudy',
|
|
14
|
+
'fog',
|
|
15
|
+
];
|
|
16
|
+
/** How likely rain is under each condition, percent. */
|
|
17
|
+
const RAIN = {
|
|
18
|
+
cloudy: 15,
|
|
19
|
+
fog: 10,
|
|
20
|
+
rainy: 70,
|
|
21
|
+
pouring: 95,
|
|
22
|
+
'lightning-rainy': 90,
|
|
23
|
+
'snowy-rainy': 80,
|
|
24
|
+
};
|
|
25
|
+
/** How much of the sky is cloud under each condition, percent. */
|
|
26
|
+
const CLOUD = {
|
|
27
|
+
sunny: 4,
|
|
28
|
+
'clear-night': 4,
|
|
29
|
+
partlycloudy: 50,
|
|
30
|
+
cloudy: 90,
|
|
31
|
+
fog: 100,
|
|
32
|
+
rainy: 95,
|
|
33
|
+
pouring: 100,
|
|
34
|
+
'lightning-rainy': 100,
|
|
35
|
+
};
|
|
36
|
+
const STEPS = { daily: 10, hourly: 48, twice_daily: 14 };
|
|
37
|
+
const round = (value) => Math.round(value * 10) / 10;
|
|
38
|
+
/**
|
|
39
|
+
* A made-up but believable forecast for a mock weather entity, so the UI that a real source feeds
|
|
40
|
+
* has something to show: a spell of weather moving through, warmer afternoons and colder nights
|
|
41
|
+
* around the entity's current temperature. The same every time for the same entity and moment (`now`
|
|
42
|
+
* is a parameter so a test can pin it). Anything that is not a weather entity has none.
|
|
43
|
+
*/
|
|
44
|
+
export function mockForecast(entity, query, now = Date.now()) {
|
|
45
|
+
if (entity?.kind !== 'weather') {
|
|
46
|
+
return { type: query.type, points: [] };
|
|
47
|
+
}
|
|
48
|
+
const base = entity.temperature ?? 15;
|
|
49
|
+
const start = new Date(now);
|
|
50
|
+
const points = [];
|
|
51
|
+
// The wind swings round slowly and gusts up with the rain.
|
|
52
|
+
const wind = (step) => entity.windSpeed === undefined
|
|
53
|
+
? {}
|
|
54
|
+
: {
|
|
55
|
+
windSpeed: round(entity.windSpeed * (0.7 + 0.6 * Math.abs(Math.sin(step * 0.7)))),
|
|
56
|
+
windBearing: Math.round(((entity.windBearing ?? 0) + step * 9) % 360),
|
|
57
|
+
};
|
|
58
|
+
const spellAt = (step) => SPELL[(step + Math.max(0, SPELL.indexOf(entity.condition))) % SPELL.length];
|
|
59
|
+
if (query.type === 'hourly') {
|
|
60
|
+
start.setMinutes(0, 0, 0);
|
|
61
|
+
for (let i = 0; i < STEPS.hourly; i += 1) {
|
|
62
|
+
const time = new Date(start.getTime() + i * HOUR);
|
|
63
|
+
const hours = time.getHours();
|
|
64
|
+
const condition = spellAt(Math.floor(i / 8));
|
|
65
|
+
const night = hours < 6 || hours >= 21;
|
|
66
|
+
points.push({
|
|
67
|
+
timestamp: time.toISOString(),
|
|
68
|
+
condition: night && condition === 'sunny' ? 'clear-night' : condition,
|
|
69
|
+
temperature: round(base + 4 * Math.sin(((hours - 9) / 24) * 2 * Math.PI) - i * 0.05),
|
|
70
|
+
precipitationProbability: RAIN[condition] ?? 0,
|
|
71
|
+
...wind(i),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else if (query.type === 'twice_daily') {
|
|
76
|
+
start.setHours(0, 0, 0, 0);
|
|
77
|
+
for (let i = 0; i < STEPS.twice_daily; i += 1) {
|
|
78
|
+
const daytime = i % 2 === 0;
|
|
79
|
+
const condition = spellAt(Math.floor(i / 2));
|
|
80
|
+
points.push({
|
|
81
|
+
timestamp: new Date(start.getTime() + i * 12 * HOUR + (daytime ? 6 * HOUR : 0)).toISOString(),
|
|
82
|
+
condition: !daytime && condition === 'sunny' ? 'clear-night' : condition,
|
|
83
|
+
temperature: round(daytime ? base + 4 + Math.sin(i) : base - 4 + Math.sin(i)),
|
|
84
|
+
precipitationProbability: RAIN[condition] ?? 0,
|
|
85
|
+
daytime,
|
|
86
|
+
...wind(i),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
start.setHours(0, 0, 0, 0);
|
|
92
|
+
for (let i = 0; i < STEPS.daily; i += 1) {
|
|
93
|
+
const condition = spellAt(i);
|
|
94
|
+
const drift = 2 * Math.sin(i * 1.3);
|
|
95
|
+
points.push({
|
|
96
|
+
timestamp: new Date(start.getTime() + i * DAY).toISOString(),
|
|
97
|
+
condition,
|
|
98
|
+
temperature: round(base + 3 + drift),
|
|
99
|
+
low: round(base - 5 + drift),
|
|
100
|
+
precipitationProbability: RAIN[condition] ?? 0,
|
|
101
|
+
...(RAIN[condition] ? { precipitation: round((RAIN[condition] ?? 0) / 12) } : {}),
|
|
102
|
+
...wind(i),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// What else a weather source gives for a step, made up from the step itself: it is more humid when
|
|
107
|
+
// cold and cloudy, the sun peaks at noon (or at its day's best, for a whole day) and is dimmed by cloud,
|
|
108
|
+
// and gusts and the feels-like follow the wind.
|
|
109
|
+
const enrich = (point) => {
|
|
110
|
+
const cloud = CLOUD[point.condition] ?? 50;
|
|
111
|
+
const temperature = point.temperature ?? base;
|
|
112
|
+
const hour = new Date(point.timestamp).getHours();
|
|
113
|
+
const sun = query.type === 'daily' ? 1 : Math.max(0, Math.sin(((hour - 6) / 12) * Math.PI));
|
|
114
|
+
const rain = RAIN[point.condition] ?? 0;
|
|
115
|
+
return {
|
|
116
|
+
...point,
|
|
117
|
+
humidity: Math.min(100, Math.max(20, Math.round(85 - (temperature - 5) * 2 + cloud * 0.1))),
|
|
118
|
+
cloudCoverage: cloud,
|
|
119
|
+
uvIndex: point.daytime === false ? 0 : round(sun * 8 * (1 - cloud / 140)),
|
|
120
|
+
apparentTemperature: round(temperature - 1 - (point.windSpeed ?? 0) * 0.05),
|
|
121
|
+
pressure: Math.round(1015 - rain * 0.12 + Math.sin(hour / 4)),
|
|
122
|
+
...(point.windSpeed !== undefined ? { windGustSpeed: round(point.windSpeed * 1.6) } : {}),
|
|
123
|
+
...(rain > 50 && point.precipitation === undefined
|
|
124
|
+
? { precipitation: round(rain / 70) }
|
|
125
|
+
: {}),
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
type: query.type,
|
|
130
|
+
points: points.map(enrich),
|
|
131
|
+
...(entity.unit ? { unit: entity.unit } : {}),
|
|
132
|
+
...(entity.precipitationUnit ? { precipitationUnit: entity.precipitationUnit } : {}),
|
|
133
|
+
...(entity.pressureUnit ? { pressureUnit: entity.pressureUnit } : {}),
|
|
134
|
+
...(entity.windUnit ? { windUnit: entity.windUnit } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Entity, HistoryQuery, HistoryResult } from './model/index.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Made-up but believable history for a numeric mock sensor, so the charts a real backend would fill
|
|
4
|
+
* have something to show: a daily rhythm (more in the evening), a slower drift over the days and a
|
|
5
|
+
* little jitter. A reading (a temperature, power) hovers around the sensor's current value; an
|
|
6
|
+
* `energy` counter (kWh or Wh) answers with how much it grew in each bucket. Nothing else has any.
|
|
7
|
+
* `now` is a parameter so a test can pin it.
|
|
8
|
+
*/
|
|
9
|
+
export declare function mockHistory(entityId: string, entity: Entity | undefined, query: HistoryQuery, now?: number): HistoryResult;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const MINUTE = 60_000;
|
|
2
|
+
const HOUR = 60 * MINUTE;
|
|
3
|
+
const DAY = 24 * HOUR;
|
|
4
|
+
const SPAN = { '1h': HOUR, '1d': DAY, '1w': 7 * DAY, '1m': 30 * DAY };
|
|
5
|
+
const BUCKET = { '5m': 5 * MINUTE, '1h': HOUR, '1d': DAY };
|
|
6
|
+
const DEFAULT_BUCKET = {
|
|
7
|
+
'1h': '5m',
|
|
8
|
+
'1d': '5m',
|
|
9
|
+
'1w': '1h',
|
|
10
|
+
'1m': '1h',
|
|
11
|
+
};
|
|
12
|
+
/** A number in [0, 1) that is the same for the same text, so a mock device always has the same past. */
|
|
13
|
+
function seed(text) {
|
|
14
|
+
let hash = 0;
|
|
15
|
+
for (const char of text) {
|
|
16
|
+
hash = (hash * 31 + char.codePointAt(0)) % 100_003;
|
|
17
|
+
}
|
|
18
|
+
return hash / 100_003;
|
|
19
|
+
}
|
|
20
|
+
const round = (value, digits) => {
|
|
21
|
+
const factor = 10 ** digits;
|
|
22
|
+
return Math.round(value * factor) / factor;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Made-up but believable history for a numeric mock sensor, so the charts a real backend would fill
|
|
26
|
+
* have something to show: a daily rhythm (more in the evening), a slower drift over the days and a
|
|
27
|
+
* little jitter. A reading (a temperature, power) hovers around the sensor's current value; an
|
|
28
|
+
* `energy` counter (kWh or Wh) answers with how much it grew in each bucket. Nothing else has any.
|
|
29
|
+
* `now` is a parameter so a test can pin it.
|
|
30
|
+
*/
|
|
31
|
+
export function mockHistory(entityId, entity, query, now = Date.now()) {
|
|
32
|
+
if (entity?.kind !== 'sensor' || entity.numeric === undefined) {
|
|
33
|
+
return { points: [], kind: 'measurement' };
|
|
34
|
+
}
|
|
35
|
+
const total = entity.measurement === 'energy' || entity.unit === 'kWh' || entity.unit === 'Wh';
|
|
36
|
+
const bucket = BUCKET[query.bucket ?? DEFAULT_BUCKET[query.range]];
|
|
37
|
+
const count = Math.ceil(SPAN[query.range] / bucket);
|
|
38
|
+
const end = Math.floor(now / bucket) * bucket;
|
|
39
|
+
const phase = seed(entityId);
|
|
40
|
+
// How busy the device is at a time: 0 to 1.
|
|
41
|
+
const level = (time) => {
|
|
42
|
+
const hours = (time % DAY) / HOUR;
|
|
43
|
+
const daily = 0.5 + 0.5 * Math.sin(((hours - 9) / 24) * 2 * Math.PI);
|
|
44
|
+
const drift = 0.15 * Math.sin((time / DAY) * 1.3 + phase * 6);
|
|
45
|
+
const jitter = 0.015 * Math.sin((time / bucket) * 1.7 + phase * 9);
|
|
46
|
+
return Math.min(1, Math.max(0, daily + drift + jitter));
|
|
47
|
+
};
|
|
48
|
+
// What a counter grows by in a day: about 0.6 to 1.8 kWh, and the same in Wh.
|
|
49
|
+
const perDay = (0.6 + 1.2 * phase) * (entity.unit === 'Wh' ? 1000 : 1);
|
|
50
|
+
const points = [];
|
|
51
|
+
for (let i = count - 1; i >= 0; i -= 1) {
|
|
52
|
+
const start = end - i * bucket;
|
|
53
|
+
points.push({
|
|
54
|
+
timestamp: new Date(start).toISOString(),
|
|
55
|
+
value: total
|
|
56
|
+
? round((perDay / 24) * (bucket / HOUR) * (0.4 + 1.2 * level(start)), 3)
|
|
57
|
+
: round(entity.numeric * (0.55 + 0.9 * level(start)), 1),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
points,
|
|
62
|
+
kind: total ? 'total' : 'measurement',
|
|
63
|
+
...(entity.unit ? { unit: entity.unit } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Entity, LogbookQuery, LogbookResult } from './model/index.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Made-up but believable activity for a mock light, switch or heater, so the History section of its
|
|
4
|
+
* drawer has something to show: it turns on and off through the past days, caused by a person or an
|
|
5
|
+
* automation, now and then going offline by itself. The same every time for the same entity and
|
|
6
|
+
* moment (`now` is a parameter so a test can pin it). Anything else has none.
|
|
7
|
+
*/
|
|
8
|
+
export declare function mockLogbook(entityId: string, entity: Entity | undefined, query: LogbookQuery, now?: number): LogbookResult;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const MINUTE = 60_000;
|
|
2
|
+
const HOUR = 60 * MINUTE;
|
|
3
|
+
/** Who the made-up activity is caused by: two people at home, and two automations. */
|
|
4
|
+
const ACTORS = [
|
|
5
|
+
{ actor: 'Dan' },
|
|
6
|
+
{ actor: 'Bedtime', actorKind: 'automation' },
|
|
7
|
+
{ actor: 'Alex' },
|
|
8
|
+
{ actor: 'Sunrise', actorKind: 'automation' },
|
|
9
|
+
{},
|
|
10
|
+
];
|
|
11
|
+
/** The kinds of entity that switch on and off, and so have activity worth showing. */
|
|
12
|
+
const SWITCHING = new Set(['light', 'switch', 'climate']);
|
|
13
|
+
/**
|
|
14
|
+
* Made-up but believable activity for a mock light, switch or heater, so the History section of its
|
|
15
|
+
* drawer has something to show: it turns on and off through the past days, caused by a person or an
|
|
16
|
+
* automation, now and then going offline by itself. The same every time for the same entity and
|
|
17
|
+
* moment (`now` is a parameter so a test can pin it). Anything else has none.
|
|
18
|
+
*/
|
|
19
|
+
export function mockLogbook(entityId, entity, query, now = Date.now()) {
|
|
20
|
+
if (!entity || !SWITCHING.has(entity.kind)) {
|
|
21
|
+
return { entries: [] };
|
|
22
|
+
}
|
|
23
|
+
const limit = Math.max(1, Math.min(query.limit ?? 20, 100));
|
|
24
|
+
const entries = [];
|
|
25
|
+
let on = entity.kind === 'light' ? entity.on === true : true;
|
|
26
|
+
for (let i = 0; i < limit; i += 1) {
|
|
27
|
+
// Further back and a little irregular, newest first.
|
|
28
|
+
const at = now - (i + 1) * 3 * HOUR - ((i * 7919) % 50) * MINUTE;
|
|
29
|
+
const cause = ACTORS[(i + entityId.length) % ACTORS.length];
|
|
30
|
+
const lost = i % 9 === 8;
|
|
31
|
+
entries.push({
|
|
32
|
+
id: `${entityId}@${at}`,
|
|
33
|
+
message: lost ? 'became unavailable' : on ? 'turned on' : 'turned off',
|
|
34
|
+
timestamp: new Date(at).toISOString(),
|
|
35
|
+
change: lost ? 'availability' : on ? 'on' : 'off',
|
|
36
|
+
...(lost ? {} : cause),
|
|
37
|
+
});
|
|
38
|
+
if (!lost) {
|
|
39
|
+
on = !on;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { entries };
|
|
43
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { BrowseItem, Entity, QueueQuery, QueueResult } from './model/index.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A made-up queue for a mock player that has one: the tracks of the mock library, round and round,
|
|
4
|
+
* with the one the player plays third. It is the same every time, so a test or a screenshot can count on it.
|
|
5
|
+
* Anything that is not a media player with a queue has none.
|
|
6
|
+
*/
|
|
7
|
+
export declare function mockQueue(entity: Entity | undefined, library: Record<string, {
|
|
8
|
+
items: BrowseItem[];
|
|
9
|
+
}>, query: QueueQuery): QueueResult;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** How many tracks the made-up queue has. */
|
|
2
|
+
const TOTAL = 24;
|
|
3
|
+
/** How many tracks are before the current one: a little of what has just played. */
|
|
4
|
+
const BEFORE = 2;
|
|
5
|
+
/**
|
|
6
|
+
* A made-up queue for a mock player that has one: the tracks of the mock library, round and round,
|
|
7
|
+
* with the one the player plays third. It is the same every time, so a test or a screenshot can count on it.
|
|
8
|
+
* Anything that is not a media player with a queue has none.
|
|
9
|
+
*/
|
|
10
|
+
export function mockQueue(entity, library, query) {
|
|
11
|
+
if (entity?.kind !== 'mediaPlayer' || !entity.capabilities.queue) {
|
|
12
|
+
return { items: [], total: 0, offset: 0 };
|
|
13
|
+
}
|
|
14
|
+
const tracks = [
|
|
15
|
+
...new Map(Object.values(library)
|
|
16
|
+
.flatMap((folder) => folder.items)
|
|
17
|
+
.filter((item) => item.kind === 'track')
|
|
18
|
+
.map((item) => [item.id, item])).values(),
|
|
19
|
+
];
|
|
20
|
+
if (tracks.length === 0) {
|
|
21
|
+
return { items: [], total: 0, offset: 0 };
|
|
22
|
+
}
|
|
23
|
+
// The track the player says is playing is the one that plays here, so the two agree.
|
|
24
|
+
const playing = Math.max(0, tracks.findIndex((track) => track.title === entity.media?.title));
|
|
25
|
+
const items = Array.from({ length: TOTAL }, (_, index) => {
|
|
26
|
+
const track = tracks[(playing + index + tracks.length * TOTAL - BEFORE) % tracks.length];
|
|
27
|
+
return {
|
|
28
|
+
id: `q${index}-${track.id}`,
|
|
29
|
+
title: track.title,
|
|
30
|
+
...(track.subtitle ? { artist: track.subtitle } : {}),
|
|
31
|
+
...(track.artworkUrl ? { artworkUrl: track.artworkUrl } : {}),
|
|
32
|
+
duration: 170 + ((index * 37) % 90),
|
|
33
|
+
...(index === BEFORE ? { current: true } : {}),
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
return { items: items.slice(0, query.limit ?? 30), total: TOTAL, offset: 0 };
|
|
37
|
+
}
|
package/dist/mock.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BaseIntegration } from './base-integration.ts';
|
|
2
|
-
import { type ActionEntity, type BrowseItem, type BrowseQuery, type BrowseResult, type ClimateEntity, type EntityInput, type GenericEntity, type LightEntity, type MediaPlayerEntity, type PersonEntity, type SensorEntity, type SwitchEntity, type WeatherEntity } from './model/index.ts';
|
|
2
|
+
import { type ActionEntity, type BrowseItem, type BrowseQuery, type BrowseResult, type ClimateEntity, type EntityInput, type ForecastQuery, type ForecastResult, type GenericEntity, type HistoryQuery, type HistoryResult, type LogbookQuery, type LogbookResult, type QueueQuery, type QueueResult, type LightEntity, type MediaPlayerEntity, type PersonEntity, type SensorEntity, type SwitchEntity, type WeatherEntity } from './model/index.ts';
|
|
3
3
|
type Init<E extends {
|
|
4
4
|
kind: string;
|
|
5
5
|
ref: unknown;
|
|
@@ -55,6 +55,14 @@ export declare class MockIntegration extends BaseIntegration {
|
|
|
55
55
|
set(entityId: string, input: EntityInput): void;
|
|
56
56
|
/** Patches an entity: top-level fields are replaced, `capabilities` and `media` are merged. */
|
|
57
57
|
update(entityId: string, patch: Partial<EntityInput>): void;
|
|
58
|
+
/** Made-up history for a numeric sensor (see `mockHistory`); empty for anything else. */
|
|
59
|
+
history(entityId: string, query: HistoryQuery): Promise<HistoryResult>;
|
|
60
|
+
/** A made-up queue for a player that has one (see `mockQueue`); empty for anything else. */
|
|
61
|
+
queue(entityId: string, query: QueueQuery): Promise<QueueResult>;
|
|
62
|
+
/** Made-up activity for a light, switch or heater (see `mockLogbook`); empty for anything else. */
|
|
63
|
+
logbook(entityId: string, query: LogbookQuery): Promise<LogbookResult>;
|
|
64
|
+
/** Made-up forecast for a weather entity (see `mockForecast`); empty for anything else. */
|
|
65
|
+
forecast(entityId: string, query: ForecastQuery): Promise<ForecastResult>;
|
|
58
66
|
browse(entityId: string, query: BrowseQuery): Promise<BrowseResult>;
|
|
59
67
|
command(entityId: string, name: string, args?: Record<string, unknown>): Promise<void>;
|
|
60
68
|
}
|
package/dist/mock.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { BaseIntegration } from './base-integration.js';
|
|
2
2
|
import { UnknownEntityError } from './entity.js';
|
|
3
|
+
import { mockForecast } from './mock-forecast.js';
|
|
4
|
+
import { mockHistory } from './mock-history.js';
|
|
5
|
+
import { mockLogbook } from './mock-logbook.js';
|
|
6
|
+
import { mockQueue } from './mock-queue.js';
|
|
3
7
|
import { isCommandOf, } from './model/index.js';
|
|
4
8
|
export function mockMediaPlayer(init = {}) {
|
|
5
9
|
const { capabilities, ...rest } = init;
|
|
@@ -19,6 +23,7 @@ export function mockMediaPlayer(init = {}) {
|
|
|
19
23
|
search: false,
|
|
20
24
|
seek: false,
|
|
21
25
|
shuffle: false,
|
|
26
|
+
queue: false,
|
|
22
27
|
transfer: false,
|
|
23
28
|
group: false,
|
|
24
29
|
...capabilities,
|
|
@@ -95,6 +100,16 @@ export function mockWeather(init = {}) {
|
|
|
95
100
|
condition: 'sunny',
|
|
96
101
|
temperature: 20,
|
|
97
102
|
unit: '°C',
|
|
103
|
+
windSpeed: 14,
|
|
104
|
+
windBearing: 225,
|
|
105
|
+
windUnit: 'km/h',
|
|
106
|
+
uvIndex: 3,
|
|
107
|
+
cloudCoverage: 40,
|
|
108
|
+
apparentTemperature: 18,
|
|
109
|
+
pressure: 1015,
|
|
110
|
+
pressureUnit: 'hPa',
|
|
111
|
+
precipitationUnit: 'mm',
|
|
112
|
+
forecasts: ['daily', 'hourly'],
|
|
98
113
|
...init,
|
|
99
114
|
};
|
|
100
115
|
}
|
|
@@ -152,6 +167,8 @@ export function mockLibrary() {
|
|
|
152
167
|
libraryFolder('pl-morning', 'Morning coffee', 'playlist', '24 tracks'),
|
|
153
168
|
libraryFolder('pl-dinner', 'Dinner party', 'playlist', '41 tracks'),
|
|
154
169
|
libraryFolder('pl-focus', 'Deep focus', 'playlist', '60 tracks'),
|
|
170
|
+
// A title far too long for any tile, to see how a layout copes with one.
|
|
171
|
+
libraryFolder('pl-bass', 'BASS BOOSTED SONGS 2026 🔊 REMIXES 🔊 🔊', 'playlist', 'A playlist with a very, very long name to show how titles are held to their room'),
|
|
155
172
|
],
|
|
156
173
|
},
|
|
157
174
|
albums: {
|
|
@@ -189,6 +206,13 @@ export function mockLibrary() {
|
|
|
189
206
|
libraryTrack('t-blank-space', 'Blank Space', 'Taylor Swift'),
|
|
190
207
|
],
|
|
191
208
|
},
|
|
209
|
+
'pl-bass': {
|
|
210
|
+
title: 'BASS BOOSTED SONGS 2026 🔊 REMIXES 🔊 🔊',
|
|
211
|
+
items: [
|
|
212
|
+
libraryTrack('t-electric', 'Electric Feel', 'MGMT'),
|
|
213
|
+
libraryTrack('t-borderline', 'Borderline', 'Tame Impala'),
|
|
214
|
+
],
|
|
215
|
+
},
|
|
192
216
|
'al-1989': {
|
|
193
217
|
title: '1989',
|
|
194
218
|
items: [
|
|
@@ -377,6 +401,38 @@ export class MockIntegration extends BaseIntegration {
|
|
|
377
401
|
}
|
|
378
402
|
this.set(entityId, merged);
|
|
379
403
|
}
|
|
404
|
+
/** Made-up history for a numeric sensor (see `mockHistory`); empty for anything else. */
|
|
405
|
+
history(entityId, query) {
|
|
406
|
+
const entity = this.getEntity(entityId);
|
|
407
|
+
if (!entity) {
|
|
408
|
+
return Promise.reject(new UnknownEntityError(this.id, entityId));
|
|
409
|
+
}
|
|
410
|
+
return Promise.resolve(mockHistory(entityId, entity, query));
|
|
411
|
+
}
|
|
412
|
+
/** A made-up queue for a player that has one (see `mockQueue`); empty for anything else. */
|
|
413
|
+
queue(entityId, query) {
|
|
414
|
+
const entity = this.getEntity(entityId);
|
|
415
|
+
if (!entity) {
|
|
416
|
+
return Promise.reject(new UnknownEntityError(this.id, entityId));
|
|
417
|
+
}
|
|
418
|
+
return Promise.resolve(mockQueue(entity, this.#library, query));
|
|
419
|
+
}
|
|
420
|
+
/** Made-up activity for a light, switch or heater (see `mockLogbook`); empty for anything else. */
|
|
421
|
+
logbook(entityId, query) {
|
|
422
|
+
const entity = this.getEntity(entityId);
|
|
423
|
+
if (!entity) {
|
|
424
|
+
return Promise.reject(new UnknownEntityError(this.id, entityId));
|
|
425
|
+
}
|
|
426
|
+
return Promise.resolve(mockLogbook(entityId, entity, query));
|
|
427
|
+
}
|
|
428
|
+
/** Made-up forecast for a weather entity (see `mockForecast`); empty for anything else. */
|
|
429
|
+
forecast(entityId, query) {
|
|
430
|
+
const entity = this.getEntity(entityId);
|
|
431
|
+
if (!entity) {
|
|
432
|
+
return Promise.reject(new UnknownEntityError(this.id, entityId));
|
|
433
|
+
}
|
|
434
|
+
return Promise.resolve(mockForecast(entity, query));
|
|
435
|
+
}
|
|
380
436
|
browse(entityId, query) {
|
|
381
437
|
if (!this.getEntity(entityId)) {
|
|
382
438
|
return Promise.reject(new UnknownEntityError(this.id, entityId));
|