The editor's mockup writes like the screens, and speaks its own language around them

- Numbers in the mockup follow what the app sends the screens (style, grouping from five digits, the space before
  %), a state without a unit stays as it is, and built-in cards carry the screens' names (Instellingen, not the
  editor's Einstellungen).
- Automatic in Language & region is labelled with what it means now (Home Assistant's language decides).
- A screen's status (Synced, Loading tiles, ...) shows in the editor's language; the screens keep reporting it in
  English, which the app reads.
- The doorbell example uses the alert examples of the editor's language; control labels keep their capitals.
- Editor bundle rebuilt with every language's texts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 16:57:35 +02:00
parent 125346d94e
commit 365ed158b9
37 changed files with 217 additions and 73 deletions
+4 -1
View File
@@ -344,7 +344,10 @@ class Region:
'ha': self.ha_language(), 'languages': self.tr.languages(),
'clock': self.clock, 'clock_effective': '24' if self.clock_24h() else '12',
'numbers': self.numbers, 'numbers_effective': self.number_style(),
'group_min': self.group_min(), 'percent_space': self.percent_space()}
'group_min': self.group_min(), 'percent_space': self.percent_space(),
# What Automatic means now, for its label: the clock and the numbers of the language.
'clock_auto': '12' if self.written().get('clock') == '12' else '24',
'numbers_auto': self.written().get('numbers', 'point'), 'group_min_auto': int(self.written().get('group_min', 1))}
+19 -4
View File
@@ -53,6 +53,20 @@ RESEND_STATES = frozenset({
'Klaar voor tegelconfiguratie', 'Indeling opnieuw nodig', 'Tegels laden',
})
RESEND_GUARD_SECONDS = 120
# What a screen reports stays English, the app reads it (protocol); the editor shows it in its own language (app 0.2.90).
SCREEN_STATUS_WORDS = {'Synced': 'synced', 'Loading tiles': 'loading_tiles', 'Layout received': 'layout_received',
'Resend needed': 'resend_needed', 'Ready for tile configuration': 'ready'}
def status_text(status):
"""A screen's status for the editor: its protocol words in the editor's language, ours as they were made."""
status = shown(status)
if isinstance(status, str) and not isinstance(status, i18n.Text):
if status in SCREEN_STATUS_WORDS:
return t(f'addon.status.screen.{SCREEN_STATUS_WORDS[status]}')
if status.startswith('Error: '):
return t('addon.status.screen.error', reason=status[len('Error: '):])
return status
FORECAST_SECONDS = 1800
# A screen that answers its ping (firmware 0.2.49+) is asked with this timeout, so a busy screen never holds
# up the others. An answer that the screen lacks something repeats everything, after this many seconds right
@@ -1794,7 +1808,7 @@ def create_app(manager, development=False):
# The delivery and our own word for a screen that reports nothing, in the editor's language (app 0.2.90).
status = manager.status.get(screen['id'])
screen['delivery'] = shown(status) if status else t('addon.status.first_tiles')
screen['status'] = shown(screen.get('status'))
screen['status'] = status_text(screen.get('status'))
screen['update'] = manager.updates.state_for(screen, profiles)
screen['alert_action'] = alert_service(screen.get('node'))
screen['dismiss_action'] = alert_service(screen.get('node'), 'dismiss_alert')
@@ -1822,8 +1836,9 @@ def create_app(manager, development=False):
# What's new for the Update badge: here only, not in every live update (app 0.2.78).
payload['changelog'] = manager.updates.changelog
payload['claude_skill'] = claude_skill.status(manager.skill_dir)
payload['builtin'] = [{'id': key, 'name': builtin_name(key, t), 'device': t('addon.labels.built_into_screen'), 'area': '', 'state': 'ok'}
for key in BUILTIN]
# The name in the editor's language for the library, and as the screens show it for the mockup (app 0.2.90).
payload['builtin'] = [{'id': key, 'name': builtin_name(key, t), 'screen_name': builtin_name(key),
'device': t('addon.labels.built_into_screen'), 'area': '', 'state': 'ok'} for key in BUILTIN]
payload['header'] = {**header_bar.catalogue(), 'suggestions': {
screen['id']: header_bar.suggestions(screen, entities, manager.ha.states, manager.registry_index()) for screen in payload['screens']}}
return web.json_response(payload)
@@ -1920,7 +1935,7 @@ def create_app(manager, development=False):
inbox=manager.aliases.get(request.match_info['inbox'], request.match_info['inbox'])
if screen is None: raise ValueError(t('addon.errors.unknown_screen'))
layout=manager.layouts.get(inbox,{'tiles':[]})
return web.json_response({'screen':{**screen, 'status':shown(screen.get('status'))},
return web.json_response({'screen':{**screen, 'status':status_text(screen.get('status'))},
'delivery':shown(manager.status.get(inbox)), 'layout':layout,
'tiles':[{'entity':t['entity'],'state':manager.ha.states.get(t['entity'],{}).get('state'),
# Home Assistant's word for the state, as its own pages show it ("Heat/Cool", app 0.2.67).
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>ESP Screens</title>
<script type="module" crossorigin src="./assets/index-BtUedJUv.js"></script>
<script type="module" crossorigin src="./assets/index-CRR4xeFy.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B7AxjXTR.css">
</head>
<body>
+13 -1
View File
@@ -560,7 +560,15 @@
"too_large": "Layout gespeichert; zu groß für eine Nachricht an den Bildschirm, entferne ein paar Kacheln",
"sent": "An Home Assistant gesendet",
"offline": "Bildschirm offline; Änderungen gespeichert",
"retrying": "Senden fehlgeschlagen; wird automatisch wiederholt"
"retrying": "Senden fehlgeschlagen; wird automatisch wiederholt",
"screen": {
"synced": "Synchronisiert",
"loading_tiles": "Kacheln werden geladen",
"layout_received": "Layout empfangen",
"resend_needed": "Erneutes Senden nötig",
"ready": "Bereit für die Kacheln",
"error": "Fehler: {reason}"
}
},
"updates": {
"offline": "Bildschirm war offline; neuer Versuch beim nächsten Mal.",
@@ -1755,6 +1763,10 @@
"every": "Alle Bildschirme? Löse {event} einmal aus (siehe „Alle Bildschirme auf einmal“). Die Bildschirme arbeiten trotzdem unabhängig, und jeder meldet sich mit seinem eigenen {screen}.",
"claude": "Für die Automation lieber {bold}? Installiere den ESP Screens-Skill unter Einstellungen → Claude.",
"claude_bold": "Claude fragen"
},
"wait_yaml": {
"comment": "Türklingel: die Meldung zeigen und warten, bis jemand die Taste drückt.",
"message": "Jemand kommt zur Tür."
}
},
"override": {
+13 -1
View File
@@ -560,7 +560,15 @@
"too_large": "Layout saved; too large for one message to the screen, remove a few tiles",
"sent": "Sent to Home Assistant",
"offline": "Screen offline; changes saved",
"retrying": "Sending failed; retrying automatically"
"retrying": "Sending failed; retrying automatically",
"screen": {
"synced": "Synced",
"loading_tiles": "Loading tiles",
"layout_received": "Layout received",
"resend_needed": "Resend needed",
"ready": "Ready for tile configuration",
"error": "Error: {reason}"
}
},
"updates": {
"offline": "Screen was offline; will retry next time.",
@@ -1755,6 +1763,10 @@
"every": "Every screen? Fire {event} once (see All screens at once). The screens still work independently and each reports with its own {screen}.",
"claude": "Rather {bold} for the automation? Install the ESP Screens skill under Settings → Claude.",
"claude_bold": "ask Claude"
},
"wait_yaml": {
"comment": "Doorbell: show the alert and wait until someone presses the button.",
"message": "Someone is coming to the door."
}
},
"override": {
+13 -1
View File
@@ -560,7 +560,15 @@
"too_large": "Diseño guardado; es demasiado grande para un mensaje a la pantalla, elimina algunos mosaicos",
"sent": "Enviado a Home Assistant",
"offline": "Pantalla sin conexión; cambios guardados",
"retrying": "Error al enviar; se reintenta automáticamente"
"retrying": "Error al enviar; se reintenta automáticamente",
"screen": {
"synced": "Sincronizado",
"loading_tiles": "Cargando mosaicos",
"layout_received": "Diseño recibido",
"resend_needed": "Hay que volver a enviar",
"ready": "Listo para los mosaicos",
"error": "Error: {reason}"
}
},
"updates": {
"offline": "La pantalla estaba sin conexión; se volverá a intentar la próxima vez.",
@@ -1755,6 +1763,10 @@
"every": "¿Todas las pantallas? Dispara {event} una vez (consulta «Todas las pantallas a la vez»). Las pantallas siguen funcionando por separado y cada una informa con su propio {screen}.",
"claude": "¿Prefieres {bold} que te haga la automatización? Instala la habilidad de ESP Screens en Configuración → Claude.",
"claude_bold": "pedirle a Claude"
},
"wait_yaml": {
"comment": "Timbre: muestra la alerta y espera a que alguien pulse el botón.",
"message": "Alguien va a abrir la puerta."
}
},
"override": {
+13 -1
View File
@@ -560,7 +560,15 @@
"too_large": "Mise en page enregistrée ; trop grande pour un seul message à l'écran, retirez quelques tuiles",
"sent": "Envoyé à Home Assistant",
"offline": "Écran hors ligne ; modifications enregistrées",
"retrying": "Échec de l'envoi ; nouvel essai automatique"
"retrying": "Échec de l'envoi ; nouvel essai automatique",
"screen": {
"synced": "Synchronisé",
"loading_tiles": "Chargement des tuiles",
"layout_received": "Disposition reçue",
"resend_needed": "Nouvel envoi nécessaire",
"ready": "Prêt pour les tuiles",
"error": "Erreur : {reason}"
}
},
"updates": {
"offline": "L'écran était hors ligne ; nouvel essai la prochaine fois.",
@@ -1755,6 +1763,10 @@
"every": "Tous les écrans ? Déclenchez {event} une seule fois (voir Tous les écrans à la fois). Les écrans restent indépendants et chacun répond avec son propre {screen}.",
"claude": "Vous préférez {bold} pour l'automatisation ? Installez la compétence ESP Screens dans Paramètres → Claude.",
"claude_bold": "demander à Claude"
},
"wait_yaml": {
"comment": "Sonnette : afficher l’alerte et attendre qu’on appuie sur le bouton.",
"message": "Quelqu’un arrive à la porte."
}
},
"override": {
+13 -1
View File
@@ -560,7 +560,15 @@
"too_large": "Disposizione salvata; troppo grande per un solo messaggio allo schermo, rimuovi qualche riquadro",
"sent": "Inviato a Home Assistant",
"offline": "Schermo offline; modifiche salvate",
"retrying": "Invio non riuscito; nuovo tentativo automatico"
"retrying": "Invio non riuscito; nuovo tentativo automatico",
"screen": {
"synced": "Sincronizzato",
"loading_tiles": "Caricamento dei riquadri",
"layout_received": "Layout ricevuto",
"resend_needed": "Serve un nuovo invio",
"ready": "Pronto per i riquadri",
"error": "Errore: {reason}"
}
},
"updates": {
"offline": "Lo schermo era offline; nuovo tentativo la prossima volta.",
@@ -1755,6 +1763,10 @@
"every": "Tutti gli schermi? Attiva {event} una sola volta (vedi Tutti gli schermi insieme). Gli schermi restano indipendenti e ognuno risponde con il proprio {screen}.",
"claude": "Preferisci {bold} l'automazione? Installa la skill di ESP Screens in Impostazioni → Claude.",
"claude_bold": "chiedere a Claude"
},
"wait_yaml": {
"comment": "Campanello: mostra l’avviso e aspetta che qualcuno prema il pulsante.",
"message": "Qualcuno sta arrivando alla porta."
}
},
"override": {
+13 -1
View File
@@ -560,7 +560,15 @@
"too_large": "Indeling bewaard; te groot voor één bericht aan het scherm, verwijder een paar tegels",
"sent": "Verstuurd naar Home Assistant",
"offline": "Scherm offline; wijzigingen bewaard",
"retrying": "Versturen mislukt; automatisch opnieuw proberen"
"retrying": "Versturen mislukt; automatisch opnieuw proberen",
"screen": {
"synced": "Gesynchroniseerd",
"loading_tiles": "Tegels laden",
"layout_received": "Indeling ontvangen",
"resend_needed": "Indeling opnieuw nodig",
"ready": "Klaar voor tegelconfiguratie",
"error": "Fout: {reason}"
}
},
"updates": {
"offline": "Scherm was offline; volgende keer opnieuw.",
@@ -1755,6 +1763,10 @@
"every": "Alle schermen? Vuur {event} één keer af (zie Alle schermen tegelijk). De schermen werken nog steeds los van elkaar en melden zich elk met hun eigen {screen}.",
"claude": "Liever {bold} om de automatisering te schrijven? Installeer de ESP Screens-skill onder Instellingen → Claude.",
"claude_bold": "Claude vragen"
},
"wait_yaml": {
"comment": "Deurbel: toon de alert en wacht tot iemand op de knop drukt.",
"message": "Er komt iemand naar de deur."
}
},
"override": {
+13 -1
View File
@@ -560,7 +560,15 @@
"too_large": "Układ zapisany; za duży na jedną wiadomość do ekranu, usuń kilka kafelków",
"sent": "Wysłano do Home Assistant",
"offline": "Ekran offline; zmiany zapisane",
"retrying": "Wysyłanie nieudane; automatyczne ponawianie"
"retrying": "Wysyłanie nieudane; automatyczne ponawianie",
"screen": {
"synced": "Zsynchronizowano",
"loading_tiles": "Ładowanie kafelków",
"layout_received": "Odebrano układ",
"resend_needed": "Wymagane ponowne wysłanie",
"ready": "Gotowy na kafelki",
"error": "Błąd: {reason}"
}
},
"updates": {
"offline": "Ekran był offline; kolejna próba następnym razem.",
@@ -1755,6 +1763,10 @@
"every": "Wszystkie ekrany? Wywołaj {event} raz (zobacz „Wszystkie ekrany naraz”). Ekrany nadal działają niezależnie i każdy zgłasza się z własnym polem {screen}.",
"claude": "Wolisz {bold} o automatyzację? Zainstaluj umiejętność ESP Screens w sekcji Ustawienia → Claude.",
"claude_bold": "poprosić Claude"
},
"wait_yaml": {
"comment": "Dzwonek: pokaż alert i poczekaj, aż ktoś naciśnie przycisk.",
"message": "Ktoś idzie do drzwi."
}
},
"override": {
+13 -1
View File
@@ -560,7 +560,15 @@
"too_large": "Layout guardado; demasiado grande para uma mensagem para o ecrã, remova alguns mosaicos",
"sent": "Enviado para o Home Assistant",
"offline": "Ecrã offline; alterações guardadas",
"retrying": "O envio falhou; a tentar novamente automaticamente"
"retrying": "O envio falhou; a tentar novamente automaticamente",
"screen": {
"synced": "Sincronizado",
"loading_tiles": "A carregar mosaicos",
"layout_received": "Layout recebido",
"resend_needed": "É preciso reenviar",
"ready": "Pronto para os mosaicos",
"error": "Erro: {reason}"
}
},
"updates": {
"offline": "O ecrã estava offline; vai tentar novamente da próxima vez.",
@@ -1755,6 +1763,10 @@
"every": "Todos os ecrãs? Dispare {event} uma vez (ver «Todos os ecrãs de uma vez»). Os ecrãs continuam a funcionar de forma independente e cada um reporta com o seu próprio {screen}.",
"claude": "Prefere {bold} que faça a automatização? Instale a habilidade do ESP Screens em Definições → Claude.",
"claude_bold": "pedir ao Claude"
},
"wait_yaml": {
"comment": "Campainha: mostra o alerta e espera que alguém prima o botão.",
"message": "Vem alguém à porta."
}
},
"override": {
+3
View File
@@ -251,6 +251,9 @@ class ClockPinTests(unittest.IsolatedAsyncioTestCase):
view = (await answer.json())['language']
self.assertEqual((view['effective'], view['numbers_effective'], view['group_min'], view['percent_space']),
('de', 'point', 1, True))
# What the screen reports stays English (the app reads it); the editor shows it in its own language.
dutch = await (await client.get('/api/inventory?light=1', headers={'X-ESP-Screens-Language': 'nl'})).json()
self.assertEqual((m.screens()[0]['status'], dutch['screens'][0]['status']), ('Synced', 'Gesynchroniseerd'))
refused = await client.put('/api/language', json={'clock': '13'}, headers={'X-Screen-CSRF': csrf, 'X-ESP-Screens-Language': 'nl'})
self.assertEqual(refused.status, 400)
self.assertEqual((await refused.json())['error'], i18n.TRANSLATIONS.text('addon.errors.region_choice', 'nl'))
+7 -5
View File
@@ -47,16 +47,18 @@ const exampleYaml = computed(() => {
const lines = (alerts.value?.fields || []).map((f: any) => ` ${f.name}: ${fieldValue(f)}`);
return `action: ${chosenAction.value || "esphome.<device_name>_show_alert"}\ndata:\n${lines.join("\n")}`;
});
// The doorbell example in the editor's language: the fields' own examples, and two lines of its own.
const example = (name: string, fallback: string) => yamlString((alerts.value?.fields || []).find((f: any) => f.name === name)?.example || fallback);
const waitYaml = computed(() => [
`# Doorbell: show the alert and wait until someone presses the button.`,
`# ${t("editor.alerts.wait_yaml.comment")}`,
`actions:`,
` - action: ${chosenAction.value || "esphome.<device_name>_show_alert"}`,
` data:`,
` title: "Someone is at the door"`,
` subtitle: "Door 3, back"`,
` title: ${example("title", "Someone is at the door")}`,
` subtitle: ${example("subtitle", "Door 3, back")}`,
` icon: doorbell`,
` color: orange`,
` button_text: "Coming"`,
` button_text: ${example("button_text", "Coming")}`,
` timeout: 0`,
` flash: true`,
` - wait_for_trigger:`,
@@ -71,7 +73,7 @@ const waitYaml = computed(() => [
` then:`,
` - action: notify.notify`,
` data:`,
` message: "Someone is coming to the door."`,
` message: ${yamlString(t("editor.alerts.wait_yaml.message"))}`,
].join("\n"));
// One event for every screen (app 0.2.45): an action for "Edit in YAML" of the Event action.
const allYaml = computed(() => {
+3 -3
View File
@@ -2,7 +2,7 @@
// Everything around the screens: firmware updates, language and region, alerts, Claude.
import { computed, ref } from "vue";
import { haProfile, matchLanguage, numberText, type NumberMarks, type NumberStyle, STYLE_MARKS, t } from "../i18n";
import { anyUpdating, go, installClaudeSkill, runUpdateAll, saveLanguage, screenLanguageMarks, setAutoUpdate, state, updateProgress } from "../store";
import { anyUpdating, autoMarks, go, installClaudeSkill, runUpdateAll, saveLanguage, setAutoUpdate, state, updateProgress } from "../store";
const u = computed(() => state.inventory.updates);
const outdated = computed(() => state.inventory.screens.filter((s) => s.update?.available).length);
@@ -125,7 +125,7 @@ async function useProfile() {
<div class="field">
<label class="f-label" for="time-format">{{ t("editor.settings.language.time") }}</label>
<select id="time-format" :value="lang.clock || 'auto'" :disabled="saving" @change="choose('clock', $event)">
<option value="auto">{{ t("editor.settings.language.time_auto", { clock: clockName(lang.clock_effective) }) }}</option>
<option value="auto">{{ t("editor.settings.language.time_auto", { clock: clockName(lang.clock_auto || lang.clock_effective) }) }}</option>
<option value="24">{{ clockName("24") }}</option>
<option value="12">{{ clockName("12") }}</option>
</select>
@@ -133,7 +133,7 @@ async function useProfile() {
<div class="field">
<label class="f-label" for="number-format">{{ t("editor.settings.language.numbers") }}</label>
<select id="number-format" :value="lang.numbers || 'auto'" :disabled="saving" @change="choose('numbers', $event)">
<option value="auto">{{ t("editor.settings.language.numbers_auto", { example: example(screenLanguageMarks) }) }}</option>
<option value="auto">{{ t("editor.settings.language.numbers_auto", { example: example(autoMarks) }) }}</option>
<option v-for="style in STYLES" :key="style" :value="style">{{ example(STYLE_MARKS[style]) }}</option>
</select>
</div>
+11 -6
View File
@@ -6,11 +6,12 @@ import { vDrag } from "../drag";
import { numberText, t, te } from "../i18n";
import { displayName, effectiveControls, isFull, isWide, pageOf, pageTarget, SLOTS_PER_PAGE } from "../model/layout";
import { clockText, glyph } from "../model/topbar";
import { clock24, entityName, isSelected, liveOf, numberMarks, openTile, placeTile, removeTile, screenText, state, tileIconCp } from "../store";
import { clock24, entityName, isSelected, liveOf, numberMarks, openTile, placeTile, removeTile, screenBuiltinName, screenText, state, tileIconCp, unitSuffix } from "../store";
import type { Tile } from "../types";
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
const name = computed(() => props.tile.name || entityName(props.tile.entity));
// A built-in card is named as the screens name it, in their language (app 0.2.90).
const name = computed(() => props.tile.name || (domain.value === "screen" && screenBuiltinName(props.tile.entity)) || entityName(props.tile.entity));
const full = computed(() => isFull(props.tile));
const wide = computed(() => isWide(props.tile) && !full.value);
const goesTo = computed(() => pageTarget(props.tile.entity));
@@ -36,8 +37,11 @@ const on = computed(() => Boolean(current.value) && !gone.value && current.value
const isOn = computed(() => ["light", "switch", "input_boolean", "fan"].includes(domain.value) && current.value?.state === "on");
const unit = computed(() => current.value?.a?.unit_of_measurement as string | undefined);
const capital = (text: string) => text.charAt(0).toUpperCase() + text.slice(1).replace(/_/g, " ");
// Numbers as the screens write them, "1,234.5" or "1.234,5" (app 0.2.90).
// Numbers as the screens write them, "1,234.5" or "1.234,5" (app 0.2.90): a state only with a unit, or of an entity
// that is a number itself, as the firmware does (value_text); an id-like "1234" without a unit stays as it is.
const num = (value: unknown) => numberText(value as string | number, numberMarks.value);
const NUMERIC = ["number", "input_number", "counter"];
const value = (state: string) => (unit.value || NUMERIC.includes(domain.value) ? `${num(state)}${unitSuffix(unit.value)}` : state);
// The screens' own words for a state where Home Assistant hands us none (screen.ha, Home Assistant's words in the
// screens' language, app 0.2.90): a binary sensor's by its device class, on and off, and the states of the domains
// the screen names itself. A weather's windy-variant is windy there too.
@@ -60,12 +64,13 @@ const status = computed(() => {
const word = c.word || haWord(c) || capital(c.state);
if (domain.value === "climate") return `${a.current_temperature !== undefined ? `${num(a.current_temperature)}° · ` : ""}${word}`;
if (domain.value === "weather") return `${word}${a.temperature !== undefined ? ` · ${num(a.temperature)}°` : ""}`;
if (domain.value === "cover" && a.current_position !== undefined && a.current_position > 0 && a.current_position < 100) return `${word} · ${a.current_position} %`;
if (domain.value === "cover" && a.current_position !== undefined && a.current_position > 0 && a.current_position < 100) return `${word} · ${a.current_position}${unitSuffix("%")}`;
if (domain.value === "media_player" && a.media_title) return `${word} · ${a.media_title}`;
if (domain.value === "sensor") return `${num(c.state)}${unit.value ? ` ${unit.value}` : ""}`;
if (domain.value === "sensor" || NUMERIC.includes(domain.value)) return value(c.state);
return word;
});
const bigValue = computed(() => (current.value && !gone.value ? num(current.value.state) : "—"));
// The big value of the watch display; its unit sits beside it in small letters.
const bigValue = computed(() => (current.value && !gone.value ? (unit.value || NUMERIC.includes(domain.value) ? num(current.value.state) : current.value.state) : "—"));
// The small slider's fill, from what the entity reports; off is empty, like the screen's grey fill.
const fill = computed(() => {
const c = current.value;
+2 -1
View File
@@ -156,7 +156,8 @@ export function effectiveControls(tile: Tile, inventory: Inventory): string | nu
export function controlsLabel(tile: Tile, inventory: Inventory) {
const key = effectiveControls(tile, inventory);
if (!key) return t("editor.inspect.control_none");
return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label.toLocaleLowerCase() || key;
// As the choice itself is labelled, like the other values in the summary: German writes its nouns with a capital.
return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label || key;
}
export const parseVersion = (v: string | undefined | null) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(v || "") || []).slice(1).map(Number);
+19 -6
View File
@@ -2,7 +2,7 @@
// former app.js state and its calls, with the DOM work moved into the components.
import { computed, reactive, toRaw, watch } from "vue";
import { api, getJson, send, setCsrf } from "./api";
import { andList, editorLanguage, languageMarks, languageMeta, loadLanguage, type NumberMarks, pickLanguage, STYLE_MARKS, t } from "./i18n";
import { andList, editorLanguage, languageMeta, loadLanguage, type NumberMarks, pickLanguage, STYLE_MARKS, t } from "./i18n";
import {
arrange, cellsOf, entriesOf, firstFree, fits, isFull, isWide, MAX_PAGES, nearestFree, newTile, normalize, occupied, pageCount, pageOf,
pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, strandedPages, supportsFirmware as supportsVersion, tileLimit as limitFor,
@@ -832,14 +832,27 @@ export const languageName = (code: string | null | undefined) =>
export const needsUpdate = (screen: Screen) => Boolean(screen.update?.available || screen.update?.language);
export const newLanguageText = () => t("editor.update.new_language", { name: languageName(state.inventory.language?.effective) });
// Time and number format, for every screen at once under Settings → Language & region: a 24-hour clock and "1,234.5"
// until the add-on says otherwise. The mockup's clocks and numbers follow them; "auto" numbers are the screens'
// language's own (screen.number), as the firmware writes them.
// until the add-on says otherwise. The mockup's clocks and numbers follow what the add-on sends the screens: the style,
// from how many digits a number is grouped, and the space before "%" (Home Assistant's language decides "auto").
export const clock24 = computed(() => state.inventory.language?.clock_effective !== "12");
export const screenLanguageMarks = computed<NumberMarks>(() => languageMarks(screenLanguage.value));
export const numberMarks = computed<NumberMarks>(() => {
const choice = state.inventory.language ? state.inventory.language.numbers || "auto" : "point";
return choice === "auto" ? screenLanguageMarks.value : STYLE_MARKS[choice];
const language = state.inventory.language;
const marks = STYLE_MARKS[language?.numbers_effective || "point"] || STYLE_MARKS.point;
return { ...marks, from: (language?.group_min || 1) >= 2 ? 5 : 4 };
});
/** How Automatic writes numbers: the marks of the language that decides, for the label of that choice. */
export const autoMarks = computed<NumberMarks>(() => {
const language = state.inventory.language;
return { ...(STYLE_MARKS[language?.numbers_auto || "point"] || STYLE_MARKS.point), from: (language?.group_min_auto || 1) >= 2 ? 5 : 4 };
});
/** What follows a number for its unit, as Home Assistant spaces it: "°", "%" or " %" by the language, " kWh". */
export function unitSuffix(unit: string | undefined | null) {
if (!unit || unit === "°") return unit || "";
if (unit === "%") return state.inventory.language?.percent_space ? " %" : "%";
return ` ${unit}`;
}
/** A built-in card's name as the screens show it (Settings, Clock, Go to page 2), in their language. */
export const screenBuiltinName = (id: string) => state.inventory.builtin?.find((e) => e.id === id)?.screen_name;
/** Saves any of the screen language, the time format and the number format. */
export async function saveLanguage(changes: { setting?: string; clock?: string; numbers?: string }) {
try {
+5 -1
View File
@@ -45,9 +45,13 @@ export type Languages = {
setting: string; effective: string; ha: string | null; languages: LanguageInfo[];
clock?: "auto" | "24" | "12"; clock_effective?: "24" | "12";
numbers?: "auto" | "point" | "comma" | "space"; numbers_effective?: "point" | "comma" | "space";
/** From how many digits a whole number gets separators: 2 is 1234 but 12.345 (CLDR); a space before "%". */
group_min?: number; percent_space?: boolean;
/** What Automatic means now: the clock and numbers of Home Assistant's language (or the chosen one). */
clock_auto?: "24" | "12"; numbers_auto?: "point" | "comma" | "space"; group_min_auto?: number;
};
export type ChangelogSection = { app: string; firmware: string; lines: string[] };
export type Entity = { id: string; name: string; area?: string; device?: string; icon?: string; state?: string; tile?: boolean };
export type Entity = { id: string; name: string; area?: string; device?: string; icon?: string; state?: string; tile?: boolean; screen_name?: string };
export type IconInfo = { name: string; cp: string; label: string };
export type Inventory = {
csrf?: string;
+2 -1
View File
@@ -181,7 +181,8 @@ describe("the mockup speaks the screens' language", () => {
}, meta("one_other"));
state.inventory = {
screens: [], entities: [{ id: "light.b", name: "Lamp B", state: "unavailable" }],
language: { setting: "td", effective: "td", ha: "en", languages: [], numbers: "auto" },
// The add-on says how the screens write numbers: Automatic, worked out for their language (1.234,5).
language: { setting: "td", effective: "td", ha: "en", languages: [], numbers: "auto", numbers_effective: "comma", group_min: 2 },
controls: { script: { default: "run", choices: [{ key: "run", label: "Run" }, { key: "none", label: "None" }] } },
} as any;
state.layout = { title: "Living room", tiles: [] };
+1
View File
@@ -13,6 +13,7 @@ const GUIDE = "https://github.com/MaxGramser/homeassistant_espscreen/blob/main/d
function language(extra: Partial<Languages> = {}): Languages {
return {
setting: "auto", effective: "nl", ha: "nl", clock: "auto", clock_effective: "24", numbers: "auto", numbers_effective: "comma",
clock_auto: "24", numbers_auto: "comma", group_min: 1, group_min_auto: 1, percent_space: false,
languages: [
{ code: "en", name: "English (US)", english: "English (US)", checked: true },
{ code: "en-GB", name: "English (UK)", english: "English (UK)", checked: true },
+1 -1
View File
@@ -94,7 +94,7 @@ describe("defaults, controls and versions", () => {
expect(effectiveControls(tile("light.a", 0, { size: "wide", inline: "slider" }), inventory)).toBeNull();
expect(effectiveControls(tile("light.a", 0, { size: "wide", display: "watch" }), inventory)).toBeNull();
expect(effectiveControls(tile("light.a", 0), inventory)).toBeNull();
expect(controlsLabel(tile("light.a", 0, { size: "wide" }), inventory)).toBe("on/off switch");
expect(controlsLabel(tile("light.a", 0, { size: "wide" }), inventory)).toBe("On/off switch");
expect(controlsLabel(tile("sensor.t", 0, { size: "wide" }), inventory)).toBe("none");
});
it("compares firmware versions and knows the tile limit", () => {