@jmcombs/pi-steward 0.0.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/LICENSE +21 -0
- package/README.md +140 -0
- package/core/disconnected-source.ts +110 -0
- package/core/drift.ts +247 -0
- package/core/format.ts +317 -0
- package/core/host-metrics.ts +121 -0
- package/core/llama-config.ts +72 -0
- package/core/llama-connection.ts +215 -0
- package/core/llama-models.ts +261 -0
- package/core/llama-slots.ts +104 -0
- package/core/llama-source.ts +1523 -0
- package/core/log-parse.ts +440 -0
- package/core/model-color.ts +59 -0
- package/core/select.ts +2923 -0
- package/core/slot-activity.ts +658 -0
- package/core/source.ts +84 -0
- package/core/state.ts +609 -0
- package/core/status-widget.ts +222 -0
- package/core/temperature.ts +149 -0
- package/core/types.ts +431 -0
- package/index.ts +503 -0
- package/package.json +51 -0
- package/server/api.ts +216 -0
- package/server/assets.ts +198 -0
- package/server/config-wiring.ts +490 -0
- package/server/drift-probe.ts +150 -0
- package/server/host-collector.ts +272 -0
- package/server/index.ts +228 -0
- package/server/log-tailer.ts +432 -0
- package/server/service-control.ts +337 -0
- package/server/service-probe.ts +71 -0
- package/server/steward-config.ts +430 -0
- package/setup/init-prompt.ts +214 -0
- package/setup/steward-setup.d.mts +16 -0
- package/setup/steward-setup.mjs +1398 -0
- package/ui/components/console.ts +511 -0
- package/ui/components/gauges.ts +120 -0
- package/ui/components/metrics.ts +63 -0
- package/ui/components/models.ts +296 -0
- package/ui/components/service.ts +358 -0
- package/ui/components/slots.ts +114 -0
- package/ui/components/sparkline.ts +59 -0
- package/ui/components/toolbar.ts +211 -0
- package/ui/dom.ts +120 -0
- package/ui/favicon.svg +17 -0
- package/ui/index.html +34 -0
- package/ui/main.ts +678 -0
- package/ui/steward.css +2008 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Steward's above-editor status widget.
|
|
3
|
+
*
|
|
4
|
+
* Built the same way `@jmcombs/pi-headroom`'s is, and for the same reason: Pi's
|
|
5
|
+
* TUI wraps each widget line in a `Text` component that renders ANSI escapes, so
|
|
6
|
+
* raw 24-bit colour plus Nerd-Font Powerline separators display correctly. The
|
|
7
|
+
* palette is Blue PSL 10K / Catppuccin Latte, and the brand block is `#3465a4` —
|
|
8
|
+
* Path Blue, the same blue as Steward's logo tile.
|
|
9
|
+
*
|
|
10
|
+
* **The subject is Steward, not llama.cpp.** An earlier version reported the
|
|
11
|
+
* inference server's state, so `/steward_stop` left a widget still reading
|
|
12
|
+
* "running :8091" — describing something the operator had not stopped. The state
|
|
13
|
+
* block now says whether the dashboard is up; llama.cpp rides along as detail,
|
|
14
|
+
* which is where it belongs on a widget carrying Steward's name.
|
|
15
|
+
*
|
|
16
|
+
* Exactly three state colours, by design: green started, red stopped, orange
|
|
17
|
+
* something is wrong and needs a person.
|
|
18
|
+
*
|
|
19
|
+
* Pure string building. No I/O, never throws.
|
|
20
|
+
*
|
|
21
|
+
* Keep this module free of Node and DOM APIs — see `./types.ts`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Snapshot } from "./types.js";
|
|
25
|
+
|
|
26
|
+
const ESC = "\x1b";
|
|
27
|
+
/** Powerline solid right-pointing separator (Nerd Font). */
|
|
28
|
+
const ARROW_RIGHT = "\u{E0B0}";
|
|
29
|
+
/**
|
|
30
|
+
* Brand mark: `nf-md-room-service` — the service bell, a dome over a base with
|
|
31
|
+
* the tap-button on top, echoing the logo's arc over rounded bars.
|
|
32
|
+
* `STEWARD_GLYPH` replaces it for an operator who has patched their own mark in;
|
|
33
|
+
* an empty value drops it.
|
|
34
|
+
*/
|
|
35
|
+
export const STEWARD_GLYPH = "\u{F088D}";
|
|
36
|
+
|
|
37
|
+
const WIDGET_COLORS = {
|
|
38
|
+
fg: "#eff1f5",
|
|
39
|
+
/**
|
|
40
|
+
* Dark ink for the light backgrounds. Near-white on orange is 2.64:1 — the
|
|
41
|
+
* state that most needs reading was the hardest to read — and on green 2.96:1.
|
|
42
|
+
* Dark lifts them to 5.50 and 4.91. Powerline convention agrees: dark text on
|
|
43
|
+
* warm accents.
|
|
44
|
+
*/
|
|
45
|
+
ink: "#1e1e2e",
|
|
46
|
+
/** Neither green, blue nor orange: llama has not been read yet. */
|
|
47
|
+
unknown: "#9ca0b0",
|
|
48
|
+
brand: "#3465a4", // Path Blue — the logo tile, always the first block
|
|
49
|
+
// Darkened from Catppuccin Latte's #40a02b so light ink clears 4.5:1 (2.96 →
|
|
50
|
+
// 4.56). White on green reads as "running" the way the palette intends; the
|
|
51
|
+
// fix is the swatch, not the ink.
|
|
52
|
+
started: "#2f7d20", // green
|
|
53
|
+
stopped: "#d20f39", // red
|
|
54
|
+
error: "#fe640b", // orange — up, but something needs a person
|
|
55
|
+
detail: "#1e66f5", // blue — llama.cpp detail, never a state
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
59
|
+
const n = Number.parseInt(hex.replace("#", ""), 16);
|
|
60
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fgCode = (hex: string): string => {
|
|
64
|
+
const [r, g, b] = hexToRgb(hex);
|
|
65
|
+
return `${ESC}[38;2;${r};${g};${b}m`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const bgCode = (hex: string): string => {
|
|
69
|
+
const [r, g, b] = hexToRgb(hex);
|
|
70
|
+
return `${ESC}[48;2;${r};${g};${b}m`;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const RESET = `${ESC}[0m`;
|
|
74
|
+
|
|
75
|
+
interface WidgetSegment {
|
|
76
|
+
text: string;
|
|
77
|
+
bg: string;
|
|
78
|
+
/** Ink for this block. Defaults to the light foreground. */
|
|
79
|
+
fg?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Backgrounds too light to carry near-white text. */
|
|
83
|
+
const DARK_INK_ON = new Set<string>([WIDGET_COLORS.error]);
|
|
84
|
+
|
|
85
|
+
/** Join segments into a left-aligned Powerline string. */
|
|
86
|
+
function buildPowerline(segments: readonly WidgetSegment[]): string {
|
|
87
|
+
let out = "";
|
|
88
|
+
for (let i = 0; i < segments.length; i++) {
|
|
89
|
+
const seg = segments[i];
|
|
90
|
+
if (seg === undefined) continue;
|
|
91
|
+
const ink = seg.fg ?? (DARK_INK_ON.has(seg.bg) ? WIDGET_COLORS.ink : WIDGET_COLORS.fg);
|
|
92
|
+
out += `${bgCode(seg.bg)}${fgCode(ink)} ${seg.text} `;
|
|
93
|
+
const next = segments[i + 1];
|
|
94
|
+
out +=
|
|
95
|
+
next !== undefined
|
|
96
|
+
? `${fgCode(seg.bg)}${bgCode(next.bg)}${ARROW_RIGHT}`
|
|
97
|
+
: `${RESET}${fgCode(seg.bg)}${ARROW_RIGHT}${RESET}`;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Resolves the brand mark, honouring an operator override. */
|
|
103
|
+
export function resolveGlyph(env: Record<string, string | undefined>): string {
|
|
104
|
+
const override = env.STEWARD_GLYPH;
|
|
105
|
+
return override === undefined ? STEWARD_GLYPH : override.trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Everything the widget needs. All of it is already known to the extension. */
|
|
109
|
+
export interface WidgetState {
|
|
110
|
+
/** The dashboard's URL when it is serving, else `null`. Steward's own state. */
|
|
111
|
+
portalUrl: string | null;
|
|
112
|
+
/** The machine as last read, or `null` when it has not been read yet. */
|
|
113
|
+
snapshot: Snapshot | null;
|
|
114
|
+
/**
|
|
115
|
+
* Where Pi's llama.cpp provider is pointing **right now**, in memory. When
|
|
116
|
+
* this differs from what Steward watches, chat fails with "Connection error"
|
|
117
|
+
* while the dashboard reports a perfectly healthy server — the failure the
|
|
118
|
+
* orange state exists for.
|
|
119
|
+
*/
|
|
120
|
+
providerBaseUrl: string | null;
|
|
121
|
+
stewardBaseUrl: string | null;
|
|
122
|
+
/**
|
|
123
|
+
* Whether Pi's provider config on disk already agrees with Steward. It can:
|
|
124
|
+
* setup edits the file, but Pi read it once at startup and keeps using the old
|
|
125
|
+
* value, so the running session dials the old address until it is restarted.
|
|
126
|
+
*
|
|
127
|
+
* That distinction is the whole difference between "restart Pi" and "Pi is
|
|
128
|
+
* configured wrong" — advice the operator cannot act on if the widget only
|
|
129
|
+
* prints two ports and a `≠`.
|
|
130
|
+
*/
|
|
131
|
+
providerFileAgrees: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Hosts that mean "this machine", where naming the host says nothing. */
|
|
135
|
+
const LOOPBACK = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* `:port` on loopback, `host:port` anywhere else — the same rule for Steward and
|
|
139
|
+
* for llama.cpp, which is what actually makes the two blocks consistent. They
|
|
140
|
+
* used to format addresses by two different rules.
|
|
141
|
+
*
|
|
142
|
+
* The host is not dropped, only elided when it carries no information. A bare
|
|
143
|
+
* `:8788` for a remote host would be a plausible-looking wrong value; a host
|
|
144
|
+
* appearing at all is the signal that this is not the usual machine.
|
|
145
|
+
*/
|
|
146
|
+
function formatAddress(host: string, port: number | string): string {
|
|
147
|
+
return LOOPBACK.has(host) ? `:${port}` : `${host}:${port}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The same, from a URL. Falls back to the raw string when it will not parse. */
|
|
151
|
+
function formatUrl(url: string): string {
|
|
152
|
+
try {
|
|
153
|
+
const parsed = new URL(url);
|
|
154
|
+
return formatAddress(parsed.hostname, parsed.port);
|
|
155
|
+
} catch {
|
|
156
|
+
return url;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function formatStatusWidget(state: WidgetState, glyph: string): string {
|
|
161
|
+
const brand = glyph === "" ? "Steward" : `${glyph} Steward`;
|
|
162
|
+
const segments: WidgetSegment[] = [{ text: brand, bg: WIDGET_COLORS.brand }];
|
|
163
|
+
|
|
164
|
+
// Steward itself first: the question this widget's name promises to answer.
|
|
165
|
+
// It needs no probe — the extension holds the server.
|
|
166
|
+
if (state.portalUrl === null) {
|
|
167
|
+
segments.push({ text: "stopped", bg: WIDGET_COLORS.stopped });
|
|
168
|
+
return buildPowerline(segments);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const here = formatUrl(state.portalUrl);
|
|
172
|
+
|
|
173
|
+
// A router Pi cannot reach is an error even when everything Steward owns is
|
|
174
|
+
// healthy: the dashboard goes green while chat fails.
|
|
175
|
+
//
|
|
176
|
+
// ONE block, not two. Two adjacent blocks of the same colour drew their
|
|
177
|
+
// separator as orange-on-orange — an invisible arrow and an unexplained gap
|
|
178
|
+
// where the two fused into a single wide band.
|
|
179
|
+
if (
|
|
180
|
+
state.providerBaseUrl !== null &&
|
|
181
|
+
state.stewardBaseUrl !== null &&
|
|
182
|
+
state.providerBaseUrl !== state.stewardBaseUrl
|
|
183
|
+
) {
|
|
184
|
+
const pi = formatUrl(state.providerBaseUrl);
|
|
185
|
+
const llama = formatUrl(state.stewardBaseUrl);
|
|
186
|
+
// Name the symptom the operator actually hit — chat failing — then the fix.
|
|
187
|
+
segments.push({
|
|
188
|
+
text: state.providerFileAgrees
|
|
189
|
+
? `chat still on ${pi} \u{B7} restart pi to use ${llama}`
|
|
190
|
+
: `chat is set to ${pi}, llama is on ${llama} \u{B7} /steward_initialize`,
|
|
191
|
+
bg: WIDGET_COLORS.error,
|
|
192
|
+
});
|
|
193
|
+
return buildPowerline(segments);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Steward keeps its own block and its own colour: the dashboard is up, and
|
|
197
|
+
// saying otherwise because llama is down would report the wrong subject. The
|
|
198
|
+
// two blocks differ in colour here, so the separator draws — which is why this
|
|
199
|
+
// one is not merged the way the mismatch block is.
|
|
200
|
+
segments.push({ text: here, bg: WIDGET_COLORS.started });
|
|
201
|
+
if (state.snapshot !== null && !state.snapshot.service.running) {
|
|
202
|
+
segments.push({ text: "llama: stopped", bg: WIDGET_COLORS.stopped });
|
|
203
|
+
return buildPowerline(segments);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Not yet read is its own state. Ending the bar here would look exactly like a
|
|
207
|
+
// healthy one with its tail cut off, which the eye reads as "fine".
|
|
208
|
+
if (state.snapshot === null) {
|
|
209
|
+
segments.push({ text: "llama \u{2014}", bg: WIDGET_COLORS.unknown });
|
|
210
|
+
return buildPowerline(segments);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// `active` is serving a request, `resident` is loaded and idle. Both hold
|
|
214
|
+
// weights in memory, which is what the operator is counting.
|
|
215
|
+
const models = state.snapshot.models;
|
|
216
|
+
const loaded = models.filter((m) => m.status === "active" || m.status === "resident").length;
|
|
217
|
+
const where = formatAddress(state.snapshot.service.host, state.snapshot.service.port);
|
|
218
|
+
const detail =
|
|
219
|
+
models.length === 0 ? `llama ${where}` : `llama ${where} \u{B7} ${loaded}/${models.length}`;
|
|
220
|
+
segments.push({ text: detail, bg: WIDGET_COLORS.detail });
|
|
221
|
+
return buildPowerline(segments);
|
|
222
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which unit temperatures are *displayed* in, and how that is decided.
|
|
3
|
+
*
|
|
4
|
+
* Only the DISPLAYED STRING ever changes unit. Every threshold, comparison and
|
|
5
|
+
* bar scale in `./format.ts` is Celsius and stays Celsius — converting a reading
|
|
6
|
+
* before comparing it would turn a 79 °C warning into a 174 °F reading against a
|
|
7
|
+
* 75 threshold and paint every gauge critical. So the unit travels as far as
|
|
8
|
+
* `formatTemperature` and no further.
|
|
9
|
+
*
|
|
10
|
+
* The unit is derived from the operator's REGION, not from a temperature API:
|
|
11
|
+
* `Intl.Locale.prototype.getTemperatureUnit()` and the `measurementSystem`
|
|
12
|
+
* proposal are not standard, are absent in the browsers this dashboard runs in,
|
|
13
|
+
* and would silently answer "metric" for everyone. A region is a fact every
|
|
14
|
+
* browser reports.
|
|
15
|
+
*
|
|
16
|
+
* Keep this module free of Node and DOM APIs — see `./types.ts`. `Intl` is
|
|
17
|
+
* neither: it is part of the language, present in both projects, and every call
|
|
18
|
+
* here is guarded, so a runtime that lacks it degrades to Celsius rather than
|
|
19
|
+
* throwing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The unit a temperature is rendered in. There is no third option. */
|
|
23
|
+
export type TemperatureUnit = "celsius" | "fahrenheit";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The operator's stored choice. `auto` — the default — means "follow the
|
|
27
|
+
* browser", and is resolved to a {@link TemperatureUnit} at apply time by
|
|
28
|
+
* {@link resolveTemperatureUnit}; the other two pin the unit regardless of
|
|
29
|
+
* locale.
|
|
30
|
+
*
|
|
31
|
+
* Modelled now, with no control to set it: the preference is what a later
|
|
32
|
+
* override toggle would store, and having the shape settled means adding that
|
|
33
|
+
* control is a control, not a migration.
|
|
34
|
+
*/
|
|
35
|
+
export type TemperaturePreference = "auto" | TemperatureUnit;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The regions that read temperatures in Fahrenheit: the three countries that
|
|
39
|
+
* use it officially (the United States, Liberia and Myanmar), the five inhabited
|
|
40
|
+
* US territories, and the handful of Caribbean and Pacific states whose everyday
|
|
41
|
+
* weather reporting follows the US.
|
|
42
|
+
*
|
|
43
|
+
* Everything not in this set is Celsius. That asymmetry is deliberate — the
|
|
44
|
+
* default has to be the one that is right for the overwhelming majority of the
|
|
45
|
+
* world's regions, so an unrecognised region is Celsius rather than a guess.
|
|
46
|
+
*/
|
|
47
|
+
export const FAHRENHEIT_REGIONS: ReadonlySet<string> = new Set([
|
|
48
|
+
"US", // United States
|
|
49
|
+
"LR", // Liberia
|
|
50
|
+
"MM", // Myanmar
|
|
51
|
+
"PR", // Puerto Rico
|
|
52
|
+
"GU", // Guam
|
|
53
|
+
"VI", // US Virgin Islands
|
|
54
|
+
"AS", // American Samoa
|
|
55
|
+
"MP", // Northern Mariana Islands
|
|
56
|
+
"PW", // Palau
|
|
57
|
+
"FM", // Micronesia
|
|
58
|
+
"MH", // Marshall Islands
|
|
59
|
+
"KY", // Cayman Islands
|
|
60
|
+
"BS", // Bahamas
|
|
61
|
+
"BZ", // Belize
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A BCP-47 tag's region subtag, upper-cased, or `null` when the tag carries
|
|
66
|
+
* none or is not a tag at all.
|
|
67
|
+
*
|
|
68
|
+
* `Intl.Locale` does the parsing where it exists, because it also canonicalises
|
|
69
|
+
* (`en-us` → `US`) and understands the tag forms a regex would have to enumerate.
|
|
70
|
+
* It THROWS on a malformed tag, so the call is guarded and a hand-rolled match on
|
|
71
|
+
* the region subtag stands in for a runtime without it. Either way this function
|
|
72
|
+
* cannot throw: an unparseable or absent tag is `null`, which reads as Celsius.
|
|
73
|
+
*
|
|
74
|
+
* The tag is deliberately NOT maximised. `en` alone stays region-less rather
|
|
75
|
+
* than being expanded to `en-Latn-US`: a language with no region attached is not
|
|
76
|
+
* evidence of a country, and inventing one would hand `en` to Fahrenheit on the
|
|
77
|
+
* strength of CLDR's likely-subtags table.
|
|
78
|
+
*/
|
|
79
|
+
export function regionFromLocale(locale: string | null | undefined): string | null {
|
|
80
|
+
if (typeof locale !== "string" || locale.trim() === "") return null;
|
|
81
|
+
try {
|
|
82
|
+
const region = new Intl.Locale(locale).region;
|
|
83
|
+
if (typeof region === "string" && region !== "") return region.toUpperCase();
|
|
84
|
+
return null;
|
|
85
|
+
} catch {
|
|
86
|
+
// Malformed for `Intl.Locale`, or no `Intl.Locale` at all: fall through to
|
|
87
|
+
// the subtag match, which is the same rule with none of the canonicalising.
|
|
88
|
+
}
|
|
89
|
+
const match = /^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?-([A-Za-z]{2}|\d{3})(?:-|$)/.exec(locale.trim());
|
|
90
|
+
return match?.[1] === undefined ? null : match[1].toUpperCase();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The unit a region reads temperatures in. An unknown region is Celsius. */
|
|
94
|
+
export function unitForRegion(region: string | null | undefined): TemperatureUnit {
|
|
95
|
+
if (typeof region !== "string") return "celsius";
|
|
96
|
+
return FAHRENHEIT_REGIONS.has(region.toUpperCase()) ? "fahrenheit" : "celsius";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The unit one browser locale implies — `en-US` → Fahrenheit, `en-GB`, `de-DE`
|
|
101
|
+
* and a tag with no region at all → Celsius.
|
|
102
|
+
*/
|
|
103
|
+
export function temperatureUnitForLocale(locale: string | null | undefined): TemperatureUnit {
|
|
104
|
+
return unitForRegion(regionFromLocale(locale));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The unit implied by a browser's locale list, in preference order.
|
|
109
|
+
*
|
|
110
|
+
* The FIRST tag that names a region decides, so a `["en", "en-GB"]` browser
|
|
111
|
+
* reads `GB` instead of stopping at a region-less `en` and defaulting. A list
|
|
112
|
+
* with no region anywhere in it is Celsius.
|
|
113
|
+
*/
|
|
114
|
+
export function temperatureUnitForLocales(
|
|
115
|
+
locales: readonly (string | null | undefined)[],
|
|
116
|
+
): TemperatureUnit {
|
|
117
|
+
for (const locale of locales) {
|
|
118
|
+
const region = regionFromLocale(locale);
|
|
119
|
+
if (region !== null) return unitForRegion(region);
|
|
120
|
+
}
|
|
121
|
+
return "celsius";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The preference applied: `auto` becomes whatever the browser was detected as,
|
|
126
|
+
* and an explicit choice wins over detection.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveTemperatureUnit(
|
|
129
|
+
preference: TemperaturePreference,
|
|
130
|
+
detected: TemperatureUnit,
|
|
131
|
+
): TemperatureUnit {
|
|
132
|
+
return preference === "auto" ? detected : preference;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A stored preference, or `auto` for anything that is not one of the three. */
|
|
136
|
+
export function parseTemperaturePreference(
|
|
137
|
+
value: string | null | undefined,
|
|
138
|
+
): TemperaturePreference {
|
|
139
|
+
return value === "celsius" || value === "fahrenheit" || value === "auto" ? value : "auto";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Celsius to Fahrenheit. Exported so the conversion is stated once and asserted
|
|
144
|
+
* on directly; note that rounding happens AFTER this, in `formatTemperature`,
|
|
145
|
+
* so a 64.4 °C reading rounds from 147.92 °F rather than from a pre-rounded 64.
|
|
146
|
+
*/
|
|
147
|
+
export function celsiusToFahrenheit(celsius: number): number {
|
|
148
|
+
return celsius * (9 / 5) + 32;
|
|
149
|
+
}
|