@khalido/home 0.1.3 → 0.2.1
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/.env.example +2 -2
- package/CHANGELOG.md +56 -1
- package/README.md +7 -2
- package/dist/config.d.ts +5 -3
- package/dist/config.js +5 -1
- package/dist/connector.d.ts +2 -0
- package/dist/connector.js +3 -0
- package/dist/connectors/amber/client.d.ts +1 -1
- package/dist/connectors/amber/client.js +1 -1
- package/dist/connectors/amber/index.js +2 -2
- package/dist/connectors/cast/index.d.ts +1 -1
- package/dist/connectors/cast/index.js +1 -1
- package/dist/connectors/cast/state.d.ts +17 -3
- package/dist/connectors/cast/state.js +26 -7
- package/dist/connectors/daikin/client.d.ts +45 -2
- package/dist/connectors/daikin/client.js +67 -2
- package/dist/connectors/daikin/index.d.ts +2 -2
- package/dist/connectors/daikin/index.js +4 -3
- package/dist/connectors/enphase/client.d.ts +1 -1
- package/dist/connectors/enphase/client.js +1 -1
- package/dist/connectors/enphase/index.js +2 -1
- package/dist/connectors/hue/ca.d.ts +2 -0
- package/dist/connectors/hue/ca.js +38 -0
- package/dist/connectors/hue/client.d.ts +81 -6
- package/dist/connectors/hue/client.js +217 -6
- package/dist/connectors/hue/color.d.ts +30 -0
- package/dist/connectors/hue/color.js +92 -0
- package/dist/connectors/hue/events.d.ts +0 -2
- package/dist/connectors/hue/events.js +1 -2
- package/dist/connectors/hue/index.d.ts +15 -4
- package/dist/connectors/hue/index.js +27 -10
- package/dist/connectors/hue/schemas.d.ts +27 -0
- package/dist/connectors/hue/schemas.js +26 -0
- package/dist/connectors/hue/state.d.ts +10 -1
- package/dist/connectors/hue/state.js +20 -1
- package/dist/connectors/printers/client.d.ts +41 -6
- package/dist/connectors/printers/client.js +81 -8
- package/dist/connectors/printers/codec.d.ts +17 -3
- package/dist/connectors/printers/codec.js +32 -8
- package/dist/connectors/printers/index.d.ts +2 -0
- package/dist/connectors/printers/index.js +4 -0
- package/dist/connectors/printers/raster.d.ts +30 -0
- package/dist/connectors/printers/raster.js +175 -0
- package/dist/connectors/printers/state.d.ts +2 -0
- package/dist/connectors/spotify/changes.d.ts +19 -5
- package/dist/connectors/spotify/changes.js +29 -7
- package/dist/connectors/spotify/index.d.ts +1 -1
- package/dist/connectors/spotify/index.js +20 -3
- package/dist/connectors/up/client.d.ts +1 -1
- package/dist/connectors/up/client.js +1 -1
- package/dist/connectors/up/index.js +2 -2
- package/dist/connectors/weather/google.js +2 -2
- package/dist/devices.d.ts +24 -0
- package/dist/devices.js +56 -2
- package/dist/discover.js +1 -1
- package/dist/finders-entry.d.ts +1 -1
- package/dist/finders-entry.js +1 -1
- package/dist/finders.d.ts +2 -1
- package/dist/finders.js +16 -5
- package/dist/home.d.ts +11 -2
- package/dist/home.js +98 -34
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node-fetch.d.ts +2 -2
- package/dist/node-fetch.js +3 -2
- package/docs/cli.md +15 -7
- package/docs/config.md +11 -3
- package/docs/decisions.md +19 -0
- package/docs/hue.md +161 -0
- package/docs/ideas.md +2 -0
- package/docs/using.md +98 -3
- package/examples/home.jsonc +2 -1
- package/home.schema.json +10 -3
- package/llms.txt +19 -6
- package/package.json +1 -1
- package/skills/draw-home/README.md +2 -0
- package/skills/draw-home/SKILL.md +1 -1
- package/skills/use-home/SKILL.md +3 -3
- package/src/cli/cast.ts +51 -5
- package/src/cli/context.ts +2 -0
- package/src/cli/daikin.ts +71 -0
- package/src/cli/enphase.ts +31 -5
- package/src/cli/hue.ts +147 -59
- package/src/cli/main.ts +43 -13
- package/src/cli/printers.ts +254 -3
- package/src/cli/probe.ts +21 -17
- package/src/cli/status.ts +61 -10
- package/src/config.ts +8 -2
- package/src/connector.ts +5 -0
- package/src/connectors/amber/README.md +1 -1
- package/src/connectors/amber/client.ts +1 -1
- package/src/connectors/amber/index.ts +2 -2
- package/src/connectors/cast/README.md +6 -3
- package/src/connectors/cast/index.ts +7 -1
- package/src/connectors/cast/state.ts +32 -5
- package/src/connectors/daikin/README.md +16 -4
- package/src/connectors/daikin/client.ts +104 -3
- package/src/connectors/daikin/index.ts +8 -2
- package/src/connectors/enphase/README.md +3 -1
- package/src/connectors/enphase/client.ts +1 -1
- package/src/connectors/enphase/index.ts +2 -1
- package/src/connectors/hue/README.md +62 -17
- package/src/connectors/hue/ca.ts +39 -0
- package/src/connectors/hue/client.ts +362 -15
- package/src/connectors/hue/color.ts +123 -0
- package/src/connectors/hue/events.ts +1 -4
- package/src/connectors/hue/index.ts +46 -13
- package/src/connectors/hue/schemas.ts +26 -0
- package/src/connectors/hue/state.ts +27 -1
- package/src/connectors/printers/README.md +9 -4
- package/src/connectors/printers/client.ts +186 -12
- package/src/connectors/printers/codec.ts +44 -10
- package/src/connectors/printers/index.ts +17 -0
- package/src/connectors/printers/raster.ts +215 -0
- package/src/connectors/printers/state.ts +2 -0
- package/src/connectors/spotify/README.md +10 -2
- package/src/connectors/spotify/changes.ts +38 -9
- package/src/connectors/spotify/index.ts +21 -2
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/client.ts +1 -1
- package/src/connectors/up/index.ts +2 -2
- package/src/connectors/weather/google.ts +6 -2
- package/src/devices.ts +83 -3
- package/src/discover.ts +1 -2
- package/src/finders-entry.ts +1 -1
- package/src/finders.ts +18 -6
- package/src/home.ts +99 -39
- package/src/index.ts +7 -1
- package/src/node-fetch.ts +6 -2
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// PWG Raster (PWG 5102.4), 8-bit grey only: what an AirPrint printer that lists `image/pwg-raster` takes when it lists no
|
|
2
|
+
// PDF. A file is "RaS2", then per page a 1796-byte big-endian header and the page's pixels, compressed line by line:
|
|
3
|
+
// a line-repeat byte (the line appears n + 1 times), then runs: 0 to 127 is one pixel repeated n + 1 times, 129 to 255 is
|
|
4
|
+
// 257 − n pixels as they are. Checked against a page macOS's own filters made for a Brother (test/fixtures/printers).
|
|
5
|
+
const HEADER = 1796;
|
|
6
|
+
const SGRAY = 18; // cupsColorSpace for sgray (PWG 5102.4 table 9)
|
|
7
|
+
const WHITE = 0xffffff; // AlternatePrimary: the white point, as PWG 5102.4 recommends
|
|
8
|
+
/** A PWG media name's size in inches: `iso_a4_210x297mm` is 8.27 × 11.69. */
|
|
9
|
+
function mediaInches(name) {
|
|
10
|
+
const m = /_(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)(mm|in)$/.exec(name);
|
|
11
|
+
if (!m)
|
|
12
|
+
throw new RangeError(`not a PWG media size: ${name}`);
|
|
13
|
+
const per = m[3] === "mm" ? 1 / 25.4 : 1;
|
|
14
|
+
return { width: Number(m[1]) * per, height: Number(m[2]) * per };
|
|
15
|
+
}
|
|
16
|
+
/** The pixels a page of this media is at its resolution, rounded down as macOS's filters do: A4 at 600 dpi is 4960 × 7015. */
|
|
17
|
+
export function mediaPixels(media) {
|
|
18
|
+
const i = mediaInches(media.name);
|
|
19
|
+
return {
|
|
20
|
+
width: Math.floor(i.width * media.dpi),
|
|
21
|
+
height: Math.floor(i.height * media.dpi),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function header(page, media, total) {
|
|
25
|
+
const h = new Uint8Array(HEADER);
|
|
26
|
+
const v = new DataView(h.buffer);
|
|
27
|
+
const u32 = (offset, n) => v.setUint32(offset, n);
|
|
28
|
+
const str = (offset, s) => h.set(new TextEncoder().encode(s).subarray(0, 63), offset);
|
|
29
|
+
const pt = mediaPixels({ name: media.name, dpi: 72 }); // PageSize is in points
|
|
30
|
+
str(0, "PwgRaster"); // MediaClass
|
|
31
|
+
u32(276, media.dpi); // HWResolution
|
|
32
|
+
u32(280, media.dpi);
|
|
33
|
+
u32(340, 1); // NumCopies: copies are the job's, not the page's
|
|
34
|
+
u32(352, pt.width); // PageSize, points
|
|
35
|
+
u32(356, pt.height);
|
|
36
|
+
u32(372, page.width); // Width, Height in pixels
|
|
37
|
+
u32(376, page.height);
|
|
38
|
+
u32(384, 8); // BitsPerColor
|
|
39
|
+
u32(388, 8); // BitsPerPixel
|
|
40
|
+
u32(392, page.width); // BytesPerLine
|
|
41
|
+
u32(400, SGRAY); // ColorSpace; ColorOrder (396) 0 is chunky
|
|
42
|
+
u32(420, 1); // NumColors
|
|
43
|
+
u32(452, total); // TotalPageCount
|
|
44
|
+
u32(456, 1); // CrossFeedTransform
|
|
45
|
+
u32(460, 1); // FeedTransform
|
|
46
|
+
u32(480, WHITE); // AlternatePrimary
|
|
47
|
+
str(1732, media.name); // PageSizeName
|
|
48
|
+
return h;
|
|
49
|
+
}
|
|
50
|
+
/** One line's runs, appended to `out`. */
|
|
51
|
+
function packLine(line, out) {
|
|
52
|
+
let i = 0;
|
|
53
|
+
while (i < line.length) {
|
|
54
|
+
let run = 1;
|
|
55
|
+
while (run < 128 && i + run < line.length && line[i + run] === line[i])
|
|
56
|
+
run++;
|
|
57
|
+
if (run > 1 || i + 1 === line.length) {
|
|
58
|
+
out.push(run - 1, line[i]);
|
|
59
|
+
i += run;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
// Literal: up to 128 pixels, stopping before two equal pixels that would pack better as a repeat.
|
|
63
|
+
let n = 1;
|
|
64
|
+
while (n < 128 &&
|
|
65
|
+
i + n < line.length &&
|
|
66
|
+
!(i + n + 1 < line.length && line[i + n] === line[i + n + 1]))
|
|
67
|
+
n++;
|
|
68
|
+
if (n === 1) {
|
|
69
|
+
out.push(0, line[i]); // a lone pixel is a repeat of one: 257 − 256 can't be written
|
|
70
|
+
i += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
out.push(257 - n);
|
|
74
|
+
for (let k = 0; k < n; k++)
|
|
75
|
+
out.push(line[i + k]);
|
|
76
|
+
i += n;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Pages as one PWG Raster document, `image/pwg-raster`. Every page must be `width × height` bytes. Pages may come one at
|
|
81
|
+
* a time (a generator), so only the packed document and the current page are held; then `total` is the page count.
|
|
82
|
+
*/
|
|
83
|
+
export function pwgRaster(pages, media, total = Array.isArray(pages) ? pages.length : 0) {
|
|
84
|
+
const chunks = [new TextEncoder().encode("RaS2")];
|
|
85
|
+
for (const page of pages) {
|
|
86
|
+
if (page.pixels.length !== page.width * page.height)
|
|
87
|
+
throw new RangeError(`a ${page.width} × ${page.height} page needs ${page.width * page.height} bytes, got ${page.pixels.length}`);
|
|
88
|
+
chunks.push(header(page, media, total));
|
|
89
|
+
let y = 0;
|
|
90
|
+
while (y < page.height) {
|
|
91
|
+
const line = page.pixels.subarray(y * page.width, (y + 1) * page.width);
|
|
92
|
+
let repeat = 0;
|
|
93
|
+
while (repeat < 255 &&
|
|
94
|
+
y + repeat + 1 < page.height &&
|
|
95
|
+
equal(line, page.pixels.subarray((y + repeat + 1) * page.width, (y + repeat + 2) * page.width)))
|
|
96
|
+
repeat++;
|
|
97
|
+
const out = [repeat]; // one line at a time: a page as a number[] would be hundreds of MB
|
|
98
|
+
packLine(line, out);
|
|
99
|
+
chunks.push(Uint8Array.from(out));
|
|
100
|
+
y += repeat + 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const size = chunks.reduce((n, c) => n + c.length, 0);
|
|
104
|
+
const file = new Uint8Array(size);
|
|
105
|
+
let at = 0;
|
|
106
|
+
for (const c of chunks)
|
|
107
|
+
(file.set(c, at), (at += c.length));
|
|
108
|
+
return file;
|
|
109
|
+
}
|
|
110
|
+
function equal(a, b) {
|
|
111
|
+
if (a.length !== b.length)
|
|
112
|
+
return false;
|
|
113
|
+
for (let i = 0; i < a.length; i++)
|
|
114
|
+
if (a[i] !== b[i])
|
|
115
|
+
return false;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
/** A PWG Raster document back as pages and their headers' key fields; for tests and for checking a file before it prints. */
|
|
119
|
+
export function readPwgRaster(file) {
|
|
120
|
+
const text = new TextDecoder();
|
|
121
|
+
if (text.decode(file.subarray(0, 4)) !== "RaS2")
|
|
122
|
+
throw new Error("pwg raster: no RaS2 sync word");
|
|
123
|
+
const pages = [];
|
|
124
|
+
let at = 4;
|
|
125
|
+
while (at < file.length) {
|
|
126
|
+
const v = new DataView(file.buffer, file.byteOffset + at, HEADER);
|
|
127
|
+
const width = v.getUint32(372);
|
|
128
|
+
const height = v.getUint32(376);
|
|
129
|
+
const bitsPerPixel = v.getUint32(388);
|
|
130
|
+
const bytes = bitsPerPixel / 8;
|
|
131
|
+
const name = file.subarray(at + 1732, at + 1796);
|
|
132
|
+
const pageSizeName = text.decode(name.subarray(0, name.indexOf(0)));
|
|
133
|
+
const page = {
|
|
134
|
+
width,
|
|
135
|
+
height,
|
|
136
|
+
dpi: v.getUint32(276),
|
|
137
|
+
pageSizeName,
|
|
138
|
+
colorSpace: v.getUint32(400),
|
|
139
|
+
bitsPerPixel,
|
|
140
|
+
pixels: new Uint8Array(width * height * bytes),
|
|
141
|
+
};
|
|
142
|
+
at += HEADER;
|
|
143
|
+
const lineBytes = width * bytes;
|
|
144
|
+
let y = 0;
|
|
145
|
+
while (y < height) {
|
|
146
|
+
const repeat = file[at++] + 1;
|
|
147
|
+
const line = new Uint8Array(lineBytes);
|
|
148
|
+
let x = 0;
|
|
149
|
+
while (x < lineBytes) {
|
|
150
|
+
const c = file[at++];
|
|
151
|
+
if (c === 128) {
|
|
152
|
+
line.fill(255, x); // the rest of the line is white
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
if (c < 128) {
|
|
156
|
+
const px = file.subarray(at, at + bytes);
|
|
157
|
+
for (let k = 0; k <= c; k++)
|
|
158
|
+
line.set(px, x + k * bytes);
|
|
159
|
+
at += bytes;
|
|
160
|
+
x += (c + 1) * bytes;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const n = (257 - c) * bytes;
|
|
164
|
+
line.set(file.subarray(at, at + n), x);
|
|
165
|
+
at += n;
|
|
166
|
+
x += n;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (let k = 0; k < repeat && y < height; k++, y++)
|
|
170
|
+
page.pixels.set(line, y * lineBytes);
|
|
171
|
+
}
|
|
172
|
+
pages.push(page);
|
|
173
|
+
}
|
|
174
|
+
return pages;
|
|
175
|
+
}
|
|
@@ -2,8 +2,16 @@ import type { Part } from "../../home.ts";
|
|
|
2
2
|
import type { SpotifyState, Stall, Track } from "./client.ts";
|
|
3
3
|
/** A stall in a few words: `playback stalled at 1:39 (Soloist issue #5); tried a seek`. */
|
|
4
4
|
export declare function stallText(s: Stall | undefined): string | undefined;
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Soloist says playing while it is not the account's active device: the music is on another Spotify device (a phone,
|
|
7
|
+
* another speaker) and this one is silent (live, 2026-11-10). Soloist doesn't say which. Unknown `active` is not elsewhere.
|
|
8
|
+
*/
|
|
9
|
+
export declare const playingElsewhere: (s: SpotifyState | undefined) => boolean;
|
|
10
|
+
/**
|
|
11
|
+
* The speaker in one word, to switch on. `offline`: not reached (or no part); `waiting`: running, nobody has picked it;
|
|
12
|
+
* `elsewhere`: the account's music is playing on another Spotify device, not this one. A status Soloist sends beyond its four passes through.
|
|
13
|
+
*/
|
|
14
|
+
export type SpeakerState = "offline" | "connecting" | "waiting" | "elsewhere" | "idle" | "playing" | "paused" | "buffering" | (string & {});
|
|
7
15
|
export declare function speakerState(part: Part<SpotifyState> | undefined): SpeakerState;
|
|
8
16
|
/**
|
|
9
17
|
* A change worth telling someone about, from two reports of the speaker. `own`, on playback events only: it happened while
|
|
@@ -24,9 +32,15 @@ export type SpotifyEvent = {
|
|
|
24
32
|
/**
|
|
25
33
|
* The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
|
|
26
34
|
* `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
|
|
27
|
-
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
|
|
35
|
+
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
|
|
36
|
+
* Music on another Spotify device (`playingElsewhere`) is not playing here: it moving away is `stopped`, it arriving is `started`
|
|
37
|
+
* then `track`, and nothing fires for its tracks meanwhile. No `before`, or one
|
|
28
38
|
* with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
|
|
29
39
|
*/
|
|
30
40
|
export declare function spotifyChanges(before: Part<SpotifyState> | undefined, after: Part<SpotifyState> | undefined): SpotifyEvent[];
|
|
31
|
-
/**
|
|
32
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
|
|
43
|
+
* for music on another Spotify device, `…: silent · "Song" by Band is playing on another Spotify device` (no volume: it isn't this speaker's playing level).
|
|
44
|
+
* `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Den speaker`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function spotifyText(s: SpotifyState, on?: string[]): string;
|
|
@@ -9,6 +9,12 @@ export function stallText(s) {
|
|
|
9
9
|
: `playback stalled at ${at} (Soloist issue #5)`;
|
|
10
10
|
return s.tried ? `${what}; tried ${s.tried}` : what;
|
|
11
11
|
}
|
|
12
|
+
const PLAYING = new Set(["playing", "buffering"]);
|
|
13
|
+
/**
|
|
14
|
+
* Soloist says playing while it is not the account's active device: the music is on another Spotify device (a phone,
|
|
15
|
+
* another speaker) and this one is silent (live, 2026-11-10). Soloist doesn't say which. Unknown `active` is not elsewhere.
|
|
16
|
+
*/
|
|
17
|
+
export const playingElsewhere = (s) => s?.active === false && PLAYING.has(s.status ?? "");
|
|
12
18
|
export function speakerState(part) {
|
|
13
19
|
if (!part || part.status === "disconnected" || part.status === "unavailable")
|
|
14
20
|
return "offline";
|
|
@@ -16,13 +22,16 @@ export function speakerState(part) {
|
|
|
16
22
|
return "connecting";
|
|
17
23
|
if (part.state?.loggedIn === false)
|
|
18
24
|
return "waiting";
|
|
25
|
+
if (playingElsewhere(part.state))
|
|
26
|
+
return "elsewhere";
|
|
19
27
|
return part.state?.status ?? "idle"; // signed in, nothing reported yet: nothing is playing
|
|
20
28
|
}
|
|
21
|
-
const PLAYING = new Set(["playing", "buffering"]);
|
|
22
29
|
/**
|
|
23
30
|
* The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
|
|
24
31
|
* `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
|
|
25
|
-
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
|
|
32
|
+
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
|
|
33
|
+
* Music on another Spotify device (`playingElsewhere`) is not playing here: it moving away is `stopped`, it arriving is `started`
|
|
34
|
+
* then `track`, and nothing fires for its tracks meanwhile. No `before`, or one
|
|
26
35
|
* with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
|
|
27
36
|
*/
|
|
28
37
|
export function spotifyChanges(before, after) {
|
|
@@ -45,8 +54,11 @@ export function spotifyChanges(before, after) {
|
|
|
45
54
|
events.push({ type: "waiting" });
|
|
46
55
|
if (b?.loggedIn === false && a?.loggedIn === true)
|
|
47
56
|
events.push({ type: "picked" });
|
|
48
|
-
|
|
49
|
-
const
|
|
57
|
+
// What this speaker itself is doing: music on another device is silence here.
|
|
58
|
+
const here = (s) => (playingElsewhere(s) ? "idle" : s?.status);
|
|
59
|
+
const from = here(b);
|
|
60
|
+
// Still inactive after playing elsewhere: a pause or stop there is the other device's, not this one's.
|
|
61
|
+
const to = playingElsewhere(b) && a?.active === false ? "idle" : here(a);
|
|
50
62
|
const playingNow = to !== undefined && PLAYING.has(to);
|
|
51
63
|
const playingThen = from !== undefined && PLAYING.has(from);
|
|
52
64
|
if (playingNow && !playingThen)
|
|
@@ -57,7 +69,11 @@ export function spotifyChanges(before, after) {
|
|
|
57
69
|
add({ type: "stopped" });
|
|
58
70
|
// Soloist's first report of a new track can have no name yet (seen live): the event waits for it.
|
|
59
71
|
const named = (p) => !!p?.track?.name;
|
|
60
|
-
|
|
72
|
+
const heard = playingElsewhere(b) && playingNow ? undefined : b; // a track that was on another device is new here once it plays here
|
|
73
|
+
if (a?.track &&
|
|
74
|
+
named(a) &&
|
|
75
|
+
!playingElsewhere(a) &&
|
|
76
|
+
(a.track.uri !== heard?.track?.uri || !named(heard)))
|
|
61
77
|
add({ type: "track", track: a.track });
|
|
62
78
|
if (a?.stalled && !b?.stalled)
|
|
63
79
|
events.push({ type: "stalled", stall: a.stalled });
|
|
@@ -68,8 +84,12 @@ export function spotifyChanges(before, after) {
|
|
|
68
84
|
events.push({ type: "recovered" });
|
|
69
85
|
return events;
|
|
70
86
|
}
|
|
71
|
-
/**
|
|
72
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
|
|
89
|
+
* for music on another Spotify device, `…: silent · "Song" by Band is playing on another Spotify device` (no volume: it isn't this speaker's playing level).
|
|
90
|
+
* `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Den speaker`.
|
|
91
|
+
*/
|
|
92
|
+
export function spotifyText(s, on = []) {
|
|
73
93
|
const who = s.deviceName ?? "Soloist";
|
|
74
94
|
if (s.loggedIn === false)
|
|
75
95
|
return `${who}: waiting for someone to pick it`;
|
|
@@ -77,6 +97,8 @@ export function spotifyText(s) {
|
|
|
77
97
|
const what = t
|
|
78
98
|
? `${t.name ? ` "${t.name}"` : ` ${t.uri}`}${t.artists.length ? ` by ${t.artists.join(", ")}` : ""}`
|
|
79
99
|
: "";
|
|
100
|
+
if (playingElsewhere(s))
|
|
101
|
+
return `${who}: silent ·${what || " music"} is playing on ${on.length ? on.join(", ") : "another Spotify device"}`;
|
|
80
102
|
const parts = [
|
|
81
103
|
`${s.status ?? "status unknown"}${what}`,
|
|
82
104
|
s.volume === undefined ? "" : `${s.volume}%`,
|
|
@@ -5,7 +5,7 @@ import type { Manifest } from "../../manifest.ts";
|
|
|
5
5
|
import { type SpeakerState } from "./changes.ts";
|
|
6
6
|
import { type SocketFactory, type SpotifyState } from "./client.ts";
|
|
7
7
|
export { positionMs, type PositionAnchor, type SocketFactory, type SocketLike, type SpotifyState, type Stall, toSpotifyUri, type Track, } from "./client.ts";
|
|
8
|
-
export { type SpeakerState, speakerState, type SpotifyEvent, spotifyChanges, spotifyText, } from "./changes.ts";
|
|
8
|
+
export { type SpeakerState, playingElsewhere, speakerState, type SpotifyEvent, spotifyChanges, spotifyText, } from "./changes.ts";
|
|
9
9
|
/** Soloist has no default port (`--ws ADDR:PORT` opens it); this library assumes this one, the port in chota-bot's unit. */
|
|
10
10
|
export declare const DEFAULT_URL = "ws://127.0.0.1:9091";
|
|
11
11
|
/** A Soloist speaker in a home config. No key: Soloist's API key is the daemon's, never the library's. */
|
|
@@ -6,7 +6,7 @@ import { writeLane } from "../../lane.js";
|
|
|
6
6
|
import { speakerState, spotifyText, stallText, } from "./changes.js";
|
|
7
7
|
import { commandFrame, fold, Frame, FRAME_TYPES, OPEN, oddities, positionMs, toSpotifyUri, } from "./client.js";
|
|
8
8
|
export { positionMs, toSpotifyUri, } from "./client.js";
|
|
9
|
-
export { speakerState, spotifyChanges, spotifyText, } from "./changes.js";
|
|
9
|
+
export { playingElsewhere, speakerState, spotifyChanges, spotifyText, } from "./changes.js";
|
|
10
10
|
/** Soloist has no default port (`--ws ADDR:PORT` opens it); this library assumes this one, the port in chota-bot's unit. */
|
|
11
11
|
export const DEFAULT_URL = "ws://127.0.0.1:9091";
|
|
12
12
|
/** A Soloist speaker in a home config. No key: Soloist's API key is the daemon's, never the library's. */
|
|
@@ -228,6 +228,17 @@ export function spotify(config = {}) {
|
|
|
228
228
|
* one raised before it is reported can't be told from the start's own jump, so it is lowered too.
|
|
229
229
|
*/
|
|
230
230
|
const applyCap = () => {
|
|
231
|
+
// Lowered, then above the cap again in the same window: Soloist putting its own level back (seen live) or a person.
|
|
232
|
+
// The note says where it is now, not only that it was lowered.
|
|
233
|
+
if (capped && capNote && cap !== undefined && state.volume !== undefined) {
|
|
234
|
+
const base = capNote.replace(/; it is back at \d+$/, "");
|
|
235
|
+
const note = state.volume > cap + VOLUME_SLACK
|
|
236
|
+
? `${base}; it is back at ${state.volume}`
|
|
237
|
+
: base;
|
|
238
|
+
if (note !== capNote)
|
|
239
|
+
((capNote = note), publish());
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
231
242
|
if (cap === undefined ||
|
|
232
243
|
capped ||
|
|
233
244
|
capping ||
|
|
@@ -247,7 +258,7 @@ export function spotify(config = {}) {
|
|
|
247
258
|
capped = true;
|
|
248
259
|
if (state.status === "idle")
|
|
249
260
|
return;
|
|
250
|
-
capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
|
|
261
|
+
capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it to ${cap} (Soloist issue #23)`;
|
|
251
262
|
note = undefined;
|
|
252
263
|
return publish();
|
|
253
264
|
}
|
|
@@ -651,6 +662,7 @@ export function spotify(config = {}) {
|
|
|
651
662
|
if (!st ||
|
|
652
663
|
state.aside === "repair" ||
|
|
653
664
|
(state.track?.uri === st.track &&
|
|
665
|
+
state.active !== false && // moved to another device: no longer this speaker's stall
|
|
654
666
|
(state.status === "playing" || state.status === "buffering")))
|
|
655
667
|
return;
|
|
656
668
|
const { stalled: _, ...rest } = state;
|
|
@@ -678,7 +690,11 @@ export function spotify(config = {}) {
|
|
|
678
690
|
a.track === b.track &&
|
|
679
691
|
b.atMs - a.atMs >= gap &&
|
|
680
692
|
Math.abs(b.ms - a.ms) < 1000;
|
|
681
|
-
|
|
693
|
+
// Not while inactive: "playing" then is another device's music, and a repair's seek or pause would land on it.
|
|
694
|
+
const playing = () => isOpen() &&
|
|
695
|
+
state.status === "playing" &&
|
|
696
|
+
state.active !== false &&
|
|
697
|
+
!state.aside;
|
|
682
698
|
/** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
|
|
683
699
|
const checkStall = async () => {
|
|
684
700
|
if (!playing())
|
|
@@ -720,6 +736,7 @@ export function spotify(config = {}) {
|
|
|
720
736
|
// Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
|
|
721
737
|
const ours = () => isOpen() &&
|
|
722
738
|
state.status === "playing" &&
|
|
739
|
+
state.active !== false &&
|
|
723
740
|
state.track?.uri === track &&
|
|
724
741
|
state.stalled?.track === track &&
|
|
725
742
|
(!state.aside || state.aside === "repair");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ConnectorError } from "../../connector.ts";
|
|
3
|
-
/** A failed Up call. `status` 401 means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
|
|
3
|
+
/** A failed Up call. `status` 401 or 403 (`isRejectedKey`) means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
|
|
4
4
|
export declare class UpError extends ConnectorError {
|
|
5
5
|
}
|
|
6
6
|
declare const TransactionResource: z.ZodObject<{
|
|
@@ -3,7 +3,7 @@ import { ConnectorError, fetchTimeout, getJson } from "../../connector.js";
|
|
|
3
3
|
// Up Bank's API v1 (https://developer.up.com.au/, spec github.com/up-banking/api, v1/openapi.json). Field names checked
|
|
4
4
|
// against one live read, 2026-10-16. Every field optional so a change degrades a reading rather than throwing.
|
|
5
5
|
const BASE = "https://api.up.com.au/api/v1";
|
|
6
|
-
/** A failed Up call. `status` 401 means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
|
|
6
|
+
/** A failed Up call. `status` 401 or 403 (`isRejectedKey`) means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
|
|
7
7
|
export class UpError extends ConnectorError {
|
|
8
8
|
}
|
|
9
9
|
const Money = z.looseObject({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { asOf, realClock } from "../../clock.js";
|
|
3
|
-
import { errorText } from "../../connector.js";
|
|
3
|
+
import { errorText, isRejectedKey } from "../../connector.js";
|
|
4
4
|
import { envName } from "../../home.js";
|
|
5
5
|
import { poller } from "../../poll.js";
|
|
6
6
|
import { upClient, UpError, } from "./client.js";
|
|
@@ -139,7 +139,7 @@ export function up(config) {
|
|
|
139
139
|
catch (error) {
|
|
140
140
|
if (signal.aborted)
|
|
141
141
|
return; // stopped mid-read: not a failure
|
|
142
|
-
if (error
|
|
142
|
+
if (isRejectedKey(error))
|
|
143
143
|
return update({
|
|
144
144
|
status: "unavailable",
|
|
145
145
|
reason: "auth",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { ConnectorError, fetchTimeout } from "../../connector.js";
|
|
2
|
+
import { ConnectorError, fetchTimeout, isRejectedKey, } from "../../connector.js";
|
|
3
3
|
import { RAIN_INTENSITIES } from "./rain.js";
|
|
4
4
|
import { sunDisagrees } from "./sun.js";
|
|
5
5
|
import { HOURLY_MINUTES, iso, nextPoll, num, RAIN_HORIZON_MINUTES, USER_AGENT, } from "./source.js";
|
|
@@ -360,7 +360,7 @@ export function google(options) {
|
|
|
360
360
|
? error
|
|
361
361
|
: {};
|
|
362
362
|
const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
|
|
363
|
-
if (
|
|
363
|
+
if (isRejectedKey(error) || why?.startsWith("API_KEY_"))
|
|
364
364
|
return {
|
|
365
365
|
stop: `google: the key was rejected (${said})`,
|
|
366
366
|
stopReason: "auth",
|
package/dist/devices.d.ts
CHANGED
|
@@ -25,3 +25,27 @@ export declare const SERVICES: readonly string[];
|
|
|
25
25
|
* no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
|
|
26
26
|
*/
|
|
27
27
|
export declare function homeDevices(snapshot: Record<string, Part | undefined>, config?: Pick<HomeConfig, "devices" | "rooms">): HomeDevice[];
|
|
28
|
+
/**
|
|
29
|
+
* Where the account's Spotify is playing when the Soloist speaker says it is on another device: the names of the Cast
|
|
30
|
+
* devices and groups playing a track of that name in Spotify. A group is named, not its members. Empty when the music is
|
|
31
|
+
* on the speaker itself, nothing is playing, or it is on something Cast can't see (a phone, a laptop). Matched by the
|
|
32
|
+
* track's name, since neither side names the other.
|
|
33
|
+
*/
|
|
34
|
+
export declare function spotifyElsewhere(snapshot: Record<string, Part | undefined>): string[];
|
|
35
|
+
/** A room of the home config with the devices placed in it. `noData`: device connectors the config places here that listed nothing in it (never reached, not set up, or a `hueRoom` the bridge doesn't have), so the room isn't empty, it is unread. Per connector, not per device. */
|
|
36
|
+
export type RoomDevices = {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
floor?: string;
|
|
40
|
+
devices: HomeDevice[];
|
|
41
|
+
noData: string[];
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* `homeDevices` room by room, in the config's order, for a map of the home. A connector that is down but has read before
|
|
45
|
+
* still lists its devices, `available: false`; `noData` names one the config places in the room (a device's `room`, or
|
|
46
|
+
* `hueRoom`) that has nothing to list. `unplaced`: devices with no room, or one the config doesn't have.
|
|
47
|
+
*/
|
|
48
|
+
export declare function byRoom(snapshot: Record<string, Part | undefined>, config: Pick<HomeConfig, "devices" | "rooms">): {
|
|
49
|
+
rooms: RoomDevices[];
|
|
50
|
+
unplaced: HomeDevice[];
|
|
51
|
+
};
|
package/dist/devices.js
CHANGED
|
@@ -3,7 +3,7 @@ import { daikinHomeDevices, } from "./connectors/daikin/index.js";
|
|
|
3
3
|
import { enphaseHomeDevices, } from "./connectors/enphase/index.js";
|
|
4
4
|
import { hueHomeDevices } from "./connectors/hue/index.js";
|
|
5
5
|
import { printersHomeDevices, } from "./connectors/printers/index.js";
|
|
6
|
-
import { spotifyHomeDevices, } from "./connectors/spotify/index.js";
|
|
6
|
+
import { playingElsewhere, spotifyHomeDevices, spotifyText, } from "./connectors/spotify/index.js";
|
|
7
7
|
import { IDENTITY, sameId } from "./discover.js";
|
|
8
8
|
import { manifestOf } from "./manifests.js";
|
|
9
9
|
/** Each connector's devices from its part, in the order `home status` lists the connectors. */
|
|
@@ -23,7 +23,13 @@ export const SERVICES = ["amber", "up", "weather"];
|
|
|
23
23
|
* no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
|
|
24
24
|
*/
|
|
25
25
|
export function homeDevices(snapshot, config) {
|
|
26
|
-
|
|
26
|
+
// Soloist doesn't say where its music went; a Cast device playing that track does, so the speaker's line names it.
|
|
27
|
+
const on = spotifyElsewhere(snapshot);
|
|
28
|
+
const all = Object.entries(DEVICES)
|
|
29
|
+
.flatMap(([name, devices]) => devices(snapshot[name]))
|
|
30
|
+
.map((d) => d.connector === "spotify" && on.length
|
|
31
|
+
? { ...d, text: spotifyText(d.detail, on) }
|
|
32
|
+
: d);
|
|
27
33
|
if (!config)
|
|
28
34
|
return all;
|
|
29
35
|
const entries = config.devices ?? [];
|
|
@@ -59,3 +65,51 @@ export function homeDevices(snapshot, config) {
|
|
|
59
65
|
return { ...d, name, text, ...(room ? { room } : {}) };
|
|
60
66
|
});
|
|
61
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Where the account's Spotify is playing when the Soloist speaker says it is on another device: the names of the Cast
|
|
70
|
+
* devices and groups playing a track of that name in Spotify. A group is named, not its members. Empty when the music is
|
|
71
|
+
* on the speaker itself, nothing is playing, or it is on something Cast can't see (a phone, a laptop). Matched by the
|
|
72
|
+
* track's name, since neither side names the other.
|
|
73
|
+
*/
|
|
74
|
+
export function spotifyElsewhere(snapshot) {
|
|
75
|
+
const s = snapshot.spotify?.state;
|
|
76
|
+
const cast = snapshot.cast?.state;
|
|
77
|
+
const name = s?.track?.name;
|
|
78
|
+
if (!playingElsewhere(s) || !name || !cast)
|
|
79
|
+
return [];
|
|
80
|
+
return Object.values(cast.devices)
|
|
81
|
+
.filter((d) => d.status === "connected" &&
|
|
82
|
+
!d.playingVia && // its group's music: the group is named
|
|
83
|
+
d.app === "Spotify" &&
|
|
84
|
+
d.title === name &&
|
|
85
|
+
(d.playerState === "PLAYING" || d.playerState === "BUFFERING"))
|
|
86
|
+
.map((d) => d.name);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* `homeDevices` room by room, in the config's order, for a map of the home. A connector that is down but has read before
|
|
90
|
+
* still lists its devices, `available: false`; `noData` names one the config places in the room (a device's `room`, or
|
|
91
|
+
* `hueRoom`) that has nothing to list. `unplaced`: devices with no room, or one the config doesn't have.
|
|
92
|
+
*/
|
|
93
|
+
export function byRoom(snapshot, config) {
|
|
94
|
+
const all = homeDevices(snapshot, config);
|
|
95
|
+
const ids = new Set((config.rooms ?? []).map((r) => r.id));
|
|
96
|
+
return {
|
|
97
|
+
rooms: (config.rooms ?? []).map((r) => {
|
|
98
|
+
const devices = all.filter((d) => d.room === r.id);
|
|
99
|
+
const expected = new Set([
|
|
100
|
+
...(config.devices ?? [])
|
|
101
|
+
.filter((d) => d.room === r.id)
|
|
102
|
+
.map((d) => d.connector),
|
|
103
|
+
...(r.hueRoom === undefined ? [] : ["hue"]),
|
|
104
|
+
]);
|
|
105
|
+
return {
|
|
106
|
+
id: r.id,
|
|
107
|
+
name: r.name,
|
|
108
|
+
...(r.floor === undefined ? {} : { floor: r.floor }),
|
|
109
|
+
devices,
|
|
110
|
+
noData: [...expected].filter((c) => c in DEVICES && !devices.some((d) => d.connector === c)),
|
|
111
|
+
};
|
|
112
|
+
}),
|
|
113
|
+
unplaced: all.filter((d) => !d.room || !ids.has(d.room)),
|
|
114
|
+
};
|
|
115
|
+
}
|
package/dist/discover.js
CHANGED
|
@@ -82,7 +82,7 @@ function newEntry(f) {
|
|
|
82
82
|
[IDENTITY[f.connector]]: f.id,
|
|
83
83
|
};
|
|
84
84
|
if (f.connector === "hue")
|
|
85
|
-
return { ...base, key: "HUE_APPLICATION_KEY"
|
|
85
|
+
return { ...base, key: "HUE_APPLICATION_KEY" };
|
|
86
86
|
if (f.connector === "enphase")
|
|
87
87
|
return { ...base, token: "~/.config/home/enphase-token.json" };
|
|
88
88
|
return base;
|
package/dist/finders-entry.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { FINDERS, fileCache } from "./finders.ts";
|
|
1
|
+
export { FINDERS, fileCache, MissingTool } from "./finders.ts";
|
package/dist/finders-entry.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// `@khalido/home/finders`: what a Node consumer passes to `loadHome`. The rest of finders.ts is the CLI's.
|
|
2
|
-
export { FINDERS, fileCache } from "./finders.js";
|
|
2
|
+
export { FINDERS, fileCache, MissingTool } from "./finders.js";
|
package/dist/finders.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type AddressCache, type Connector, type Finder, type Found } from "./discover.ts";
|
|
2
2
|
/** The system's mDNS tool is not installed: discovery can't run until it is. */
|
|
3
|
+
/** The discovery tool isn't installed (`dns-sd` on macOS, `avahi-browse` from avahi-utils on Linux): the message names it. */
|
|
3
4
|
export declare class MissingTool extends Error {
|
|
4
5
|
}
|
|
5
6
|
/**
|
|
@@ -27,7 +28,7 @@ export declare function mdnsResolve(type: string): Promise<{
|
|
|
27
28
|
}[]>;
|
|
28
29
|
/** Daikin's `key=value,key=value` answers, percent-decoded. */
|
|
29
30
|
export declare const daikinFields: (text: string) => any;
|
|
30
|
-
/** Broadcast pydaikin's discovery packet (UDP 30000 → 30050) and collect who answers
|
|
31
|
+
/** Broadcast pydaikin's discovery packet (UDP 30000 → 30050), three times across `ms`, and collect who answers, each unit once. */
|
|
31
32
|
export declare function findDaikins(ms?: number): Promise<{
|
|
32
33
|
ip: string;
|
|
33
34
|
fields: Record<string, string>;
|
package/dist/finders.js
CHANGED
|
@@ -13,6 +13,7 @@ import { parseAvahi, parseAvahiBrowse, parseBrowse, parseResolve, } from "./disc
|
|
|
13
13
|
// does not slow a scan. Without the tool the mDNS finders throw, naming it, rather than "nothing answered".
|
|
14
14
|
// `fileCache` keeps the addresses discovery found in a file between runs, so the next run connects straight away.
|
|
15
15
|
/** The system's mDNS tool is not installed: discovery can't run until it is. */
|
|
16
|
+
/** The discovery tool isn't installed (`dns-sd` on macOS, `avahi-browse` from avahi-utils on Linux): the message names it. */
|
|
16
17
|
export class MissingTool extends Error {
|
|
17
18
|
}
|
|
18
19
|
/**
|
|
@@ -94,17 +95,27 @@ function decoded(v) {
|
|
|
94
95
|
return v;
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
|
-
/** Broadcast pydaikin's discovery packet (UDP 30000 → 30050) and collect who answers
|
|
98
|
+
/** Broadcast pydaikin's discovery packet (UDP 30000 → 30050), three times across `ms`, and collect who answers, each unit once. */
|
|
98
99
|
export function findDaikins(ms = 1500) {
|
|
99
100
|
return new Promise((resolve) => {
|
|
100
101
|
const found = [];
|
|
101
102
|
const sock = createSocket({ type: "udp4", reuseAddr: true });
|
|
102
|
-
sock.on("message", (msg, from) =>
|
|
103
|
-
|
|
103
|
+
sock.on("message", (msg, from) => {
|
|
104
|
+
if (!found.some((f) => f.ip === from.address))
|
|
105
|
+
found.push({ ip: from.address, fields: daikinFields(msg.toString()) });
|
|
106
|
+
});
|
|
107
|
+
const asks = [];
|
|
108
|
+
const done = () => (asks.forEach(clearTimeout),
|
|
109
|
+
sock.close(),
|
|
110
|
+
resolve(found));
|
|
111
|
+
sock.on("error", done);
|
|
104
112
|
sock.bind(30000, () => {
|
|
105
113
|
sock.setBroadcast(true);
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
// UDP drops packets, more so while mDNS is busy beside it: one ask missed KO's unit in 2 runs of 3 (2026-11-11). Ask three times.
|
|
115
|
+
const ask = () => sock.send("DAIKIN_UDP/common/basic_info", 30050, "255.255.255.255");
|
|
116
|
+
ask();
|
|
117
|
+
asks.push(setTimeout(ask, ms / 3), setTimeout(ask, (2 * ms) / 3));
|
|
118
|
+
setTimeout(done, ms);
|
|
108
119
|
});
|
|
109
120
|
});
|
|
110
121
|
}
|