Merge branch 'i18n-editor' into i18n
This commit is contained in:
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
@@ -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-CteHDGjl.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-Ci37AxLI.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-C6Sfd-UM.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -394,5 +394,850 @@
|
||||
"region_choice": "Choose a clock and a number format from the lists."
|
||||
}
|
||||
},
|
||||
"editor": {}
|
||||
"editor": {
|
||||
"common": {
|
||||
"undo": "Undo",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"unknown": "unknown",
|
||||
"unsaved": "Unsaved changes",
|
||||
"cancel": "Cancel",
|
||||
"open_integrations": "Open Devices & services",
|
||||
"save_send": "Save & send",
|
||||
"saving": "Saving…",
|
||||
"read_current_data": "Read current data",
|
||||
"results": "1 result. Keep typing to narrow it down. | {n} results. Keep typing to narrow it down.",
|
||||
"close": "Close",
|
||||
"remove": "Remove",
|
||||
"change": "Change",
|
||||
"loading": "Loading…",
|
||||
"back": "← Back",
|
||||
"copy": "Copy"
|
||||
},
|
||||
"nav": {
|
||||
"new_screen": "New screen",
|
||||
"firmware": "Firmware & USB",
|
||||
"alerts": "Alerts",
|
||||
"settings": "Settings",
|
||||
"new_screen_detail": "Connect and install"
|
||||
},
|
||||
"api": {
|
||||
"failed": "That didn't work. Refresh the page and try again."
|
||||
},
|
||||
"copy": {
|
||||
"api_key": {
|
||||
"copied": "API key copied.",
|
||||
"selected": "API key is selected. Copy with Ctrl+C or Command+C."
|
||||
},
|
||||
"layout_json": {
|
||||
"copied": "Layout JSON copied.",
|
||||
"selected": "Layout JSON is selected. Copy with Ctrl+C or Command+C."
|
||||
},
|
||||
"action_name": {
|
||||
"copied": "Action name copied.",
|
||||
"selected": "Action name is selected. Copy with Ctrl+C or Command+C."
|
||||
},
|
||||
"yaml": {
|
||||
"copied": "YAML copied.",
|
||||
"selected": "YAML is selected. Copy with Ctrl+C or Command+C."
|
||||
},
|
||||
"icon_name": {
|
||||
"copied": "Icon name copied.",
|
||||
"selected": "Icon name is selected. Copy with Ctrl+C or Command+C."
|
||||
},
|
||||
"empty_color": {
|
||||
"copied": "Empty color copied.",
|
||||
"selected": "Empty color is selected. Copy with Ctrl+C or Command+C."
|
||||
},
|
||||
"color_name": {
|
||||
"copied": "Color name copied.",
|
||||
"selected": "Color name is selected. Copy with Ctrl+C or Command+C."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"connection": {
|
||||
"unreachable": "Management page unreachable · retrying…",
|
||||
"connected": "Home Assistant connected",
|
||||
"reconnecting": "Reconnecting to Home Assistant…"
|
||||
},
|
||||
"search": "Search",
|
||||
"screens": "Screens",
|
||||
"more": "More",
|
||||
"refresh": "Refresh",
|
||||
"update": {
|
||||
"pill": "Update",
|
||||
"button": "Update",
|
||||
"queued": "Queued for the update",
|
||||
"available": "Update {version}",
|
||||
"no_profile": "No ESPHome profile found with this device name.",
|
||||
"whats_new": "What's new",
|
||||
"full_log": "Full log"
|
||||
},
|
||||
"host": {
|
||||
"hint": "The IP address, once; new firmware reports it itself.",
|
||||
"placeholder": "IP address, e.g. 192.168.1.50",
|
||||
"label": "IP address",
|
||||
"start": "Start"
|
||||
},
|
||||
"pending": {
|
||||
"installed": "Installed, but not yet in Home Assistant. Add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and turn on “Allow the device to perform Home Assistant actions” under Configure.",
|
||||
"downloaded": "Firmware downloaded, not yet in Home Assistant. Put it on the screen with ESPHome Web, then add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and allow the Home Assistant actions under Configure.",
|
||||
"not_flashed": "Not yet in Home Assistant. Already flashed? Add the ESPHome device under Settings → Devices & services, then allow the Home Assistant actions under Configure. Not flashed yet? Firmware & USB → {file}."
|
||||
},
|
||||
"copy_api_key": "Copy API key"
|
||||
},
|
||||
"palette": {
|
||||
"groups": {
|
||||
"screens": "Screens",
|
||||
"actions": "Actions",
|
||||
"add": "Add to this screen"
|
||||
},
|
||||
"alerts_detail": "Cheatsheet and a test alert",
|
||||
"settings_detail": "Updates, Claude",
|
||||
"nothing_to_save": "Nothing to save",
|
||||
"identify": "Identify this screen",
|
||||
"identify_detail": "Blinks the backlight and shows a card",
|
||||
"identify_needs": "Needs a newer firmware",
|
||||
"export": "Export layout",
|
||||
"export_detail": "Download and copy as JSON",
|
||||
"placeholder": "Search screens, entities, actions…",
|
||||
"empty": "Nothing matches. Type part of a screen, entity or action."
|
||||
},
|
||||
"empty": {
|
||||
"choose": {
|
||||
"title": "Choose a screen",
|
||||
"text": "Pick one of your screens on the left to change its tiles, top bar and settings."
|
||||
},
|
||||
"start": {
|
||||
"title": "Start with a screen",
|
||||
"text": "Connect a CYD or Guition via USB to your Home Assistant machine. Profile, firmware, and the first flash happen in one go.",
|
||||
"button": "Install new screen"
|
||||
}
|
||||
},
|
||||
"screen_view": {
|
||||
"confirm": {
|
||||
"switch": "You have unsaved changes. Open a different screen anyway?",
|
||||
"copy": "Replace the unsaved layout with a copy of the other screen's?",
|
||||
"import": "Replace the unsaved layout with the imported one?"
|
||||
},
|
||||
"saved": {
|
||||
"current": "Saved. Your screen is being updated.",
|
||||
"other": "Saved. {name} is being updated.",
|
||||
"other_unnamed": "Saved. The screen is being updated.",
|
||||
"newer_edit": "Saved. Your newest change isn't sent yet: press Save & send again."
|
||||
},
|
||||
"identified": "{name} blinks and shows a card for a few seconds.",
|
||||
"offline": "Offline · changes are saved",
|
||||
"tabs": {
|
||||
"layout": "Layout",
|
||||
"settings": "Screen settings"
|
||||
},
|
||||
"sent": "Saved · sent to screen",
|
||||
"all_saved": "All saved",
|
||||
"more": "More",
|
||||
"menu": {
|
||||
"identify": "Identify",
|
||||
"identify_hint": "blink the screen",
|
||||
"identify_needs": "Needs firmware 0.2.31 or newer",
|
||||
"inspect_hint": "from Home Assistant",
|
||||
"copy": "Copy layout from…",
|
||||
"copy_screens": "{n} screen | {n} screens",
|
||||
"copy_none": "no other screen",
|
||||
"copy_tiles": "{n} tile | {n} tiles",
|
||||
"export": "Export layout",
|
||||
"import": "Import layout…",
|
||||
"override": "Override YAML",
|
||||
"override_hint": "advanced",
|
||||
"override_none": "No ESPHome profile found for this screen",
|
||||
"update": "Update firmware"
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"removed": "{name} removed",
|
||||
"no_room": "There is no room for this tile on page {page}.",
|
||||
"no_free_page": "No page is free for a full-page tile. Free a page first.",
|
||||
"page_taken": "This screen already has a tile that goes to page {page}.",
|
||||
"copied": "Layout of {name} copied. Save & send when it looks right.",
|
||||
"copied_part": "Layout of {name} copied: {kept} of {total} tiles fit this screen's firmware. Save & send when it looks right.",
|
||||
"imported": "Layout imported. Save & send when it looks right.",
|
||||
"imported_part": "Layout imported: {kept} of {total} tiles fit this screen's firmware. Save & send when it looks right.",
|
||||
"not_json": "That isn't JSON. Export a layout first, or paste one from another ESP Screens.",
|
||||
"no_tiles_in_file": "No tiles in this file. Expected a layout exported from ESP Screens.",
|
||||
"count": "{n} page · {tiles} / {limit} tiles | {n} pages · {tiles} / {limit} tiles",
|
||||
"no_tiles": "Add your first light, scene, or device from the library.",
|
||||
"how_to": "Tap the top bar or a tile to change it. Drag to move; drop on the next page for a new one.",
|
||||
"positions_hint": "Empty slots and fixed positions work from firmware 0.2.26. This screen (firmware {firmware}) shifts the tiles up to the first free slot until that update.",
|
||||
"aria": "Screen layout",
|
||||
"add_page": "+ Add page",
|
||||
"max_pages": "1 page is the most | {n} pages is the most"
|
||||
},
|
||||
"page": {
|
||||
"label": "Page {page}",
|
||||
"remove": "Remove page",
|
||||
"remove_title": "The pages after this one shift up one slot",
|
||||
"edit_bar": "Edit top bar",
|
||||
"cell": {
|
||||
"title": "Empty slot. Click to add a tile here, or drag one over.",
|
||||
"aria": "Empty slot {slot} on page {page}: add the next tile here",
|
||||
"next": "Next tile goes here",
|
||||
"empty": "Empty"
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
"title": "Library",
|
||||
"entities": "{n} entity | {n} entities",
|
||||
"search": "Search by name, device, or room…",
|
||||
"search_label": "Add an entity",
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"light": "Lights",
|
||||
"climate": "Climate",
|
||||
"switch": "Switches",
|
||||
"binary_sensor": "Status",
|
||||
"button": "Actions",
|
||||
"script": "Scripts",
|
||||
"fan": "Fans",
|
||||
"cover": "Covers",
|
||||
"scene": "Scenes",
|
||||
"vacuum": "Vacuum",
|
||||
"sensor": "Sensors",
|
||||
"media_player": "Media",
|
||||
"weather": "Weather",
|
||||
"number": "Values",
|
||||
"select": "Selects",
|
||||
"person": "People",
|
||||
"timer": "Timers",
|
||||
"screen": "Screen"
|
||||
},
|
||||
"room": "Room",
|
||||
"all_rooms": "All rooms",
|
||||
"hide_placed": "Hide placed",
|
||||
"hide_placed_title": "Hide what is already on this screen",
|
||||
"all_placed": "Everything here is already on this screen.",
|
||||
"none_found": "No entities found. Try a different name, room or filter.",
|
||||
"full": "This screen holds 1 tile. | This screen holds {n} tiles.",
|
||||
"full_update": "This screen holds 1 tile; update its firmware for 48. | This screen holds {n} tiles; update its firmware for 48.",
|
||||
"hint": "Drag onto a page, or tap + to add to the first free slot. ⌘K searches everything."
|
||||
},
|
||||
"domains": {
|
||||
"light": "Light",
|
||||
"climate": "Climate",
|
||||
"vacuum": "Vacuum",
|
||||
"fan": "Fan",
|
||||
"cover": "Cover",
|
||||
"media_player": "Media",
|
||||
"sensor": "Sensor",
|
||||
"binary_sensor": "Status",
|
||||
"switch": "Switch",
|
||||
"input_boolean": "Switch",
|
||||
"scene": "Scene",
|
||||
"script": "Script",
|
||||
"weather": "Weather",
|
||||
"number": "Value",
|
||||
"input_number": "Value",
|
||||
"select": "Select",
|
||||
"input_select": "Select",
|
||||
"button": "Action",
|
||||
"input_button": "Action",
|
||||
"screen": "Screen",
|
||||
"sun": "Sun",
|
||||
"timer": "Timer",
|
||||
"person": "Person",
|
||||
"camera": "Camera",
|
||||
"image": "Image",
|
||||
"entity": "Entity"
|
||||
},
|
||||
"displays": {
|
||||
"standard": "standard",
|
||||
"watch": "large value",
|
||||
"forecast": "weather forecast",
|
||||
"graph": "graph",
|
||||
"digital": "digital clock",
|
||||
"analog": "analog clock",
|
||||
"sunpath": "sun path"
|
||||
},
|
||||
"sizes": {
|
||||
"single": "normal",
|
||||
"wide": "double",
|
||||
"full": "full page"
|
||||
},
|
||||
"tile": {
|
||||
"name": "Name on the screen",
|
||||
"icon": {
|
||||
"auto_ha": "Automatic (from Home Assistant)",
|
||||
"auto_default": "Automatic (default)",
|
||||
"needs_firmware": "The screen shows a chosen icon from firmware 0.2.18."
|
||||
},
|
||||
"goes_to": {
|
||||
"label": "Goes to page",
|
||||
"empty": "{page} (empty)",
|
||||
"needs_firmware": "Navigation tiles need firmware 0.2.62: press Update on the screen first.",
|
||||
"no_page": "This screen has no page {page}, so a tap opens its last page. Choose another page.",
|
||||
"hint": "A tap on the tile opens that page."
|
||||
},
|
||||
"display": {
|
||||
"label": "Display",
|
||||
"digital": "Digital clock",
|
||||
"analog": "Analog clock",
|
||||
"standard": "Name and status",
|
||||
"watch": "Large value",
|
||||
"forecast": "Weather forecast",
|
||||
"graph": "Graph",
|
||||
"sunpath": "Sun path",
|
||||
"no_graph": "Home Assistant has no numbers for this entity, so the graph stays empty. Choose another display.",
|
||||
"no_forecast": "This weather service has no daily forecast in Home Assistant. Choose another display."
|
||||
},
|
||||
"size": {
|
||||
"label": "Size",
|
||||
"single": "Normal",
|
||||
"wide": "Double-width",
|
||||
"full": "Full page",
|
||||
"full_hint": "Full page: one big button that lights up while on; a slider, controls or a graph sit at the bottom of it.",
|
||||
"needs_firmware": "A full-page tile needs firmware 0.2.62: press Update on the screen first."
|
||||
},
|
||||
"page": {
|
||||
"label": "Page",
|
||||
"new": "New page"
|
||||
},
|
||||
"controls": {
|
||||
"label": "Direct control on the tile",
|
||||
"not_offered": "Home Assistant doesn't offer this control for this entity, so it stays empty on the screen. Choose another one.",
|
||||
"full_hint": "At the bottom of the full-page tile; a tap anywhere else works as set below.",
|
||||
"wide_hint": "On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as set below.",
|
||||
"needs_firmware": "The screen shows direct control from firmware 0.2.19; until then the tile stays as it was."
|
||||
},
|
||||
"tap": {
|
||||
"label": "On tap",
|
||||
"auto": "Automatic",
|
||||
"detail": "Open control",
|
||||
"none": "View only",
|
||||
"toggle": "On / off",
|
||||
"action": "Perform action",
|
||||
"no_toggle": "Home Assistant can't turn this on and off, so a tap does nothing. Choose another option.",
|
||||
"toggle_needs_firmware": "The screen switches this from firmware 0.2.58: press Update on the screen. Until then a tap opens its card.",
|
||||
"hold": "Hold the tile to open its card."
|
||||
},
|
||||
"slider": {
|
||||
"label": "Small slider on the tile",
|
||||
"no": "No",
|
||||
"yes": "Yes, control directly",
|
||||
"nothing": "Home Assistant has nothing a slider can change for this entity. Choose No."
|
||||
},
|
||||
"history": {
|
||||
"label": "History",
|
||||
"hours": "{n} hour | {n} hours"
|
||||
},
|
||||
"background": {
|
||||
"label": "Pastel background",
|
||||
"aria": "Background: {name}"
|
||||
}
|
||||
},
|
||||
"tile_card": {
|
||||
"label": "{name}, slot {slot} on page {page}. Enter: configure, arrow keys: move",
|
||||
"remove": "Remove tile",
|
||||
"remove_named": "Remove {name}"
|
||||
},
|
||||
"action": {
|
||||
"label": "Action",
|
||||
"asking": "Asking Home Assistant which actions this entity has…",
|
||||
"not_answering": "Home Assistant isn't answering right now. Try again in a moment.",
|
||||
"choose": "Choose an action",
|
||||
"search": "Search, for example toggle, position, or play",
|
||||
"search_label": "Search for an action",
|
||||
"none_found": "No action found.",
|
||||
"gone": "Home Assistant doesn't offer this action for this entity any more, so a tap does nothing. Choose another one.",
|
||||
"optional": "{name} (optional)",
|
||||
"not_set": "Not set",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"needs": "Home Assistant needs {fields}.",
|
||||
"needs_firmware": "The screen performs an action from firmware 0.2.58: press Update on the screen. Until then a tap works as Automatic."
|
||||
},
|
||||
"icon": {
|
||||
"label": "Icon",
|
||||
"none": "No icon",
|
||||
"search": "Search, for example lamp, music, or door",
|
||||
"search_label": "Search for an icon",
|
||||
"none_text_only": "No icon, text only",
|
||||
"aria": "Icon: {name}",
|
||||
"none_found": "No icon found."
|
||||
},
|
||||
"inspect": {
|
||||
"title": "Current data",
|
||||
"subtitle": "Connection, status, and supported properties",
|
||||
"reading": "Reading…",
|
||||
"fetching": "Fetching current HA status...",
|
||||
"status": "Status: {status}",
|
||||
"options": "Small slider: {slider} · Display: {display} · Size: {size} · Control: {control} · Background: {background}",
|
||||
"yes": "yes",
|
||||
"no": "no",
|
||||
"control_none": "none",
|
||||
"background_default": "Default",
|
||||
"read_again": "Read again"
|
||||
},
|
||||
"topbar": {
|
||||
"removed": "{name} removed from the top bar",
|
||||
"full": "The top bar has room for 1 item. | The top bar has room for {n} items.",
|
||||
"already": "This is already in the top bar.",
|
||||
"title": "Top bar",
|
||||
"aria": "Top bar: {name}",
|
||||
"builtin": "From the screen itself, also works without Home Assistant",
|
||||
"items": "{used} of {n} item | {used} of {n} items",
|
||||
"name": "Left: name",
|
||||
"name_placeholder": "For example: Living room",
|
||||
"right": "Right: time, sensors, or a clock",
|
||||
"item_label": "{name}, slot {slot}. Enter: configure, arrows: move",
|
||||
"remove_named": "Remove {name} from the top bar",
|
||||
"max": "Maximum 1 item | Maximum {n} items",
|
||||
"add_title": "Add the time, date, analog clock, or an entity",
|
||||
"add_button": "+ Add time, date, or an entity",
|
||||
"hint": {
|
||||
"overflow": "Not everything fits next to the name: the screen drops the dashed items. Remove one or choose a shorter name.",
|
||||
"reorder": "Drag to reorder; tap an item to configure it.",
|
||||
"needs_firmware": "Sensors, the date, and the analog clock appear from firmware {version}; until that update, this screen shows the name and, if the time is in the bar, the clock."
|
||||
},
|
||||
"detail": {
|
||||
"hidden": "Hidden: not active right now",
|
||||
"overflow": "Doesn't fit next to the name",
|
||||
"dial": "Dial"
|
||||
},
|
||||
"live": {
|
||||
"hidden": "Hidden right now: not active",
|
||||
"overflow": "Doesn't currently fit next to the name",
|
||||
"looks": "How it looks on the screen"
|
||||
},
|
||||
"analog_sample": "Small dial with the time",
|
||||
"content": {
|
||||
"label": "What to show",
|
||||
"hint": "Last changed keeps counting on the screen itself: “Just now”, “5 min ago”, “Yesterday”."
|
||||
},
|
||||
"auto_icon": "Automatic (like Home Assistant)",
|
||||
"show": {
|
||||
"label": "Show",
|
||||
"hint": "Only when active hides the item as long as it's off, closed, away, or 0. Handy for an open door, a running washing machine, or who's home."
|
||||
},
|
||||
"clock_24": "24 hour clock · change under {settings}",
|
||||
"clock_12": "12 hour clock · change under {settings}",
|
||||
"clock_settings": "Settings → Language & region",
|
||||
"date_hint": "{date} today. The date follows the screen's own clock.",
|
||||
"up": "↑ Up",
|
||||
"down": "↓ Down",
|
||||
"add": {
|
||||
"title": "Add to the top bar",
|
||||
"slots": "{used} of {n} slot used | {used} of {n} slots used",
|
||||
"builtin": "From the screen itself",
|
||||
"added": "Already added",
|
||||
"suggestions": "Suggestions from Home Assistant",
|
||||
"entity": "An entity",
|
||||
"search": "Search by name, room, or entity, e.g. temperature or door",
|
||||
"search_label": "Search for an entity for the top bar",
|
||||
"none_found": "No entities found."
|
||||
}
|
||||
},
|
||||
"screen_settings": {
|
||||
"groups": {
|
||||
"brightness": "Brightness",
|
||||
"night": "Night",
|
||||
"screen": "Screen"
|
||||
},
|
||||
"rows": {
|
||||
"brightness": "Brightness",
|
||||
"dark_mode": "Dark mode",
|
||||
"standby_enabled": "Auto standby",
|
||||
"standby_seconds": "Standby after",
|
||||
"standby_brightness": "Standby brightness",
|
||||
"night_enabled": "Night mode",
|
||||
"night_start": "Starts",
|
||||
"night_end": "Ends",
|
||||
"night_brightness": "Night brightness",
|
||||
"auto_home": "Back to page 1",
|
||||
"auto_home_seconds": "After",
|
||||
"home_on_standby": "Also on standby",
|
||||
"swipe_pages": "Swipe between pages",
|
||||
"page_buttons": "Page buttons",
|
||||
"rotation": "Rotation"
|
||||
},
|
||||
"duration": {
|
||||
"seconds": "{n} sec",
|
||||
"minutes": "{n} min",
|
||||
"hours": "{n} h",
|
||||
"hours_minutes": "{h} h {m}"
|
||||
},
|
||||
"time": {
|
||||
"am": "{time} AM",
|
||||
"pm": "{time} PM"
|
||||
},
|
||||
"other_screen_busy": "Still saving the other screen's settings. Try again in a moment.",
|
||||
"reach": {
|
||||
"intro": "Page buttons and swiping are off, so only Go to page tiles change the page.",
|
||||
"pages": "page {list} | pages {list}",
|
||||
"tiles": "You have {n} Go to page tile, to {targets}. | You have {n} Go to page tiles, to {targets}.",
|
||||
"tiles_missing_one": "You have {n} Go to page tile, to {targets}, but none to {missing}, so you can't reach it. | You have {n} Go to page tiles, to {targets}, but none to {missing}, so you can't reach it.",
|
||||
"tiles_missing_more": "You have {n} Go to page tile, to {targets}, but none to {missing}, so you can't reach them. | You have {n} Go to page tiles, to {targets}, but none to {missing}, so you can't reach them.",
|
||||
"none": "You have no Go to page tiles.",
|
||||
"none_missing_one": "You have no Go to page tiles to {missing}, so you can't reach it.",
|
||||
"none_missing_more": "You have no Go to page tiles to {missing}, so you can't reach them.",
|
||||
"cut_off": "The tiles to {pages} are only on pages you can't reach.",
|
||||
"no_way_back": "From {pages} no tile leads back to page 1."
|
||||
},
|
||||
"status": {
|
||||
"offline": "This screen is offline. Its settings can change once it's back.",
|
||||
"screen": "Changes apply on the screen at once, and show up here when they change there.",
|
||||
"app": "Changes apply at once. Firmware 0.2.49 lets the screen keep them itself."
|
||||
},
|
||||
"unavailable": "This entity is off in Home Assistant, or the screen is restarting.",
|
||||
"lower": "{name} lower",
|
||||
"higher": "{name} higher"
|
||||
},
|
||||
"update": {
|
||||
"starting": "Starting update…",
|
||||
"building": "Building the firmware…",
|
||||
"writing": "Writing the firmware over Wi-Fi…",
|
||||
"phases": {
|
||||
"install": "Building and installing…",
|
||||
"verify": "Waiting for the screen to come back…",
|
||||
"settle": "Checking that it stays stable…"
|
||||
},
|
||||
"new_language": "New language: {name}"
|
||||
},
|
||||
"settings": {
|
||||
"updates": {
|
||||
"auto_on": "Screens will now update automatically at night.",
|
||||
"auto_off": "Automatic updates are off.",
|
||||
"title": "Firmware updates",
|
||||
"busy": "Updating to firmware {version}…",
|
||||
"no_screens": "No screen paired yet. New screens get firmware {version}.",
|
||||
"available": "Firmware {version} is available for {n} screen. | Firmware {version} is available for {n} screens.",
|
||||
"current": "All screens have firmware {version}.",
|
||||
"all": "Update 1 screen | Update all {n} screens",
|
||||
"whats_new": "What's new in firmware {version}",
|
||||
"auto_label": "Update automatically every night",
|
||||
"auto_hint": "Between 03:00 and 06:00, one screen at a time. Stops on an error and reports it in Home Assistant."
|
||||
},
|
||||
"claude": {
|
||||
"installed": "Skill installed. Claude Code picks it up right away.",
|
||||
"installed_restart": "Skill installed. Restart Claude Code once so it finds the new skills folder.",
|
||||
"text": "Teach Claude about ESP Screens, then ask: \"Show an alert on all my screens when the mailbox is full.\" Claude knows the alert events, every field, color, and icon.",
|
||||
"install": "Install for Claude Code",
|
||||
"install_again": "Install again",
|
||||
"update": "Update the skill",
|
||||
"not_installed": "Not installed",
|
||||
"installed_chip": "● Installed",
|
||||
"newer": "Installed · a newer version is ready",
|
||||
"writes": "For Claude Code in Home Assistant. Writes one file to {path}; installing again replaces it.",
|
||||
"download": "Download for claude.ai",
|
||||
"upload": "Upload the zip in Claude under Customize → Skills."
|
||||
},
|
||||
"title": "Everything around your screens.",
|
||||
"back": "← My screens",
|
||||
"screens": {
|
||||
"title": "Screens",
|
||||
"text": "Add a screen, or build and reinstall the firmware of one you already have.",
|
||||
"firmware_detail": "Build and install via USB or Wi-Fi"
|
||||
},
|
||||
"language": {
|
||||
"title": "Language & region",
|
||||
"screen_language": "Screen language",
|
||||
"auto": "Home Assistant's language ({name})",
|
||||
"auto_plain": "Home Assistant's language",
|
||||
"ha_missing": "The screens use English until Home Assistant's language is available.",
|
||||
"unchecked": "{name} · not checked yet, help welcome",
|
||||
"help": "{name} is not checked yet, help welcome: {guide}",
|
||||
"guide": "how to translate",
|
||||
"update_hint": "After a change every screen needs a firmware update, one build each: with its Update button, or in the nightly updates.",
|
||||
"time": "Time format",
|
||||
"time_auto": "Automatic, follows the language ({clock})",
|
||||
"clock_24": "24 hour",
|
||||
"clock_12": "12 hour",
|
||||
"numbers": "Number format",
|
||||
"numbers_auto": "Automatic, follows the language ({example})",
|
||||
"profile": "Your Home Assistant profile: {formats}",
|
||||
"use_profile": "Use these",
|
||||
"editor_hint": "The editor itself follows the language of your Home Assistant profile.",
|
||||
"saved": "Saved.",
|
||||
"saved_language": "Saved. Every screen takes the new language with its next update."
|
||||
},
|
||||
"alerts": {
|
||||
"text": "A card over the whole screen from an automation, on one screen or on all of them. The cheatsheet has the action names, fields, icons, and colors, and a form to try one.",
|
||||
"detail": "Full-screen notifications, from an automation"
|
||||
}
|
||||
},
|
||||
"firmware": {
|
||||
"title": "Build and install screen firmware",
|
||||
"intro": "The built-in ESPHome CLI uses your own profile (YAML with keys) and secrets. Choose the screen's profile, then a USB port (screen connected to the Home Assistant machine), {ota} with the hostname, or {download} to put the firmware on the screen from your own computer. {check} only validates, {build} compiles, {install} puts the firmware on the screen. Your tiles stay as they are in ESP Screens. The first build takes a few minutes.",
|
||||
"ota": "Wi-Fi / OTA",
|
||||
"download": "Download",
|
||||
"check": "Check",
|
||||
"build": "Build only",
|
||||
"install": "Build & install",
|
||||
"build_download": "Build & download",
|
||||
"no_cli": "The ESPHome CLI is missing. Update the app to 0.2.0.",
|
||||
"choose": "Choose the intended profile, then a USB port, an IP address, or Download.",
|
||||
"profile": "ESPHome profile",
|
||||
"install_to": "Install to",
|
||||
"no_board": "USB · no board found on the Home Assistant machine yet",
|
||||
"download_target": "Download · flash from your own computer",
|
||||
"host": "IP address or hostname",
|
||||
"download_file": "Download {name}",
|
||||
"download_how": "Put it on the screen with {esphome_web} in Chrome or Edge: Connect, choose the screen's USB port, Install, and select this file. It holds your Wi-Fi password and the screen's keys: keep it to yourself."
|
||||
},
|
||||
"installer": {
|
||||
"title": {
|
||||
"setup": "Connect and install.",
|
||||
"saved": "Profile saved.",
|
||||
"running": "One moment…",
|
||||
"ready": "Ready to download.",
|
||||
"done": "Done.",
|
||||
"failed": "That didn't work."
|
||||
},
|
||||
"intro": "ESP Screens creates the screen's own profile with unique keys and builds the firmware. Connect the screen with a USB data cable to the machine running Home Assistant, or download the firmware and put it on the screen from your own computer.",
|
||||
"board": "Which screen do you have?",
|
||||
"board_cyd": "CYD · 2.8 inch",
|
||||
"board_guition": "Guition · 4 inch",
|
||||
"name": "Name",
|
||||
"name_placeholder": "Living room screen",
|
||||
"device_name": "Device name",
|
||||
"customize": "customize",
|
||||
"name_hint": "Every screen gets its own profile with its own keys. Four screens? Go through this four times with a different name.",
|
||||
"device_name_hint": "Lowercase letters, digits, and dashes. A different name for each screen.",
|
||||
"wifi": {
|
||||
"title": "Wi-Fi",
|
||||
"ssid": "Wi-Fi name (2.4 GHz)",
|
||||
"password": "Wi-Fi password",
|
||||
"new": "Fill in once: ESP Screens saves this in ESPHome secrets.yaml, later screens use it automatically.",
|
||||
"missing": "Your ESPHome secrets.yaml is still missing Wi-Fi details. ESP Screens only fills in the missing lines.",
|
||||
"ready": "Wi-Fi comes from your ESPHome secrets.yaml.",
|
||||
"invalid": "secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten."
|
||||
},
|
||||
"install_via": "Install via",
|
||||
"later": "Later · save profile only",
|
||||
"target": {
|
||||
"usb": "Connect the screen with a USB data cable to the Home Assistant machine; the list refreshes on its own. Or choose Download to use your own computer.",
|
||||
"download": "Download the firmware and put it on the screen from your own computer, in Chrome or Edge. After that, updates go over Wi-Fi.",
|
||||
"later": "The profile goes into the ESPHome folder. You can install later via Firmware & USB, or from ESPHome Device Builder.",
|
||||
"several": "More than one board connected: choose this screen's port.",
|
||||
"once": "Once over USB; after that, everything is wireless."
|
||||
},
|
||||
"install": "Install",
|
||||
"save_profile": "Save profile",
|
||||
"busy": "A build or installation is already running ({file}). Wait for it to finish.",
|
||||
"no_cli": "The ESPHome CLI is missing from this installation; only saving the profile is possible.",
|
||||
"progress": {
|
||||
"saved": "{file} is in the ESPHome folder",
|
||||
"writing": "Writing firmware to {name}…",
|
||||
"building": "Building firmware…",
|
||||
"ready": "Firmware for {name} is ready",
|
||||
"installed": "Firmware is on {name}",
|
||||
"build_failed": "Build failed",
|
||||
"install_failed": "Install failed"
|
||||
},
|
||||
"detail": {
|
||||
"saved": "Install it later from Firmware & USB: choose this profile, then the USB port of the Home Assistant machine, or Download to put the firmware on the screen from your own computer. Save the API key for pairing:",
|
||||
"uploading": "Don't disconnect the USB cable yet.",
|
||||
"building": "A first build takes a few minutes on a Raspberry Pi. You can leave this page: the installation keeps running and you'll find it again under New screen.",
|
||||
"building_download": "A first build takes a few minutes on a Raspberry Pi. You can leave this page: the build keeps running and you'll find it again under New screen.",
|
||||
"downloaded": "Put it on the screen from your own computer, then pair the screen with Home Assistant.",
|
||||
"booted": "The screen boots up and connects to your Wi-Fi. Pair it with Home Assistant now:",
|
||||
"booted_cyd": "The screen boots up and connects to your Wi-Fi. The CYD first asks for a touch calibration: tap the crosshairs. Pair it with Home Assistant now:",
|
||||
"see_log": "See the log below."
|
||||
},
|
||||
"download_steps": {
|
||||
"plug": "{bold} with a USB data cable.",
|
||||
"plug_bold": "Plug the screen into your computer",
|
||||
"open": "{bold} in Chrome or Edge, click Connect and choose the screen's USB port.",
|
||||
"open_bold": "Open {esphome_web}",
|
||||
"install": "{bold} and select {file}. The screen restarts and joins your Wi-Fi.",
|
||||
"install_cyd": "{bold} and select {file}. The screen restarts and joins your Wi-Fi. The CYD first asks for a touch calibration: tap the crosshairs.",
|
||||
"install_bold": "Click Install"
|
||||
},
|
||||
"download_keep": "The file holds your Wi-Fi password and the screen's keys: keep it to yourself.",
|
||||
"api_key": "API key",
|
||||
"pairing": {
|
||||
"ha": "{bold} This happens outside ESP Screens. Home Assistant discovers {name} as an ESPHome device; click Add. Not discovered? Add ESPHome manually with the screen's IP address.",
|
||||
"ha_bold": "Go to Home Assistant → Settings → Devices & services.",
|
||||
"key": "{bold} above when Home Assistant asks for an encryption key.",
|
||||
"key_bold": "Paste the API key",
|
||||
"actions": "{bold} ESPHome integration → Configure → “Allow the device to perform Home Assistant actions”. Without this, the screen sees everything but controls nothing.",
|
||||
"actions_bold": "Allow HA actions:",
|
||||
"tiles": "{bold} Back in ESP Screens, the screen appears in the list on the left within about thirty seconds; until then it's shown there as “not yet in Home Assistant”.",
|
||||
"tiles_bold": "Choose your tiles."
|
||||
},
|
||||
"log": "ESPHome log",
|
||||
"retry": "Retry",
|
||||
"another": "Install another screen",
|
||||
"start_over": "Start over",
|
||||
"done": "Done"
|
||||
},
|
||||
"alerts": {
|
||||
"eyebrow": "Alerts · cheatsheet",
|
||||
"title": "A full-screen notification, from an automation.",
|
||||
"intro": "Every screen has the {action} action. It places a card over everything, wakes the screen, and keeps the backlight at normal brightness until someone taps the button or the timeout runs out. Home Assistant itself shows no explanation or dropdowns for ESPHome actions; everything you can fill in is listed here.",
|
||||
"loading": "The cheatsheet is still loading; one moment.",
|
||||
"example": {
|
||||
"title": "Someone is at the door",
|
||||
"subtitle": "Door 3, back",
|
||||
"button": "Coming"
|
||||
},
|
||||
"sections": "Sections",
|
||||
"nav": {
|
||||
"try": "Try it",
|
||||
"screens": "Your screens",
|
||||
"howto": "In an automation",
|
||||
"all": "All screens",
|
||||
"fields": "The fields",
|
||||
"icons": "Icons",
|
||||
"colors": "Colors",
|
||||
"behaviour": "Behavior",
|
||||
"events": "Events",
|
||||
"tips": "Tips"
|
||||
},
|
||||
"try": {
|
||||
"text": "Send one alert now, with the same fields an automation sends. The screen shows the card and, with blinking on, flashes its backlight.",
|
||||
"screen": "Screen",
|
||||
"all": "All screens ({ready} ready)",
|
||||
"not_ready": "{name} · not ready",
|
||||
"title": "Title",
|
||||
"subtitle": "Subtitle",
|
||||
"icon": "Icon",
|
||||
"color": "Color",
|
||||
"button": "Button text",
|
||||
"timeout": "Timeout (seconds, 0 waits for the button)",
|
||||
"flash": "Blink the backlight",
|
||||
"flash_hint": "Four times when the alert arrives.",
|
||||
"sending": "Sending…",
|
||||
"send": "Send alert",
|
||||
"sent_all": "Sent to {n} screen. | Sent to {n} screens.",
|
||||
"sent_to": "Sent to {name}.",
|
||||
"sent_one": "Sent to the screen.",
|
||||
"skipped": "1 skipped (offline or old firmware). | {n} skipped (offline or old firmware).",
|
||||
"left_empty": "Left empty: {fields}.",
|
||||
"nothing": "No screen could show it: check that a screen is online with firmware {version} or newer."
|
||||
},
|
||||
"white": "White (default)",
|
||||
"screens": {
|
||||
"text": "Every screen has its own action, with the device name in it. From firmware {version}; update an older screen with its Update button under Screens.",
|
||||
"none": "No screen paired yet. Install one via New screen; the action appears once Home Assistant sees the screen.",
|
||||
"ready": "● firmware {version}",
|
||||
"update": "Update needed · firmware {version}",
|
||||
"unknown": "Device name unknown · update the screen",
|
||||
"show": "Show alert",
|
||||
"dismiss": "Dismiss alert"
|
||||
},
|
||||
"howto": {
|
||||
"add": "{add} and search for {esphome}. Choose your screen's action: Home Assistant calls it \"Performs the action show_alert of the node {device}\".",
|
||||
"add_bold": "Add action",
|
||||
"device": "device name",
|
||||
"fields": "{bold} Home Assistant asks for all seven; leave what you don't use empty ({empty}, {zero}, off).",
|
||||
"fields_bold": "Fill in the fields.",
|
||||
"yaml": "Prefer YAML? Choose {edit} on the action and paste the example below.",
|
||||
"example_for": "Example for",
|
||||
"no_screen": "a screen (not paired yet)"
|
||||
},
|
||||
"edit_yaml": "Edit in YAML",
|
||||
"copy_yaml": "Copy YAML",
|
||||
"all": {
|
||||
"title": "All screens at once",
|
||||
"text": "Fire the event {show} with the same fields, and ESP Screens passes the alert on to every screen that is online, including screens you add later. {dismiss} clears it everywhere. In an automation, add the {event} action or paste the example under {edit}. Leave out the fields you don't use. ESP Screens has to be running for this.",
|
||||
"event": "Event"
|
||||
},
|
||||
"fields": {
|
||||
"text": "Text is free-form; the limit is in bytes, and an accented letter counts as two. What doesn't fit gets an ellipsis (title) or is dropped (subtitle).",
|
||||
"field": "Field",
|
||||
"type": "Type",
|
||||
"what": "What it does",
|
||||
"example": "Example",
|
||||
"limit": "Limit",
|
||||
"types": {
|
||||
"string": "text",
|
||||
"int": "number",
|
||||
"bool": "on / off",
|
||||
"entity": "entity"
|
||||
},
|
||||
"bytes": "CYD {cyd} · Guition {guition} bytes",
|
||||
"seconds": "0 to 86400 s",
|
||||
"camera_limit": "Guition, firmware 0.2.57+"
|
||||
},
|
||||
"icons": {
|
||||
"text": "Fill in the name in {field}, for example {example}. {mdi} and the hex codepoint ({hex}) also work. Only these icons are included in the firmware; any other name shows the warning triangle. Tap an icon to copy its name.",
|
||||
"handy": "Handy for notifications",
|
||||
"search_label": "Search for an icon",
|
||||
"search": "Search by name or description…",
|
||||
"extra": "Also available: control and weather icons",
|
||||
"copy": "Copy {name}",
|
||||
"none": "No icon for \"{query}\". Unknown names show the warning triangle ({fallback})."
|
||||
},
|
||||
"colors": {
|
||||
"text": "Fill in the name in {field}. These are the pastel shades of the tiles, with dark text. Empty or unknown gives the white card. Tap to copy."
|
||||
},
|
||||
"behaviour": {
|
||||
"wakes": "{bold} An alert immediately wakes a dimmed or dark screen and appears above every page, every open light card, and the standby layer.",
|
||||
"wakes_bold": "Wakes the screen.",
|
||||
"on": "{bold} As long as the card is showing, the backlight stays at normal brightness. Standby and night hours wait. After the button, the regular standby time starts again.",
|
||||
"on_bold": "On until the button.",
|
||||
"timeout": "{bold} {zero} waits for the button, however long that takes. With {minute} the card disappears after a minute, but the button closes it sooner.",
|
||||
"timeout_bold": "Timeout.",
|
||||
"blinking": "{bold} {flash} blinks the backlight four times when the alert arrives (about a second) and leaves the screen on afterward.",
|
||||
"blinking_bold": "Blinking.",
|
||||
"replacing": "{bold} A new alert replaces the current one; there's no stack. The replaced alert reports itself as {replaced}.",
|
||||
"replacing_bold": "Replacing.",
|
||||
"closing": "{bold} {dismiss} clears the card, for example if the door is already open. Without an alert, that action does nothing.",
|
||||
"closing_bold": "Closing remotely.",
|
||||
"beside": "{bold} does nothing; only the button closes it. That way a wake-up tap never accidentally dismisses the alert.",
|
||||
"beside_bold": "Tapping beside the card"
|
||||
},
|
||||
"events": {
|
||||
"text": "Every end fires the event {event} with {action}, {title}, {screen} (the device name) and the {device_id} that Home Assistant adds. Visible under Settings → Tools → Events.",
|
||||
"when": "When",
|
||||
"wait": "Waiting until someone presses the button"
|
||||
},
|
||||
"tips": {
|
||||
"color": "Use a {bold}: orange for the door, red for smoke or water, green for \"done\". The text always stays dark and readable.",
|
||||
"color_bold": "color per kind of notification",
|
||||
"short": "Keep the title {bold}; a screen shows about twenty characters on one line and ends a longer title with an ellipsis. Put details in the subtitle.",
|
||||
"short_bold": "short",
|
||||
"button": "A {bold} turns the notification into a question: \"Coming\", \"Seen\", \"Turn off\". The event then tells you who responded.",
|
||||
"button_bold": "custom button text",
|
||||
"resolve": "For an alert that resolves itself (the door opens, the laundry gets taken out) close it in the same automation with {dismiss}.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"override": {
|
||||
"eyebrow": "Advanced · local override",
|
||||
"title": "Override YAML",
|
||||
"title_named": "Override YAML · {name}",
|
||||
"intro": "This small file is yours. It is loaded after the shared screen package and stays in place when ESP Screen Manager updates. Change hardware-specific details here, such as a display model.",
|
||||
"safe_title": "What is safe to change?",
|
||||
"safe_text": "Display, touchscreen, substitutions, pins and other device details. Name, Wi-Fi, API, OTA and package links remain managed.",
|
||||
"active": "Active",
|
||||
"ready": "Ready to attach",
|
||||
"editor_label": "Local override YAML",
|
||||
"use_example": "Use example",
|
||||
"clear": "Clear",
|
||||
"characters": "1 character | {n} characters",
|
||||
"lines": "1 line | {n} lines",
|
||||
"save_check": "Save & check",
|
||||
"save": "Save override",
|
||||
"no_profile": "No ESPHome profile was found for this screen.",
|
||||
"attached": "Changes here apply on the next build.",
|
||||
"not_attached": "Save once to attach this file to the profile.",
|
||||
"saving": "Checking and saving…",
|
||||
"saved": "Saved. The override is kept during firmware updates.",
|
||||
"saved_toast": "Override YAML saved.",
|
||||
"checking": "Saved. ESPHome is checking the complete profile…",
|
||||
"checking_profile": "ESPHome is checking the profile…",
|
||||
"checking_stage": "ESPHome is checking the profile… {stage}",
|
||||
"valid": "The complete profile is valid. Safe to build and install.",
|
||||
"rejected": "ESPHome rejected the complete profile. See Firmware & USB for the full log.",
|
||||
"confirm_example": "Replace the current text with the example?",
|
||||
"example_loaded": "Example loaded. Save it when you are ready.",
|
||||
"confirm_clear": "Clear the local override?",
|
||||
"cleared": "The override will be empty after you save."
|
||||
},
|
||||
"mockup": {
|
||||
"home": "Home",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -2,8 +2,10 @@
|
||||
|
||||
The page is a Vue app in web/ (app 0.2.73); `npm run build` writes it to screen_manager/app/static. Tests look at
|
||||
the source, not the build: every .ts and .vue file under web/src as one text (SCRIPT, which is also the markup:
|
||||
a .vue file carries its template), and the stylesheets as another (CSS).
|
||||
a .vue file carries its template), and the stylesheets as another (CSS). Its English words are in the `editor` section
|
||||
of screen_manager/translations/en.json since app 0.2.90: text() reads one of them.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -23,3 +25,14 @@ def source(name):
|
||||
|
||||
def component(name):
|
||||
return source(f'components/{name}.vue')
|
||||
|
||||
|
||||
TEXTS = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['editor']
|
||||
|
||||
|
||||
def text(key):
|
||||
"""One English text of the editor by its key under `editor`, e.g. 'empty.choose.title'."""
|
||||
node = TEXTS
|
||||
for part in key.split('.'):
|
||||
node = node[int(part)] if isinstance(node, list) else node[part]
|
||||
return node
|
||||
|
||||
@@ -185,10 +185,13 @@ class Page(unittest.TestCase):
|
||||
def test_cheatsheet_explains_the_event(self):
|
||||
import editor_sources
|
||||
cheatsheet = editor_sources.component('AlertsView')
|
||||
for marker in ('id="alerts-all"', '["alerts-all", "All screens"]', 'id="alerts-all-example"', 'id="alerts-all-copy"',
|
||||
'esp_screens_show_alert', 'esp_screens_dismiss_alert', 'Settings → Claude'):
|
||||
for marker in ('id="alerts-all"', '"alerts-all",', 'id="alerts-all-example"', 'id="alerts-all-copy"',
|
||||
'esp_screens_show_alert', 'esp_screens_dismiss_alert', 'editor.alerts.tips.claude'):
|
||||
self.assertIn(marker, cheatsheet, marker)
|
||||
for marker in ('const allYaml = computed', 'broadcast?.show', 'copyText(allYaml, undefined, \'YAML\')'):
|
||||
# The words are in en.json (app 0.2.90).
|
||||
self.assertEqual(editor_sources.text('alerts.nav.all'), 'All screens')
|
||||
self.assertIn('Settings → Claude', editor_sources.text('alerts.tips.claude'))
|
||||
for marker in ('const allYaml = computed', 'broadcast?.show', 'copyText(allYaml, undefined, \'yaml\')'):
|
||||
self.assertIn(marker, cheatsheet, marker)
|
||||
|
||||
|
||||
|
||||
@@ -225,10 +225,12 @@ class Editor(unittest.TestCase):
|
||||
for marker in ('export const SIZES: Size[] = ["single", "wide", "full"];', 'export const pageTarget', 'versionAtLeast(firmware, "0.2.62") ? MAX_TILES'):
|
||||
self.assertIn(marker, layout, marker)
|
||||
drawer = editor_sources.component('TileInspector')
|
||||
for marker in ('["full", "Full page"]', 'Goes to page', 'retargetPageTile(tile, Number(v))'):
|
||||
for marker in ('["single", "wide", "full"]', 't("editor.tile.goes_to.label")', 'retargetPageTile(tile, Number(v))'):
|
||||
self.assertIn(marker, drawer, marker)
|
||||
self.assertEqual((editor_sources.text('tile.size.full'), editor_sources.text('tile.goes_to.label')), ('Full page', 'Goes to page'))
|
||||
self.assertIn(':class="{ wide, full, bare, placeholder: placeholder || !live, chosen }"', editor_sources.component('TileCard'))
|
||||
self.assertIn('["screen", "Screen"]', editor_sources.component('Library'))
|
||||
self.assertIn('"timer", "screen",', editor_sources.component('Library'))
|
||||
self.assertEqual(editor_sources.text('library.filters.screen'), 'Screen')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -37,7 +37,8 @@ class Startup(unittest.TestCase):
|
||||
empty = editor_sources.component('EmptyState')
|
||||
choose = re.search(r'<section v-if="state.inventory.screens.length" id="choose" class="empty">(.*?)</section>', empty, re.S)
|
||||
self.assertTrue(choose, 'the card asks for a screen while there are screens')
|
||||
self.assertIn('<h2>Choose a screen</h2>', choose[1])
|
||||
self.assertIn('<h2>{{ t("editor.empty.choose.title") }}</h2>', choose[1])
|
||||
self.assertEqual(editor_sources.text('empty.choose.title'), 'Choose a screen')
|
||||
self.assertNotIn('<button', choose[1], 'the list beside it is the choice')
|
||||
self.assertIn('id="empty" class="empty"', empty, 'the install card when there are no screens yet')
|
||||
self.assertIn('id="start"', empty)
|
||||
|
||||
@@ -207,7 +207,9 @@ class ParityTests(unittest.TestCase):
|
||||
for phrase in ('Just now', ' min ago', ' hour ago', ' hours ago', 'Yesterday', ' days ago', '1 week ago', ' weeks ago',
|
||||
'1 month ago', ' months ago', ' year ago', ' years ago', 'In ', 'Tomorrow'):
|
||||
self.assertIn(phrase, FIRMWARE)
|
||||
self.assertIn(phrase, EDITOR)
|
||||
# The editor's mockup draws the screens' own words since app 0.2.90: screen.time and screen.date of the translations.
|
||||
for key in ('screen.time.${key}', 'screen.date.top_bar', 'screen.date.weekdays_min.${now.getDay()}', 'screen.date.months_short.${now.getMonth()}'):
|
||||
self.assertIn(key, EDITOR)
|
||||
for threshold in ('3600', '86400', '172800', '604800', '2592000', '31536000'):
|
||||
self.assertIn(threshold, FIRMWARE)
|
||||
self.assertIn(threshold, EDITOR)
|
||||
@@ -215,7 +217,8 @@ class ParityTests(unittest.TestCase):
|
||||
self.assertIn('Math.max(2, Math.floor((cap * 4 + 5) / 10)), item: Math.max(6, Math.floor((cap * 125 + 50) / 100)), name: Math.max(8, Math.floor((cap * 16 + 5) / 10))', EDITOR)
|
||||
self.assertIn('width * 35 / 100', FIRMWARE)
|
||||
self.assertIn('(metrics.width * 35) / 100', EDITOR)
|
||||
self.assertEqual(header_bar.MONTHS, tuple(re.search(r'const MONTHS = \[(.*?)\];', EDITOR).group(1).replace('"', '').replace(' ', '').split(',')))
|
||||
english = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['screen']
|
||||
self.assertEqual(header_bar.MONTHS, tuple(english['date']['months_short']))
|
||||
|
||||
def test_profiles_draw_the_bar_from_the_runtime(self):
|
||||
for name in PROFILES:
|
||||
|
||||
@@ -567,16 +567,15 @@ class Editor(unittest.TestCase):
|
||||
self.assertIn(':class="{ unknown: values[row.key] === null || values[row.key] === undefined }"', self.tab)
|
||||
|
||||
def test_the_rows_are_the_settings_of_the_screen_page(self):
|
||||
keys = re.findall(r'\{ key: "(\w+)", label: "[^"]+", kind: "(\w+)"', self.script)
|
||||
self.assertEqual([key for key, _ in keys], [key for key in SETTING_RULES if key != 'show_clock'][:0] or [key for key, _ in keys])
|
||||
import editor_sources
|
||||
keys = re.findall(r'\{ key: "(\w+)", kind: "(\w+)"', self.script)
|
||||
# 12 or 24 hours is Settings -> Language & region's, for every screen at once (app 0.2.90).
|
||||
self.assertEqual({key for key, _ in keys}, set(SETTING_RULES) - {'show_clock', 'clock_24h'})
|
||||
# Labels and steps as on the screen: the same rows, the same -/+ steps, the same duration ladder.
|
||||
# The page's labels are keys into the translations since app 0.2.90; English is the reference.
|
||||
# The labels are keys into the translations since app 0.2.90; English is the reference.
|
||||
english = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['screen']['settings']
|
||||
page_labels = {english[key] for key in re.findall(r'(?:number|toggle|duration|moment|choice)\(screen_text::txt::settings_(\w+)', SCREEN_PAGE)}
|
||||
groups = self.script[self.script.index('const SETTING_GROUPS = ['):self.script.index('export type SettingRow')]
|
||||
script_labels = set(re.findall(r'label: "([^"]+)"', groups))
|
||||
script_labels = {editor_sources.text(f'screen_settings.rows.{key}') for key, _ in keys}
|
||||
self.assertEqual(page_labels, script_labels)
|
||||
for key, step in (('brightness', 5), ('standby_brightness', 5), ('night_brightness', 5)):
|
||||
self.assertRegex(self.script, rf'key: "{key}", [^}}]*step: {step}')
|
||||
@@ -589,7 +588,11 @@ class Editor(unittest.TestCase):
|
||||
self.assertIn('api(`screens/${encodeURIComponent(screen)}/settings`', self.script)
|
||||
self.assertIn('const { settings: _settings, ...tiles } = state.layout;', self.script)
|
||||
self.assertIn('await send(`screens/${encodeURIComponent(state.selected)}`, "PUT", tiles);', self.script)
|
||||
self.assertIn("setSetting('clock_24h', v === '24', 150)", self.script, 'the top bar clock format is the same setting')
|
||||
# The top bar's clock follows the one clock of Settings → Language & region (app 0.2.90).
|
||||
import editor_sources
|
||||
self.assertIn('state.inventory.language?.clock_effective !== "12"', self.script)
|
||||
self.assertNotIn("setSetting('clock_24h'", self.script)
|
||||
self.assertIn('<a href="#settings">', editor_sources.component('TopbarInspector'))
|
||||
|
||||
|
||||
class Firmware(unittest.TestCase):
|
||||
|
||||
@@ -226,7 +226,9 @@ class Editor(unittest.TestCase):
|
||||
self.PICKER = editor_sources.component('ActionPicker')
|
||||
|
||||
def test_perform_action_is_a_tap_choice_with_home_assistants_list(self):
|
||||
self.assertIn('list.push(["action", "Perform action"]);', self.SCRIPT)
|
||||
import editor_sources
|
||||
self.assertIn('keys.push("action");', self.SCRIPT)
|
||||
self.assertEqual(editor_sources.text('tile.tap.action'), 'Perform action')
|
||||
self.assertIn('getJson(`entity-actions?entity=${encodeURIComponent(entity)}`)', self.SCRIPT)
|
||||
self.assertIn('<ActionPicker v-if="domain !== \'screen\' && !goesTo && tap === \'action\'" :tile="tile" />', self.SCRIPT)
|
||||
# Fields follow Home Assistant's selectors; an empty field is left out of the data.
|
||||
|
||||
+5
-2
@@ -6,6 +6,7 @@ the firmware asks theme.h for roles, so a new look is a refill of a few styles p
|
||||
way: no colour written anywhere else, every paint defined and filled, and the look applied at boot and after
|
||||
every settings change. tests/test_theme.cpp checks the values themselves.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
@@ -154,8 +155,10 @@ class Setting(unittest.TestCase):
|
||||
self.assertIn('toggle(screen_text::txt::settings_dark_mode, []() -> int32_t { return dark_mode; },', page)
|
||||
self.assertIn('else if (key == "dark_mode") reported = dark_mode = flag(value);', page)
|
||||
editor = (ROOT / 'web/src/store.ts').read_text()
|
||||
self.assertIn('{ key: "dark_mode", label: "Dark mode", kind: "toggle" },', editor)
|
||||
brightness = editor.split('{ title: "Brightness"', 1)[1].split('] },', 1)[0]
|
||||
self.assertIn('{ key: "dark_mode", kind: "toggle" },', editor)
|
||||
texts = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['editor']
|
||||
self.assertEqual(texts['screen_settings']['rows']['dark_mode'], 'Dark mode')
|
||||
brightness = editor.split('{ group: "brightness"', 1)[1].split('] },', 1)[0]
|
||||
self.assertLess(brightness.index('"brightness"'), brightness.index('"dark_mode"'), 'right under Brightness, as on the screen')
|
||||
self.assertLess(brightness.index('"dark_mode"'), brightness.index('"standby_enabled"'))
|
||||
|
||||
|
||||
Vendored
+5
@@ -4,3 +4,8 @@ declare module "*.vue" {
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
// Every language's _meta by its code, from screen_manager/translations (vite.config.ts, app 0.2.90).
|
||||
declare module "virtual:esp-screens-languages" {
|
||||
const languages: Record<string, { name: string; english: string; script?: string; plural: string; clock?: string; checked: boolean }>;
|
||||
export default languages;
|
||||
}
|
||||
|
||||
Generated
+2271
-1
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -4,7 +4,7 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"devEngines": {
|
||||
"runtime": { "name": "node", "version": "^20.19.0 || >=22.12.0", "onFail": "error" }
|
||||
"runtime": { "name": "node", "version": ">=22.13.0", "onFail": "error" }
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,9 +15,11 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.18"
|
||||
"vue": "^3.5.18",
|
||||
"vue-i18n": "^11.4.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/unplugin-vue-i18n": "^11.2.5",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"jsdom": "^26.1.0",
|
||||
|
||||
+5
-1
@@ -1,6 +1,8 @@
|
||||
// Relative URLs on purpose: the page lives under Home Assistant's ingress path
|
||||
// (/api/hassio_ingress/<token>/) and on http://127.0.0.1:8099 in SCREEN_DEV; `api/...`
|
||||
// resolves against the page in both. Writes carry the CSRF token the inventory handed out.
|
||||
import { editorLanguage, t } from "./i18n";
|
||||
|
||||
let csrf = "";
|
||||
export function setCsrf(token: string) {
|
||||
csrf = token || "";
|
||||
@@ -12,13 +14,15 @@ export async function api(path: string, options: RequestInit = {}): Promise<Resp
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Screen-CSRF": csrf,
|
||||
// The add-on writes its own texts, such as its labels and errors, in the editor's language (app 0.2.90).
|
||||
"X-ESP-Screens-Language": editorLanguage(),
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Any answer with a JSON {"error": "..."} says what went wrong, such as Home Assistant refusing an action (400) or
|
||||
// not answering in time (503, app 0.2.78); anything else, like an error page of the ingress proxy, gets this one.
|
||||
let message = "That didn't work. Refresh the page and try again.";
|
||||
let message = t("editor.api.failed");
|
||||
try {
|
||||
const error = (await response.json())?.error;
|
||||
if (typeof error === "string" && error.trim()) message = error;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Perform action (app 0.2.67): the actions Home Assistant offers for a tile's entity, under its own names,
|
||||
// and one field per value Home Assistant asks for (its selector). An empty field is left out.
|
||||
import { computed } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { loadEntityActions, markDirty, state, supports } from "../store";
|
||||
import type { EntityAction, Tile } from "../types";
|
||||
import Segmented from "./Segmented.vue";
|
||||
@@ -64,42 +65,42 @@ const unitOf = (field: Field) => configOf(field).unit_of_measurement || (kindOf(
|
||||
|
||||
<template>
|
||||
<div class="f">
|
||||
<span class="f-label">Action</span>
|
||||
<small v-if="list === undefined">Asking Home Assistant which actions this entity has…</small>
|
||||
<small v-else-if="list === null" class="warn">Home Assistant isn't answering right now. Try again in a moment.</small>
|
||||
<span class="f-label">{{ t("editor.action.label") }}</span>
|
||||
<small v-if="list === undefined">{{ t("editor.action.asking") }}</small>
|
||||
<small v-else-if="list === null" class="warn">{{ t("editor.action.not_answering") }}</small>
|
||||
<template v-else>
|
||||
<button type="button" class="row" :aria-expanded="open ? 'true' : 'false'" @click="state.actionPickerOpen = !open">
|
||||
<span class="tx">
|
||||
<b>{{ entry ? entry.name : chosen ? chosen.action : "Choose an action" }}</b>
|
||||
<b>{{ entry ? entry.name : chosen ? chosen.action : t("editor.action.choose") }}</b>
|
||||
<small v-if="chosen" class="mono">{{ chosen.action }}</small>
|
||||
</span>
|
||||
<span class="link">{{ open ? "Close" : "Change" }}</span>
|
||||
<span class="link">{{ open ? t("editor.common.close") : t("editor.common.change") }}</span>
|
||||
</button>
|
||||
<div v-if="open" class="picker">
|
||||
<input v-model="state.actionSearch" type="search" placeholder="Search, for example toggle, position, or play" aria-label="Search for an action" />
|
||||
<input v-model="state.actionSearch" type="search" :placeholder="t('editor.action.search')" :aria-label="t('editor.action.search_label')" />
|
||||
<div class="action-list">
|
||||
<button v-for="action in rows" :key="action.action" type="button" class="action-choice" :aria-pressed="action.action === chosen?.action ? 'true' : 'false'" @click="pick(action)">
|
||||
<strong>{{ action.name }}</strong>
|
||||
<small>{{ action.description ? `${action.action} · ${action.description}` : action.action }}</small>
|
||||
</button>
|
||||
<p v-if="!rows.length" class="hint">No action found.</p>
|
||||
<p v-if="!rows.length" class="hint">{{ t("editor.action.none_found") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="chosen && !entry" class="warn">Home Assistant doesn't offer this action for this entity any more, so a tap does nothing. Choose another one.</small>
|
||||
<small v-if="chosen && !entry" class="warn">{{ t("editor.action.gone") }}</small>
|
||||
<template v-if="entry && !open">
|
||||
<div v-for="field in entry.fields" :key="field.key" class="f">
|
||||
<span class="f-label">{{ field.required ? field.name : `${field.name} (optional)` }}</span>
|
||||
<Segmented v-if="kindOf(field) === 'boolean'" :choices="[['', 'Not set'], ['true', 'On'], ['false', 'Off']]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
|
||||
<span class="f-label">{{ field.required ? field.name : t("editor.action.optional", { name: field.name }) }}</span>
|
||||
<Segmented v-if="kindOf(field) === 'boolean'" :choices="[['', t('editor.action.not_set')], ['true', t('editor.action.on')], ['false', t('editor.action.off')]]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
|
||||
@pick="(v) => setField(field.key, v === '' ? undefined : v === 'true')" />
|
||||
<Segmented v-else-if="choicesOf(field) && choicesOf(field)!.length <= 4" :choices="[['', 'Not set'], ...choicesOf(field)!]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
|
||||
<Segmented v-else-if="choicesOf(field) && choicesOf(field)!.length <= 4" :choices="[['', t('editor.action.not_set')], ...choicesOf(field)!]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
|
||||
@pick="(v) => setField(field.key, v === '' ? undefined : v)" />
|
||||
<select v-else-if="choicesOf(field)" :aria-label="field.name" :value="String(chosen?.data?.[field.key] ?? '')" @change="setField(field.key, ($event.target as HTMLSelectElement).value === '' ? undefined : ($event.target as HTMLSelectElement).value)">
|
||||
<option value="">Not set</option>
|
||||
<option value="">{{ t("editor.action.not_set") }}</option>
|
||||
<option v-for="[key, text] in choicesOf(field)!" :key="key" :value="key">{{ text }}</option>
|
||||
</select>
|
||||
<div v-else-if="kindOf(field) === 'color_rgb'" class="action-number">
|
||||
<input type="color" :aria-label="field.name" :value="colorValue(chosen?.data?.[field.key])" @input="setField(field.key, [1, 3, 5].map((i) => parseInt(($event.target as HTMLInputElement).value.slice(i, i + 2), 16)))" />
|
||||
<button type="button" class="btn quiet mini" @click="setField(field.key, undefined)">Not set</button>
|
||||
<button type="button" class="btn quiet mini" @click="setField(field.key, undefined)">{{ t("editor.action.not_set") }}</button>
|
||||
</div>
|
||||
<div v-else-if="kindOf(field) === 'number' || kindOf(field) === 'color_temp'" class="action-number">
|
||||
<input type="number" :aria-label="field.name" :min="configOf(field).min" :max="configOf(field).max" :step="configOf(field).step === 'any' ? 'any' : configOf(field).step"
|
||||
@@ -109,9 +110,9 @@ const unitOf = (field: Field) => configOf(field).unit_of_measurement || (kindOf(
|
||||
<input v-else type="text" :aria-label="field.name" :value="textValue(chosen?.data?.[field.key])" :placeholder="exampleOf(field)" @input="onText(field, ($event.target as HTMLInputElement).value)" />
|
||||
<small v-if="field.description">{{ field.description }}</small>
|
||||
</div>
|
||||
<small v-if="missing.length" class="warn">Home Assistant needs {{ missing.join(", ") }}.</small>
|
||||
<small v-if="missing.length" class="warn">{{ t("editor.action.needs", { fields: missing.join(", ") }) }}</small>
|
||||
</template>
|
||||
<small v-if="!supports(0, 2, 58)">The screen performs an action from firmware 0.2.58: press Update on the screen. Until then a tap works as Automatic.</small>
|
||||
<small v-if="!supports(0, 2, 58)">{{ t("editor.action.needs_firmware") }}</small>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// Alerts: the cheatsheet for esphome.<node>_show_alert, built from the inventory.
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { versionAtLeast } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { canAlert, copyText, firmwareVersion, go, sendTestAlert, state } from "../store";
|
||||
|
||||
const alerts = computed(() => state.inventory.alerts);
|
||||
// Try it: the same seven fields an automation sends, to one screen or to all of them.
|
||||
const tryForm = reactive({ screen: "all", title: "Someone is at the door", subtitle: "Door 3, back", icon: "doorbell", color: "orange", button_text: "Coming", timeout: 30, flash: true });
|
||||
// Try it: the same seven fields an automation sends, to one screen or to all of them. The example is the doorbell, in
|
||||
// the editor's language; the YAML examples below stay as code.
|
||||
const tryForm = reactive({
|
||||
screen: "all", title: t("editor.alerts.example.title"), subtitle: t("editor.alerts.example.subtitle"), icon: "doorbell", color: "orange",
|
||||
button_text: t("editor.alerts.example.button"), timeout: 30, flash: true,
|
||||
});
|
||||
const trying = ref(false);
|
||||
const tryResult = ref("");
|
||||
const readyScreens = computed(() => state.inventory.screens.filter((s) => canAlert(s) && s.online));
|
||||
@@ -17,10 +22,14 @@ async function tryAlert() {
|
||||
try {
|
||||
const { screen, ...data } = tryForm;
|
||||
const result = await sendTestAlert(screen, data);
|
||||
const where = screen === "all" ? `${result.sent} screen${result.sent === 1 ? "" : "s"}` : state.inventory.screens.find((s) => s.id === screen)?.name || "the screen";
|
||||
const name = state.inventory.screens.find((s) => s.id === screen)?.name;
|
||||
const sent = screen === "all"
|
||||
? t("editor.alerts.try.sent_all", result.sent)
|
||||
: name ? t("editor.alerts.try.sent_to", { name }) : t("editor.alerts.try.sent_one");
|
||||
tryResult.value = result.sent
|
||||
? `Sent to ${where}.${result.skipped ? ` ${result.skipped} skipped (offline or old firmware).` : ""}${result.unusable?.length ? ` Left empty: ${result.unusable.join(", ")}.` : ""}`
|
||||
: "No screen could show it: check that a screen is online with firmware " + (alerts.value?.min_firmware || "0.2.31") + " or newer.";
|
||||
? [sent, result.skipped ? t("editor.alerts.try.skipped", result.skipped) : "",
|
||||
result.unusable?.length ? t("editor.alerts.try.left_empty", { fields: result.unusable.join(", ") }) : ""].filter(Boolean).join(" ")
|
||||
: t("editor.alerts.try.nothing", { version: alerts.value?.min_firmware || "0.2.31" });
|
||||
} catch (e: any) {
|
||||
tryResult.value = e.message;
|
||||
} finally {
|
||||
@@ -74,201 +83,269 @@ const allYaml = computed(() => {
|
||||
const iconGroups = computed(() => {
|
||||
if (!alerts.value || !icons.value) return [];
|
||||
const query = iconQuery.value.trim().toLowerCase();
|
||||
const all = [...icons.value.groups, { label: "Also available: control and weather icons", icons: alerts.value.extra_icons }];
|
||||
const all = [...icons.value.groups, { label: t("editor.alerts.icons.extra"), icons: alerts.value.extra_icons }];
|
||||
return all.map((group) => ({ label: group.label, icons: group.icons.filter((i: any) => !query || i.name.includes(query) || (i.label || "").toLowerCase().includes(query)) })).filter((g) => g.icons.length);
|
||||
});
|
||||
const doorbell = computed(() => alerts.value?.suggested_icons?.find((i: any) => i.name === "doorbell"));
|
||||
const orange = computed(() => alerts.value?.colors?.find((c: any) => c.name === "orange"));
|
||||
const types: Record<string, string> = { string: "text", int: "number", bool: "on / off" };
|
||||
// A field's type in words (editor.alerts.fields.types); another type shows as it is.
|
||||
const typeName = (type: string) => (["string", "int", "bool"].includes(type) ? t(`editor.alerts.fields.types.${type}`) : type);
|
||||
function jump(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); }
|
||||
const sections = [["alerts-try", "Try it"], ["alerts-screens", "Your screens"], ["alerts-howto", "In an automation"], ["alerts-all", "All screens"], ["alerts-fields", "The fields"], ["alerts-icons", "Icons"], ["alerts-colors", "Colors"], ["alerts-behaviour", "Behavior"], ["alerts-events", "Events"], ["alerts-tips", "Tips"]];
|
||||
// The sections by id; each one's name in the bar is editor.alerts.nav.<the id without "alerts-">.
|
||||
const sections = ["alerts-try", "alerts-screens", "alerts-howto", "alerts-all", "alerts-fields", "alerts-icons", "alerts-colors", "alerts-behaviour", "alerts-events", "alerts-tips"];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel wide alerts" id="alerts-dialog">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Alerts · cheatsheet</span>
|
||||
<h1>A full-screen notification, from an automation.</h1>
|
||||
<p>Every screen has the <code>show_alert</code> action. It places a card over everything, wakes the screen, and keeps the backlight at normal brightness until someone taps the button or the timeout runs out. Home Assistant itself shows no explanation or dropdowns for ESPHome actions; everything you can fill in is listed here.</p>
|
||||
<span class="eyebrow">{{ t("editor.alerts.eyebrow") }}</span>
|
||||
<h1>{{ t("editor.alerts.title") }}</h1>
|
||||
<i18n-t keypath="editor.alerts.intro" tag="p" scope="global">
|
||||
<template #action><code>show_alert</code></template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-alerts" @click="go('')">← Back</button>
|
||||
<button type="button" class="btn quiet" id="close-alerts" @click="go('')">{{ t("editor.common.back") }}</button>
|
||||
</div>
|
||||
<p v-if="!alerts" class="hint">The cheatsheet is still loading; one moment.</p>
|
||||
<p v-if="!alerts" class="hint">{{ t("editor.alerts.loading") }}</p>
|
||||
<template v-else>
|
||||
<div class="alert-hero">
|
||||
<div class="alert-mock" id="alert-mock" aria-hidden="true">
|
||||
<div class="alert-mock-card" :style="orange ? { background: orange.color } : undefined">
|
||||
<span class="mdi alert-mock-icon" id="alert-mock-icon">{{ doorbell ? glyph(doorbell.cp) : "" }}</span>
|
||||
<strong id="alert-mock-title">Someone is at the door</strong>
|
||||
<span id="alert-mock-subtitle">Door 3, back</span>
|
||||
<span class="fake" id="alert-mock-button">Coming</span>
|
||||
<strong id="alert-mock-title">{{ t("editor.alerts.example.title") }}</strong>
|
||||
<span id="alert-mock-subtitle">{{ t("editor.alerts.example.subtitle") }}</span>
|
||||
<span class="fake" id="alert-mock-button">{{ t("editor.alerts.example.button") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="alerts-nav" aria-label="Sections">
|
||||
<button v-for="[id, label] in sections" :key="id" type="button" :data-jump="id" @click="jump(id)">{{ label }}</button>
|
||||
<nav class="alerts-nav" :aria-label="t('editor.alerts.sections')">
|
||||
<button v-for="id in sections" :key="id" type="button" :data-jump="id" @click="jump(id)">{{ t(`editor.alerts.nav.${id.slice("alerts-".length)}`) }}</button>
|
||||
</nav>
|
||||
</div>
|
||||
<section id="alerts-try" class="card">
|
||||
<h2>Try it</h2>
|
||||
<p>Send one alert now, with the same fields an automation sends. The screen shows the card and, with blinking on, flashes its backlight.</p>
|
||||
<h2>{{ t("editor.alerts.nav.try") }}</h2>
|
||||
<p>{{ t("editor.alerts.try.text") }}</p>
|
||||
<form class="try-grid" @submit.prevent="tryAlert">
|
||||
<div class="field"><label class="f-label" for="try-screen">Screen</label>
|
||||
<div class="field"><label class="f-label" for="try-screen">{{ t("editor.alerts.try.screen") }}</label>
|
||||
<select id="try-screen" v-model="tryForm.screen">
|
||||
<option value="all">All screens ({{ readyScreens.length }} ready)</option>
|
||||
<option v-for="screen in state.inventory.screens" :key="screen.id" :value="screen.id" :disabled="!canAlert(screen) || !screen.online">{{ screen.name }}{{ canAlert(screen) && screen.online ? "" : " · not ready" }}</option>
|
||||
<option value="all">{{ t("editor.alerts.try.all", { ready: readyScreens.length }) }}</option>
|
||||
<option v-for="screen in state.inventory.screens" :key="screen.id" :value="screen.id" :disabled="!canAlert(screen) || !screen.online">{{ canAlert(screen) && screen.online ? screen.name : t("editor.alerts.try.not_ready", { name: screen.name }) }}</option>
|
||||
</select></div>
|
||||
<div class="field"><label class="f-label" for="try-title">Title</label><input id="try-title" v-model="tryForm.title" maxlength="64" /></div>
|
||||
<div class="field"><label class="f-label" for="try-subtitle">Subtitle</label><input id="try-subtitle" v-model="tryForm.subtitle" maxlength="240" /></div>
|
||||
<div class="field"><label class="f-label" for="try-icon">Icon</label><input id="try-icon" v-model="tryForm.icon" list="try-icons" placeholder="doorbell" />
|
||||
<div class="field"><label class="f-label" for="try-title">{{ t("editor.alerts.try.title") }}</label><input id="try-title" v-model="tryForm.title" maxlength="64" /></div>
|
||||
<div class="field"><label class="f-label" for="try-subtitle">{{ t("editor.alerts.try.subtitle") }}</label><input id="try-subtitle" v-model="tryForm.subtitle" maxlength="240" /></div>
|
||||
<div class="field"><label class="f-label" for="try-icon">{{ t("editor.alerts.try.icon") }}</label><input id="try-icon" v-model="tryForm.icon" list="try-icons" placeholder="doorbell" />
|
||||
<datalist id="try-icons"><option v-for="icon in alerts.suggested_icons" :key="icon.name" :value="icon.name">{{ icon.label || icon.name }}</option></datalist></div>
|
||||
<div class="field"><label class="f-label" for="try-color">Color</label>
|
||||
<select id="try-color" v-model="tryForm.color"><option value="">White (default)</option><option v-for="colour in alerts.colors" :key="colour.name" :value="colour.name">{{ colour.label }}</option></select></div>
|
||||
<div class="field"><label class="f-label" for="try-button">Button text</label><input id="try-button" v-model="tryForm.button_text" maxlength="16" /></div>
|
||||
<div class="field"><label class="f-label" for="try-timeout">Timeout (seconds, 0 waits for the button)</label><input id="try-timeout" v-model.number="tryForm.timeout" type="number" min="0" max="86400" /></div>
|
||||
<label class="check field"><input type="checkbox" id="try-flash" v-model="tryForm.flash" /><span>Blink the backlight<small>Four times when the alert arrives.</small></span></label>
|
||||
<div class="field"><label class="f-label" for="try-color">{{ t("editor.alerts.try.color") }}</label>
|
||||
<select id="try-color" v-model="tryForm.color"><option value="">{{ t("editor.alerts.white") }}</option><option v-for="colour in alerts.colors" :key="colour.name" :value="colour.name">{{ colour.label }}</option></select></div>
|
||||
<div class="field"><label class="f-label" for="try-button">{{ t("editor.alerts.try.button") }}</label><input id="try-button" v-model="tryForm.button_text" maxlength="16" /></div>
|
||||
<div class="field"><label class="f-label" for="try-timeout">{{ t("editor.alerts.try.timeout") }}</label><input id="try-timeout" v-model.number="tryForm.timeout" type="number" min="0" max="86400" /></div>
|
||||
<label class="check field"><input type="checkbox" id="try-flash" v-model="tryForm.flash" /><span>{{ t("editor.alerts.try.flash") }}<small>{{ t("editor.alerts.try.flash_hint") }}</small></span></label>
|
||||
<div class="actions field wide">
|
||||
<button type="submit" class="btn primary" id="try-send" :disabled="trying || !readyScreens.length">{{ trying ? "Sending…" : "Send alert" }}</button>
|
||||
<button type="submit" class="btn primary" id="try-send" :disabled="trying || !readyScreens.length">{{ trying ? t("editor.alerts.try.sending") : t("editor.alerts.try.send") }}</button>
|
||||
<span class="status-line" id="try-result" role="status">{{ tryResult }}</span>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section id="alerts-screens" class="card">
|
||||
<h2>Your screens</h2>
|
||||
<p>Every screen has its own action, with the device name in it. From firmware <code id="alerts-min-firmware">{{ alerts.min_firmware }}</code>; update an older screen with its Update button under Screens.</p>
|
||||
<h2>{{ t("editor.alerts.nav.screens") }}</h2>
|
||||
<i18n-t keypath="editor.alerts.screens.text" tag="p" scope="global">
|
||||
<template #version><code id="alerts-min-firmware">{{ alerts.min_firmware }}</code></template>
|
||||
</i18n-t>
|
||||
<div id="alerts-screen-list" class="options">
|
||||
<p v-if="!state.inventory.screens.length" class="hint">No screen paired yet. Install one via New screen; the action appears once Home Assistant sees the screen.</p>
|
||||
<p v-if="!state.inventory.screens.length" class="hint">{{ t("editor.alerts.screens.none") }}</p>
|
||||
<div v-for="screen in state.inventory.screens" :key="screen.id" class="alert-screen">
|
||||
<div class="alert-screen-head">
|
||||
<strong>{{ screen.name }}</strong>
|
||||
<span class="chip" :class="versionAtLeast(firmwareVersion(screen), alerts.min_firmware) && screen.alert_action ? 'good' : 'update'">
|
||||
{{ versionAtLeast(firmwareVersion(screen), alerts.min_firmware) && screen.alert_action ? `● firmware ${screen.firmware}` : screen.alert_action ? `Update needed · firmware ${screen.firmware || "unknown"}` : "Device name unknown · update the screen" }}
|
||||
{{ versionAtLeast(firmwareVersion(screen), alerts.min_firmware) && screen.alert_action
|
||||
? t("editor.alerts.screens.ready", { version: screen.firmware })
|
||||
: screen.alert_action ? t("editor.alerts.screens.update", { version: screen.firmware || t("editor.common.unknown") }) : t("editor.alerts.screens.unknown") }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-for="[label, action] in [['Show alert', screen.alert_action], ['Dismiss alert', screen.dismiss_action]]" :key="label" class="copy-line">
|
||||
<div v-for="[label, action] in [[t('editor.alerts.screens.show'), screen.alert_action], [t('editor.alerts.screens.dismiss'), screen.dismiss_action]]" :key="label" class="copy-line">
|
||||
<span class="copy-label">{{ label }}</span><code>{{ action || "esphome.<device_name>_show_alert" }}</code>
|
||||
<button v-if="action" type="button" class="btn quiet mini" @click="copyText(action!, undefined, 'Action name')">Copy</button>
|
||||
<button v-if="action" type="button" class="btn quiet mini" @click="copyText(action!, undefined, 'action_name')">{{ t("editor.common.copy") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-howto" class="card">
|
||||
<h2>In an automation</h2>
|
||||
<h2>{{ t("editor.alerts.nav.howto") }}</h2>
|
||||
<ol class="steps">
|
||||
<li><b>Add action</b> and search for <b>ESPHome</b>. Choose your screen's action: Home Assistant calls it "Performs the action show_alert of the node <i>device name</i>".</li>
|
||||
<li><b>Fill in the fields.</b> Home Assistant asks for all seven; leave what you don't use empty (<code>""</code>, <code>0</code>, off).</li>
|
||||
<li>Prefer YAML? Choose <b>Edit in YAML</b> on the action and paste the example below.</li>
|
||||
<i18n-t keypath="editor.alerts.howto.add" tag="li" scope="global">
|
||||
<template #add><b>{{ t("editor.alerts.howto.add_bold") }}</b></template>
|
||||
<template #esphome><b>ESPHome</b></template>
|
||||
<template #device><i>{{ t("editor.alerts.howto.device") }}</i></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.howto.fields" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.howto.fields_bold") }}</b></template>
|
||||
<template #empty><code>""</code></template>
|
||||
<template #zero><code>0</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.howto.yaml" tag="li" scope="global">
|
||||
<template #edit><b>{{ t("editor.alerts.edit_yaml") }}</b></template>
|
||||
</i18n-t>
|
||||
</ol>
|
||||
<div class="field">
|
||||
<label class="f-label" for="alerts-example-screen">Example for</label>
|
||||
<label class="f-label" for="alerts-example-screen">{{ t("editor.alerts.howto.example_for") }}</label>
|
||||
<select id="alerts-example-screen" v-model="exampleAction" style="max-width: 360px">
|
||||
<option v-for="screen in screensWithAction" :key="screen.id" :value="screen.alert_action">{{ screen.name }}</option>
|
||||
<option v-if="!screensWithAction.length" value="">a screen (not paired yet)</option>
|
||||
<option v-if="!screensWithAction.length" value="">{{ t("editor.alerts.howto.no_screen") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="copy-line">
|
||||
<pre id="alerts-example">{{ exampleYaml }}</pre>
|
||||
<button type="button" class="btn quiet mini" id="alerts-example-copy" @click="copyText(exampleYaml, undefined, 'YAML')">Copy YAML</button>
|
||||
<button type="button" class="btn quiet mini" id="alerts-example-copy" @click="copyText(exampleYaml, undefined, 'yaml')">{{ t("editor.alerts.copy_yaml") }}</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-all" class="card">
|
||||
<h2>All screens at once</h2>
|
||||
<p>Fire the event <code id="alerts-all-event">{{ alerts.broadcast?.show || "esp_screens_show_alert" }}</code> with the same fields, and ESP Screens passes the alert on to every screen that is online, including screens you add later. <code id="alerts-all-dismiss">{{ alerts.broadcast?.dismiss || "esp_screens_dismiss_alert" }}</code> clears it everywhere. In an automation, add the <b>Event</b> action or paste the example under <b>Edit in YAML</b>. Leave out the fields you don't use. ESP Screens has to be running for this.</p>
|
||||
<h2>{{ t("editor.alerts.all.title") }}</h2>
|
||||
<i18n-t keypath="editor.alerts.all.text" tag="p" scope="global">
|
||||
<template #show><code id="alerts-all-event">{{ alerts.broadcast?.show || "esp_screens_show_alert" }}</code></template>
|
||||
<template #dismiss><code id="alerts-all-dismiss">{{ alerts.broadcast?.dismiss || "esp_screens_dismiss_alert" }}</code></template>
|
||||
<template #event><b>{{ t("editor.alerts.all.event") }}</b></template>
|
||||
<template #edit><b>{{ t("editor.alerts.edit_yaml") }}</b></template>
|
||||
</i18n-t>
|
||||
<div class="copy-line">
|
||||
<pre id="alerts-all-example">{{ allYaml }}</pre>
|
||||
<button type="button" class="btn quiet mini" id="alerts-all-copy" @click="copyText(allYaml, undefined, 'YAML')">Copy YAML</button>
|
||||
<button type="button" class="btn quiet mini" id="alerts-all-copy" @click="copyText(allYaml, undefined, 'yaml')">{{ t("editor.alerts.copy_yaml") }}</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-fields" class="card">
|
||||
<h2>The fields</h2>
|
||||
<p>Text is free-form; the limit is in bytes, and an accented letter counts as two. What doesn't fit gets an ellipsis (title) or is dropped (subtitle).</p>
|
||||
<h2>{{ t("editor.alerts.nav.fields") }}</h2>
|
||||
<p>{{ t("editor.alerts.fields.text") }}</p>
|
||||
<div class="table-scroll">
|
||||
<table id="alerts-field-table">
|
||||
<tr><th>Field</th><th>Type</th><th>What it does</th><th>Example</th><th>Limit</th></tr>
|
||||
<tr><th>{{ t("editor.alerts.fields.field") }}</th><th>{{ t("editor.alerts.fields.type") }}</th><th>{{ t("editor.alerts.fields.what") }}</th><th>{{ t("editor.alerts.fields.example") }}</th><th>{{ t("editor.alerts.fields.limit") }}</th></tr>
|
||||
<tr v-for="field in alerts.fields" :key="field.name">
|
||||
<td><code>{{ field.name }}</code><small>{{ field.label }}</small></td>
|
||||
<td>{{ types[field.type] || field.type }}</td>
|
||||
<td>{{ typeName(field.type) }}</td>
|
||||
<td>{{ field.help }}</td>
|
||||
<td><code>{{ typeof field.example === "string" ? field.example : String(field.example) }}</code></td>
|
||||
<td>{{ alerts.limits.cyd[field.name] ? `CYD ${alerts.limits.cyd[field.name]} · Guition ${alerts.limits.guition[field.name]} bytes` : field.type === "int" ? "0 to 86400 s" : "—" }}</td>
|
||||
<td>{{ alerts.limits.cyd[field.name] ? t("editor.alerts.fields.bytes", { cyd: alerts.limits.cyd[field.name], guition: alerts.limits.guition[field.name] }) : field.type === "int" ? t("editor.alerts.fields.seconds") : "—" }}</td>
|
||||
</tr>
|
||||
<tr v-if="alerts.camera">
|
||||
<td><code>{{ alerts.camera.name }}</code><small>{{ alerts.camera.label }}</small></td>
|
||||
<td>entity</td>
|
||||
<td>{{ t("editor.alerts.fields.types.entity") }}</td>
|
||||
<td>{{ alerts.camera.help }}</td>
|
||||
<td><code>{{ alerts.camera.example }}</code></td>
|
||||
<td>Guition, firmware 0.2.57+</td>
|
||||
<td>{{ t("editor.alerts.fields.camera_limit") }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-icons" class="card">
|
||||
<h2>Icons</h2>
|
||||
<p>Fill in the name in <code>icon</code>, for example <code>doorbell</code>. <code>mdi:doorbell</code> and the hex codepoint (<code>F12E6</code>) also work. Only these icons are included in the firmware; any other name shows the warning triangle. Tap an icon to copy its name.</p>
|
||||
<p class="subhead">Handy for notifications</p>
|
||||
<h2>{{ t("editor.alerts.nav.icons") }}</h2>
|
||||
<i18n-t keypath="editor.alerts.icons.text" tag="p" scope="global">
|
||||
<template #field><code>icon</code></template>
|
||||
<template #example><code>doorbell</code></template>
|
||||
<template #mdi><code>mdi:doorbell</code></template>
|
||||
<template #hex><code>F12E6</code></template>
|
||||
</i18n-t>
|
||||
<p class="subhead">{{ t("editor.alerts.icons.handy") }}</p>
|
||||
<div class="chips" id="alerts-suggested">
|
||||
<button v-for="icon in alerts.suggested_icons" :key="icon.name" type="button" class="chip" @click="copyText(icon.name, undefined, 'Icon name')"><span class="mdi">{{ glyph(icon.cp) }}</span><code>{{ icon.name }}</code></button>
|
||||
<button v-for="icon in alerts.suggested_icons" :key="icon.name" type="button" class="chip" @click="copyText(icon.name, undefined, 'icon_name')"><span class="mdi">{{ glyph(icon.cp) }}</span><code>{{ icon.name }}</code></button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="f-label" for="alerts-icon-search">Search for an icon</label>
|
||||
<input id="alerts-icon-search" v-model="iconQuery" type="search" placeholder="Search by name or description…" autocomplete="off" />
|
||||
<label class="f-label" for="alerts-icon-search">{{ t("editor.alerts.icons.search_label") }}</label>
|
||||
<input id="alerts-icon-search" v-model="iconQuery" type="search" :placeholder="t('editor.alerts.icons.search')" autocomplete="off" />
|
||||
</div>
|
||||
<div id="alerts-icon-groups">
|
||||
<div v-for="group in iconGroups" :key="group.label" class="field">
|
||||
<p class="subhead">{{ group.label }} · {{ group.icons.length }}</p>
|
||||
<div class="alert-icon-grid">
|
||||
<button v-for="icon in group.icons" :key="icon.name" type="button" class="alert-icon" :title="`Copy ${icon.name}`" @click="copyText(icon.name, undefined, 'Icon name')">
|
||||
<button v-for="icon in group.icons" :key="icon.name" type="button" class="alert-icon" :title="t('editor.alerts.icons.copy', { name: icon.name })" @click="copyText(icon.name, undefined, 'icon_name')">
|
||||
<span class="mdi">{{ glyph(icon.cp) }}</span><code>{{ icon.name }}</code><small v-if="icon.label">{{ icon.label }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!iconGroups.length" class="hint">No icon for "{{ iconQuery }}". Unknown names show the warning triangle ({{ alerts.fallback_icon }}).</p>
|
||||
<p v-if="!iconGroups.length" class="hint">{{ t("editor.alerts.icons.none", { query: iconQuery, fallback: alerts.fallback_icon }) }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-colors" class="card">
|
||||
<h2>Colors</h2>
|
||||
<p>Fill in the name in <code>color</code>. These are the pastel shades of the tiles, with dark text. Empty or unknown gives the white card. Tap to copy.</p>
|
||||
<h2>{{ t("editor.alerts.nav.colors") }}</h2>
|
||||
<i18n-t keypath="editor.alerts.colors.text" tag="p" scope="global">
|
||||
<template #field><code>color</code></template>
|
||||
</i18n-t>
|
||||
<div class="swatches" id="alerts-swatches">
|
||||
<button type="button" class="swatch" @click="copyText('', undefined, 'Empty color')"><i style="background: #ffffff"></i><span><code>empty</code><small>White (default)</small></span></button>
|
||||
<button v-for="colour in alerts.colors" :key="colour.name" type="button" class="swatch" @click="copyText(colour.name, undefined, 'Color name')"><i :style="{ background: colour.color }"></i><span><code>{{ colour.name }}</code><small>{{ colour.label }} · {{ colour.color }}</small></span></button>
|
||||
<button type="button" class="swatch" @click="copyText('', undefined, 'empty_color')"><i style="background: #ffffff"></i><span><code>empty</code><small>{{ t("editor.alerts.white") }}</small></span></button>
|
||||
<button v-for="colour in alerts.colors" :key="colour.name" type="button" class="swatch" @click="copyText(colour.name, undefined, 'color_name')"><i :style="{ background: colour.color }"></i><span><code>{{ colour.name }}</code><small>{{ colour.label }} · {{ colour.color }}</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-behaviour" class="card">
|
||||
<h2>Behavior</h2>
|
||||
<h2>{{ t("editor.alerts.nav.behaviour") }}</h2>
|
||||
<ul class="alerts-list">
|
||||
<li><b>Wakes the screen.</b> An alert immediately wakes a dimmed or dark screen and appears above every page, every open light card, and the standby layer.</li>
|
||||
<li><b>On until the button.</b> As long as the card is showing, the backlight stays at normal brightness. Standby and night hours wait. After the button, the regular standby time starts again.</li>
|
||||
<li><b>Timeout.</b> <code>timeout: 0</code> waits for the button, however long that takes. With <code>timeout: 60</code> the card disappears after a minute, but the button closes it sooner.</li>
|
||||
<li><b>Blinking.</b> <code>flash: true</code> blinks the backlight four times when the alert arrives (about a second) and leaves the screen on afterward.</li>
|
||||
<li><b>Replacing.</b> A new alert replaces the current one; there's no stack. The replaced alert reports itself as <code>replaced</code>.</li>
|
||||
<li><b>Closing remotely.</b> <code>dismiss_alert</code> clears the card, for example if the door is already open. Without an alert, that action does nothing.</li>
|
||||
<li><b>Tapping beside the card</b> does nothing; only the button closes it. That way a wake-up tap never accidentally dismisses the alert.</li>
|
||||
<i18n-t keypath="editor.alerts.behaviour.wakes" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.wakes_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.behaviour.on" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.on_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.behaviour.timeout" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.timeout_bold") }}</b></template>
|
||||
<template #zero><code>timeout: 0</code></template>
|
||||
<template #minute><code>timeout: 60</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.behaviour.blinking" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.blinking_bold") }}</b></template>
|
||||
<template #flash><code>flash: true</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.behaviour.replacing" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.replacing_bold") }}</b></template>
|
||||
<template #replaced><code>replaced</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.behaviour.closing" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.closing_bold") }}</b></template>
|
||||
<template #dismiss><code>dismiss_alert</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.behaviour.beside" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.beside_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="alerts-events" class="card">
|
||||
<h2>Events</h2>
|
||||
<p>Every end fires the event <code id="alerts-event-name">{{ alerts.event }}</code> with <code>action</code>, <code>title</code>, <code>screen</code> (the device name) and the <code>device_id</code> that Home Assistant adds. Visible under Settings → Tools → Events.</p>
|
||||
<h2>{{ t("editor.alerts.nav.events") }}</h2>
|
||||
<i18n-t keypath="editor.alerts.events.text" tag="p" scope="global">
|
||||
<template #event><code id="alerts-event-name">{{ alerts.event }}</code></template>
|
||||
<template #action><code>action</code></template>
|
||||
<template #title><code>title</code></template>
|
||||
<template #screen><code>screen</code></template>
|
||||
<template #device_id><code>device_id</code></template>
|
||||
</i18n-t>
|
||||
<div class="table-scroll">
|
||||
<table id="alerts-ending-table">
|
||||
<tr><th>action</th><th>When</th></tr>
|
||||
<tr><th>action</th><th>{{ t("editor.alerts.events.when") }}</th></tr>
|
||||
<tr v-for="ending in alerts.endings" :key="ending.action"><td><code>{{ ending.action }}</code></td><td>{{ ending.label }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<p class="subhead">Waiting until someone presses the button</p>
|
||||
<p class="subhead">{{ t("editor.alerts.events.wait") }}</p>
|
||||
<div class="copy-line">
|
||||
<pre id="alerts-wait-example">{{ waitYaml }}</pre>
|
||||
<button type="button" class="btn quiet mini" id="alerts-wait-copy" @click="copyText(waitYaml, undefined, 'YAML')">Copy YAML</button>
|
||||
<button type="button" class="btn quiet mini" id="alerts-wait-copy" @click="copyText(waitYaml, undefined, 'yaml')">{{ t("editor.alerts.copy_yaml") }}</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-tips" class="card">
|
||||
<h2>Tips</h2>
|
||||
<h2>{{ t("editor.alerts.nav.tips") }}</h2>
|
||||
<ul class="alerts-list">
|
||||
<li>Use a <b>color per kind of notification</b>: orange for the door, red for smoke or water, green for "done". The text always stays dark and readable.</li>
|
||||
<li>Keep the title <b>short</b>; a screen shows about twenty characters on one line and ends a longer title with an ellipsis. Put details in the subtitle.</li>
|
||||
<li>A <b>custom button text</b> turns the notification into a question: "Coming", "Seen", "Turn off". The event then tells you who responded.</li>
|
||||
<li>For an alert that resolves itself (the door opens, the laundry gets taken out) close it in the same automation with <code>dismiss_alert</code>.</li>
|
||||
<li>Every screen? Fire <code>{{ alerts.broadcast?.show || "esp_screens_show_alert" }}</code> once (see All screens at once). The screens still work independently and each reports with its own <code>screen</code>.</li>
|
||||
<li>Rather <b>ask Claude</b> for the automation? Install the ESP Screens skill under Settings → Claude.</li>
|
||||
<i18n-t keypath="editor.alerts.tips.color" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.tips.color_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.tips.short" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.tips.short_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.tips.button" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.tips.button_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.tips.resolve" tag="li" scope="global">
|
||||
<template #dismiss><code>dismiss_alert</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.tips.every" tag="li" scope="global">
|
||||
<template #event><code>{{ alerts.broadcast?.show || "esp_screens_show_alert" }}</code></template>
|
||||
<template #screen><code>screen</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.tips.claude" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.tips.claude_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// Everything around the screens: firmware updates, alerts, Claude.
|
||||
// Everything around the screens: firmware updates, language and region, alerts, Claude.
|
||||
import { computed, ref } from "vue";
|
||||
import { anyUpdating, go, installClaudeSkill, runUpdateAll, setAutoUpdate, state, updateProgress } from "../store";
|
||||
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";
|
||||
|
||||
const u = computed(() => state.inventory.updates);
|
||||
const outdated = computed(() => state.inventory.screens.filter((s) => s.update?.available).length);
|
||||
const updatesHint = computed(() => !u.value ? "" : u.value.busy
|
||||
? `Updating to firmware ${u.value.target}…`
|
||||
? t("editor.settings.updates.busy", { version: u.value.target })
|
||||
: !state.inventory.screens.length
|
||||
? `No screen paired yet. New screens get firmware ${u.value.target}.`
|
||||
? t("editor.settings.updates.no_screens", { version: u.value.target })
|
||||
: outdated.value
|
||||
? `Firmware ${u.value.target} is available for ${outdated.value} screen${outdated.value === 1 ? "" : "s"}.`
|
||||
: `All screens have firmware ${u.value.target}.`);
|
||||
? t("editor.settings.updates.available", { version: u.value.target }, outdated.value)
|
||||
: t("editor.settings.updates.current", { version: u.value.target }));
|
||||
const running = computed(() => state.inventory.screens.find((s) => s.update?.state === "running" || state.updating.includes(s.id)));
|
||||
const progress = computed(() => (running.value ? updateProgress(running.value) : null));
|
||||
const logTail = computed(() => (state.firmwareJob?.logs || []).slice(-12).join("\n"));
|
||||
@@ -27,66 +28,145 @@ async function install() {
|
||||
installing.value = true;
|
||||
try { await installClaudeSkill(); } finally { installing.value = false; }
|
||||
}
|
||||
|
||||
// ---- Language & region (app 0.2.90): one place for every screen ----
|
||||
// The screen language ("auto" is Home Assistant's), the time format and the number format. An add-on without them
|
||||
// shows no card.
|
||||
const GUIDE = "https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/TRANSLATING.md";
|
||||
const STYLES: NumberStyle[] = ["point", "comma", "space"];
|
||||
const lang = computed(() => state.inventory.language);
|
||||
// Home Assistant's language as the list has it: the same one, else its base language.
|
||||
const haName = computed(() => {
|
||||
const l = lang.value, code = l?.ha ? matchLanguage(l.ha, l.languages.map((own) => own.code)) : undefined;
|
||||
return l?.languages.find((own) => own.code === code)?.name;
|
||||
});
|
||||
const unchecked = computed(() => lang.value?.languages.find((own) => own.code === lang.value!.effective && !own.checked));
|
||||
const clockName = (clock: string | undefined) => t(clock === "12" ? "editor.settings.language.clock_12" : "editor.settings.language.clock_24");
|
||||
const example = (marks: NumberMarks) => numberText("1234.5", marks);
|
||||
// The user's own Home Assistant profile, where it names a clock or a number format that the screens don't use.
|
||||
const profile = haProfile();
|
||||
const different = computed(() => {
|
||||
const l = lang.value;
|
||||
if (!l) return {};
|
||||
return {
|
||||
...(profile.clock && profile.clock !== (l.clock_effective || "24") ? { clock: profile.clock } : {}),
|
||||
...(profile.numbers && profile.numbers !== (l.numbers_effective || "point") ? { numbers: profile.numbers } : {}),
|
||||
};
|
||||
});
|
||||
const profileText = computed(() => [different.value.clock && clockName(different.value.clock), different.value.numbers && example(STYLE_MARKS[different.value.numbers])]
|
||||
.filter(Boolean).join(" · "));
|
||||
const saving = ref(false);
|
||||
async function choose(field: "setting" | "clock" | "numbers", event: Event) {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
saving.value = true;
|
||||
const saved = await saveLanguage({ [field]: select.value });
|
||||
saving.value = false;
|
||||
// A refused change shows the stored choice again.
|
||||
if (!saved) select.value = String(lang.value?.[field] ?? "auto");
|
||||
}
|
||||
async function useProfile() {
|
||||
saving.value = true;
|
||||
await saveLanguage(different.value);
|
||||
saving.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel" id="settings-view">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Settings</span>
|
||||
<h1 id="settings-title">Everything around your screens.</h1>
|
||||
<span class="eyebrow">{{ t("editor.nav.settings") }}</span>
|
||||
<h1 id="settings-title">{{ t("editor.settings.title") }}</h1>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-settings" @click="go('')">← My screens</button>
|
||||
<button type="button" class="btn quiet" id="close-settings" @click="go('')">{{ t("editor.settings.back") }}</button>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
<section class="card">
|
||||
<h2>Screens</h2>
|
||||
<p>Add a screen, or build and reinstall the firmware of one you already have.</p>
|
||||
<h2>{{ t("editor.settings.screens.title") }}</h2>
|
||||
<p>{{ t("editor.settings.screens.text") }}</p>
|
||||
<div class="tools">
|
||||
<button type="button" class="tool" @click="go('#new-screen')"><span class="tool-icon">+</span><span class="tx"><b>New screen</b><small>Connect and install</small></span></button>
|
||||
<button type="button" class="tool" @click="go('#firmware')"><span class="tool-icon">⇪</span><span class="tx"><b>Firmware & USB</b><small>Build and install via USB or Wi-Fi</small></span></button>
|
||||
<button type="button" class="tool" @click="go('#new-screen')"><span class="tool-icon">+</span><span class="tx"><b>{{ t("editor.nav.new_screen") }}</b><small>{{ t("editor.nav.new_screen_detail") }}</small></span></button>
|
||||
<button type="button" class="tool" @click="go('#firmware')"><span class="tool-icon">⇪</span><span class="tx"><b>{{ t("editor.nav.firmware") }}</b><small>{{ t("editor.settings.screens.firmware_detail") }}</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="u" class="card updates" id="updates">
|
||||
<h2>Firmware updates</h2>
|
||||
<h2>{{ t("editor.settings.updates.title") }}</h2>
|
||||
<p id="updates-hint">{{ updatesHint }}</p>
|
||||
<template v-if="running && progress">
|
||||
<div class="progress" role="progressbar" :aria-valuenow="progress.percent" aria-valuemin="0" aria-valuemax="100"><i :style="{ width: progress.percent + '%' }"></i></div>
|
||||
<div class="progress-text"><span>{{ running.name }} · {{ progress.percent }} %</span><span>{{ progress.text }}</span></div>
|
||||
<pre v-if="logTail" class="log-lines">{{ logTail }}</pre>
|
||||
</template>
|
||||
<button v-if="u.pending && !u.busy && u.pending >= 2" id="update-all" type="button" class="btn primary" @click="runUpdateAll">Update all {{ u.pending }} screens</button>
|
||||
<button v-if="u.pending && !u.busy && u.pending >= 2" id="update-all" type="button" class="btn primary" @click="runUpdateAll">{{ t("editor.settings.updates.all", u.pending) }}</button>
|
||||
<details v-if="targetNotes.length && !anyUpdating()" class="whatsnew">
|
||||
<summary>What's new in firmware {{ u.target }}</summary>
|
||||
<summary>{{ t("editor.settings.updates.whats_new", { version: u.target }) }}</summary>
|
||||
<ul><li v-for="line in targetNotes" :key="line">{{ line }}</li></ul>
|
||||
</details>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="auto-update" :checked="Boolean(u.auto)" @change="setAutoUpdate(($event.target as HTMLInputElement).checked)" />
|
||||
<span>Update automatically every night<small>Between 03:00 and 06:00, one screen at a time. Stops on an error and reports it in Home Assistant.</small></span>
|
||||
<span>{{ t("editor.settings.updates.auto_label") }}<small>{{ t("editor.settings.updates.auto_hint") }}</small></span>
|
||||
</label>
|
||||
</section>
|
||||
<section v-if="lang" class="card" id="language">
|
||||
<h2>{{ t("editor.settings.language.title") }}</h2>
|
||||
<div class="field">
|
||||
<label class="f-label" for="screen-language">{{ t("editor.settings.language.screen_language") }}</label>
|
||||
<select id="screen-language" :value="lang.setting" :disabled="saving" @change="choose('setting', $event)">
|
||||
<option value="auto">{{ haName ? t("editor.settings.language.auto", { name: haName }) : t("editor.settings.language.auto_plain") }}</option>
|
||||
<option v-for="own in lang.languages" :key="own.code" :value="own.code">{{ own.checked ? own.name : t("editor.settings.language.unchecked", { name: own.name }) }}</option>
|
||||
</select>
|
||||
<small v-if="lang.setting === 'auto' && !haName" id="language-ha-missing">{{ t("editor.settings.language.ha_missing") }}</small>
|
||||
<i18n-t v-if="unchecked" keypath="editor.settings.language.help" tag="small" id="language-help" scope="global">
|
||||
<template #name>{{ unchecked.name }}</template>
|
||||
<template #guide><a :href="GUIDE" target="_blank" rel="noopener">{{ t("editor.settings.language.guide") }}</a></template>
|
||||
</i18n-t>
|
||||
<small>{{ t("editor.settings.language.update_hint") }}</small>
|
||||
</div>
|
||||
<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="24">{{ clockName("24") }}</option>
|
||||
<option value="12">{{ clockName("12") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<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 v-for="style in STYLES" :key="style" :value="style">{{ example(STYLE_MARKS[style]) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="profileText" class="actions" id="language-profile">
|
||||
<small>{{ t("editor.settings.language.profile", { formats: profileText }) }}</small>
|
||||
<button type="button" class="btn quiet mini" :disabled="saving" @click="useProfile">{{ t("editor.settings.language.use_profile") }}</button>
|
||||
</div>
|
||||
<small>{{ t("editor.settings.language.editor_hint") }}</small>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Alerts</h2>
|
||||
<p>A card over the whole screen from an automation, on one screen or on all of them. The cheatsheet has the action names, fields, icons, and colors, and a form to try one.</p>
|
||||
<h2>{{ t("editor.nav.alerts") }}</h2>
|
||||
<p>{{ t("editor.settings.alerts.text") }}</p>
|
||||
<div class="tools">
|
||||
<button type="button" class="tool" @click="go('#alerts')"><span class="tool-icon">!</span><span class="tx"><b>Alerts</b><small>Full-screen notifications, from an automation</small></span></button>
|
||||
<button type="button" class="tool" @click="go('#alerts')"><span class="tool-icon">!</span><span class="tx"><b>{{ t("editor.nav.alerts") }}</b><small>{{ t("editor.settings.alerts.detail") }}</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card" id="claude">
|
||||
<h2>Claude</h2>
|
||||
<p>Teach Claude about ESP Screens, then ask: "Show an alert on all my screens when the mailbox is full." Claude knows the alert events, every field, color, and icon.</p>
|
||||
<p>{{ t("editor.settings.claude.text") }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" id="claude-install" class="btn" :class="skill?.installed && skill.current ? 'quiet' : 'primary'" :disabled="!skill || installing" @click="install">
|
||||
{{ !skill || !skill.installed ? "Install for Claude Code" : skill.current ? "Install again" : "Update the skill" }}
|
||||
{{ !skill || !skill.installed ? t("editor.settings.claude.install") : skill.current ? t("editor.settings.claude.install_again") : t("editor.settings.claude.update") }}
|
||||
</button>
|
||||
<span id="claude-status" class="chip" :class="!skill ? '' : !skill.installed ? '' : skill.current ? 'good' : 'update'" role="status">
|
||||
{{ !skill ? "Loading…" : !skill.installed ? "Not installed" : skill.current ? "● Installed" : "Installed · a newer version is ready" }}
|
||||
{{ !skill ? t("editor.common.loading") : !skill.installed ? t("editor.settings.claude.not_installed") : skill.current ? t("editor.settings.claude.installed_chip") : t("editor.settings.claude.newer") }}
|
||||
</span>
|
||||
</div>
|
||||
<small>For Claude Code in Home Assistant. Writes one file to <code id="claude-path">{{ skill?.path || "/homeassistant/.claude/skills/esp-screens" }}</code>; installing again replaces it.</small>
|
||||
<i18n-t keypath="editor.settings.claude.writes" tag="small" scope="global">
|
||||
<template #path><code id="claude-path">{{ skill?.path || "/homeassistant/.claude/skills/esp-screens" }}</code></template>
|
||||
</i18n-t>
|
||||
<div class="actions">
|
||||
<a class="btn quiet" id="claude-download" href="api/claude-skill.zip" download="esp-screens.zip">Download for claude.ai</a>
|
||||
<small>Upload the zip in Claude under Customize → Skills.</small>
|
||||
<a class="btn quiet" id="claude-download" href="api/claude-skill.zip" download="esp-screens.zip">{{ t("editor.settings.claude.download") }}</a>
|
||||
<small>{{ t("editor.settings.claude.upload") }}</small>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// ⌘K: screens, entities for the open screen, and the editor's actions, from one search field.
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { domainInfo } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { addTile, automaticIcon, canAlert, currentScreen, exportLayout, go, identify, save, select, state, tileLimit } from "../store";
|
||||
@@ -12,22 +13,23 @@ const input = ref<HTMLInputElement | null>(null);
|
||||
const items = computed<Item[]>(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
const list: Item[] = [];
|
||||
const screens = t("editor.palette.groups.screens"), actionsGroup = t("editor.palette.groups.actions");
|
||||
for (const screen of state.inventory.screens)
|
||||
list.push({ group: "Screens", label: screen.name, detail: `${screen.online ? "Online" : "Offline"} · ${screen.firmware || "unknown"}`, glyphText: "▦", run: () => select(screen.id) });
|
||||
list.push({ group: screens, label: screen.name, detail: `${screen.online ? t("editor.common.online") : t("editor.common.offline")} · ${screen.firmware || t("editor.common.unknown")}`, glyphText: "▦", run: () => select(screen.id) });
|
||||
const screen = currentScreen.value;
|
||||
const actions: Item[] = [
|
||||
{ group: "Actions", label: "New screen", detail: "Connect and install", glyphText: "+", run: () => go("#new-screen") },
|
||||
{ group: "Actions", label: "Firmware & USB", icon: "F0241", run: () => go("#firmware") },
|
||||
{ group: "Actions", label: "Alerts", detail: "Cheatsheet and a test alert", icon: "F0594", run: () => go("#alerts") },
|
||||
{ group: "Actions", label: "Settings", detail: "Updates, Claude", icon: "F0493", run: () => go("#settings") },
|
||||
{ group: actionsGroup, label: t("editor.nav.new_screen"), detail: t("editor.nav.new_screen_detail"), glyphText: "+", run: () => go("#new-screen") },
|
||||
{ group: actionsGroup, label: t("editor.nav.firmware"), icon: "F0241", run: () => go("#firmware") },
|
||||
{ group: actionsGroup, label: t("editor.nav.alerts"), detail: t("editor.palette.alerts_detail"), icon: "F0594", run: () => go("#alerts") },
|
||||
{ group: actionsGroup, label: t("editor.nav.settings"), detail: t("editor.palette.settings_detail"), icon: "F0493", run: () => go("#settings") },
|
||||
];
|
||||
if (screen && state.layout) {
|
||||
actions.unshift(
|
||||
{ group: "Actions", label: "Save & send", detail: state.dirty ? "Unsaved changes" : "Nothing to save", key: "⌘S", run: () => save() },
|
||||
{ group: "Actions", label: "Layout", detail: screen.name, run: () => { go(""); state.tab = "layout"; } },
|
||||
{ group: "Actions", label: "Screen settings", detail: screen.name, run: () => { go(""); state.tab = "settings"; } },
|
||||
{ group: "Actions", label: "Identify this screen", detail: canAlert(screen) ? "Blinks the backlight and shows a card" : "Needs a newer firmware", run: () => { if (canAlert(screen)) identify(screen); } },
|
||||
{ group: "Actions", label: "Export layout", detail: "Download and copy as JSON", run: exportLayout },
|
||||
{ group: actionsGroup, label: t("editor.common.save_send"), detail: state.dirty ? t("editor.common.unsaved") : t("editor.palette.nothing_to_save"), key: "⌘S", run: () => save() },
|
||||
{ group: actionsGroup, label: t("editor.screen_view.tabs.layout"), detail: screen.name, run: () => { go(""); state.tab = "layout"; } },
|
||||
{ group: actionsGroup, label: t("editor.screen_view.tabs.settings"), detail: screen.name, run: () => { go(""); state.tab = "settings"; } },
|
||||
{ group: actionsGroup, label: t("editor.palette.identify"), detail: canAlert(screen) ? t("editor.palette.identify_detail") : t("editor.palette.identify_needs"), run: () => { if (canAlert(screen)) identify(screen); } },
|
||||
{ group: actionsGroup, label: t("editor.palette.export"), detail: t("editor.palette.export_detail"), run: exportLayout },
|
||||
);
|
||||
}
|
||||
list.push(...actions);
|
||||
@@ -37,7 +39,7 @@ const items = computed<Item[]>(() => {
|
||||
for (const e of state.inventory.entities) {
|
||||
if (e.tile === false || chosen.has(e.id)) continue;
|
||||
if (!`${e.name} ${e.id} ${e.area || ""} ${e.device || ""}`.toLocaleLowerCase().includes(q)) continue;
|
||||
list.push({ group: "Add to this screen", label: e.name, detail: [domainInfo(e.id)[0], e.area].filter(Boolean).join(" · "), icon: state.inventory.icons ? automaticIcon(e.id) : undefined,
|
||||
list.push({ group: t("editor.palette.groups.add"), label: e.name, detail: [domainInfo(e.id)[0], e.area].filter(Boolean).join(" · "), icon: state.inventory.icons ? automaticIcon(e.id) : undefined,
|
||||
run: () => { if (!full) addTile(e.id); } });
|
||||
if (list.length > 60) break;
|
||||
}
|
||||
@@ -66,8 +68,8 @@ watch(() => state.palette, async (open) => { if (open) { query.value = ""; activ
|
||||
|
||||
<template>
|
||||
<div v-if="state.palette" class="palette-backdrop" @click="close">
|
||||
<div class="palette" role="dialog" aria-label="Search" @click.stop @keydown="onKey">
|
||||
<input ref="input" v-model="query" id="palette-input" placeholder="Search screens, entities, actions…" aria-label="Search" autocomplete="off" />
|
||||
<div class="palette" role="dialog" :aria-label="t('editor.sidebar.search')" @click.stop @keydown="onKey">
|
||||
<input ref="input" v-model="query" id="palette-input" :placeholder="t('editor.palette.placeholder')" :aria-label="t('editor.sidebar.search')" autocomplete="off" />
|
||||
<div class="palette-list">
|
||||
<template v-for="g in grouped" :key="g.group">
|
||||
<div class="palette-group">{{ g.group }}</div>
|
||||
@@ -78,7 +80,7 @@ watch(() => state.palette, async (open) => { if (open) { query.value = ""; activ
|
||||
<kbd v-if="item.key" class="hint-key">{{ item.key }}</kbd>
|
||||
</button>
|
||||
</template>
|
||||
<p v-if="!items.length" class="palette-empty">Nothing matches. Type part of a screen, entity or action.</p>
|
||||
<p v-if="!items.length" class="palette-empty">{{ t("editor.palette.empty") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// One page of the screen as the mockup draws it: the top bar and a 2 × 3 grid of cells.
|
||||
import { computed } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { cellsOf, pageOf, sizeOf, SLOTS_PER_PAGE, spanOf } from "../model/layout";
|
||||
import { isGuition, openBar, removePage, state } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
@@ -24,12 +25,12 @@ function pickCell(slot: number) {
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-label">
|
||||
<span>Page {{ page + 1 }}</span>
|
||||
<button v-if="empty" type="button" class="btn mini" title="The pages after this one shift up one slot" @click="removePage(page)">Remove page</button>
|
||||
<span>{{ t("editor.page.label", { page: page + 1 }) }}</span>
|
||||
<button v-if="empty" type="button" class="btn mini" :title="t('editor.page.remove_title')" @click="removePage(page)">{{ t("editor.page.remove") }}</button>
|
||||
<span v-else>{{ filled }} / {{ SLOTS_PER_PAGE }}</span>
|
||||
</div>
|
||||
<div class="device" :class="{ cyd: !isGuition }">
|
||||
<div class="bar-wrap" :class="{ selected: barSelected }" title="Edit top bar" role="button" tabindex="0"
|
||||
<div class="bar-wrap" :class="{ selected: barSelected }" :title="t('editor.page.edit_bar')" role="button" tabindex="0"
|
||||
@click="openBar(0)" @keydown.enter.prevent="openBar(0)">
|
||||
<TopbarSvg />
|
||||
</div>
|
||||
@@ -37,9 +38,9 @@ function pickCell(slot: number) {
|
||||
<template v-for="slot in cells" :key="slot">
|
||||
<TileCard v-if="bySlot.get(slot)" :tile="bySlot.get(slot)!.tile" :slot="slot" :placeholder="bySlot.get(slot)!.tile === moving" />
|
||||
<button v-else type="button" class="cell" :class="{ 'insert-here': state.insertAt === slot }" :data-slot="slot"
|
||||
title="Empty slot. Click to add a tile here, or drag one over."
|
||||
:aria-label="`Empty slot ${(slot % SLOTS_PER_PAGE) + 1} on page ${page + 1}: add the next tile here`" @click="pickCell(slot)">
|
||||
<span>+</span><small>{{ state.insertAt === slot ? "Next tile goes here" : "Empty" }}</small>
|
||||
:title="t('editor.page.cell.title')"
|
||||
:aria-label="t('editor.page.cell.aria', { slot: (slot % SLOTS_PER_PAGE) + 1, page: page + 1 })" @click="pickCell(slot)">
|
||||
<span>+</span><small>{{ state.insertAt === slot ? t("editor.page.cell.next") : t("editor.page.cell.empty") }}</small>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from "../i18n";
|
||||
import { go, state } from "../store";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="state.inventory.screens.length" id="choose" class="empty">
|
||||
<span class="empty-icon">▦</span>
|
||||
<h2>Choose a screen</h2>
|
||||
<p>Pick one of your screens on the left to change its tiles, top bar and settings.</p>
|
||||
<h2>{{ t("editor.empty.choose.title") }}</h2>
|
||||
<p>{{ t("editor.empty.choose.text") }}</p>
|
||||
</section>
|
||||
<section v-else id="empty" class="empty">
|
||||
<span class="empty-icon">▦</span>
|
||||
<h2>Start with a screen</h2>
|
||||
<p>Connect a CYD or Guition via USB to your Home Assistant machine. Profile, firmware, and the first flash happen in one go.</p>
|
||||
<button id="start" type="button" class="btn primary" @click="go('#new-screen')">Install new screen</button>
|
||||
<h2>{{ t("editor.empty.start.title") }}</h2>
|
||||
<p>{{ t("editor.empty.start.text") }}</p>
|
||||
<button id="start" type="button" class="btn primary" @click="go('#new-screen')">{{ t("editor.empty.start.button") }}</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// Shared firmware workspace; always a concrete profile and upload target.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { getJson, send } from "../api";
|
||||
import { t } from "../i18n";
|
||||
import { go, toast } from "../store";
|
||||
|
||||
const ESPHOME_WEB = "https://web.esphome.io/?dashboard_install";
|
||||
const DOWNLOAD_TARGET = "Download · flash from your own computer";
|
||||
const data = ref<any>(null);
|
||||
const file = ref("");
|
||||
const target = ref("ota");
|
||||
@@ -28,11 +28,11 @@ const running = computed(() => data.value?.job?.state === "running");
|
||||
const disabled = computed(() => !data.value || running.value || !data.value.available || !data.value.profiles?.length);
|
||||
const installDisabled = computed(() => disabled.value || target.value === "usb");
|
||||
const ports = computed<string[]>(() => data.value?.ports || []);
|
||||
const statusText = computed(() => !data.value ? "Loading…" : !data.value.available
|
||||
? "The ESPHome CLI is missing. Update the app to 0.2.0."
|
||||
const statusText = computed(() => !data.value ? t("editor.common.loading") : !data.value.available
|
||||
? t("editor.firmware.no_cli")
|
||||
: data.value.job
|
||||
? `${data.value.job.file} · ${data.value.job.action} · ${data.value.job.state}`
|
||||
: "Choose the intended profile, then a USB port, an IP address, or Download.");
|
||||
: t("editor.firmware.choose"));
|
||||
const downloadReady = computed(() => target.value === "download" && !!file.value && !!data.value?.downloads?.includes(file.value));
|
||||
const image = computed(() => ({ href: `api/firmware/profiles/${encodeURIComponent(file.value)}/download`, name: file.value.replace(/\.yaml$/, "") + ".factory.bin" }));
|
||||
async function run(action: "validate" | "build" | "install") {
|
||||
@@ -55,44 +55,52 @@ onBeforeUnmount(() => clearInterval(timer));
|
||||
<div class="panel" id="firmware-dialog">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Firmware & USB</span>
|
||||
<h1>Build and install screen firmware</h1>
|
||||
<p>The built-in ESPHome CLI uses your own profile (YAML with keys) and secrets. Choose the screen's profile, then a USB port (screen connected to the Home Assistant machine), <b>Wi-Fi / OTA</b> with the hostname, or <b>Download</b> to put the firmware on the screen from your own computer. <b>Check</b> only validates, <b>Build only</b> compiles, <b>Build & install</b> puts the firmware on the screen. Your tiles stay as they are in ESP Screens. The first build takes a few minutes.</p>
|
||||
<span class="eyebrow">{{ t("editor.nav.firmware") }}</span>
|
||||
<h1>{{ t("editor.firmware.title") }}</h1>
|
||||
<i18n-t keypath="editor.firmware.intro" tag="p" scope="global">
|
||||
<template #ota><b>{{ t("editor.firmware.ota") }}</b></template>
|
||||
<template #download><b>{{ t("editor.firmware.download") }}</b></template>
|
||||
<template #check><b>{{ t("editor.firmware.check") }}</b></template>
|
||||
<template #build><b>{{ t("editor.firmware.build") }}</b></template>
|
||||
<template #install><b>{{ t("editor.firmware.install") }}</b></template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-firmware" @click="go('')">← Back</button>
|
||||
<button type="button" class="btn quiet" id="close-firmware" @click="go('')">{{ t("editor.common.back") }}</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-grid">
|
||||
<div class="field">
|
||||
<label class="f-label" for="firmware-file">ESPHome profile</label>
|
||||
<label class="f-label" for="firmware-file">{{ t("editor.firmware.profile") }}</label>
|
||||
<select id="firmware-file" v-model="file">
|
||||
<option v-for="p in data?.profiles || []" :key="p.file" :value="p.file">{{ p.file }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="f-label" for="firmware-port">Install to</label>
|
||||
<label class="f-label" for="firmware-port">{{ t("editor.firmware.install_to") }}</label>
|
||||
<select id="firmware-port" v-model="target">
|
||||
<option value="ota">Wi-Fi / OTA</option>
|
||||
<option v-if="!ports.length" value="usb">USB · no board found on the Home Assistant machine yet</option>
|
||||
<option value="ota">{{ t("editor.firmware.ota") }}</option>
|
||||
<option v-if="!ports.length" value="usb">{{ t("editor.firmware.no_board") }}</option>
|
||||
<option v-for="p in ports" :key="p" :value="p">{{ p }}</option>
|
||||
<option value="download">{{ DOWNLOAD_TARGET }}</option>
|
||||
<option value="download">{{ t("editor.firmware.download_target") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="target === 'ota'" class="field" id="firmware-host-label">
|
||||
<label class="f-label" for="firmware-host">IP address or hostname</label>
|
||||
<label class="f-label" for="firmware-host">{{ t("editor.firmware.host") }}</label>
|
||||
<input id="firmware-host" v-model="host" :placeholder="file ? file.replace(/\.yaml$/, '.local') : 'screen-livingroom.local'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn quiet" id="firmware-validate" :disabled="disabled" @click="run('validate')">Check</button>
|
||||
<button type="button" class="btn quiet" id="firmware-build" :disabled="disabled" @click="run('build')">Build only</button>
|
||||
<button type="button" class="btn primary" id="firmware-install" :disabled="installDisabled" @click="run('install')">{{ target === "download" ? "Build & download" : "Build & install" }}</button>
|
||||
<button type="button" class="btn quiet" id="firmware-validate" :disabled="disabled" @click="run('validate')">{{ t("editor.firmware.check") }}</button>
|
||||
<button type="button" class="btn quiet" id="firmware-build" :disabled="disabled" @click="run('build')">{{ t("editor.firmware.build") }}</button>
|
||||
<button type="button" class="btn primary" id="firmware-install" :disabled="installDisabled" @click="run('install')">{{ target === "download" ? t("editor.firmware.build_download") : t("editor.firmware.install") }}</button>
|
||||
<span v-if="running" class="spin"></span>
|
||||
</div>
|
||||
<p id="firmware-status" class="status-line" role="status">{{ statusText }}</p>
|
||||
<div v-if="downloadReady" id="firmware-download" class="card" style="background: var(--surface-2)">
|
||||
<a class="btn primary" id="firmware-download-link" :href="image.href" :download="image.name">Download {{ image.name }}</a>
|
||||
<small>Put it on the screen with <a :href="ESPHOME_WEB" target="_blank" rel="noopener">ESPHome Web</a> in Chrome or Edge: Connect, choose the screen's USB port, Install, and select this file. It holds your Wi-Fi password and the screen's keys: keep it to yourself.</small>
|
||||
<a class="btn primary" id="firmware-download-link" :href="image.href" :download="image.name">{{ t("editor.firmware.download_file", { name: image.name }) }}</a>
|
||||
<i18n-t keypath="editor.firmware.download_how" tag="small" scope="global">
|
||||
<template #esphome_web><a :href="ESPHOME_WEB" target="_blank" rel="noopener">ESPHome Web</a></template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="firmware-log" class="log">{{ (data?.logs || []).join("\n") }}</pre>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// The icon choice for tiles and top bar items: automatic, optionally none, or one from the set.
|
||||
// A choice applies live; the open picker keeps its search and scroll position.
|
||||
import { computed, ref } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { iconNamed, state } from "../store";
|
||||
|
||||
@@ -10,7 +11,7 @@ const emit = defineEmits<{ (e: "pick", name: string): void }>();
|
||||
const query = ref("");
|
||||
const chosen = computed(() => iconNamed(props.selected));
|
||||
const currentGlyph = computed(() => (props.selected === "none" ? "" : glyph(chosen.value?.cp || props.automatic)));
|
||||
const currentText = computed(() => (props.selected === "none" ? "No icon" : chosen.value?.label || props.autoLabel));
|
||||
const currentText = computed(() => (props.selected === "none" ? t("editor.icon.none") : chosen.value?.label || props.autoLabel));
|
||||
const groups = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
return (state.inventory.icons?.groups || []).map((group) => ({
|
||||
@@ -22,29 +23,29 @@ const groups = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="f">
|
||||
<span class="f-label">Icon</span>
|
||||
<span class="f-label">{{ t("editor.icon.label") }}</span>
|
||||
<button type="button" class="row" :aria-expanded="state.iconPickerOpen ? 'true' : 'false'" @click="state.iconPickerOpen = !state.iconPickerOpen">
|
||||
<span class="av mdi">{{ currentGlyph }}</span>
|
||||
<span class="tx"><b>{{ currentText }}</b></span>
|
||||
<span class="link">{{ state.iconPickerOpen ? "Close" : "Change" }}</span>
|
||||
<span class="link">{{ state.iconPickerOpen ? t("editor.common.close") : t("editor.common.change") }}</span>
|
||||
</button>
|
||||
<div v-if="state.iconPickerOpen" class="picker">
|
||||
<input v-model="query" type="search" placeholder="Search, for example lamp, music, or door" aria-label="Search for an icon" />
|
||||
<input v-model="query" type="search" :placeholder="t('editor.icon.search')" :aria-label="t('editor.icon.search_label')" />
|
||||
<button type="button" class="icon-choice icon-auto" :aria-pressed="selected === 'auto' ? 'true' : 'false'" :title="autoLabel" @click="emit('pick', 'auto')">
|
||||
<span class="mdi">{{ glyph(automatic) }}</span><span>{{ autoLabel }}</span>
|
||||
</button>
|
||||
<button v-if="allowNone" type="button" class="icon-choice icon-auto" :aria-pressed="selected === 'none' ? 'true' : 'false'" title="No icon" @click="emit('pick', 'none')">
|
||||
<span class="mdi"></span><span>No icon, text only</span>
|
||||
<button v-if="allowNone" type="button" class="icon-choice icon-auto" :aria-pressed="selected === 'none' ? 'true' : 'false'" :title="t('editor.icon.none')" @click="emit('pick', 'none')">
|
||||
<span class="mdi"></span><span>{{ t("editor.icon.none_text_only") }}</span>
|
||||
</button>
|
||||
<div class="icon-list">
|
||||
<section v-for="group in groups" :key="group.label">
|
||||
<small>{{ group.label }}</small>
|
||||
<div class="icon-grid">
|
||||
<button v-for="icon in group.icons" :key="icon.name" type="button" class="icon-choice" :title="icon.label" :aria-label="`Icon: ${icon.label}`"
|
||||
<button v-for="icon in group.icons" :key="icon.name" type="button" class="icon-choice" :title="icon.label" :aria-label="t('editor.icon.aria', { name: icon.label })"
|
||||
:aria-pressed="selected === icon.name ? 'true' : 'false'" @click="emit('pick', icon.name)"><span class="mdi">{{ glyph(icon.cp) }}</span></button>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="!groups.length" class="hint">No icon found.</p>
|
||||
<p v-if="!groups.length" class="hint">{{ t("editor.icon.none_found") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="note">{{ note }}</small>
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
// Read current data: what Home Assistant reports for the screen's tiles right now, and how each tile is set.
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { getJson } from "../api";
|
||||
import { controlsLabel, displayNames, sizeNames } from "../model/layout";
|
||||
import { t } from "../i18n";
|
||||
import { controlsLabel, displayName, sizeName } from "../model/layout";
|
||||
import { closeInspector, state, toast } from "../store";
|
||||
|
||||
const props = defineProps<{ entity?: string }>();
|
||||
const summary = ref<any[] | null>(null);
|
||||
const raw = ref("Reading…");
|
||||
const raw = ref(t("editor.inspect.reading"));
|
||||
const error = ref("");
|
||||
async function load() {
|
||||
if (!state.selected) return;
|
||||
summary.value = null;
|
||||
error.value = "";
|
||||
raw.value = "Fetching current HA status...";
|
||||
raw.value = t("editor.inspect.fetching");
|
||||
try {
|
||||
const data = await getJson(`screens/${encodeURIComponent(state.selected)}/inspect`);
|
||||
const tiles = props.entity ? data.tiles.filter((t: any) => t.entity === props.entity) : data.tiles;
|
||||
@@ -27,7 +28,13 @@ async function load() {
|
||||
}
|
||||
const optionsText = (entity: string) => {
|
||||
const options = state.layout?.tiles.find((t) => t.entity === entity)?.options || {};
|
||||
return `Small slider: ${options.inline === "slider" ? "yes" : "no"} · Display: ${displayNames[options.display || "standard"] || options.display} · Size: ${sizeNames[options.size as string] || "normal"} · Control: ${controlsLabel({ entity, name: "", slot: 0, options }, state.inventory)} · Background: ${state.inventory.backgrounds?.[options.background || "auto"]?.label || "Default"}`;
|
||||
return t("editor.inspect.options", {
|
||||
slider: t(options.inline === "slider" ? "editor.inspect.yes" : "editor.inspect.no"),
|
||||
display: displayName(options.display || "standard"),
|
||||
size: sizeName(options.size as string),
|
||||
control: controlsLabel({ entity, name: "", slot: 0, options }, state.inventory),
|
||||
background: state.inventory.backgrounds?.[options.background || "auto"]?.label || t("editor.inspect.background_default"),
|
||||
});
|
||||
};
|
||||
onMounted(load);
|
||||
watch(() => props.entity, load);
|
||||
@@ -36,23 +43,23 @@ watch(() => props.entity, load);
|
||||
<template>
|
||||
<div class="dr-head">
|
||||
<span class="av" style="background: var(--seg); color: var(--ink-2); font-size: 16px">⌕</span>
|
||||
<span class="tx"><b>Current data</b><small :class="{ mono: entity }">{{ entity || "Connection, status, and supported properties" }}</small></span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
<span class="tx"><b>{{ t("editor.inspect.title") }}</b><small :class="{ mono: entity }">{{ entity || t("editor.inspect.subtitle") }}</small></span>
|
||||
<button type="button" class="icon-btn" :aria-label="t('editor.common.close')" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body" id="inspector-section">
|
||||
<div id="inspection-summary">
|
||||
<p v-if="error" class="hint warn">{{ error }}</p>
|
||||
<p v-else-if="!summary" class="hint">Fetching current HA status...</p>
|
||||
<p v-else-if="!summary" class="hint">{{ t("editor.inspect.fetching") }}</p>
|
||||
<article v-for="(tile, i) in summary || []" :key="i" class="inspection-tile">
|
||||
<strong>{{ tile.entity }}</strong>
|
||||
<span>Status: {{ tile.word || tile.state }}</span>
|
||||
<span>{{ t("editor.inspect.status", { status: tile.word || tile.state }) }}</span>
|
||||
<small>{{ optionsText(tile.entity) }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<pre id="inspection">{{ raw }}</pre>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<button type="button" class="btn quiet" @click="load">Read again</button>
|
||||
<button type="button" class="btn quiet" @click="load">{{ t("editor.inspect.read_again") }}</button>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
// New screen: profile, Wi-Fi, and the first flash in one go.
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { getJson, send } from "../api";
|
||||
import { t } from "../i18n";
|
||||
import { copyText, go, openIntegrations, toast } from "../store";
|
||||
|
||||
// Download: ESP Screens builds, the owner flashes the file from their own computer. ESPHome Web is ESPHome's own
|
||||
// browser flasher; this address opens it with its hint for a downloaded project (as ESPHome Device Builder does).
|
||||
const ESPHOME_WEB = "https://web.esphome.io/?dashboard_install";
|
||||
const DOWNLOAD_TARGET = "Download · flash from your own computer";
|
||||
const form = reactive({ board: "cyd", friendly_name: "", name: "", wifi_ssid: "", wifi_password: "", target: "" });
|
||||
const installer = reactive({
|
||||
view: "setup" as "setup" | "progress" | "done", file: null as string | null, friendly: "", board: "cyd", target: "",
|
||||
@@ -40,24 +40,22 @@ const ports = computed<string[]>(() => data.value?.ports || []);
|
||||
const wifi = computed(() => data.value?.wifi);
|
||||
const askWifi = computed(() => wifi.value?.state === "new" || wifi.value?.state === "missing");
|
||||
const wifiMissing = computed<string[]>(() => wifi.value?.missing || []);
|
||||
const wifiStatus = computed(() => wifi.value?.state === "new"
|
||||
? "Fill in once: ESP Screens saves this in ESPHome secrets.yaml, later screens use it automatically."
|
||||
: "Your ESPHome secrets.yaml is still missing Wi-Fi details. ESP Screens only fills in the missing lines.");
|
||||
const wifiStatus = computed(() => t(wifi.value?.state === "new" ? "editor.installer.wifi.new" : "editor.installer.wifi.missing"));
|
||||
const wifiNote = computed(() => wifi.value?.state === "ready"
|
||||
? "Wi-Fi comes from your ESPHome secrets.yaml."
|
||||
? t("editor.installer.wifi.ready")
|
||||
: wifi.value?.state === "invalid"
|
||||
? "secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten."
|
||||
? t("editor.installer.wifi.invalid")
|
||||
: "");
|
||||
const targetHint = computed(() => form.target === "usb"
|
||||
? "Connect the screen with a USB data cable to the Home Assistant machine; the list refreshes on its own. Or choose Download to use your own computer."
|
||||
const targetHint = computed(() => t(form.target === "usb"
|
||||
? "editor.installer.target.usb"
|
||||
: form.target === "download"
|
||||
? "Download the firmware and put it on the screen from your own computer, in Chrome or Edge. After that, updates go over Wi-Fi."
|
||||
? "editor.installer.target.download"
|
||||
: !form.target
|
||||
? "The profile goes into the ESPHome folder. You can install later via Firmware & USB, or from ESPHome Device Builder."
|
||||
? "editor.installer.target.later"
|
||||
: ports.value.length > 1
|
||||
? "More than one board connected: choose this screen's port."
|
||||
: "Once over USB; after that, everything is wireless.");
|
||||
const goLabel = computed(() => (form.target === "download" ? "Build & download" : form.target ? "Install" : "Save profile"));
|
||||
? "editor.installer.target.several"
|
||||
: "editor.installer.target.once"));
|
||||
const goLabel = computed(() => (form.target === "download" ? t("editor.firmware.build_download") : form.target ? t("editor.installer.install") : t("editor.installer.save_profile")));
|
||||
const busyElsewhere = computed(() => data.value?.job?.state === "running" && !(installer.file && data.value.job.file === installer.file));
|
||||
const goDisabled = computed(() => submitting.value || wifi.value?.state === "invalid" || form.target === "usb" ||
|
||||
(!!form.target && (busyElsewhere.value || !data.value?.available)));
|
||||
@@ -84,9 +82,9 @@ async function installerRefresh() {
|
||||
if (ours && current.state === "running") { showProgress(current, next.logs || []); return; }
|
||||
syncTarget();
|
||||
note.value = busyElsewhere.value
|
||||
? `A build or installation is already running (${current.file}). Wait for it to finish.`
|
||||
? t("editor.installer.busy", { file: current.file })
|
||||
: !next.available && form.target
|
||||
? "The ESPHome CLI is missing from this installation; only saving the profile is possible."
|
||||
? t("editor.installer.no_cli")
|
||||
: wifiNote.value;
|
||||
} else if (installer.view === "progress" && ours) {
|
||||
job.value = current;
|
||||
@@ -104,32 +102,28 @@ function showProgress(current: any, lines: string[]) {
|
||||
const running = computed(() => job.value?.state === "running");
|
||||
const ok = computed(() => installer.view === "done" || job.value?.state === "success");
|
||||
const download = computed(() => installer.action === "download");
|
||||
const title = computed(() => installer.view === "done" ? "Profile saved." : running.value ? "One moment…" : ok.value ? (download.value ? "Ready to download." : "Done.") : "That didn't work.");
|
||||
const title = computed(() => t(installer.view === "done"
|
||||
? "editor.installer.title.saved"
|
||||
: running.value ? "editor.installer.title.running" : ok.value ? (download.value ? "editor.installer.title.ready" : "editor.installer.title.done") : "editor.installer.title.failed"));
|
||||
const progressTitle = computed(() => installer.view === "done"
|
||||
? `${installer.file} is in the ESPHome folder`
|
||||
? t("editor.installer.progress.saved", { file: installer.file })
|
||||
: running.value
|
||||
? job.value?.stage === "upload" ? `Writing firmware to ${installer.friendly}…` : "Building firmware…"
|
||||
? job.value?.stage === "upload" ? t("editor.installer.progress.writing", { name: installer.friendly }) : t("editor.installer.progress.building")
|
||||
: ok.value
|
||||
? download.value ? `Firmware for ${installer.friendly} is ready` : `Firmware is on ${installer.friendly}`
|
||||
: download.value ? "Build failed" : "Install failed");
|
||||
? download.value ? t("editor.installer.progress.ready", { name: installer.friendly }) : t("editor.installer.progress.installed", { name: installer.friendly })
|
||||
: t(download.value ? "editor.installer.progress.build_failed" : "editor.installer.progress.install_failed"));
|
||||
const progressDetail = computed(() => installer.view === "done"
|
||||
? "Install it later from Firmware & USB: choose this profile, then the USB port of the Home Assistant machine, or Download to put the firmware on the screen from your own computer. Save the API key for pairing:"
|
||||
? t("editor.installer.detail.saved")
|
||||
: running.value
|
||||
? job.value?.stage === "upload"
|
||||
? "Don't disconnect the USB cable yet."
|
||||
: `A first build takes a few minutes on a Raspberry Pi. You can leave this page: the ${download.value ? "build" : "installation"} keeps running and you'll find it again under New screen.`
|
||||
? t("editor.installer.detail.uploading")
|
||||
: t(download.value ? "editor.installer.detail.building_download" : "editor.installer.detail.building")
|
||||
: ok.value
|
||||
? download.value
|
||||
? "Put it on the screen from your own computer, then pair the screen with Home Assistant."
|
||||
: `The screen boots up and connects to your Wi-Fi.${installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : ""} Pair it with Home Assistant now:`
|
||||
: logs.value.filter((l) => /error/i.test(l)).pop() || logs.value.filter((l) => /failed/i.test(l)).pop() || "See the log below.");
|
||||
? t("editor.installer.detail.downloaded")
|
||||
: t(installer.board === "cyd" ? "editor.installer.detail.booted_cyd" : "editor.installer.detail.booted")
|
||||
: logs.value.filter((l) => /error/i.test(l)).pop() || logs.value.filter((l) => /failed/i.test(l)).pop() || t("editor.installer.detail.see_log"));
|
||||
const image = computed(() => ({ href: `api/firmware/profiles/${encodeURIComponent(installer.file || "")}/download`, name: (installer.file || "").replace(/\.yaml$/, "") + ".factory.bin" }));
|
||||
const pairingSteps = computed(() => [
|
||||
["Go to Home Assistant → Settings → Devices & services.", ` This happens outside ESP Screens. Home Assistant discovers ${installer.friendly} as an ESPHome device; click Add. Not discovered? Add ESPHome manually with the screen's IP address. `],
|
||||
["Paste the API key", " above when Home Assistant asks for an encryption key."],
|
||||
["Allow HA actions:", " ESPHome integration → Configure → “Allow the device to perform Home Assistant actions”. Without this, the screen sees everything but controls nothing."],
|
||||
["Choose your tiles.", " Back in ESP Screens, the screen appears in the list on the left within about thirty seconds; until then it's shown there as “not yet in Home Assistant”."],
|
||||
]);
|
||||
async function submit(event: Event) {
|
||||
const element = event.target as HTMLFormElement;
|
||||
if (!installer.nodeEdited) form.name = slug(form.friendly_name);
|
||||
@@ -183,44 +177,44 @@ onBeforeUnmount(() => clearInterval(poll));
|
||||
<div class="panel" id="installer">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">New screen</span>
|
||||
<h1 id="install-title">{{ installer.view === "setup" ? "Connect and install." : title }}</h1>
|
||||
<p v-if="installer.view === 'setup'">ESP Screens creates the screen's own profile with unique keys and builds the firmware. Connect the screen with a USB data cable to the machine running Home Assistant, or download the firmware and put it on the screen from your own computer.</p>
|
||||
<span class="eyebrow">{{ t("editor.nav.new_screen") }}</span>
|
||||
<h1 id="install-title">{{ installer.view === "setup" ? t("editor.installer.title.setup") : title }}</h1>
|
||||
<p v-if="installer.view === 'setup'">{{ t("editor.installer.intro") }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-install" aria-label="Close" @click="close">← Back</button>
|
||||
<button type="button" class="btn quiet" id="close-install" :aria-label="t('editor.common.close')" @click="close">{{ t("editor.common.back") }}</button>
|
||||
</div>
|
||||
<form v-if="installer.view === 'setup'" id="install-form" class="card" @submit.prevent="submit">
|
||||
<fieldset>
|
||||
<legend>Which screen do you have?</legend>
|
||||
<legend>{{ t("editor.installer.board") }}</legend>
|
||||
<div class="boards">
|
||||
<label class="board"><input type="radio" name="board" value="cyd" v-model="form.board" /><span><b>CYD · 2.8 inch</b><small>ESP32-2432S028 · 320 × 240</small></span></label>
|
||||
<label class="board"><input type="radio" name="board" value="guition" v-model="form.board" /><span><b>Guition · 4 inch</b><small>ESP32-S3-4848S040 · 480 × 480 · GT911</small></span></label>
|
||||
<label class="board"><input type="radio" name="board" value="cyd" v-model="form.board" /><span><b>{{ t("editor.installer.board_cyd") }}</b><small>ESP32-2432S028 · 320 × 240</small></span></label>
|
||||
<label class="board"><input type="radio" name="board" value="guition" v-model="form.board" /><span><b>{{ t("editor.installer.board_guition") }}</b><small>ESP32-S3-4848S040 · 480 × 480 · GT911</small></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="field">
|
||||
<label class="f-label" for="friendly_name">Name</label>
|
||||
<input id="friendly_name" name="friendly_name" v-model="form.friendly_name" required maxlength="60" placeholder="Living room screen" autocomplete="off" />
|
||||
<small class="node-line">Device name <code id="node-preview">{{ nodePreview }}</code><button type="button" class="btn link mini" id="edit-node" @click="installer.nodeEdited = true; nodeVisible = true">customize</button></small>
|
||||
<small>Every screen gets its own profile with its own keys. Four screens? Go through this four times with a different name.</small>
|
||||
<label class="f-label" for="friendly_name">{{ t("editor.installer.name") }}</label>
|
||||
<input id="friendly_name" name="friendly_name" v-model="form.friendly_name" required maxlength="60" :placeholder="t('editor.installer.name_placeholder')" autocomplete="off" />
|
||||
<small class="node-line">{{ t("editor.installer.device_name") }} <code id="node-preview">{{ nodePreview }}</code><button type="button" class="btn link mini" id="edit-node" @click="installer.nodeEdited = true; nodeVisible = true">{{ t("editor.installer.customize") }}</button></small>
|
||||
<small>{{ t("editor.installer.name_hint") }}</small>
|
||||
</div>
|
||||
<div v-if="nodeVisible" class="field" id="node-label">
|
||||
<label class="f-label" for="node-name">Device name</label>
|
||||
<label class="f-label" for="node-name">{{ t("editor.installer.device_name") }}</label>
|
||||
<input id="node-name" name="name" v-model="form.name" pattern="[a-z][a-z0-9\-]{0,29}" maxlength="30" autocomplete="off" @input="installer.nodeEdited = true" />
|
||||
<small>Lowercase letters, digits, and dashes. A different name for each screen.</small>
|
||||
<small>{{ t("editor.installer.device_name_hint") }}</small>
|
||||
</div>
|
||||
<fieldset v-if="askWifi" id="wifi-fields" class="wifi">
|
||||
<legend>Wi-Fi</legend>
|
||||
<legend>{{ t("editor.installer.wifi.title") }}</legend>
|
||||
<p class="hint" id="wifi-status">{{ wifiStatus }}</p>
|
||||
<div v-if="wifiMissing.includes('wifi_ssid')" class="field" id="wifi-ssid-label"><label class="f-label" for="wifi_ssid">Wi-Fi name (2.4 GHz)</label><input id="wifi_ssid" name="wifi_ssid" v-model="form.wifi_ssid" autocomplete="off" /></div>
|
||||
<div v-if="wifiMissing.includes('wifi_password')" class="field" id="wifi-password-label"><label class="f-label" for="wifi_password">Wi-Fi password</label><input id="wifi_password" name="wifi_password" type="password" v-model="form.wifi_password" autocomplete="new-password" /></div>
|
||||
<div v-if="wifiMissing.includes('wifi_ssid')" class="field" id="wifi-ssid-label"><label class="f-label" for="wifi_ssid">{{ t("editor.installer.wifi.ssid") }}</label><input id="wifi_ssid" name="wifi_ssid" v-model="form.wifi_ssid" autocomplete="off" /></div>
|
||||
<div v-if="wifiMissing.includes('wifi_password')" class="field" id="wifi-password-label"><label class="f-label" for="wifi_password">{{ t("editor.installer.wifi.password") }}</label><input id="wifi_password" name="wifi_password" type="password" v-model="form.wifi_password" autocomplete="new-password" /></div>
|
||||
</fieldset>
|
||||
<div class="field">
|
||||
<label class="f-label" for="install-target">Install via</label>
|
||||
<label class="f-label" for="install-target">{{ t("editor.installer.install_via") }}</label>
|
||||
<select id="install-target" name="target" v-model="form.target" @change="installer.picked = true; installerRefresh()">
|
||||
<option v-if="!ports.length" value="usb">USB · no board found on the Home Assistant machine yet</option>
|
||||
<option v-if="!ports.length" value="usb">{{ t("editor.firmware.no_board") }}</option>
|
||||
<option v-for="p in ports" :key="p" :value="p">{{ portLabel(p) }}</option>
|
||||
<option value="download">{{ DOWNLOAD_TARGET }}</option>
|
||||
<option value="">Later · save profile only</option>
|
||||
<option value="download">{{ t("editor.firmware.download_target") }}</option>
|
||||
<option value="">{{ t("editor.installer.later") }}</option>
|
||||
</select>
|
||||
<small id="target-hint">{{ targetHint }}</small>
|
||||
</div>
|
||||
@@ -238,31 +232,39 @@ onBeforeUnmount(() => clearInterval(poll));
|
||||
</div>
|
||||
<p id="progress-detail">{{ progressDetail }}</p>
|
||||
<div v-if="ok && download && installer.view !== 'done'" id="install-download" class="card" style="background: var(--surface-2)">
|
||||
<a class="btn primary" id="download-firmware" :href="image.href" :download="image.name">Download {{ image.name }}</a>
|
||||
<a class="btn primary" id="download-firmware" :href="image.href" :download="image.name">{{ t("editor.firmware.download_file", { name: image.name }) }}</a>
|
||||
<ol class="steps" id="download-steps">
|
||||
<li><b>Plug the screen into your computer</b> with a USB data cable.</li>
|
||||
<li><b>Open <a :href="ESPHOME_WEB" target="_blank" rel="noopener">ESPHome Web</a></b> in Chrome or Edge, click Connect and choose the screen's USB port.</li>
|
||||
<li><b>Click Install</b> and select {{ image.name }}. The screen restarts and joins your Wi-Fi.{{ installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : "" }}</li>
|
||||
<li><i18n-t keypath="editor.installer.download_steps.plug" scope="global"><template #bold><b>{{ t("editor.installer.download_steps.plug_bold") }}</b></template></i18n-t></li>
|
||||
<li><i18n-t keypath="editor.installer.download_steps.open" scope="global">
|
||||
<template #bold><b><i18n-t keypath="editor.installer.download_steps.open_bold" scope="global"><template #esphome_web><a :href="ESPHOME_WEB" target="_blank" rel="noopener">ESPHome Web</a></template></i18n-t></b></template>
|
||||
</i18n-t></li>
|
||||
<li><i18n-t :keypath="installer.board === 'cyd' ? 'editor.installer.download_steps.install_cyd' : 'editor.installer.download_steps.install'" scope="global">
|
||||
<template #bold><b>{{ t("editor.installer.download_steps.install_bold") }}</b></template>
|
||||
<template #file>{{ image.name }}</template>
|
||||
</i18n-t></li>
|
||||
</ol>
|
||||
<small>The file holds your Wi-Fi password and the screen's keys: keep it to yourself.</small>
|
||||
<small>{{ t("editor.installer.download_keep") }}</small>
|
||||
</div>
|
||||
<div v-if="ok" id="install-result" class="field">
|
||||
<div class="key-box">
|
||||
<span>API key</span><code id="api-key" ref="keyBox">{{ installer.apiKey || "" }}</code>
|
||||
<button type="button" class="btn quiet mini" id="copy-key" @click="copyText(installer.apiKey || '', keyBox)">Copy</button>
|
||||
<span>{{ t("editor.installer.api_key") }}</span><code id="api-key" ref="keyBox">{{ installer.apiKey || "" }}</code>
|
||||
<button type="button" class="btn quiet mini" id="copy-key" @click="copyText(installer.apiKey || '', keyBox)">{{ t("editor.common.copy") }}</button>
|
||||
</div>
|
||||
<ol class="steps" id="install-steps">
|
||||
<li v-for="([b, t], i) in pairingSteps" :key="i"><b>{{ b }}</b>{{ t }}<button v-if="i === 0" type="button" class="btn quiet mini" @click="openIntegrations">Open Devices & services</button></li>
|
||||
<li><i18n-t keypath="editor.installer.pairing.ha" scope="global"><template #bold><b>{{ t("editor.installer.pairing.ha_bold") }}</b></template><template #name>{{ installer.friendly }}</template></i18n-t> <button type="button" class="btn quiet mini" @click="openIntegrations">{{ t("editor.common.open_integrations") }}</button></li>
|
||||
<li><i18n-t keypath="editor.installer.pairing.key" scope="global"><template #bold><b>{{ t("editor.installer.pairing.key_bold") }}</b></template></i18n-t></li>
|
||||
<li><i18n-t keypath="editor.installer.pairing.actions" scope="global"><template #bold><b>{{ t("editor.installer.pairing.actions_bold") }}</b></template></i18n-t></li>
|
||||
<li><i18n-t keypath="editor.installer.pairing.tiles" scope="global"><template #bold><b>{{ t("editor.installer.pairing.tiles_bold") }}</b></template></i18n-t></li>
|
||||
</ol>
|
||||
</div>
|
||||
<details v-if="installer.view !== 'done'" id="install-log-wrap" class="log-wrap" :open="logOpen" @toggle="logOpen = ($event.target as HTMLDetailsElement).open">
|
||||
<summary>ESPHome log</summary>
|
||||
<summary>{{ t("editor.installer.log") }}</summary>
|
||||
<pre id="install-log" class="log">{{ logs.join("\n") }}</pre>
|
||||
</details>
|
||||
<div class="actions">
|
||||
<button v-if="!running && !ok" type="button" class="btn primary" id="install-retry" @click="retry">Retry</button>
|
||||
<button type="button" class="btn quiet" id="install-close" @click="reset">{{ ok ? "Install another screen" : "Start over" }}</button>
|
||||
<button type="button" class="btn" :class="ok ? 'primary' : 'quiet'" @click="close">{{ ok ? "Done" : "Close" }}</button>
|
||||
<button v-if="!running && !ok" type="button" class="btn primary" id="install-retry" @click="retry">{{ t("editor.installer.retry") }}</button>
|
||||
<button type="button" class="btn quiet" id="install-close" @click="reset">{{ ok ? t("editor.installer.another") : t("editor.installer.start_over") }}</button>
|
||||
<button type="button" class="btn" :class="ok ? 'primary' : 'quiet'" @click="close">{{ ok ? t("editor.installer.done") : t("editor.common.close") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// The pages side by side, like swiping on the screen, and the library on the right.
|
||||
import { computed } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { entriesOf, hasGaps, MAX_PAGES, pageCount } from "../model/layout";
|
||||
import { addPage, closeInspector, currentScreen, pageReachWarning, pagesShown, state, supports, tileLimit } from "../store";
|
||||
import DevicePage from "./DevicePage.vue";
|
||||
@@ -12,7 +13,7 @@ const pages = computed(() => pageCount(entries.value, layout.value.pages));
|
||||
const shown = computed(() => pagesShown());
|
||||
const canAdd = computed(() => pages.value < MAX_PAGES);
|
||||
const positionsHint = computed(() => hasGaps(layout.value.tiles) && !supports(0, 2, 26)
|
||||
? `Empty slots and fixed positions work from firmware 0.2.26. This screen (firmware ${currentScreen.value?.firmware || "unknown"}) shifts the tiles up to the first free slot until that update.`
|
||||
? t("editor.layout.positions_hint", { firmware: currentScreen.value?.firmware || t("editor.common.unknown") })
|
||||
: "");
|
||||
// Page buttons and swiping off: a page no Go to page tile reaches, or one without a way back.
|
||||
const reachHint = computed(() => pageReachWarning(entries.value, pages.value));
|
||||
@@ -26,18 +27,18 @@ function onCanvasClick(e: MouseEvent) {
|
||||
<template>
|
||||
<div class="canvas" id="canvas" @click="onCanvasClick">
|
||||
<div class="canvas-head">
|
||||
<b id="count">{{ pages }} {{ pages === 1 ? "page" : "pages" }} · {{ layout.tiles.length }} / {{ tileLimit }} tiles</b>
|
||||
<span v-if="!layout.tiles.length" id="no-tiles">Add your first light, scene, or device from the library.</span>
|
||||
<span v-else>Tap the top bar or a tile to change it. Drag to move; drop on the next page for a new one.</span>
|
||||
<b id="count">{{ t("editor.layout.count", { tiles: layout.tiles.length, limit: tileLimit }, pages) }}</b>
|
||||
<span v-if="!layout.tiles.length" id="no-tiles">{{ t("editor.layout.no_tiles") }}</span>
|
||||
<span v-else>{{ t("editor.layout.how_to") }}</span>
|
||||
<span v-if="positionsHint" id="positions-hint" class="warn">{{ positionsHint }}</span>
|
||||
<span v-if="reachHint" id="page-reach-hint" class="warn">{{ reachHint }}</span>
|
||||
</div>
|
||||
<div class="pages" id="layout-preview" aria-label="Screen layout">
|
||||
<div class="pages" id="layout-preview" :aria-label="t('editor.layout.aria')">
|
||||
<DevicePage v-for="page in shown" :key="page" :page="page - 1" :entries="entries" :pages="pages" :moving="state.drag.moving" />
|
||||
<div class="page ghost" :class="{ disabled: !canAdd }">
|
||||
<div class="page-label"><span>Page {{ shown + 1 }}</span></div>
|
||||
<div class="page-label"><span>{{ t("editor.page.label", { page: shown + 1 }) }}</span></div>
|
||||
<div class="device" :class="{ cyd: currentScreen?.board !== 'guition' }" id="add-page" role="button" :tabindex="canAdd ? 0 : -1" @click="canAdd && addPage()" @keydown.enter.prevent="canAdd && addPage()">
|
||||
{{ canAdd ? "+ Add page" : `${MAX_PAGES} pages is the most` }}
|
||||
{{ canAdd ? t("editor.layout.add_page") : t("editor.layout.max_pages", MAX_PAGES) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
// exactly where it lands.
|
||||
import { computed } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { t } from "../i18n";
|
||||
import { domainInfo } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { addTile, automaticIcon, isGuition, repeatable, state, tileLimit } from "../store";
|
||||
|
||||
const FILTERS: [string, string][] = [
|
||||
["", "All"], ["light", "Lights"], ["climate", "Climate"], ["switch", "Switches"], ["binary_sensor", "Status"], ["button", "Actions"],
|
||||
["script", "Scripts"], ["fan", "Fans"], ["cover", "Covers"], ["scene", "Scenes"], ["vacuum", "Vacuum"], ["sensor", "Sensors"],
|
||||
["media_player", "Media"], ["weather", "Weather"], ["number", "Values"], ["select", "Selects"], ["person", "People"], ["timer", "Timers"], ["screen", "Screen"],
|
||||
// The domains to filter on; the label of each is editor.library.filters.<domain>, "all" for no filter.
|
||||
const FILTERS = [
|
||||
"", "light", "climate", "switch", "binary_sensor", "button", "script", "fan", "cover", "scene", "vacuum", "sensor",
|
||||
"media_player", "weather", "number", "select", "person", "timer", "screen",
|
||||
];
|
||||
const ALIAS: Record<string, string> = { switch: "input_boolean", number: "input_number", select: "input_select", weather: "sun", button: "input_button" };
|
||||
const chosen = computed(() => new Set(state.layout?.tiles.map((t) => t.entity) || []));
|
||||
@@ -43,19 +44,19 @@ const tone = (e: { id: string; state?: string }) => {
|
||||
<template>
|
||||
<aside class="library" id="library">
|
||||
<div class="lib-head">
|
||||
<div class="lib-title">Library <small>{{ count }} entities</small></div>
|
||||
<input id="search" v-model="state.search" type="search" placeholder="Search by name, device, or room…" autocomplete="off" aria-label="Add an entity" />
|
||||
<div class="lib-title">{{ t("editor.library.title") }} <small>{{ t("editor.library.entities", count) }}</small></div>
|
||||
<input id="search" v-model="state.search" type="search" :placeholder="t('editor.library.search')" autocomplete="off" :aria-label="t('editor.library.search_label')" />
|
||||
<div class="filters" id="filters">
|
||||
<button v-for="[value, label] in FILTERS" :key="value" type="button" :aria-pressed="state.filter === value ? 'true' : 'false'" @click="state.filter = value">
|
||||
<span v-if="value" class="domain-icon" :style="{ color: domainInfo(value + '.')[2], background: domainInfo(value + '.')[3] }" aria-hidden="true">{{ domainInfo(value + ".")[1] }}</span>{{ label }}
|
||||
<button v-for="value in FILTERS" :key="value" type="button" :aria-pressed="state.filter === value ? 'true' : 'false'" @click="state.filter = value">
|
||||
<span v-if="value" class="domain-icon" :style="{ color: domainInfo(value + '.')[2], background: domainInfo(value + '.')[3] }" aria-hidden="true">{{ domainInfo(value + ".")[1] }}</span>{{ t(`editor.library.filters.${value || "all"}`) }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="lib-row">
|
||||
<select id="room" v-model="state.room" aria-label="Room">
|
||||
<option value="">All rooms</option>
|
||||
<select id="room" v-model="state.room" :aria-label="t('editor.library.room')">
|
||||
<option value="">{{ t("editor.library.all_rooms") }}</option>
|
||||
<option v-for="room in rooms" :key="room" :value="room">{{ room }}</option>
|
||||
</select>
|
||||
<button type="button" class="chip-toggle" id="hide-placed" :aria-pressed="state.hidePlaced ? 'true' : 'false'" title="Hide what is already on this screen" @click="state.hidePlaced = !state.hidePlaced">Hide placed</button>
|
||||
<button type="button" class="chip-toggle" id="hide-placed" :aria-pressed="state.hidePlaced ? 'true' : 'false'" :title="t('editor.library.hide_placed_title')" @click="state.hidePlaced = !state.hidePlaced">{{ t("editor.library.hide_placed") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-list" id="results" aria-live="polite">
|
||||
@@ -68,9 +69,9 @@ const tone = (e: { id: string; state?: string }) => {
|
||||
</span>
|
||||
<span class="add" :class="{ done: placed(entity.id) }">{{ placed(entity.id) ? "✓" : "+" }}</span>
|
||||
</button>
|
||||
<p v-if="!matches.length" class="hint">{{ state.hidePlaced && !state.search && !state.filter && !state.room ? "Everything here is already on this screen." : "No entities found. Try a different name, room or filter." }}</p>
|
||||
<p v-else-if="matches.length > 80" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
|
||||
<p v-if="!matches.length" class="hint">{{ state.hidePlaced && !state.search && !state.filter && !state.room ? t("editor.library.all_placed") : t("editor.library.none_found") }}</p>
|
||||
<p v-else-if="matches.length > 80" class="hint">{{ t("editor.common.results", matches.length) }}</p>
|
||||
</div>
|
||||
<div class="lib-foot">{{ full ? `This screen holds ${tileLimit} tiles${tileLimit < 48 ? "; update its firmware for 48" : ""}.` : "Drag onto a page, or tap + to add to the first free slot. ⌘K searches everything." }}</div>
|
||||
<div class="lib-foot">{{ full ? t(tileLimit < 48 ? "editor.library.full_update" : "editor.library.full", tileLimit) : t("editor.library.hint") }}</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Per-screen local YAML override: a small file of the owner's, loaded after the shared screen package.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { getJson, send } from "../api";
|
||||
import { t } from "../i18n";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { go, state, toast } from "../store";
|
||||
|
||||
@@ -20,25 +21,25 @@ const profile = computed(() => state.overrideProfile);
|
||||
const content = ref("");
|
||||
const file = ref("");
|
||||
const attached = ref(false);
|
||||
const status = ref("Loading…");
|
||||
const status = ref(t("editor.common.loading"));
|
||||
const kind = ref("");
|
||||
const busy = ref(false);
|
||||
const editor = ref<HTMLTextAreaElement | null>(null);
|
||||
const gutter = ref<HTMLDivElement | null>(null);
|
||||
const lines = computed(() => content.value.split("\n").length);
|
||||
const gutterText = computed(() => Array.from({ length: lines.value }, (_, i) => i + 1).join("\n"));
|
||||
const count = computed(() => `${content.value.length} characters · ${lines.value} line${lines.value === 1 ? "" : "s"}`);
|
||||
const count = computed(() => `${t("editor.override.characters", content.value.length)} · ${t("editor.override.lines", lines.value)}`);
|
||||
let alive = true;
|
||||
function setStatus(message: string, k = "") { status.value = message; kind.value = k; }
|
||||
async function load() {
|
||||
if (!profile.value) { setStatus("No ESPHome profile was found for this screen.", "error"); return; }
|
||||
setStatus("Loading…");
|
||||
if (!profile.value) { setStatus(t("editor.override.no_profile"), "error"); return; }
|
||||
setStatus(t("editor.common.loading"));
|
||||
try {
|
||||
const data = await getJson(`firmware/profiles/${encodeURIComponent(profile.value)}/override`);
|
||||
file.value = data.override_file;
|
||||
attached.value = Boolean(data.attached);
|
||||
content.value = data.exists && data.content !== "{}\n" ? data.content : "";
|
||||
setStatus(data.attached ? "Changes here apply on the next build." : "Save once to attach this file to the profile.");
|
||||
setStatus(t(data.attached ? "editor.override.attached" : "editor.override.not_attached"));
|
||||
} catch (error: any) {
|
||||
setStatus(error.message, "error");
|
||||
}
|
||||
@@ -46,17 +47,17 @@ async function load() {
|
||||
async function saveOverride(runCheck = false) {
|
||||
if (!profile.value || busy.value) return;
|
||||
busy.value = true;
|
||||
setStatus("Checking and saving…");
|
||||
setStatus(t("editor.override.saving"));
|
||||
try {
|
||||
const data = await send(`firmware/profiles/${encodeURIComponent(profile.value)}/override`, "PUT", { content: content.value });
|
||||
file.value = data.override_file;
|
||||
attached.value = true;
|
||||
if (!runCheck) {
|
||||
setStatus("Saved. The override is kept during firmware updates.", "ok");
|
||||
toast("Override YAML saved.");
|
||||
setStatus(t("editor.override.saved"), "ok");
|
||||
toast(t("editor.override.saved_toast"));
|
||||
return;
|
||||
}
|
||||
setStatus("Saved. ESPHome is checking the complete profile…");
|
||||
setStatus(t("editor.override.checking"));
|
||||
await send("firmware/jobs", "POST", { file: profile.value, action: "validate" });
|
||||
pollCheck();
|
||||
} catch (error: any) {
|
||||
@@ -73,13 +74,13 @@ function pollCheck() {
|
||||
const data = await getJson("firmware");
|
||||
const current = data.job;
|
||||
if (current && current.file === checked && current.state === "running") {
|
||||
setStatus(`ESPHome is checking the profile… ${current.stage || ""}`.trim());
|
||||
setStatus(current.stage ? t("editor.override.checking_stage", { stage: current.stage }) : t("editor.override.checking_profile"));
|
||||
} else if (current && current.file === checked && current.state === "success") {
|
||||
setStatus("The complete profile is valid. Safe to build and install.", "ok");
|
||||
setStatus(t("editor.override.valid"), "ok");
|
||||
return;
|
||||
} else if (current && current.file === checked && current.state === "failed") {
|
||||
const error = (data.logs || []).filter((line: string) => /error|failed/i.test(line)).pop();
|
||||
setStatus(error || "ESPHome rejected the complete profile. See Firmware & USB for the full log.", "error");
|
||||
setStatus(error || t("editor.override.rejected"), "error");
|
||||
return;
|
||||
}
|
||||
if (Date.now() - started < 7200000) setTimeout(poll, 1200);
|
||||
@@ -90,16 +91,16 @@ function pollCheck() {
|
||||
poll();
|
||||
}
|
||||
function useExample() {
|
||||
if (!content.value.trim() || confirm("Replace the current text with the example?")) {
|
||||
if (!content.value.trim() || confirm(t("editor.override.confirm_example"))) {
|
||||
content.value = OVERRIDE_EXAMPLE;
|
||||
setStatus("Example loaded. Save it when you are ready.");
|
||||
setStatus(t("editor.override.example_loaded"));
|
||||
editor.value?.focus();
|
||||
}
|
||||
}
|
||||
function clear() {
|
||||
if (!content.value.trim() || confirm("Clear the local override?")) {
|
||||
if (!content.value.trim() || confirm(t("editor.override.confirm_clear"))) {
|
||||
content.value = "";
|
||||
setStatus("The override will be empty after you save.");
|
||||
setStatus(t("editor.override.cleared"));
|
||||
editor.value?.focus();
|
||||
}
|
||||
}
|
||||
@@ -124,34 +125,34 @@ onBeforeUnmount(() => { alive = false; });
|
||||
<div class="panel" id="override-dialog">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Advanced · local override</span>
|
||||
<h1 id="override-title">Override YAML{{ state.overrideFriendly ? ` · ${state.overrideFriendly}` : "" }}</h1>
|
||||
<p>This small file is yours. It is loaded after the shared screen package and stays in place when ESP Screen Manager updates. Change hardware-specific details here, such as a display model.</p>
|
||||
<span class="eyebrow">{{ t("editor.override.eyebrow") }}</span>
|
||||
<h1 id="override-title">{{ state.overrideFriendly ? t("editor.override.title_named", { name: state.overrideFriendly }) : t("editor.override.title") }}</h1>
|
||||
<p>{{ t("editor.override.intro") }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-override" @click="go('')">← Back</button>
|
||||
<button type="button" class="btn quiet" id="close-override" @click="go('')">{{ t("editor.common.back") }}</button>
|
||||
</div>
|
||||
<div class="notice">
|
||||
<span class="mdi">{{ glyph("F0493") }}</span>
|
||||
<div><strong>What is safe to change?</strong><span>Display, touchscreen, substitutions, pins and other device details. Name, Wi-Fi, API, OTA and package links remain managed.</span></div>
|
||||
<div><strong>{{ t("editor.override.safe_title") }}</strong><span>{{ t("editor.override.safe_text") }}</span></div>
|
||||
</div>
|
||||
<div class="file-row">
|
||||
<code id="override-file">{{ file || profile || "" }}</code>
|
||||
<span id="override-state" class="chip" :class="{ good: attached }">{{ attached ? "Active" : "Ready to attach" }}</span>
|
||||
<span id="override-state" class="chip" :class="{ good: attached }">{{ attached ? t("editor.override.active") : t("editor.override.ready") }}</span>
|
||||
</div>
|
||||
<div class="yaml-editor" id="yaml-editor-wrap">
|
||||
<div ref="gutter" class="yaml-gutter" id="override-gutter" aria-hidden="true">{{ gutterText }}</div>
|
||||
<textarea ref="editor" id="override-editor" v-model="content" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off"
|
||||
aria-label="Local override YAML" placeholder="# Example display: - id: !extend my_display model: ST7789V" @keydown="onKey" @scroll="syncScroll"></textarea>
|
||||
:aria-label="t('editor.override.editor_label')" placeholder="# Example display: - id: !extend my_display model: ST7789V" @keydown="onKey" @scroll="syncScroll"></textarea>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn quiet mini" id="override-example" @click="useExample">Use example</button>
|
||||
<button type="button" class="btn quiet mini" id="override-empty" @click="clear">Clear</button>
|
||||
<button type="button" class="btn quiet mini" id="override-example" @click="useExample">{{ t("editor.override.use_example") }}</button>
|
||||
<button type="button" class="btn quiet mini" id="override-empty" @click="clear">{{ t("editor.override.clear") }}</button>
|
||||
<span id="override-count" class="hint">{{ count }}</span>
|
||||
</div>
|
||||
<p id="override-status" class="status-line" :class="kind" role="status">{{ status }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn quiet" id="override-check" :disabled="busy || !profile" @click="saveOverride(true)">Save & check</button>
|
||||
<button type="button" class="btn primary" id="override-save" :disabled="busy || !profile" @click="saveOverride(false)">Save override</button>
|
||||
<button type="button" class="btn quiet" id="override-check" :disabled="busy || !profile" @click="saveOverride(true)">{{ t("editor.override.save_check") }}</button>
|
||||
<button type="button" class="btn primary" id="override-save" :disabled="busy || !profile" @click="saveOverride(false)">{{ t("editor.override.save") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// One screen: the head with its status, the Layout and Settings tabs, and the drawer over the right side.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { canAlert, closeInspector, copyLayoutFrom, currentScreen, exportLayout, go, identify, importLayout, save, startUpdate, state } from "../store";
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
canAlert, closeInspector, copyLayoutFrom, currentScreen, exportLayout, go, identify, importLayout, needsUpdate, save, screenText, startUpdate, state,
|
||||
} from "../store";
|
||||
import LayoutView from "./LayoutView.vue";
|
||||
import SettingsTab from "./SettingsTab.vue";
|
||||
import Drawer from "./Drawer.vue";
|
||||
@@ -9,8 +12,8 @@ import Drawer from "./Drawer.vue";
|
||||
const screen = computed(() => currentScreen.value!);
|
||||
const statusText = computed(() => screen.value.online
|
||||
? `${screen.value.delivery} · ${screen.value.status}`
|
||||
: "Offline · changes are saved");
|
||||
const updateReady = computed(() => screen.value.update?.available && screen.value.online && screen.value.update?.profile && screen.value.update?.host);
|
||||
: t("editor.screen_view.offline"));
|
||||
const updateReady = computed(() => needsUpdate(screen.value) && screen.value.online && screen.value.update?.profile && screen.value.update?.host);
|
||||
const others = computed(() => state.inventory.screens.filter((s) => s.id !== screen.value.id && s.layout?.tiles?.length));
|
||||
const copyOpen = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
@@ -28,7 +31,7 @@ function inspectAll() {
|
||||
}
|
||||
function copyFrom(id: string) {
|
||||
closeMenu();
|
||||
if (state.dirty && !confirm("Replace the unsaved layout with a copy of the other screen's?")) return;
|
||||
if (state.dirty && !confirm(t("editor.screen_view.confirm.copy"))) return;
|
||||
copyLayoutFrom(id);
|
||||
}
|
||||
function pickFile() { closeMenu(); fileInput.value?.click(); }
|
||||
@@ -37,7 +40,7 @@ async function onFile(e: Event) {
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
if (state.dirty && !confirm("Replace the unsaved layout with the imported one?")) return;
|
||||
if (state.dirty && !confirm(t("editor.screen_view.confirm.import"))) return;
|
||||
importLayout(await file.text());
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
@@ -61,34 +64,34 @@ onBeforeUnmount(() => { document.removeEventListener("keydown", onKey); document
|
||||
<header class="main-head">
|
||||
<div>
|
||||
<span class="eyebrow" id="screen-name">{{ screen.name }}</span>
|
||||
<h1>{{ state.layout?.title || "Home" }}</h1>
|
||||
<h1>{{ state.layout?.title || screenText("editor.mockup.home") }}</h1>
|
||||
</div>
|
||||
<span id="delivery" class="chip" :class="{ off: !screen.online }" :title="statusText"><span class="dot"></span>{{ statusText }}</span>
|
||||
<div class="head-right">
|
||||
<div class="seg" role="tablist">
|
||||
<button type="button" id="tab-layout" role="tab" :aria-pressed="state.tab === 'layout' ? 'true' : 'false'" @click="state.tab = 'layout'">Layout</button>
|
||||
<button type="button" id="tab-settings" role="tab" :aria-pressed="state.tab === 'settings' ? 'true' : 'false'" @click="state.tab = 'settings'; closeInspector()">Screen settings</button>
|
||||
<button type="button" id="tab-layout" role="tab" :aria-pressed="state.tab === 'layout' ? 'true' : 'false'" @click="state.tab = 'layout'">{{ t("editor.screen_view.tabs.layout") }}</button>
|
||||
<button type="button" id="tab-settings" role="tab" :aria-pressed="state.tab === 'settings' ? 'true' : 'false'" @click="state.tab = 'settings'; closeInspector()">{{ t("editor.screen_view.tabs.settings") }}</button>
|
||||
</div>
|
||||
<span v-if="!state.dirty" id="dirty" class="chip" :class="{ sent: state.saved }">{{ state.saved ? "Saved · sent to screen" : "All saved" }}</span>
|
||||
<span v-else id="dirty" class="chip dirty">Unsaved changes</span>
|
||||
<span v-if="!state.dirty" id="dirty" class="chip" :class="{ sent: state.saved }">{{ state.saved ? t("editor.screen_view.sent") : t("editor.screen_view.all_saved") }}</span>
|
||||
<span v-else id="dirty" class="chip dirty">{{ t("editor.common.unsaved") }}</span>
|
||||
<button v-if="state.dirty" id="save" type="button" class="btn primary" :disabled="state.busy" title="⌘S" @click="save()">
|
||||
<span v-if="state.busy" class="spin small"></span>{{ state.busy ? "Saving…" : "Save & send" }}
|
||||
<span v-if="state.busy" class="spin small"></span>{{ state.busy ? t("editor.common.saving") : t("editor.common.save_send") }}
|
||||
</button>
|
||||
<button id="more" type="button" class="icon-btn" aria-label="More" aria-haspopup="menu" :aria-expanded="state.menuOpen ? 'true' : 'false'" @click.stop="state.menuOpen = !state.menuOpen; copyOpen = false">···</button>
|
||||
<button id="more" type="button" class="icon-btn" :aria-label="t('editor.screen_view.more')" aria-haspopup="menu" :aria-expanded="state.menuOpen ? 'true' : 'false'" @click.stop="state.menuOpen = !state.menuOpen; copyOpen = false">···</button>
|
||||
<div v-if="state.menuOpen" class="menu" role="menu">
|
||||
<button type="button" role="menuitem" id="identify" :disabled="!canAlert(screen) || !screen.online" :title="canAlert(screen) ? '' : 'Needs firmware 0.2.31 or newer'" @click="closeMenu(); identify(screen)">Identify <small>blink the screen</small></button>
|
||||
<button type="button" role="menuitem" id="inspect" @click="inspectAll">Read current data <small>from Home Assistant</small></button>
|
||||
<button type="button" role="menuitem" id="identify" :disabled="!canAlert(screen) || !screen.online" :title="canAlert(screen) ? '' : t('editor.screen_view.menu.identify_needs')" @click="closeMenu(); identify(screen)">{{ t("editor.screen_view.menu.identify") }} <small>{{ t("editor.screen_view.menu.identify_hint") }}</small></button>
|
||||
<button type="button" role="menuitem" id="inspect" @click="inspectAll">{{ t("editor.common.read_current_data") }} <small>{{ t("editor.screen_view.menu.inspect_hint") }}</small></button>
|
||||
<div class="sep"></div>
|
||||
<button type="button" role="menuitem" id="copy-layout" :disabled="!others.length" :aria-expanded="copyOpen ? 'true' : 'false'" @click.stop="copyOpen = !copyOpen">Copy layout from… <small>{{ others.length ? `${others.length} screen${others.length === 1 ? "" : "s"}` : "no other screen" }}</small></button>
|
||||
<button type="button" role="menuitem" id="copy-layout" :disabled="!others.length" :aria-expanded="copyOpen ? 'true' : 'false'" @click.stop="copyOpen = !copyOpen">{{ t("editor.screen_view.menu.copy") }} <small>{{ others.length ? t("editor.screen_view.menu.copy_screens", others.length) : t("editor.screen_view.menu.copy_none") }}</small></button>
|
||||
<div v-if="copyOpen" class="sub">
|
||||
<button v-for="other in others" :key="other.id" type="button" role="menuitem" @click="copyFrom(other.id)">{{ other.name }} <small>{{ other.layout.tiles.length }} tiles</small></button>
|
||||
<button v-for="other in others" :key="other.id" type="button" role="menuitem" @click="copyFrom(other.id)">{{ other.name }} <small>{{ t("editor.screen_view.menu.copy_tiles", other.layout.tiles.length) }}</small></button>
|
||||
</div>
|
||||
<button type="button" role="menuitem" id="export-layout" @click="closeMenu(); exportLayout()">Export layout <small>JSON</small></button>
|
||||
<button type="button" role="menuitem" id="import-layout" @click="pickFile">Import layout… <small>JSON</small></button>
|
||||
<button type="button" role="menuitem" id="export-layout" @click="closeMenu(); exportLayout()">{{ t("editor.screen_view.menu.export") }} <small>JSON</small></button>
|
||||
<button type="button" role="menuitem" id="import-layout" @click="pickFile">{{ t("editor.screen_view.menu.import") }} <small>JSON</small></button>
|
||||
<div class="sep"></div>
|
||||
<button type="button" role="menuitem" id="open-override" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : 'No ESPHome profile found for this screen'" @click="openOverride">Override YAML <small>advanced</small></button>
|
||||
<button type="button" role="menuitem" @click="closeMenu(); go('#firmware')">Firmware & USB</button>
|
||||
<button v-if="updateReady" type="button" role="menuitem" @click="closeMenu(); startUpdate(screen)">Update firmware <small>{{ screen.update?.target }}</small></button>
|
||||
<button type="button" role="menuitem" id="open-override" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : t('editor.screen_view.menu.override_none')" @click="openOverride">{{ t("editor.screen_view.menu.override") }} <small>{{ t("editor.screen_view.menu.override_hint") }}</small></button>
|
||||
<button type="button" role="menuitem" @click="closeMenu(); go('#firmware')">{{ t("editor.nav.firmware") }}</button>
|
||||
<button v-if="updateReady" type="button" role="menuitem" @click="closeMenu(); startUpdate(screen)">{{ t("editor.screen_view.menu.update") }} <small>{{ screen.update?.target }}</small></button>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" accept="application/json,.json" hidden @change="onFile" />
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
// Screen settings: the same groups and rows as the settings page on the screen itself. Every change applies at
|
||||
// once, like on the screen; no Save needed.
|
||||
import { computed } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { currentScreen, pageReachWarning, SETTING_GROUPS, setSetting, settingText, settingValues, settingsView, state, steppedSetting, type SettingRow } from "../store";
|
||||
import {
|
||||
choiceText, currentScreen, pageReachWarning, SETTING_GROUPS, setSetting, settingLabel, settingText, settingValues, settingsView, state, steppedSetting,
|
||||
type SettingRow,
|
||||
} from "../store";
|
||||
|
||||
const view = computed(() => settingsView());
|
||||
const values = computed(() => settingValues());
|
||||
@@ -14,12 +18,12 @@ const reachWarning = computed(() => pageReachWarning());
|
||||
const unavailable = (row: SettingRow) => offline.value || Boolean(view.value?.unavailable.includes(row.key));
|
||||
const needs = (row: SettingRow) => (row.needs ? Boolean(values.value[row.needs]) : true);
|
||||
const status = computed(() => offline.value
|
||||
? "This screen is offline. Its settings can change once it's back."
|
||||
? t("editor.screen_settings.status.offline")
|
||||
: state.settingPending
|
||||
? "Saving…"
|
||||
? t("editor.common.saving")
|
||||
: view.value?.owner === "screen"
|
||||
? "Changes apply on the screen at once, and show up here when they change there."
|
||||
: "Changes apply at once. Firmware 0.2.49 lets the screen keep them itself.");
|
||||
? t("editor.screen_settings.status.screen")
|
||||
: t("editor.screen_settings.status.app"));
|
||||
const stepDisabled = (row: SettingRow, direction: number) => {
|
||||
const value = values.value[row.key];
|
||||
if (unavailable(row) || !needs(row) || value === null || value === undefined) return true;
|
||||
@@ -60,23 +64,23 @@ function click(e: MouseEvent, row: SettingRow, direction: number) {
|
||||
<div class="settings" id="general-settings" :class="{ offline }">
|
||||
<span class="status" id="settings-status" role="status">{{ status }}</span>
|
||||
<div v-if="view" class="set-grid" id="settings-groups">
|
||||
<section v-for="group in groups" :key="group.title" class="set-card">
|
||||
<h4><span class="mdi">{{ glyph(group.icon) }}</span>{{ group.title }}</h4>
|
||||
<section v-for="group in groups" :key="group.group" class="set-card">
|
||||
<h4><span class="mdi">{{ glyph(group.icon) }}</span>{{ t(`editor.screen_settings.groups.${group.group}`) }}</h4>
|
||||
<div v-for="row in group.rows" :key="row.key" class="srow" :class="[`setting-${row.kind}`, { inactive: !needs(row) || unavailable(row) }]" :data-setting="row.key"
|
||||
:title="unavailable(row) && !offline ? 'This entity is off in Home Assistant, or the screen is restarting.' : ''"
|
||||
:title="unavailable(row) && !offline ? t('editor.screen_settings.unavailable') : ''"
|
||||
@click="row.kind === 'toggle' && ($event.target as HTMLElement).closest('.srow') === $event.currentTarget && !($event.target as HTMLElement).closest('button') && !unavailable(row) && setSetting(row.key, !values[row.key], 150)">
|
||||
<span class="s-label" :id="`setting-label-${row.key}`">{{ row.label }}</span>
|
||||
<span class="s-label" :id="`setting-label-${row.key}`">{{ settingLabel(row) }}</span>
|
||||
<div class="s-control">
|
||||
<button v-if="row.kind === 'toggle'" type="button" class="switch" :class="{ unknown: values[row.key] === null || values[row.key] === undefined }" role="switch"
|
||||
:id="`setting-${row.key}`" :aria-checked="Boolean(values[row.key]) ? 'true' : 'false'" :aria-labelledby="`setting-label-${row.key}`"
|
||||
:disabled="unavailable(row)" @click.stop="setSetting(row.key, !values[row.key], 150)"></button>
|
||||
<div v-else-if="row.kind === 'choice'" class="seg" role="group" :aria-labelledby="`setting-label-${row.key}`">
|
||||
<button v-for="[value, text] in row.options!" :key="String(value)" type="button" :aria-pressed="values[row.key] === value ? 'true' : 'false'" :disabled="unavailable(row)" @click="setSetting(row.key, value, 150)">{{ text }}</button>
|
||||
<button v-for="value in row.options!" :key="String(value)" type="button" :aria-pressed="values[row.key] === value ? 'true' : 'false'" :disabled="unavailable(row)" @click="setSetting(row.key, value, 150)">{{ choiceText(row, value) }}</button>
|
||||
</div>
|
||||
<div v-else class="step">
|
||||
<button type="button" :aria-label="`${row.label} lower`" :disabled="stepDisabled(row, -1)" @pointerdown="down($event, row, -1)" @pointerup="up" @pointercancel="up" @pointerleave="up" @click="click($event, row, -1)"><span class="mdi">{{ glyph("F0374") }}</span></button>
|
||||
<button type="button" :aria-label="t('editor.screen_settings.lower', { name: settingLabel(row) })" :disabled="stepDisabled(row, -1)" @pointerdown="down($event, row, -1)" @pointerup="up" @pointercancel="up" @pointerleave="up" @click="click($event, row, -1)"><span class="mdi">{{ glyph("F0374") }}</span></button>
|
||||
<output :id="`setting-${row.key}`" :aria-labelledby="`setting-label-${row.key}`">{{ settingText(row, values) }}</output>
|
||||
<button type="button" :aria-label="`${row.label} higher`" :disabled="stepDisabled(row, 1)" @pointerdown="down($event, row, 1)" @pointerup="up" @pointercancel="up" @pointerleave="up" @click="click($event, row, 1)"><span class="mdi">{{ glyph("F0415") }}</span></button>
|
||||
<button type="button" :aria-label="t('editor.screen_settings.higher', { name: settingLabel(row) })" :disabled="stepDisabled(row, 1)" @pointerdown="down($event, row, 1)" @pointerup="up" @pointercancel="up" @pointerleave="up" @click="click($event, row, 1)"><span class="mdi">{{ glyph("F0415") }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { versionAtLeast } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { copyText, go, openIntegrations, PHASES, refresh, route, select, startUpdate, state, updateProgress, whatsNew } from "../store";
|
||||
import {
|
||||
copyText, firmwareVersion, go, needsUpdate, newLanguageText, openIntegrations, phaseText, refresh, route, select, startUpdate, state,
|
||||
updateProgress, whatsNew,
|
||||
} from "../store";
|
||||
import type { Screen } from "../types";
|
||||
|
||||
const hostFor = ref<string | null>(null);
|
||||
const host = ref("");
|
||||
// A screen that only needs the new language (app 0.2.90) says so instead of naming the version it already has.
|
||||
const languageOnly = (screen: Screen) => {
|
||||
const u = screen.update || {};
|
||||
return Boolean(u.language) && (!u.target || versionAtLeast(firmwareVersion(screen), u.target));
|
||||
};
|
||||
function updateState(screen: Screen) {
|
||||
const u = screen.update || {};
|
||||
if (u.state === "running" || state.updating.includes(screen.id)) return { kind: "running", text: PHASES[u.phase || ""] || "Starting update…" };
|
||||
if (u.state === "queued") return { kind: "queued", text: "Queued for the update" };
|
||||
if (u.available && screen.online) return { kind: "available", text: `Update ${u.target}` };
|
||||
if (u.state === "running" || state.updating.includes(screen.id)) return { kind: "running", text: phaseText(u.phase) };
|
||||
if (u.state === "queued") return { kind: "queued", text: t("editor.sidebar.update.queued") };
|
||||
if (needsUpdate(screen) && screen.online)
|
||||
return { kind: "available", text: languageOnly(screen) ? newLanguageText() : t("editor.sidebar.update.available", { version: u.target }) };
|
||||
if (u.result && Date.now() / 1000 - u.result.time < 86400) return { kind: u.result.state === "failed" ? "failed" : "done", text: u.result.message };
|
||||
return null;
|
||||
}
|
||||
// What the update brings: the new language first, when the version changes as well, then the firmware's notes.
|
||||
const notes = (screen: Screen) => [...(screen.update?.language && !languageOnly(screen) ? [newLanguageText()] : []), ...whatsNew(screen)];
|
||||
function update(screen: Screen) {
|
||||
const u = screen.update || {};
|
||||
if (u.host && u.profile) startUpdate(screen);
|
||||
@@ -30,56 +43,56 @@ const lastLog = () => {
|
||||
return lines.length ? lines[lines.length - 1] : "";
|
||||
};
|
||||
const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: string }) => p.installed
|
||||
? "Installed, but not yet in Home Assistant. Add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and turn on “Allow the device to perform Home Assistant actions” under Configure."
|
||||
? t("editor.sidebar.pending.installed")
|
||||
: p.downloaded
|
||||
? "Firmware downloaded, not yet in Home Assistant. Put it on the screen with ESPHome Web, then add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and allow the Home Assistant actions under Configure."
|
||||
: `Not yet in Home Assistant. Already flashed? Add the ESPHome device under Settings → Devices & services, then allow the Home Assistant actions under Configure. Not flashed yet? Firmware & USB → ${p.file}.`;
|
||||
? t("editor.sidebar.pending.downloaded")
|
||||
: t("editor.sidebar.pending.not_flashed", { file: p.file });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="side">
|
||||
<div class="brand"><span class="mark">▦</span><span>ESP Screens</span></div>
|
||||
<span id="connection" class="conn" :class="{ online: state.connected }" role="status">
|
||||
{{ !state.reachable ? "Management page unreachable · retrying…" : state.connected ? "Home Assistant connected" : "Reconnecting to Home Assistant…" }}
|
||||
{{ !state.reachable ? t("editor.sidebar.connection.unreachable") : state.connected ? t("editor.sidebar.connection.connected") : t("editor.sidebar.connection.reconnecting") }}
|
||||
</span>
|
||||
<button type="button" class="search-btn" id="open-palette" @click="state.palette = true">⌕ Search<kbd>⌘K</kbd></button>
|
||||
<div class="label">Screens</div>
|
||||
<button type="button" class="search-btn" id="open-palette" @click="state.palette = true">⌕ {{ t("editor.sidebar.search") }}<kbd>⌘K</kbd></button>
|
||||
<div class="label">{{ t("editor.sidebar.screens") }}</div>
|
||||
<div id="screens">
|
||||
<div v-for="screen in state.inventory.screens" :key="screen.id" class="screen-item" :class="{ selected: screen.id === state.selected && route === '' }">
|
||||
<button type="button" class="nav-item" :aria-current="screen.id === state.selected && route === '' ? 'true' : 'false'" @click="select(screen.id)">
|
||||
<span class="dot" :class="{ off: !screen.online }"></span>
|
||||
<span class="txt">
|
||||
<span>{{ screen.name }}</span>
|
||||
<small>{{ screen.online ? "Online" : "Offline" }}{{ screen.area ? " · " + screen.area : "" }} · {{ screen.firmware || "unknown" }}</small>
|
||||
<small>{{ screen.online ? t("editor.common.online") : t("editor.common.offline") }}{{ screen.area ? " · " + screen.area : "" }} · {{ screen.firmware || t("editor.common.unknown") }}</small>
|
||||
</span>
|
||||
<span v-if="screen.id === state.selected && state.dirty" class="unsaved" role="img" aria-label="Unsaved changes" title="Unsaved changes"></span>
|
||||
<span v-if="updateState(screen)?.kind === 'available'" class="pill">Update</span>
|
||||
<span v-if="screen.id === state.selected && state.dirty" class="unsaved" role="img" :aria-label="t('editor.common.unsaved')" :title="t('editor.common.unsaved')"></span>
|
||||
<span v-if="updateState(screen)?.kind === 'available'" class="pill">{{ t("editor.sidebar.update.pill") }}</span>
|
||||
<span v-else-if="updateState(screen)?.kind === 'running'" class="spin small"></span>
|
||||
</button>
|
||||
<div v-if="updateState(screen)" class="screen-update" :class="updateState(screen)!.kind">
|
||||
<template v-if="updateState(screen)!.kind === 'available'">
|
||||
<template v-if="hostFor === screen.id">
|
||||
<small>The IP address, once; new firmware reports it itself.</small>
|
||||
<small>{{ t("editor.sidebar.host.hint") }}</small>
|
||||
<form class="screen-host" @submit.prevent="startWithHost(screen)">
|
||||
<input v-model="host" placeholder="IP address, e.g. 192.168.1.50" required pattern="[A-Za-z0-9][A-Za-z0-9.\-]*" aria-label="IP address" autofocus />
|
||||
<button type="submit" class="btn mini primary">Start</button>
|
||||
<button type="button" class="icon-btn" aria-label="Cancel" @click="hostFor = null">✕</button>
|
||||
<input v-model="host" :placeholder="t('editor.sidebar.host.placeholder')" required pattern="[A-Za-z0-9][A-Za-z0-9.\-]*" :aria-label="t('editor.sidebar.host.label')" autofocus />
|
||||
<button type="submit" class="btn mini primary">{{ t("editor.sidebar.host.start") }}</button>
|
||||
<button type="button" class="icon-btn" :aria-label="t('editor.common.cancel')" @click="hostFor = null">✕</button>
|
||||
</form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<small>{{ updateState(screen)!.text }}</small>
|
||||
<button type="button" class="btn mini primary" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : 'No ESPHome profile found with this device name.'" @click="update(screen)">Update</button>
|
||||
<button type="button" class="btn mini primary" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : t('editor.sidebar.update.no_profile')" @click="update(screen)">{{ t("editor.sidebar.update.button") }}</button>
|
||||
</template>
|
||||
<details v-if="whatsNew(screen).length" class="whatsnew">
|
||||
<summary>What's new</summary>
|
||||
<ul><li v-for="line in whatsNew(screen).slice(0, 8)" :key="line">{{ line }}</li></ul>
|
||||
<details v-if="notes(screen).length" class="whatsnew">
|
||||
<summary>{{ t("editor.sidebar.update.whats_new") }}</summary>
|
||||
<ul><li v-for="line in notes(screen).slice(0, 8)" :key="line">{{ line }}</li></ul>
|
||||
</details>
|
||||
</template>
|
||||
<template v-else-if="updateState(screen)!.kind === 'running' && updateProgress(screen)">
|
||||
<div class="progress" role="progressbar" :aria-valuenow="updateProgress(screen)!.percent" aria-valuemin="0" aria-valuemax="100"><i :style="{ width: updateProgress(screen)!.percent + '%' }"></i></div>
|
||||
<div class="progress-text"><span>{{ updateProgress(screen)!.percent }} %</span><span :title="lastLog()">{{ updateProgress(screen)!.text }}</span></div>
|
||||
<small v-if="lastLog()" :title="lastLog()" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{ lastLog() }}</small>
|
||||
<button type="button" class="btn link mini" style="justify-self: start" @click="go('#firmware')">Full log</button>
|
||||
<button type="button" class="btn link mini" style="justify-self: start" @click="go('#firmware')">{{ t("editor.sidebar.update.full_log") }}</button>
|
||||
</template>
|
||||
<small v-else :class="{ failed: updateState(screen)!.kind === 'failed' }">{{ updateState(screen)!.text }}</small>
|
||||
</div>
|
||||
@@ -90,21 +103,21 @@ const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: strin
|
||||
<strong>{{ p.friendly }}</strong>
|
||||
<small>{{ pendingText(p) }}</small>
|
||||
<div class="pending-actions">
|
||||
<button type="button" class="btn mini quiet" @click="openIntegrations">Open Devices & services</button>
|
||||
<button v-if="p.api_key" type="button" class="btn mini quiet" @click="copyText(p.api_key!)">Copy API key</button>
|
||||
<button type="button" class="btn mini quiet" @click="openIntegrations">{{ t("editor.common.open_integrations") }}</button>
|
||||
<button v-if="p.api_key" type="button" class="btn mini quiet" @click="copyText(p.api_key!)">{{ t("editor.sidebar.copy_api_key") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="new-screen" type="button" class="nav-item ghost" :aria-current="route === '#new-screen' ? 'true' : 'false'" @click="go('#new-screen')">
|
||||
<span class="plus">+</span><span class="txt">New screen</span>
|
||||
<span class="plus">+</span><span class="txt">{{ t("editor.nav.new_screen") }}</span>
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<div class="more">
|
||||
<div class="label">More</div>
|
||||
<button id="open-firmware" type="button" class="nav-item" :aria-current="route === '#firmware' ? 'true' : 'false'" @click="go('#firmware')"><span class="mdi">{{ glyph("F0241") }}</span><span class="txt">Firmware & USB</span></button>
|
||||
<button id="open-alerts" type="button" class="nav-item" :aria-current="route === '#alerts' ? 'true' : 'false'" @click="go('#alerts')"><span class="mdi">{{ glyph("F0594") }}</span><span class="txt">Alerts</span></button>
|
||||
<button id="open-settings" type="button" class="nav-item" :aria-current="route === '#settings' ? 'true' : 'false'" @click="go('#settings')"><span class="mdi">{{ glyph("F0493") }}</span><span class="txt">Settings</span></button>
|
||||
<button id="refresh" type="button" class="nav-item ghost" @click="refresh()"><span class="plus">↻</span><span class="txt">Refresh</span></button>
|
||||
<div class="label">{{ t("editor.sidebar.more") }}</div>
|
||||
<button id="open-firmware" type="button" class="nav-item" :aria-current="route === '#firmware' ? 'true' : 'false'" @click="go('#firmware')"><span class="mdi">{{ glyph("F0241") }}</span><span class="txt">{{ t("editor.nav.firmware") }}</span></button>
|
||||
<button id="open-alerts" type="button" class="nav-item" :aria-current="route === '#alerts' ? 'true' : 'false'" @click="go('#alerts')"><span class="mdi">{{ glyph("F0594") }}</span><span class="txt">{{ t("editor.nav.alerts") }}</span></button>
|
||||
<button id="open-settings" type="button" class="nav-item" :aria-current="route === '#settings' ? 'true' : 'false'" @click="go('#settings')"><span class="mdi">{{ glyph("F0493") }}</span><span class="txt">{{ t("editor.nav.settings") }}</span></button>
|
||||
<button id="refresh" type="button" class="nav-item ghost" @click="refresh()"><span class="plus">↻</span><span class="txt">{{ t("editor.sidebar.refresh") }}</span></button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
// dragged, drawn where it will land.
|
||||
import { computed, nextTick } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { displayNames, effectiveControls, isFull, isWide, pageOf, pageTarget, SLOTS_PER_PAGE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { entityName, isSelected, liveOf, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
|
||||
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 type { Tile } from "../types";
|
||||
|
||||
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
|
||||
@@ -16,14 +17,14 @@ const goesTo = computed(() => pageTarget(props.tile.entity));
|
||||
const background = computed(() => state.inventory.backgrounds?.[props.tile.options?.background || ""]?.color);
|
||||
const bare = computed(() => props.tile.options?.background === "none");
|
||||
const display = computed(() => props.tile.options?.display || "standard");
|
||||
const note = computed(() => (display.value !== "standard" ? displayNames[display.value] || display.value : ""));
|
||||
const note = computed(() => (display.value !== "standard" ? displayName(display.value) : ""));
|
||||
const controls = computed(() => effectiveControls(props.tile, state.inventory));
|
||||
const domain = computed(() => props.tile.entity.split(".")[0]);
|
||||
const cp = computed(() => state.inventory.icons?.controls || {});
|
||||
const key = (n: string) => (cp.value[n] ? glyph(cp.value[n]) : "");
|
||||
const chosen = computed(() => isSelected(props.tile) && state.inspector?.kind === "tile");
|
||||
const live = computed(() => !props.placeholder && state.layout?.tiles.includes(props.tile));
|
||||
const label = computed(() => `${name.value}, slot ${(props.slot % SLOTS_PER_PAGE) + 1} on page ${pageOf(props.slot) + 1}. Enter: configure, arrow keys: move`);
|
||||
const label = computed(() => t("editor.tile_card.label", { name: name.value, slot: (props.slot % SLOTS_PER_PAGE) + 1, page: pageOf(props.slot) + 1 }));
|
||||
const now = computed(() => new Date(state.now));
|
||||
const hourAngle = computed(() => (now.value.getHours() % 12 + now.value.getMinutes() / 60) * 30);
|
||||
const minuteAngle = computed(() => now.value.getMinutes() * 6);
|
||||
@@ -35,25 +36,36 @@ 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, " ");
|
||||
// Home Assistant's words for the weather states, for a Home Assistant that hands us none.
|
||||
const WEATHER_WORDS: Record<string, string> = { "clear-night": "Clear, night", cloudy: "Cloudy", exceptional: "Exceptional", fog: "Fog", hail: "Hail", lightning: "Lightning", "lightning-rainy": "Lightning, rainy", partlycloudy: "Partly cloudy", pouring: "Pouring", rainy: "Rainy", snowy: "Snowy", "snowy-rainy": "Snowy, rainy", sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" };
|
||||
// Numbers as the screens write them, "1,234.5" or "1.234,5" (app 0.2.90).
|
||||
const num = (value: unknown) => numberText(value as string | number, numberMarks.value);
|
||||
// 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.
|
||||
const HA_WORDS: Record<string, string> = { climate: "climate", cover: "cover", media_player: "media", person: "person", sun: "sun", vacuum: "vacuum", weather: "weather" };
|
||||
function haWord(c: { state: string; a: Record<string, any> }) {
|
||||
const key = (path: string) => (te(`screen.ha.${path}`) ? screenText(`screen.ha.${path}`) : "");
|
||||
const value = c.state === "windy-variant" ? "windy" : c.state.replace(/-/g, "_");
|
||||
if (domain.value === "binary_sensor" && ["on", "off"].includes(value)) return key(`binary.${c.a?.device_class}_${value}`) || key(value);
|
||||
if (HA_WORDS[domain.value]) return key(`${HA_WORDS[domain.value]}.${value}`) || (["on", "off"].includes(value) ? key(value) : "");
|
||||
return ["on", "off"].includes(value) ? key(value) : "";
|
||||
}
|
||||
// A scene, script or button has no state worth a word: its state is the moment it last ran.
|
||||
const NO_STATUS = ["scene", "script", "button", "input_button"];
|
||||
// The text under the name: Home Assistant's word where it has one, the value with its unit for a sensor.
|
||||
const status = computed(() => {
|
||||
const c = current.value;
|
||||
if (!c || NO_STATUS.includes(domain.value)) return note.value;
|
||||
if (gone.value) return c.state === "unknown" ? "Unknown" : "Unavailable";
|
||||
if (gone.value) return screenText(c.state === "unknown" ? "editor.mockup.unknown" : "screen.ha.unavailable");
|
||||
const a = c.a || {};
|
||||
if (domain.value === "climate") return `${a.current_temperature !== undefined ? `${a.current_temperature}° · ` : ""}${c.word || capital(c.state)}`;
|
||||
if (domain.value === "weather") return `${c.word || WEATHER_WORDS[c.state] || capital(c.state)}${a.temperature !== undefined ? ` · ${a.temperature}°` : ""}`;
|
||||
if (domain.value === "cover" && a.current_position !== undefined && a.current_position > 0 && a.current_position < 100) return `${c.word || capital(c.state)} · ${a.current_position} %`;
|
||||
if (domain.value === "media_player" && a.media_title) return `${c.word || capital(c.state)} · ${a.media_title}`;
|
||||
if (domain.value === "sensor") return `${c.state}${unit.value ? ` ${unit.value}` : ""}`;
|
||||
if (domain.value === "timer") return c.word || capital(c.state);
|
||||
return c.word || capital(c.state);
|
||||
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 === "media_player" && a.media_title) return `${word} · ${a.media_title}`;
|
||||
if (domain.value === "sensor") return `${num(c.state)}${unit.value ? ` ${unit.value}` : ""}`;
|
||||
return word;
|
||||
});
|
||||
const bigValue = computed(() => (current.value && !gone.value ? current.value.state : "—"));
|
||||
const bigValue = computed(() => (current.value && !gone.value ? num(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;
|
||||
@@ -70,11 +82,14 @@ const fill = computed(() => {
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
// The key on a scene, script or button, and the page a navigation tile opens, as the screen labels them.
|
||||
const runText = computed(() => screenText(`screen.ha.button.${({ scene: "activate", script: "run" } as Record<string, string>)[domain.value] || "press"}`));
|
||||
const pageLink = computed(() => `${screenText("screen.tile.page", { n: goesTo.value })} ›`);
|
||||
const sliderStyle = computed(() => ({ background: `linear-gradient(to right, ${fill.value ? "#ffbf38" : "#c9ccd1"} ${fill.value}%, ${fill.value ? "#fff1d3" : "#e6e8ec"} ${fill.value}%)` }));
|
||||
const volumeStyle = computed(() => ({ background: `linear-gradient(to right, #2196f3 ${fill.value}%, #d3e8fb ${fill.value}%)` }));
|
||||
const setpoint = computed(() => {
|
||||
const t = current.value?.a?.temperature;
|
||||
return t !== undefined && t !== null ? `${t}°` : "—";
|
||||
const temperature = current.value?.a?.temperature;
|
||||
return temperature !== undefined && temperature !== null ? `${num(temperature)}°` : "—";
|
||||
});
|
||||
|
||||
async function onKey(e: KeyboardEvent) {
|
||||
@@ -107,13 +122,13 @@ async function onKey(e: KeyboardEvent) {
|
||||
</template>
|
||||
<template v-else-if="display === 'digital' && domain === 'screen'">
|
||||
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="lead"><span class="big">{{ String(now.getHours()).padStart(2, "0") }}:{{ String(now.getMinutes()).padStart(2, "0") }}</span><span class="nm">{{ name }}</span></span>
|
||||
<span class="lead"><span class="big">{{ clockText(clock24, now) }}</span><span class="nm">{{ name }}</span></span>
|
||||
</template>
|
||||
<template v-else-if="full">
|
||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="lead">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="goesTo" class="goto">Page {{ goesTo }} ›</span>
|
||||
<span v-if="goesTo" class="goto">{{ pageLink }}</span>
|
||||
<span v-else-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
|
||||
<span v-else-if="status" class="st" :class="{ off: gone }">{{ status }}</span>
|
||||
</span>
|
||||
@@ -124,7 +139,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
<template v-else-if="controls === 'volume'"><span class="range" :style="volumeStyle"></span><span class="key mdi">{{ key("volume-high") }}</span></template>
|
||||
<template v-else-if="controls === 'playback'"><span class="key mdi">{{ key("skip-previous") }}</span><span class="key mdi">{{ key(on ? "pause" : "play") || key("play") }}</span><span class="key mdi">{{ key("skip-next") }}</span></template>
|
||||
<template v-else-if="controls === 'buttons' && domain === 'cover'"><span class="key mdi">{{ key("arrow-expand-horizontal") }}</span><span class="key mdi">{{ key("stop") }}</span><span class="key mdi">{{ key("arrow-collapse-horizontal") }}</span></template>
|
||||
<span v-else-if="controls === 'run'" class="run">{{ ({ scene: "Activate", script: "Run" } as Record<string, string>)[domain] || "Press" }}</span>
|
||||
<span v-else-if="controls === 'run'" class="run">{{ runText }}</span>
|
||||
<span v-else class="range" :style="{ background: `linear-gradient(to right, #2196f3 ${fill}%, #d3e8fb ${fill}%)` }"></span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -133,7 +148,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="goesTo" class="goto">Page {{ goesTo }} ›</span>
|
||||
<span v-if="goesTo" class="goto">{{ pageLink }}</span>
|
||||
<span v-else-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
|
||||
<span v-else-if="status" class="st" :class="{ off: gone }">{{ status }}</span>
|
||||
</span>
|
||||
@@ -150,7 +165,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
<template v-else-if="controls === 'buttons' && domain === 'cover'"><span class="key mdi">{{ key("arrow-expand-horizontal") }}</span><span class="key mdi">{{ key("stop") }}</span><span class="key mdi">{{ key("arrow-collapse-horizontal") }}</span></template>
|
||||
<template v-else-if="controls === 'buttons' && domain === 'vacuum'"><span class="key mdi">{{ key("play") }}</span><span class="key mdi">{{ key("stop") }}</span><span class="key mdi">{{ key("home-map-marker") }}</span></template>
|
||||
<template v-else-if="controls === 'buttons' && domain === 'timer'"><span class="key mdi">{{ key("play") }}</span><span class="key mdi">{{ key("close") }}</span></template>
|
||||
<span v-else-if="controls === 'run'" class="run">{{ ({ scene: "Activate", script: "Run" } as Record<string, string>)[domain] || "Press" }}</span>
|
||||
<span v-else-if="controls === 'run'" class="run">{{ runText }}</span>
|
||||
<span v-else class="range" :style="{ background: `linear-gradient(to right, #2196f3 ${fill}%, #d3e8fb ${fill}%)` }"></span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -161,13 +176,13 @@ async function onKey(e: KeyboardEvent) {
|
||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="goesTo" class="goto">Page {{ goesTo }} ›</span>
|
||||
<span v-if="goesTo" class="goto">{{ pageLink }}</span>
|
||||
<span v-else-if="display !== 'watch' && status" class="st" :class="{ off: gone }">{{ status }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
|
||||
<span v-if="tile.options?.inline === 'slider'" class="mini-slider" :style="sliderStyle"></span>
|
||||
</template>
|
||||
<button v-if="live" type="button" class="remove" title="Remove tile" :aria-label="`Remove ${name}`" @click.stop="removeTile(tile)">✕</button>
|
||||
<button v-if="live" type="button" class="remove" :title="t('editor.tile_card.remove')" :aria-label="t('editor.tile_card.remove_named', { name })" @click.stop="removeTile(tile)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// One tile's settings. Every change applies live, so the card on the mockup shows the result while you pick.
|
||||
import { computed, toRaw } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { domainInfo, entriesOf, MAX_PAGES, pageCount, pageOf, pageTarget, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { automaticIcon, closeInspector, entityName, fullPage, markDirty, moveTileToPage, removeTile, retargetPageTile, setTileOption, state, supports, tileIconCp } from "../store";
|
||||
@@ -22,42 +23,38 @@ const emptyPage = (n: number) => !state.layout?.tiles.some((t) => pageOf(t.slot)
|
||||
const pages = computed(() => {
|
||||
const list = Array.from({ length: Math.min(MAX_PAGES, pageTotal.value + 1) }, (_, i) => i + 1);
|
||||
if (goesTo.value > list.length) list.push(goesTo.value);
|
||||
return list.map((n) => [n, emptyPage(n) ? `${n} (empty)` : String(n)] as [number, string]);
|
||||
return list.map((n) => [n, emptyPage(n) ? t("editor.tile.goes_to.empty", { page: n }) : String(n)] as [number, string]);
|
||||
});
|
||||
const goesToHint = computed(() => !fullPage.value
|
||||
? { text: "Navigation tiles need firmware 0.2.62: press Update on the screen first.", warn: false }
|
||||
? { text: t("editor.tile.goes_to.needs_firmware"), warn: false }
|
||||
: goesTo.value > pageTotal.value
|
||||
? { text: `This screen has no page ${goesTo.value}, so a tap opens its last page. Choose another page.`, warn: true }
|
||||
: { text: "A tap on the tile opens that page.", warn: false });
|
||||
? { text: t("editor.tile.goes_to.no_page", { page: goesTo.value }), warn: true }
|
||||
: { text: t("editor.tile.goes_to.hint"), warn: false });
|
||||
// Moving the tile without a drag (app 0.2.78): another page, or a new one after the last. A tile alone on the last page
|
||||
// gets no "New page", which would only leave an empty page behind; with nowhere to go the row stays hidden.
|
||||
const alone = computed(() => !state.layout?.tiles.some((t) => toRaw(t) !== toRaw(props.tile) && pageOf(t.slot) === pageHere.value - 1));
|
||||
const onPage = computed(() => {
|
||||
const list = Array.from({ length: pageTotal.value }, (_, i) => [i + 1, String(i + 1)] as [number, string]);
|
||||
if (pageTotal.value < MAX_PAGES && !(alone.value && pageHere.value === pageTotal.value)) list.push([pageTotal.value + 1, "New page"]);
|
||||
if (pageTotal.value < MAX_PAGES && !(alone.value && pageHere.value === pageTotal.value)) list.push([pageTotal.value + 1, t("editor.tile.page.new")]);
|
||||
return list;
|
||||
});
|
||||
const sizes = computed<[string, string][]>(() => goesTo.value ? [["single", "Normal"], ["wide", "Double-width"]] : [["single", "Normal"], ["wide", "Double-width"], ["full", "Full page"]]);
|
||||
const sizeHint = computed(() => goesTo.value ? "" : fullPage.value
|
||||
? "Full page: one big button that lights up while on; a slider, controls or a graph sit at the bottom of it."
|
||||
: "A full-page tile needs firmware 0.2.62: press Update on the screen first.");
|
||||
const sizes = computed<[string, string][]>(() => (goesTo.value ? ["single", "wide"] : ["single", "wide", "full"]).map((key) => [key, t(`editor.tile.size.${key}`)]));
|
||||
const sizeHint = computed(() => goesTo.value ? "" : fullPage.value ? t("editor.tile.size.full_hint") : t("editor.tile.size.needs_firmware"));
|
||||
const caps = computed(() => state.capabilities[props.tile.entity]);
|
||||
const current = (key: string, fallback: unknown) => props.tile.options?.[key] ?? fallback;
|
||||
const display = computed(() => current("display", domain.value === "screen" ? "digital" : "standard") as string);
|
||||
const displays = computed(() => {
|
||||
const list: [string, string][] = domain.value === "screen"
|
||||
? [["digital", "Digital clock"], ["analog", "Analog clock"]]
|
||||
: [["standard", "Name and status"], ["watch", "Large value"]];
|
||||
const keys = domain.value === "screen" ? ["digital", "analog"] : ["standard", "watch"];
|
||||
const c = caps.value;
|
||||
if (domain.value === "weather" && (!c || c.displays.includes("forecast") || display.value === "forecast")) list.push(["forecast", "Weather forecast"]);
|
||||
if (domain.value === "sensor" && (!c || c.displays.includes("graph") || display.value === "graph")) list.push(["graph", "Graph"]);
|
||||
if (domain.value === "sun") list.push(["sunpath", "Sun path"]);
|
||||
return list;
|
||||
if (domain.value === "weather" && (!c || c.displays.includes("forecast") || display.value === "forecast")) keys.push("forecast");
|
||||
if (domain.value === "sensor" && (!c || c.displays.includes("graph") || display.value === "graph")) keys.push("graph");
|
||||
if (domain.value === "sun") keys.push("sunpath");
|
||||
return keys.map((key) => [key, t(`editor.tile.display.${key}`)] as [string, string]);
|
||||
});
|
||||
const displayHint = computed(() => {
|
||||
const c = caps.value;
|
||||
if (c && display.value === "graph" && !c.displays.includes("graph")) return "Home Assistant has no numbers for this entity, so the graph stays empty. Choose another display.";
|
||||
if (c && display.value === "forecast" && !c.displays.includes("forecast")) return "This weather service has no daily forecast in Home Assistant. Choose another display.";
|
||||
if (c && display.value === "graph" && !c.displays.includes("graph")) return t("editor.tile.display.no_graph");
|
||||
if (c && display.value === "forecast" && !c.displays.includes("forecast")) return t("editor.tile.display.no_forecast");
|
||||
return "";
|
||||
});
|
||||
const size = computed(() => current("size", "single") as string);
|
||||
@@ -69,23 +66,23 @@ const controlChoices = computed(() => {
|
||||
});
|
||||
const controlHint = computed(() => {
|
||||
const c = caps.value;
|
||||
if (c && controls.value !== "none" && !c.controls.includes(controls.value)) return { text: "Home Assistant doesn't offer this control for this entity, so it stays empty on the screen. Choose another one.", warn: true };
|
||||
if (c && controls.value !== "none" && !c.controls.includes(controls.value)) return { text: t("editor.tile.controls.not_offered"), warn: true };
|
||||
return { text: supports(0, 2, 19)
|
||||
? (size.value === "full" ? "At the bottom of the full-page tile; a tap anywhere else works as set below." : "On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as set below.")
|
||||
: "The screen shows direct control from firmware 0.2.19; until then the tile stays as it was.", warn: false };
|
||||
? t(size.value === "full" ? "editor.tile.controls.full_hint" : "editor.tile.controls.wide_hint")
|
||||
: t("editor.tile.controls.needs_firmware"), warn: false };
|
||||
});
|
||||
const tap = computed(() => current("tap", "auto") as string);
|
||||
const taps = computed(() => {
|
||||
const list: [string, string][] = [["auto", "Automatic"], ["detail", "Open control"], ["none", "View only"]];
|
||||
const keys = ["auto", "detail", "none"];
|
||||
// On / off where Home Assistant can toggle the entity, such as a cover; a speaker without on and off gets none.
|
||||
if ((caps.value ? caps.value.toggle : TOGGLE_BEFORE.includes(domain.value)) || tap.value === "toggle") list.push(["toggle", "On / off"]);
|
||||
list.push(["action", "Perform action"]);
|
||||
return list;
|
||||
if ((caps.value ? caps.value.toggle : TOGGLE_BEFORE.includes(domain.value)) || tap.value === "toggle") keys.push("toggle");
|
||||
keys.push("action");
|
||||
return keys.map((key) => [key, t(`editor.tile.tap.${key}`)] as [string, string]);
|
||||
});
|
||||
const tapHint = computed(() => {
|
||||
if (tap.value === "toggle" && caps.value && !caps.value.toggle) return { text: "Home Assistant can't turn this on and off, so a tap does nothing. Choose another option.", warn: true };
|
||||
if (tap.value === "toggle" && !TOGGLE_BEFORE.includes(domain.value) && !supports(0, 2, 58)) return { text: "The screen switches this from firmware 0.2.58: press Update on the screen. Until then a tap opens its card.", warn: false };
|
||||
if (tap.value === "toggle") return { text: "Hold the tile to open its card.", warn: false };
|
||||
if (tap.value === "toggle" && caps.value && !caps.value.toggle) return { text: t("editor.tile.tap.no_toggle"), warn: true };
|
||||
if (tap.value === "toggle" && !TOGGLE_BEFORE.includes(domain.value) && !supports(0, 2, 58)) return { text: t("editor.tile.tap.toggle_needs_firmware"), warn: false };
|
||||
if (tap.value === "toggle") return { text: t("editor.tile.tap.hold"), warn: false };
|
||||
return null;
|
||||
});
|
||||
const inline = computed(() => current("inline", "none") as string);
|
||||
@@ -108,60 +105,60 @@ function inspect() {
|
||||
<div class="dr-head">
|
||||
<span class="av mdi" :style="{ color: domainInfo(tile.entity)[2], background: domainInfo(tile.entity)[3] }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx"><b>{{ tile.name || name }}</b><small class="mono">{{ tile.entity }}</small></span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
<button type="button" class="icon-btn" :aria-label="t('editor.common.close')" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<label class="f-label" for="tile-name">Name on the screen</label>
|
||||
<label class="f-label" for="tile-name">{{ t("editor.tile.name") }}</label>
|
||||
<input id="tile-name" :value="tile.name" :placeholder="name" maxlength="60" @input="rename(($event.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<IconPicker v-if="showIcon" :selected="tile.options?.icon || 'auto'" :automatic="automaticIcon(tile.entity)"
|
||||
:auto-label="`Automatic (${fromHA ? 'from Home Assistant' : 'default'})`"
|
||||
:note="supports(0, 2, 18) ? '' : 'The screen shows a chosen icon from firmware 0.2.18.'"
|
||||
:auto-label="t(fromHA ? 'editor.tile.icon.auto_ha' : 'editor.tile.icon.auto_default')"
|
||||
:note="supports(0, 2, 18) ? '' : t('editor.tile.icon.needs_firmware')"
|
||||
@pick="(n) => setTileOption(tile, 'icon', n)" />
|
||||
<div v-if="goesTo" class="f">
|
||||
<span class="f-label">Goes to page</span>
|
||||
<span class="f-label">{{ t("editor.tile.goes_to.label") }}</span>
|
||||
<Segmented :choices="pages" :value="goesTo" @pick="(v) => retargetPageTile(tile, Number(v))" />
|
||||
<small :class="{ warn: goesToHint.warn }">{{ goesToHint.text }}</small>
|
||||
</div>
|
||||
<div v-else class="f">
|
||||
<span class="f-label">Display</span>
|
||||
<span class="f-label">{{ t("editor.tile.display.label") }}</span>
|
||||
<Segmented :choices="displays" :value="display" @pick="(v) => setTileOption(tile, 'display', v)" />
|
||||
<small v-if="displayHint" class="warn">{{ displayHint }}</small>
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label">Size</span>
|
||||
<span class="f-label">{{ t("editor.tile.size.label") }}</span>
|
||||
<Segmented :choices="sizes" :value="size" @pick="(v) => setTileOption(tile, 'size', v)" />
|
||||
<small v-if="sizeHint">{{ sizeHint }}</small>
|
||||
</div>
|
||||
<div v-if="onPage.length > 1" class="f">
|
||||
<span class="f-label">Page</span>
|
||||
<span class="f-label">{{ t("editor.tile.page.label") }}</span>
|
||||
<Segmented :choices="onPage" :value="pageHere" @pick="(v) => moveTileToPage(tile, Number(v) - 1)" />
|
||||
</div>
|
||||
<div v-if="catalogue && size !== 'single' && !goesTo" class="f">
|
||||
<span class="f-label">Direct control on the tile</span>
|
||||
<span class="f-label">{{ t("editor.tile.controls.label") }}</span>
|
||||
<Segmented :choices="controlChoices" :value="controls" @pick="(v) => setTileOption(tile, 'controls', v)" />
|
||||
<small :class="{ warn: controlHint.warn }">{{ controlHint.text }}</small>
|
||||
</div>
|
||||
<div v-if="domain !== 'screen' && !goesTo" class="f">
|
||||
<span class="f-label">On tap</span>
|
||||
<span class="f-label">{{ t("editor.tile.tap.label") }}</span>
|
||||
<Segmented :choices="taps" :value="tap" @pick="(v) => setTileOption(tile, 'tap', v)" />
|
||||
<small v-if="tapHint" :class="{ warn: tapHint.warn }">{{ tapHint.text }}</small>
|
||||
</div>
|
||||
<ActionPicker v-if="domain !== 'screen' && !goesTo && tap === 'action'" :tile="tile" />
|
||||
<div v-if="showSlider && !goesTo" class="f">
|
||||
<span class="f-label">Small slider on the tile</span>
|
||||
<Segmented :choices="[['none', 'No'], ['slider', 'Yes, control directly']]" :value="inline" @pick="(v) => setTileOption(tile, 'inline', v)" />
|
||||
<small v-if="sliderWarn" class="warn">Home Assistant has nothing a slider can change for this entity. Choose No.</small>
|
||||
<span class="f-label">{{ t("editor.tile.slider.label") }}</span>
|
||||
<Segmented :choices="[['none', t('editor.tile.slider.no')], ['slider', t('editor.tile.slider.yes')]]" :value="inline" @pick="(v) => setTileOption(tile, 'inline', v)" />
|
||||
<small v-if="sliderWarn" class="warn">{{ t("editor.tile.slider.nothing") }}</small>
|
||||
</div>
|
||||
<div v-if="domain === 'sensor' && !goesTo" class="f">
|
||||
<span class="f-label">History</span>
|
||||
<Segmented :choices="[[1, '1 hour'], [6, '6 hours'], [24, '24 hours']]" :value="history" @pick="(v) => setTileOption(tile, 'history_hours', Number(v))" />
|
||||
<span class="f-label">{{ t("editor.tile.history.label") }}</span>
|
||||
<Segmented :choices="[1, 6, 24].map((hours) => [hours, t('editor.tile.history.hours', hours)] as [number, string])" :value="history" @pick="(v) => setTileOption(tile, 'history_hours', Number(v))" />
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label">Pastel background</span>
|
||||
<span class="f-label">{{ t("editor.tile.background.label") }}</span>
|
||||
<div class="sw">
|
||||
<button v-for="[key, choice] in backgrounds" :key="key" type="button" :aria-label="`Background: ${choice.label}`"
|
||||
<button v-for="[key, choice] in backgrounds" :key="key" type="button" :aria-label="t('editor.tile.background.aria', { name: choice.label })"
|
||||
:aria-pressed="(tile.options?.background || 'auto') === key ? 'true' : 'false'" @click="setTileOption(tile, 'background', key)">
|
||||
<i :class="choice.color ? '' : key === 'none' ? 'none' : 'auto'" :style="choice.color ? { background: choice.color } : undefined"></i>{{ choice.label }}
|
||||
</button>
|
||||
@@ -169,8 +166,8 @@ function inspect() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<button type="button" class="btn danger" @click="removeTile(tile)">Remove</button>
|
||||
<button type="button" class="btn danger" @click="removeTile(tile)">{{ t("editor.common.remove") }}</button>
|
||||
<span class="spacer"></span>
|
||||
<button v-if="domain !== 'screen'" type="button" class="btn quiet" @click="inspect">Read current data</button>
|
||||
<button v-if="domain !== 'screen'" type="button" class="btn quiet" @click="inspect">{{ t("editor.common.read_current_data") }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// Adding to the top bar: the screen's own items, Home Assistant's suggestions, or any entity.
|
||||
import { computed, ref } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { BUILTIN_ICONS, clockText, dateText, glyph, itemKey } from "../model/topbar";
|
||||
import { addTopbarItem, automaticIcon, closeInspector, iconNamed, settingValues, state, topbarItems, topbarMax } from "../store";
|
||||
import { addTopbarItem, automaticIcon, clock24, closeInspector, iconNamed, screenLanguage, state, topbarItems, topbarMax } from "../store";
|
||||
import type { HeaderItem } from "../types";
|
||||
|
||||
const query = ref("");
|
||||
const taken = computed(() => new Set(topbarItems().map(itemKey)));
|
||||
const clock24 = computed(() => settingValues().clock_24h !== false);
|
||||
const samples = computed(() => ({ clock: clockText(clock24.value, new Date(state.now)), analog: "Small dial with the time", date: dateText(new Date(state.now)) } as Record<string, string>));
|
||||
const samples = computed(() => ({
|
||||
clock: clockText(clock24.value, new Date(state.now)),
|
||||
analog: t("editor.topbar.analog_sample"),
|
||||
date: dateText(new Date(state.now), screenLanguage.value),
|
||||
} as Record<string, string>));
|
||||
const suggested = computed(() => state.inventory.header?.suggestions?.[state.selected || ""] || []);
|
||||
const matches = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
@@ -20,43 +24,43 @@ const entityItem = (id: string): HeaderItem => ({ type: "entity", entity: id, co
|
||||
<template>
|
||||
<div class="dr-head">
|
||||
<span class="av" style="background: var(--seg); color: var(--ink-2); font-size: 18px">+</span>
|
||||
<span class="tx"><b>Add to the top bar</b><small>{{ topbarItems().length }} of {{ topbarMax() }} slots used</small></span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
<span class="tx"><b>{{ t("editor.topbar.add.title") }}</b><small>{{ t("editor.topbar.add.slots", { used: topbarItems().length }, topbarMax()) }}</small></span>
|
||||
<button type="button" class="icon-btn" :aria-label="t('editor.common.close')" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<span class="f-label">From the screen itself</span>
|
||||
<span class="f-label">{{ t("editor.topbar.add.builtin") }}</span>
|
||||
<div class="options">
|
||||
<button v-for="b in state.inventory.header?.builtin || []" :key="b.type" type="button" class="option" :disabled="taken.has(itemKey({ type: b.type }))" @click="addTopbarItem({ type: b.type })">
|
||||
<span class="mdi">{{ glyph(iconNamed(BUILTIN_ICONS[b.type])?.cp || "F0150") }}</span>
|
||||
<span class="tx"><strong>{{ b.label }}</strong><small>{{ taken.has(itemKey({ type: b.type })) ? "Already added" : samples[b.type] }}</small></span>
|
||||
<span class="tx"><strong>{{ b.label }}</strong><small>{{ taken.has(itemKey({ type: b.type })) ? t("editor.topbar.add.added") : samples[b.type] }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="suggested.length" class="f">
|
||||
<span class="f-label">Suggestions from Home Assistant</span>
|
||||
<span class="f-label">{{ t("editor.topbar.add.suggestions") }}</span>
|
||||
<div class="options">
|
||||
<button v-for="s in suggested" :key="itemKey(s.item)" type="button" class="option" :disabled="taken.has(itemKey(s.item))" @click="addTopbarItem(s.item)">
|
||||
<span class="mdi">{{ glyph(s.icon || automaticIcon(s.item.entity!)) }}</span>
|
||||
<span class="tx"><strong>{{ s.label }}</strong><small>{{ taken.has(itemKey(s.item)) ? "Already added" : [s.name, s.area].filter(Boolean).join(" · ") }}</small></span>
|
||||
<span class="tx"><strong>{{ s.label }}</strong><small>{{ taken.has(itemKey(s.item)) ? t("editor.topbar.add.added") : [s.name, s.area].filter(Boolean).join(" · ") }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f">
|
||||
<label class="f-label" for="topbar-search">An entity</label>
|
||||
<input id="topbar-search" v-model="query" type="search" placeholder="Search by name, room, or entity, e.g. temperature or door" aria-label="Search for an entity for the top bar" />
|
||||
<label class="f-label" for="topbar-search">{{ t("editor.topbar.add.entity") }}</label>
|
||||
<input id="topbar-search" v-model="query" type="search" :placeholder="t('editor.topbar.add.search')" :aria-label="t('editor.topbar.add.search_label')" />
|
||||
<div class="options">
|
||||
<button v-for="e in matches.slice(0, 40)" :key="e.id" type="button" class="option" :disabled="taken.has(itemKey(entityItem(e.id)))" @click="addTopbarItem(entityItem(e.id))">
|
||||
<span class="mdi">{{ glyph(automaticIcon(e.id)) }}</span>
|
||||
<span class="tx"><strong>{{ e.name }}</strong><small>{{ [e.area, e.id].filter(Boolean).join(" · ") }}</small></span>
|
||||
</button>
|
||||
<p v-if="!matches.length" class="hint">No entities found.</p>
|
||||
<p v-else-if="matches.length > 40" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
|
||||
<p v-if="!matches.length" class="hint">{{ t("editor.topbar.add.none_found") }}</p>
|
||||
<p v-else-if="matches.length > 40" class="hint">{{ t("editor.common.results", matches.length) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="btn quiet" @click="closeInspector">Cancel</button>
|
||||
<button type="button" class="btn quiet" @click="closeInspector">{{ t("editor.common.cancel") }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
// The top bar: the name on the left; on the right up to six items: the time, an analog clock, the date, or an
|
||||
// entity's state or last change. Choices apply live; the bar on every page follows.
|
||||
import { computed, ref } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { barLayout, BUILTIN_ICONS, clockText, dateText, glyph, itemKey } from "../model/topbar";
|
||||
import {
|
||||
automaticIcon, barMetrics, closeInspector, entityName, iconNamed, markDirty, moveTopbarItem, openBar, openBarAdd,
|
||||
removeTopbarItem, setSetting, settingValues, setTopbarItems, state, supports, topbarItems, topbarLabel, topbarMax, topbarView,
|
||||
automaticIcon, barMetrics, clock24, closeInspector, entityName, iconNamed, markDirty, moveTopbarItem, openBar, openBarAdd,
|
||||
removeTopbarItem, screenLanguage, screenText, setTopbarItems, state, supports, topbarItems, topbarLabel, topbarMax, topbarView,
|
||||
} from "../store";
|
||||
import type { HeaderItem } from "../types";
|
||||
import IconPicker from "./IconPicker.vue";
|
||||
@@ -17,17 +18,17 @@ const items = computed(() => topbarItems());
|
||||
const item = computed<HeaderItem | undefined>(() => items.value[props.index]);
|
||||
const lay = computed(() => {
|
||||
void state.fontsVersion; void state.now; void state.topbarPreviews;
|
||||
return barLayout(items.value, barMetrics.value, state.layout?.title || "Home", topbarView);
|
||||
return barLayout(items.value, barMetrics.value, state.layout?.title || screenText("editor.mockup.home"), topbarView);
|
||||
});
|
||||
const overflow = computed(() => lay.value.dropped);
|
||||
const needed = computed(() => state.inventory.header?.min_firmware || "0.2.32");
|
||||
const supported = computed(() => { const [a, b, c] = needed.value.split(".").map(Number); return supports(a, b, c); });
|
||||
const hint = computed(() => supported.value
|
||||
? overflow.value.size ? "Not everything fits next to the name: the screen drops the dashed items. Remove one or choose a shorter name." : "Drag to reorder; tap an item to configure it."
|
||||
: `Sensors, the date, and the analog clock appear from firmware ${needed.value}; until that update, this screen shows the name and, if the time is in the bar, the clock.`);
|
||||
? t(overflow.value.size ? "editor.topbar.hint.overflow" : "editor.topbar.hint.reorder")
|
||||
: t("editor.topbar.hint.needs_firmware", { version: needed.value }));
|
||||
const detail = (it: HeaderItem, i: number) => {
|
||||
const view = topbarView(it);
|
||||
return !view.shown ? "Hidden: not active right now" : overflow.value.has(i) ? "Doesn't fit next to the name" : view.analog ? "Dial" : view.text;
|
||||
return !view.shown ? t("editor.topbar.detail.hidden") : overflow.value.has(i) ? t("editor.topbar.detail.overflow") : view.analog ? t("editor.topbar.detail.dial") : view.text;
|
||||
};
|
||||
const iconOf = (it: HeaderItem) => {
|
||||
const view = topbarView(it);
|
||||
@@ -47,10 +48,13 @@ function update(patch: Partial<HeaderItem>) {
|
||||
const liveNote = computed(() => {
|
||||
if (!item.value) return "";
|
||||
const view = topbarView(item.value);
|
||||
return !view.shown ? "Hidden right now: not active" : overflow.value.has(props.index) ? "Doesn't currently fit next to the name" : "How it looks on the screen";
|
||||
return !view.shown ? t("editor.topbar.live.hidden") : overflow.value.has(props.index) ? t("editor.topbar.live.overflow") : t("editor.topbar.live.looks");
|
||||
});
|
||||
const clock24 = computed(() => settingValues().clock_24h !== false);
|
||||
const samples = computed(() => ({ clock: clockText(clock24.value, new Date(state.now)), analog: "Small dial with the time", date: dateText(new Date(state.now)) } as Record<string, string>));
|
||||
const samples = computed(() => ({
|
||||
clock: clockText(clock24.value, new Date(state.now)),
|
||||
analog: t("editor.topbar.analog_sample"),
|
||||
date: dateText(new Date(state.now), screenLanguage.value),
|
||||
} as Record<string, string>));
|
||||
|
||||
// Pointer drag between the rows, mouse and touch (touch after a short hold, so the list still scrolls). The order
|
||||
// updates while dragging, the mockup follows, and a finished drag is not a click.
|
||||
@@ -128,29 +132,29 @@ function onKey(e: KeyboardEvent, i: number) {
|
||||
<div class="dr-head">
|
||||
<span class="av mdi" style="background: var(--seg); color: var(--ink-2)">{{ item ? glyph(iconOf(item) || "F0150") : glyph("F0150") }}</span>
|
||||
<span class="tx">
|
||||
<b>{{ item ? (item.type === "entity" ? entityName(item.entity!) : topbarLabel(item)) : "Top bar" }}</b>
|
||||
<small :class="{ mono: item?.type === 'entity' }">{{ item ? (item.type === "entity" ? item.entity : "From the screen itself, also works without Home Assistant") : `${items.length} of ${topbarMax()} items` }}</small>
|
||||
<b>{{ item ? (item.type === "entity" ? entityName(item.entity!) : topbarLabel(item)) : t("editor.topbar.title") }}</b>
|
||||
<small :class="{ mono: item?.type === 'entity' }">{{ item ? (item.type === "entity" ? item.entity : t("editor.topbar.builtin")) : t("editor.topbar.items", { used: items.length }, topbarMax()) }}</small>
|
||||
</span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
<button type="button" class="icon-btn" :aria-label="t('editor.common.close')" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<label class="f-label" for="title">Left: name</label>
|
||||
<input id="title" :value="state.layout?.title" maxlength="60" placeholder="For example: Living room" @input="rename(($event.target as HTMLInputElement).value)" />
|
||||
<label class="f-label" for="title">{{ t("editor.topbar.name") }}</label>
|
||||
<input id="title" :value="state.layout?.title" maxlength="60" :placeholder="t('editor.topbar.name_placeholder')" @input="rename(($event.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label" id="topbar-caption">Right: time, sensors, or a clock <span style="text-transform: none; letter-spacing: 0; font-weight: 500"> · {{ items.length }} / {{ topbarMax() }}</span></span>
|
||||
<span class="f-label" id="topbar-caption">{{ t("editor.topbar.right") }} <span style="text-transform: none; letter-spacing: 0; font-weight: 500"> · {{ items.length }} / {{ topbarMax() }}</span></span>
|
||||
<div class="items" id="topbar-chips" role="list" aria-labelledby="topbar-caption">
|
||||
<div v-for="(it, i) in items" :key="itemKey(it) + i" class="item" role="listitem" tabindex="0" :data-index="i"
|
||||
:class="{ selected: i === index, 'is-hidden': !topbarView(it).shown, 'is-overflow': overflow.has(i), 'just-added': justAdded(it), 'dragging-chip': drag.active && drag.index === i }"
|
||||
:aria-label="`${topbarLabel(it)}, slot ${i + 1}. Enter: configure, arrows: move`"
|
||||
:aria-label="t('editor.topbar.item_label', { name: topbarLabel(it), slot: i + 1 })"
|
||||
@pointerdown="down($event, i)" @click="pick(i)" @keydown="onKey($event, i)">
|
||||
<span class="grip" aria-hidden="true">⋮⋮</span>
|
||||
<span class="av mdi" :style="topbarView(it).color ? { color: topbarView(it).color! } : undefined">{{ iconOf(it) ? glyph(iconOf(it)!) : "" }}</span>
|
||||
<span class="tx"><b>{{ topbarLabel(it) }}</b><small>{{ detail(it, i) }}</small></span>
|
||||
<button type="button" class="x" :aria-label="`Remove ${topbarLabel(it)} from the top bar`" @click.stop="removeTopbarItem(i)">✕</button>
|
||||
<button type="button" class="x" :aria-label="t('editor.topbar.remove_named', { name: topbarLabel(it) })" @click.stop="removeTopbarItem(i)">✕</button>
|
||||
</div>
|
||||
<button type="button" class="ghost-btn" id="topbar-add" :disabled="items.length >= topbarMax()" :title="items.length >= topbarMax() ? `Maximum ${topbarMax()} items` : 'Add the time, date, analog clock, or an entity'" @click="openBarAdd">+ Add time, date, or an entity</button>
|
||||
<button type="button" class="ghost-btn" id="topbar-add" :disabled="items.length >= topbarMax()" :title="items.length >= topbarMax() ? t('editor.topbar.max', topbarMax()) : t('editor.topbar.add_title')" @click="openBarAdd">{{ t("editor.topbar.add_button") }}</button>
|
||||
</div>
|
||||
<small id="topbar-hint" :class="{ warn: overflow.size > 0 && supported }">{{ hint }}</small>
|
||||
</div>
|
||||
@@ -161,29 +165,28 @@ function onKey(e: KeyboardEvent, i: number) {
|
||||
</div>
|
||||
<template v-if="item.type === 'entity'">
|
||||
<div class="f">
|
||||
<span class="f-label">What to show</span>
|
||||
<span class="f-label">{{ t("editor.topbar.content.label") }}</span>
|
||||
<Segmented :choices="(state.inventory.header?.contents || []).map((c) => [c.key, c.label] as [string, string])" :value="item.content" @pick="(v) => update({ content: v })" />
|
||||
<small>Last changed keeps counting on the screen itself: “Just now”, “5 min ago”, “Yesterday”.</small>
|
||||
<small>{{ t("editor.topbar.content.hint") }}</small>
|
||||
</div>
|
||||
<IconPicker :selected="item.icon || 'auto'" :automatic="state.topbarPreviews[itemKey(item)]?.auto_icon || automaticIcon(item.entity!)" auto-label="Automatic (like Home Assistant)" allow-none @pick="(n) => update({ icon: n })" />
|
||||
<IconPicker :selected="item.icon || 'auto'" :automatic="state.topbarPreviews[itemKey(item)]?.auto_icon || automaticIcon(item.entity!)" :auto-label="t('editor.topbar.auto_icon')" allow-none @pick="(n) => update({ icon: n })" />
|
||||
<div class="f">
|
||||
<span class="f-label">Show</span>
|
||||
<span class="f-label">{{ t("editor.topbar.show.label") }}</span>
|
||||
<Segmented :choices="(state.inventory.header?.shows || []).map((s) => [s.key, s.label] as [string, string])" :value="item.show" @pick="(v) => update({ show: v })" />
|
||||
<small>Only when active hides the item as long as it's off, closed, away, or 0. Handy for an open door, a running washing machine, or who's home.</small>
|
||||
<small>{{ t("editor.topbar.show.hint") }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="item.type !== 'date'" class="f">
|
||||
<span class="f-label">Format</span>
|
||||
<Segmented :choices="[['24', '24 hour'], ['12', '12 hour']]" :value="clock24 ? '24' : '12'" @pick="(v) => setSetting('clock_24h', v === '24', 150)" />
|
||||
<small>The screen's own clock setting: it applies at once to every clock on this screen, including the clock tiles.</small>
|
||||
</div>
|
||||
<small v-else>{{ samples.date }} today. The date follows the screen's own clock.</small>
|
||||
<!-- The clock's format is one choice for every screen, under Settings → Language & region (app 0.2.90). -->
|
||||
<i18n-t v-else-if="item.type !== 'date'" :keypath="clock24 ? 'editor.topbar.clock_24' : 'editor.topbar.clock_12'" tag="small" id="topbar-clock" scope="global">
|
||||
<template #settings><a href="#settings">{{ t("editor.topbar.clock_settings") }}</a></template>
|
||||
</i18n-t>
|
||||
<small v-else>{{ t("editor.topbar.date_hint", { date: samples.date }) }}</small>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="item" class="dr-foot">
|
||||
<button type="button" class="btn danger" @click="removeTopbarItem(index)">Remove</button>
|
||||
<button type="button" class="btn danger" @click="removeTopbarItem(index)">{{ t("editor.common.remove") }}</button>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="btn quiet" :disabled="index === 0" @click="moveTopbarItem(index, index - 1) && openBar(index - 1)">↑ Up</button>
|
||||
<button type="button" class="btn quiet" :disabled="index >= items.length - 1" @click="moveTopbarItem(index, index + 1) && openBar(index + 1)">↓ Down</button>
|
||||
<button type="button" class="btn quiet" :disabled="index === 0" @click="moveTopbarItem(index, index - 1) && openBar(index - 1)">{{ t("editor.topbar.up") }}</button>
|
||||
<button type="button" class="btn quiet" :disabled="index >= items.length - 1" @click="moveTopbarItem(index, index + 1) && openBar(index + 1)">{{ t("editor.topbar.down") }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// The bar at the top of a mockup page, drawn as the screen draws it: name left, items right.
|
||||
import { computed } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { barLayout, dotted, type BarPart } from "../model/topbar";
|
||||
import { barMetrics, state, topbarItems, topbarView } from "../store";
|
||||
import { barMetrics, screenText, state, topbarItems, topbarView } from "../store";
|
||||
|
||||
const props = defineProps<{ items?: any[]; nameText?: string; single?: boolean }>();
|
||||
|
||||
@@ -11,7 +12,7 @@ const lay = computed(() => {
|
||||
void state.now;
|
||||
void state.topbarPreviews;
|
||||
const items = props.items || topbarItems();
|
||||
return barLayout(items, barMetrics.value, props.nameText ?? (state.layout?.title || "Home"), topbarView);
|
||||
return barLayout(items, barMetrics.value, props.nameText ?? (state.layout?.title || screenText("editor.mockup.home")), topbarView);
|
||||
});
|
||||
const m = computed(() => lay.value.metrics);
|
||||
const height = computed(() => (props.single ? Math.round(m.value.text * 1.4) : m.value.top + Math.round(m.value.name * 0.45)));
|
||||
@@ -43,7 +44,7 @@ defineExpose({ lay });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :viewBox="viewBox" role="img" :aria-label="single ? 'How it looks on the screen' : `Top bar: ${lay.nameText}`" :style="single ? { width: singleWidth } : undefined">
|
||||
<svg :viewBox="viewBox" role="img" :aria-label="single ? t('editor.topbar.live.looks') : t('editor.topbar.aria', { name: lay.nameText })" :style="single ? { width: singleWidth } : undefined">
|
||||
<text v-if="!single" x="0" :y="m.top" fill="#1b1b1b" :style="{ font: lay.fonts.name }">{{ name }}</text>
|
||||
<g>
|
||||
<template v-for="p in parts" :key="p.index">
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
// The editor's texts (app 0.2.90). Every language is one file in screen_manager/translations, the same file the firmware
|
||||
// and the add-on read (docs/TRANSLATING.md). The page takes its `editor` section, and for the mockup the words the screens
|
||||
// themselves show (`screen.ha`, `time`, `date`, `number`, `tile.page`); the build leaves the rest out and compiles every
|
||||
// text (vite.config.ts). English is the source and comes with the page. Another language loads the first time the
|
||||
// editor or a mockup needs it, and a text it doesn't have yet shows in English.
|
||||
import metas from "virtual:esp-screens-languages";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import english from "../../screen_manager/translations/en.json";
|
||||
|
||||
export type LanguageMeta = { name: string; english: string; script?: string; plural: string; clock?: string; checked: boolean };
|
||||
// A language's texts as the build hands them over: compiled, and only the page's part (not the file's own shape).
|
||||
type Texts = Record<string, unknown>;
|
||||
const en = english as unknown as Texts;
|
||||
|
||||
// Which form of a plural text ("1 hour ago | {n} hours ago") fits n, by the file's `_meta.plural`: the same rules as the
|
||||
// screens (components/smart_display/screen_text_gen.py).
|
||||
const few = (n: number) => n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14);
|
||||
export const PLURAL_RULES: Record<string, (n: number) => number> = {
|
||||
one_other: (n) => (n === 1 ? 0 : 1),
|
||||
one_upto_1: (n) => (Math.abs(n) <= 1 ? 0 : 1),
|
||||
slavic_pl: (n) => (n === 1 ? 0 : few(n) ? 1 : 2),
|
||||
east_slavic: (n) => (n % 10 === 1 && n % 100 !== 11 ? 0 : few(n) ? 1 : 2),
|
||||
none: () => 0,
|
||||
};
|
||||
|
||||
// Every other language by its code, as a chunk of its own; every language's _meta comes with the page.
|
||||
const files = import.meta.glob<Texts>(
|
||||
["../../screen_manager/translations/*.json", "!../../screen_manager/translations/en.json"], { import: "default" });
|
||||
const LOADERS: Record<string, () => Promise<Texts>> = Object.fromEntries(
|
||||
Object.entries(files).map(([path, load]) => [path.slice(path.lastIndexOf("/") + 1, -".json".length), load]));
|
||||
const META: Record<string, LanguageMeta> = { ...metas };
|
||||
const LOADED = new Set(["en"]);
|
||||
// A text with fewer forms than its language's rule uses its last one, as on the screens.
|
||||
const RULES: Record<string, (choice: number, forms: number) => number> = {};
|
||||
const addRule = (code: string) =>
|
||||
(RULES[code] = (choice, forms) => Math.min((PLURAL_RULES[META[code]?.plural] || PLURAL_RULES.one_other)(choice), forms - 1));
|
||||
|
||||
/** Every language the editor has, English first. */
|
||||
export const languages = () => ["en", ...Object.keys(META).filter((code) => code !== "en").sort()];
|
||||
languages().forEach(addRule);
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
fallbackLocale: "en",
|
||||
messages: { en } as Record<string, any>,
|
||||
pluralRules: RULES,
|
||||
// A language without a text falls back to English quietly. Only a key English lacks is a mistake, told while developing.
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
missing: (locale, key) => {
|
||||
if (import.meta.env.DEV && locale === "en") console.warn(`[i18n] ${key} is not in en.json`);
|
||||
},
|
||||
});
|
||||
export const t = i18n.global.t;
|
||||
/** Whether a text exists, in English when no language is named. */
|
||||
export const te = (key: string, locale = "en"): boolean => (i18n.global.te as (key: string, locale?: string) => boolean)(key, locale);
|
||||
export const editorLanguage = () => i18n.global.locale.value;
|
||||
/** A language's `_meta`: its own name, its name in English, its plural rule, whether it is checked. */
|
||||
export const languageMeta = (code: string): LanguageMeta | undefined => META[code];
|
||||
|
||||
// Home Assistant opens the page in its ingress iframe, on its own origin, and sets its <html lang> to the language of
|
||||
// the user's profile. Elsewhere (SCREEN_DEV, `npm run dev`) the browser's language, and English after that.
|
||||
export function requestedLanguage(win: Window = window) {
|
||||
try {
|
||||
if (win.parent !== win) {
|
||||
const lang = win.parent.document.documentElement.lang;
|
||||
if (lang) return lang;
|
||||
}
|
||||
} catch {
|
||||
// A parent on another origin keeps its document to itself.
|
||||
}
|
||||
return win.navigator.language || "en";
|
||||
}
|
||||
// The language of the list that fits a code best: the same one (pt-BR), else its base language (pt), else none.
|
||||
export function matchLanguage(wanted: string | null | undefined, available = languages()) {
|
||||
const find = (code: string) => available.find((own) => own.toLowerCase() === code.toLowerCase());
|
||||
const code = (wanted || "").trim();
|
||||
return code ? find(code) || find(code.split("-")[0]) : undefined;
|
||||
}
|
||||
export const pickLanguage = (wanted: string | null | undefined, available = languages()) => matchLanguage(wanted, available) || "en";
|
||||
|
||||
/** A language's texts, as the build hands them over; `meta` for a language without a file (the tests). */
|
||||
export function addLanguage(code: string, texts: Texts, meta: LanguageMeta | undefined = META[code]) {
|
||||
if (meta) META[code] = meta;
|
||||
if (!RULES[code]) addRule(code);
|
||||
i18n.global.setLocaleMessage(code, texts as any);
|
||||
LOADED.add(code);
|
||||
}
|
||||
const loading: Record<string, Promise<boolean>> = {};
|
||||
/** Loads a language once; true when the editor has it. */
|
||||
export function loadLanguage(code: string): Promise<boolean> {
|
||||
if (LOADED.has(code)) return Promise.resolve(true);
|
||||
const load = LOADERS[code];
|
||||
if (!load) return Promise.resolve(false);
|
||||
return (loading[code] ??= load().then(
|
||||
(texts) => (addLanguage(code, texts), true),
|
||||
() => (delete loading[code], false),
|
||||
));
|
||||
}
|
||||
/** The editor's own language: the one of the user's Home Assistant profile, as far as ESP Screens has it. */
|
||||
export async function setEditorLanguage(code = pickLanguage(requestedLanguage())) {
|
||||
const chosen = (await loadLanguage(code)) ? code : "en";
|
||||
i18n.global.locale.value = chosen;
|
||||
document.documentElement.lang = chosen;
|
||||
return chosen;
|
||||
}
|
||||
|
||||
// How the screens write a number (app 0.2.90), as the firmware does (screen_text::localize): the decimal mark, the mark
|
||||
// between thousands, and from how many digits a whole number gets them. The choice under Language & region is one of
|
||||
// three styles; "auto" writes numbers as the screens' language does (its screen.number).
|
||||
export type NumberStyle = "point" | "comma" | "space";
|
||||
export type NumberMarks = { decimal: string; group: string; from: number };
|
||||
export const STYLE_MARKS: Record<NumberStyle, NumberMarks> = {
|
||||
point: { decimal: ".", group: ",", from: 4 }, comma: { decimal: ",", group: ".", from: 4 }, space: { decimal: ",", group: " ", from: 4 },
|
||||
};
|
||||
/** The marks of a language's own numbers: "1.234,5" in Dutch, 1234 but 12.345 in Italian (CLDR's minimum grouping). */
|
||||
export function languageMarks(locale: string): NumberMarks {
|
||||
const say = (key: string) => t(`screen.number.${key}`, {}, { locale });
|
||||
return { decimal: say("decimal"), group: say("group"), from: Number(say("group_min")) >= 2 ? 5 : 4 };
|
||||
}
|
||||
/** A state as Home Assistant sends a number ("1234.5", "-3"), written with these marks; any other text as it is. */
|
||||
export function numberText(value: string | number, marks: NumberMarks) {
|
||||
const text = String(value), plain = /^(-?)(\d+)(?:\.(\d+))?$/.exec(text);
|
||||
if (!plain) return text;
|
||||
const whole = plain[2].length >= marks.from ? plain[2].replace(/\B(?=(\d{3})+$)/g, marks.group) : plain[2];
|
||||
return `${plain[1]}${whole}${plain[3] !== undefined ? marks.decimal + plain[3] : ""}`;
|
||||
}
|
||||
|
||||
// The time and number format of the user's own Home Assistant profile, where it names one: a 12- or 24-hour clock, and
|
||||
// "1,234.5" (comma_decimal), "1.234,5" (decimal_comma) or "1 234,5" (space_comma). "Follow the language", "use the
|
||||
// system's" and "none" name none. Only inside Home Assistant's page, whose <home-assistant> element holds them.
|
||||
const PROFILE_NUMBERS: Record<string, NumberStyle> = { comma_decimal: "point", decimal_comma: "comma", space_comma: "space" };
|
||||
export function haProfile(win: Window = window): { clock?: "12" | "24"; numbers?: NumberStyle } {
|
||||
try {
|
||||
if (win.parent === win) return {};
|
||||
const locale = (win.parent.document.querySelector("home-assistant") as any)?.hass?.locale;
|
||||
const clock = ["12", "24"].includes(locale?.time_format) ? locale.time_format : undefined;
|
||||
return { ...(clock ? { clock } : {}), ...(PROFILE_NUMBERS[locale?.number_format] ? { numbers: PROFILE_NUMBERS[locale.number_format] } : {}) };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// "1, 2 and 3", the way the language writes a list (the CLDR, from the browser). The English texts write it without a
|
||||
// comma before "and", as British English does.
|
||||
export const andList = (items: (string | number)[], locale = editorLanguage()) =>
|
||||
new Intl.ListFormat(locale === "en" ? "en-GB" : locale, { type: "conjunction" }).format(items.map(String));
|
||||
+7
-2
@@ -2,7 +2,12 @@ import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/app.css";
|
||||
import { i18n, setEditorLanguage } from "./i18n";
|
||||
import { boot } from "./store";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
boot();
|
||||
// The page draws once its language is there, so it never flashes English first, and the first request already asks the
|
||||
// add-on for that language (app 0.2.90).
|
||||
setEditorLanguage().finally(() => {
|
||||
createApp(App).use(i18n).mount("#app");
|
||||
boot();
|
||||
});
|
||||
|
||||
+38
-33
@@ -3,6 +3,7 @@
|
||||
// cell (page * 6 + row * 2 + column); a wide tile starts in the left column and also covers
|
||||
// the cell to its right; a full tile (firmware 0.2.62+) starts a page and covers all six cells.
|
||||
// Empty cells are allowed and stay exactly where they are.
|
||||
import { t } from "../i18n";
|
||||
import type { Inventory, Layout, Tile } from "../types";
|
||||
|
||||
export const SLOTS_PER_PAGE = 6;
|
||||
@@ -154,7 +155,7 @@ export function effectiveControls(tile: Tile, inventory: Inventory): string | nu
|
||||
}
|
||||
export function controlsLabel(tile: Tile, inventory: Inventory) {
|
||||
const key = effectiveControls(tile, inventory);
|
||||
if (!key) return "none";
|
||||
if (!key) return t("editor.inspect.control_none");
|
||||
return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label.toLocaleLowerCase() || key;
|
||||
}
|
||||
|
||||
@@ -174,39 +175,43 @@ export function tileLimit(firmware: string | undefined | null) {
|
||||
if (parseVersion(firmware).length !== 3) return 10;
|
||||
return versionAtLeast(firmware, "0.2.62") ? MAX_TILES : versionAtLeast(firmware, "0.2.7") ? 20 : 10;
|
||||
}
|
||||
export const displayNames: Record<string, string> = {
|
||||
standard: "standard", watch: "large value", forecast: "weather forecast", graph: "graph",
|
||||
digital: "digital clock", analog: "analog clock", sunpath: "sun path",
|
||||
};
|
||||
export const sizeNames: Record<string, string> = { single: "normal", wide: "double", full: "full page" };
|
||||
// What a tile shows and how big it is, in a few words (editor.displays, editor.sizes); a key it doesn't know stays as it is.
|
||||
export const DISPLAYS = ["standard", "watch", "forecast", "graph", "digital", "analog", "sunpath"];
|
||||
export const displayName = (display: string) => (DISPLAYS.includes(display) ? t(`editor.displays.${display}`) : display);
|
||||
export const sizeName = (size: string | undefined) => t(`editor.sizes.${SIZES.includes(size as Size) ? size : "single"}`);
|
||||
export const TOGGLE_BEFORE = ["light", "switch", "input_boolean", "fan", "media_player", "climate"];
|
||||
export const SLIDER_DOMAINS = ["light", "fan", "cover", "number", "input_number", "media_player"];
|
||||
|
||||
export const domains: Record<string, [string, string, string, string]> = {
|
||||
light: ["Light", "☀", "#ad7600", "#fff3d3"],
|
||||
climate: ["Climate", "❄", "#c86620", "#ffebdc"],
|
||||
vacuum: ["Vacuum", "◉", "#008577", "#def3ed"],
|
||||
fan: ["Fan", "✣", "#008aab", "#def5fa"],
|
||||
cover: ["Cover", "▤", "#8053af", "#eee5f8"],
|
||||
media_player: ["Media", "▶", "#007cad", "#def2fc"],
|
||||
sensor: ["Sensor", "⌁", "#3476b1", "#e5effa"],
|
||||
binary_sensor: ["Status", "◈", "#ad7600", "#fff3d3"],
|
||||
switch: ["Switch", "⏻", "#ad7600", "#fff3d3"],
|
||||
input_boolean: ["Switch", "⏻", "#ad7600", "#fff3d3"],
|
||||
scene: ["Scene", "✦", "#8053af", "#eee5f8"],
|
||||
script: ["Script", "▷", "#8053af", "#eee5f8"],
|
||||
weather: ["Weather", "☁", "#007cad", "#def2fc"],
|
||||
number: ["Value", "±", "#008577", "#def3ed"],
|
||||
input_number: ["Value", "±", "#008577", "#def3ed"],
|
||||
select: ["Select", "≡", "#5862af", "#eaecfa"],
|
||||
input_select: ["Select", "≡", "#5862af", "#eaecfa"],
|
||||
button: ["Action", "↗", "#5862af", "#eaecfa"],
|
||||
input_button: ["Action", "↗", "#5862af", "#eaecfa"],
|
||||
screen: ["Screen", "◷", "#25282c", "#e9ecf1"],
|
||||
sun: ["Sun", "☼", "#c86620", "#ffebdc"],
|
||||
timer: ["Timer", "⏱", "#008577", "#def3ed"],
|
||||
person: ["Person", "☺", "#2f7d32", "#e1f2e2"],
|
||||
camera: ["Camera", "◧", "#3d4a57", "#e6ebf0"],
|
||||
image: ["Image", "◧", "#3d4a57", "#e6ebf0"],
|
||||
// Per domain its sign and colours; its name is the text editor.domains.<domain> (app 0.2.90).
|
||||
export const domains: Record<string, [string, string, string]> = {
|
||||
light: ["☀", "#ad7600", "#fff3d3"],
|
||||
climate: ["❄", "#c86620", "#ffebdc"],
|
||||
vacuum: ["◉", "#008577", "#def3ed"],
|
||||
fan: ["✣", "#008aab", "#def5fa"],
|
||||
cover: ["▤", "#8053af", "#eee5f8"],
|
||||
media_player: ["▶", "#007cad", "#def2fc"],
|
||||
sensor: ["⌁", "#3476b1", "#e5effa"],
|
||||
binary_sensor: ["◈", "#ad7600", "#fff3d3"],
|
||||
switch: ["⏻", "#ad7600", "#fff3d3"],
|
||||
input_boolean: ["⏻", "#ad7600", "#fff3d3"],
|
||||
scene: ["✦", "#8053af", "#eee5f8"],
|
||||
script: ["▷", "#8053af", "#eee5f8"],
|
||||
weather: ["☁", "#007cad", "#def2fc"],
|
||||
number: ["±", "#008577", "#def3ed"],
|
||||
input_number: ["±", "#008577", "#def3ed"],
|
||||
select: ["≡", "#5862af", "#eaecfa"],
|
||||
input_select: ["≡", "#5862af", "#eaecfa"],
|
||||
button: ["↗", "#5862af", "#eaecfa"],
|
||||
input_button: ["↗", "#5862af", "#eaecfa"],
|
||||
screen: ["◷", "#25282c", "#e9ecf1"],
|
||||
sun: ["☼", "#c86620", "#ffebdc"],
|
||||
timer: ["⏱", "#008577", "#def3ed"],
|
||||
person: ["☺", "#2f7d32", "#e1f2e2"],
|
||||
camera: ["◧", "#3d4a57", "#e6ebf0"],
|
||||
image: ["◧", "#3d4a57", "#e6ebf0"],
|
||||
};
|
||||
export const domainInfo = (id: string) => domains[id.split(".")[0]] || (["Entity", "◇", "#637184", "#edf0f4"] as [string, string, string, string]);
|
||||
// [name, sign, colour, background] of an entity's domain.
|
||||
export function domainInfo(id: string): [string, string, string, string] {
|
||||
const domain = id.split(".")[0], known = domains[domain];
|
||||
return known ? [t(`editor.domains.${domain}`), ...known] : [t("editor.domains.entity"), "◇", "#637184", "#edf0f4"];
|
||||
}
|
||||
|
||||
+23
-17
@@ -3,10 +3,11 @@
|
||||
// time too) in the same 400 Roboto on the name's baseline; icons and the dial centred on the
|
||||
// height of the digits; the gaps measured between what you see (glyph ink), not between boxes, so
|
||||
// an icon with side bearings sits exactly as close to its value as one without.
|
||||
import { t } from "../i18n";
|
||||
import type { HeaderItem } from "../types";
|
||||
|
||||
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
export const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
// The words are the screens' own (app 0.2.90): screen.date and screen.time of the translations, in the language the
|
||||
// screens have, which `locale` names; English where none is given.
|
||||
export const BUILTIN_ICONS: Record<string, string> = { clock: "clock-outline", analog: "clock-outline", date: "calendar" };
|
||||
export const itemKey = (item: HeaderItem) => JSON.stringify(item);
|
||||
export const glyph = (cp: string) => String.fromCodePoint(parseInt(cp, 16));
|
||||
@@ -17,24 +18,29 @@ export function clockText(clock24: boolean, now = new Date()) {
|
||||
if (!clock24) hours = hours % 12 || 12;
|
||||
return `${String(hours).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
export const dateText = (now = new Date()) => `${WEEKDAYS[now.getDay()]} ${now.getDate()} ${MONTHS[now.getMonth()]}`;
|
||||
// Same wording and thresholds as header_bar::ago_text() in the firmware.
|
||||
export function agoText(then: number, now = Math.floor(Date.now() / 1000)) {
|
||||
export const dateText = (now = new Date(), locale = "en") => t("screen.date.top_bar", {
|
||||
weekday: t(`screen.date.weekdays_min.${now.getDay()}`, {}, { locale }),
|
||||
day: now.getDate(),
|
||||
month: t(`screen.date.months_short.${now.getMonth()}`, {}, { locale }),
|
||||
}, { locale });
|
||||
// Same thresholds as header_bar::ago_text() in the firmware, and the same words.
|
||||
export function agoText(then: number, now = Math.floor(Date.now() / 1000), locale = "en") {
|
||||
const seconds = now - then, span = Math.abs(seconds), per = (unit: number) => Math.floor(span / unit);
|
||||
const say = (key: string, n?: number) => (n === undefined ? t(`screen.time.${key}`, {}, { locale }) : t(`screen.time.${key}`, n, { locale }));
|
||||
if (seconds < 0) {
|
||||
if (span < 3600) return `In ${Math.max(1, per(60))} min`;
|
||||
if (span < 86400) return per(3600) === 1 ? "In 1 hour" : `In ${per(3600)} hours`;
|
||||
if (span < 172800) return "Tomorrow";
|
||||
return `In ${per(86400)} days`;
|
||||
if (span < 3600) return say("in_minutes", Math.max(1, per(60)));
|
||||
if (span < 86400) return say("in_hours", per(3600));
|
||||
if (span < 172800) return say("tomorrow");
|
||||
return say("in_days", per(86400));
|
||||
}
|
||||
if (span < 60) return "Just now";
|
||||
if (span < 3600) return `${per(60)} min ago`;
|
||||
if (span < 86400) return per(3600) === 1 ? "1 hour ago" : `${per(3600)} hours ago`;
|
||||
if (span < 172800) return "Yesterday";
|
||||
if (span < 604800) return `${per(86400)} days ago`;
|
||||
if (span < 2592000) return per(604800) === 1 ? "1 week ago" : `${per(604800)} weeks ago`;
|
||||
if (span < 31536000) return per(2592000) === 1 ? "1 month ago" : `${per(2592000)} months ago`;
|
||||
return per(31536000) === 1 ? "1 year ago" : `${per(31536000)} years ago`;
|
||||
if (span < 60) return say("just_now");
|
||||
if (span < 3600) return say("minutes_ago", per(60));
|
||||
if (span < 86400) return say("hours_ago", per(3600));
|
||||
if (span < 172800) return say("yesterday");
|
||||
if (span < 604800) return say("days_ago", per(86400));
|
||||
if (span < 2592000) return say("weeks_ago", per(604800));
|
||||
if (span < 31536000) return say("months_ago", per(2592000));
|
||||
return say("years_ago", per(31536000));
|
||||
}
|
||||
|
||||
export type BarMetrics = { width: number; top: number; name: number; text: number; icon: number };
|
||||
|
||||
+133
-80
@@ -1,7 +1,8 @@
|
||||
// One reactive state for the whole editor. The Python API (server.py) is unchanged: this file is the
|
||||
// former app.js state and its calls, with the DOM work moved into the components.
|
||||
import { computed, reactive, toRaw } from "vue";
|
||||
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 {
|
||||
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,
|
||||
@@ -102,11 +103,13 @@ export function toast(message: string, action?: { label: string; run: () => void
|
||||
export function dismissToast() {
|
||||
state.toast = null;
|
||||
}
|
||||
export async function copyText(text: string, element?: Element | null, what = "API key") {
|
||||
// What was copied, each with its own sentences so every language can say it its own way.
|
||||
export type Copied = "api_key" | "layout_json" | "action_name" | "yaml" | "icon_name" | "empty_color" | "color_name";
|
||||
export async function copyText(text: string, element?: Element | null, what: Copied = "api_key") {
|
||||
try {
|
||||
if (!navigator.clipboard || !window.isSecureContext) throw new Error();
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast(`${what} copied.`);
|
||||
toast(t(`editor.copy.${what}.copied`));
|
||||
} catch {
|
||||
if (element) {
|
||||
const range = document.createRange();
|
||||
@@ -115,7 +118,7 @@ export async function copyText(text: string, element?: Element | null, what = "A
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
toast(document.execCommand("copy") ? `${what} copied.` : `${what} is selected. Copy with Ctrl+C or Command+C.`);
|
||||
toast(t(document.execCommand("copy") ? `editor.copy.${what}.copied` : `editor.copy.${what}.selected`));
|
||||
}
|
||||
}
|
||||
export function openIntegrations() {
|
||||
@@ -231,7 +234,7 @@ export function select(id: string | null) {
|
||||
go("");
|
||||
return;
|
||||
}
|
||||
if (id !== state.selected && state.dirty && !confirm("You have unsaved changes. Open a different screen anyway?")) return;
|
||||
if (id !== state.selected && state.dirty && !confirm(t("editor.screen_view.confirm.switch"))) return;
|
||||
if (id !== state.selected) {
|
||||
flushSettings();
|
||||
state.settingEdits = {};
|
||||
@@ -294,7 +297,7 @@ export function removeTile(tile: Tile) {
|
||||
if (isSelected(tile)) closeInspector();
|
||||
markDirty();
|
||||
layout.pages = pageCount(entriesOf(layout), layout.pages);
|
||||
toast(`${tile.name || entityName(tile.entity)} removed`, { label: "Undo", run: () => placeTile(tile, tile.slot) });
|
||||
toast(t("editor.layout.removed", { name: tile.name || entityName(tile.entity) }), { label: t("editor.common.undo"), run: () => placeTile(tile, tile.slot) });
|
||||
}
|
||||
export function addPage() {
|
||||
const layout = state.layout;
|
||||
@@ -318,7 +321,7 @@ export function moveTileToPage(tile: Tile, page: number) {
|
||||
if (!layout || !Number.isInteger(page) || page < 0 || page >= MAX_PAGES || page === pageOf(tile.slot)) return false;
|
||||
const slot = firstFree(occupied(entriesOf(layout).filter((e) => e.tile !== tile)), sizeOf(tile), page * SLOTS_PER_PAGE);
|
||||
const moved = placeTile(tile, slot >= 0 && pageOf(slot) === page ? slot : page * SLOTS_PER_PAGE);
|
||||
if (!moved) toast(`There is no room for this tile on page ${page + 1}.`);
|
||||
if (!moved) toast(t("editor.layout.no_room", { page: page + 1 }));
|
||||
return moved;
|
||||
}
|
||||
export function pagesShown() {
|
||||
@@ -362,7 +365,7 @@ export function setTileOption(tile: Tile, key: string, value: unknown) {
|
||||
else {
|
||||
const slot = firstFree(occupied(entriesOf(layout).filter((e) => e.tile !== tile)), "full");
|
||||
if (slot >= 0) tile.slot = slot;
|
||||
else { tile.options.size = wasSize; toast("No page is free for a full-page tile. Free a page first."); }
|
||||
else { tile.options.size = wasSize; toast(t("editor.layout.no_free_page")); }
|
||||
}
|
||||
} else if (isWide(tile) && !wasWide) {
|
||||
const taken = occupied(entriesOf(layout).filter((e) => e.tile !== tile)), own = rowStart(tile.slot);
|
||||
@@ -377,7 +380,7 @@ export function setTileOption(tile: Tile, key: string, value: unknown) {
|
||||
export function retargetPageTile(tile: Tile, page: number) {
|
||||
const entity = `screen.page_${page}`, layout = state.layout;
|
||||
if (!layout || entity === tile.entity || !pageTarget(entity)) return false;
|
||||
if (!repeatable(entity) && layout.tiles.some((t) => t.entity === entity)) { toast(`This screen already has a tile that goes to page ${page}.`); return false; }
|
||||
if (!repeatable(entity) && layout.tiles.some((t) => t.entity === entity)) { toast(t("editor.layout.page_taken", { page })); return false; }
|
||||
tile.entity = entity;
|
||||
layout.pages = Math.max(pageCount(entriesOf(layout), layout.pages), page);
|
||||
markDirty();
|
||||
@@ -416,12 +419,14 @@ export async function save() {
|
||||
// Screen settings apply on their own (flushSettings); the stored ones stay as they are.
|
||||
const { settings: _settings, ...tiles } = state.layout;
|
||||
await send(`screens/${encodeURIComponent(state.selected)}`, "PUT", tiles);
|
||||
if (state.selected !== screen) toast(`Saved. ${state.inventory.screens.find((s) => s.id === screen)?.name || "The screen"} is being updated.`);
|
||||
else if (edits === sent) {
|
||||
if (state.selected !== screen) {
|
||||
const name = state.inventory.screens.find((s) => s.id === screen)?.name;
|
||||
toast(name ? t("editor.screen_view.saved.other", { name }) : t("editor.screen_view.saved.other_unnamed"));
|
||||
} else if (edits === sent) {
|
||||
state.dirty = false;
|
||||
state.saved = Date.now();
|
||||
toast("Saved. Your screen is being updated.");
|
||||
} else toast("Saved. Your newest change isn't sent yet: press Save & send again.");
|
||||
toast(t("editor.screen_view.saved.current"));
|
||||
} else toast(t("editor.screen_view.saved.newer_edit"));
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
@@ -436,7 +441,7 @@ export const canAlert = (screen: Screen | undefined) =>
|
||||
export async function identify(screen: Screen) {
|
||||
try {
|
||||
await send(`screens/${encodeURIComponent(screen.id)}/identify`, "POST");
|
||||
toast(`${screen.name} blinks and shows a card for a few seconds.`);
|
||||
toast(t("editor.screen_view.identified", { name: screen.name }));
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
@@ -447,7 +452,8 @@ export async function sendTestAlert(target: string, data: Record<string, unknown
|
||||
|
||||
// ---- Copying and sharing a layout (app 0.2.73) ----
|
||||
const LAYOUT_KEYS = ["title", "tiles", "header", "pages"] as const;
|
||||
function adopt(source: Partial<Layout>, what: string) {
|
||||
// `said` tells what happened, with how many tiles fit when not all of them do.
|
||||
function adopt(source: Partial<Layout>, said: (fit?: { kept: number; total: number }) => string) {
|
||||
const layout = state.layout;
|
||||
if (!layout) return;
|
||||
const tiles = (Array.isArray(source.tiles) ? source.tiles : [])
|
||||
@@ -468,14 +474,13 @@ function adopt(source: Partial<Layout>, what: string) {
|
||||
loadCapabilities(kept.map((t) => t.entity));
|
||||
loadStates();
|
||||
loadTopbarPreview(0);
|
||||
toast(kept.length < unique.length
|
||||
? `${what}: ${kept.length} of ${unique.length} tiles fit this screen's firmware. Save & send when it looks right.`
|
||||
: `${what}. Save & send when it looks right.`);
|
||||
toast(said(kept.length < unique.length ? { kept: kept.length, total: unique.length } : undefined));
|
||||
}
|
||||
export function copyLayoutFrom(id: string) {
|
||||
const other = state.inventory.screens.find((s) => s.id === id);
|
||||
if (!other || !state.layout) return;
|
||||
adopt(JSON.parse(JSON.stringify(other.layout)), `Layout of ${other.name} copied`);
|
||||
adopt(JSON.parse(JSON.stringify(other.layout)), (fit) =>
|
||||
fit ? t("editor.layout.copied_part", { name: other.name, ...fit }) : t("editor.layout.copied", { name: other.name }));
|
||||
}
|
||||
export function layoutJson() {
|
||||
const layout = state.layout;
|
||||
@@ -492,13 +497,13 @@ export function exportLayout() {
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = name; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
copyText(text, undefined, "Layout JSON");
|
||||
copyText(text, undefined, "layout_json");
|
||||
}
|
||||
export function importLayout(text: string) {
|
||||
let data: any;
|
||||
try { data = JSON.parse(text); } catch { toast("That isn't JSON. Export a layout first, or paste one from another ESP Screens."); return; }
|
||||
if (!data || typeof data !== "object" || !Array.isArray(data.tiles)) { toast("No tiles in this file. Expected a layout exported from ESP Screens."); return; }
|
||||
adopt(data, "Layout imported");
|
||||
try { data = JSON.parse(text); } catch { toast(t("editor.layout.not_json")); return; }
|
||||
if (!data || typeof data !== "object" || !Array.isArray(data.tiles)) { toast(t("editor.layout.no_tiles_in_file")); return; }
|
||||
adopt(data, (fit) => (fit ? t("editor.layout.imported_part", fit) : t("editor.layout.imported")));
|
||||
}
|
||||
|
||||
// ---- Updates with content (app 0.2.73): what a screen gets, and how far its update is ----
|
||||
@@ -535,14 +540,14 @@ export function updateProgress(screen: Screen): { percent: number; text: string
|
||||
const u = screen.update || {};
|
||||
if (!(u.state === "running" || state.updating.includes(screen.id))) return null;
|
||||
const stage = state.firmwareJob?.job?.stage as string | undefined;
|
||||
if (u.phase === "verify") return { percent: 78, text: PHASES.verify };
|
||||
if (u.phase === "settle") return { percent: 92, text: PHASES.settle };
|
||||
if (u.phase === "verify") return { percent: 78, text: phaseText("verify") };
|
||||
if (u.phase === "settle") return { percent: 92, text: phaseText("settle") };
|
||||
if (u.phase === "install" || !u.phase) {
|
||||
if (stage === "upload") return { percent: 66, text: "Writing the firmware over Wi-Fi…" };
|
||||
if (stage) return { percent: 40, text: "Building the firmware…" };
|
||||
return { percent: 12, text: PHASES.install };
|
||||
if (stage === "upload") return { percent: 66, text: t("editor.update.writing") };
|
||||
if (stage) return { percent: 40, text: t("editor.update.building") };
|
||||
return { percent: 12, text: phaseText("install") };
|
||||
}
|
||||
return { percent: 12, text: PHASES[u.phase] || "Starting update…" };
|
||||
return { percent: 12, text: phaseText(u.phase) };
|
||||
}
|
||||
|
||||
// ---- Top bar ----
|
||||
@@ -578,12 +583,12 @@ export function topbarLabel(item: HeaderItem) {
|
||||
// What the item shows right now: { icon, text, color, shown }. Entities wait for the add-on's preview.
|
||||
export function topbarView(item: HeaderItem): ItemView {
|
||||
const now = new Date(state.now);
|
||||
if (item.type === "clock") return { text: clockText(settingValues().clock_24h !== false, now), shown: true };
|
||||
if (item.type === "date") return { text: dateText(now), shown: true };
|
||||
if (item.type === "clock") return { text: clockText(clock24.value, now), shown: true };
|
||||
if (item.type === "date") return { text: dateText(now, screenLanguage.value), shown: true };
|
||||
if (item.type === "analog") return { analog: true, shown: true };
|
||||
const p = state.topbarPreviews[itemKey(item)];
|
||||
if (!p) return { icon: item.icon === "none" ? null : iconNamed(item.icon)?.cp || automaticIcon(item.entity!), text: "…", shown: true, loading: true };
|
||||
return { icon: p.i || null, text: p.k === "ago" ? agoText(p.e, Math.floor(state.now / 1000)) : p.t, color: p.c ? `#${p.c}` : null, shown: p.shown };
|
||||
return { icon: p.i || null, text: p.k === "ago" ? agoText(p.e, Math.floor(state.now / 1000), screenLanguage.value) : p.t, color: p.c ? `#${p.c}` : null, shown: p.shown };
|
||||
}
|
||||
export function moveTopbarItem(from: number, to: number) {
|
||||
const items = [...topbarItems()];
|
||||
@@ -598,15 +603,15 @@ export function removeTopbarItem(index: number) {
|
||||
if (!item) return;
|
||||
if (state.inspector?.kind === "bar") closeInspector();
|
||||
setTopbarItems(items);
|
||||
toast(`${topbarLabel(item)} removed from the top bar`, {
|
||||
label: "Undo",
|
||||
toast(t("editor.topbar.removed", { name: topbarLabel(item) }), {
|
||||
label: t("editor.common.undo"),
|
||||
run: () => { const back = [...topbarItems()]; back.splice(Math.min(index, back.length), 0, item); setTopbarItems(back); },
|
||||
});
|
||||
}
|
||||
export function addTopbarItem(item: HeaderItem) {
|
||||
const items = topbarItems();
|
||||
if (items.length >= topbarMax()) return toast(`The top bar has room for ${topbarMax()} items.`);
|
||||
if (items.some((other) => itemKey(other) === itemKey(item))) return toast("This is already in the top bar.");
|
||||
if (items.length >= topbarMax()) return toast(t("editor.topbar.full", topbarMax()));
|
||||
if (items.some((other) => itemKey(other) === itemKey(item))) return toast(t("editor.topbar.already"));
|
||||
// The new chip lights up briefly so the eye finds it.
|
||||
state.topbarAdded = { key: itemKey(item), time: Date.now() };
|
||||
setTopbarItems([...items, item]);
|
||||
@@ -615,32 +620,36 @@ export function addTopbarItem(item: HeaderItem) {
|
||||
|
||||
// ---- Screen settings: the same groups and rows as the settings page on the screen itself ----
|
||||
// Every change applies at once, like on the screen; no Save needed. A screen with firmware 0.2.49+ owns its
|
||||
// settings and ESP Screens changes them through its entities in Home Assistant.
|
||||
// settings and ESP Screens changes them through its entities in Home Assistant. A group's title and a row's label
|
||||
// are the texts editor.screen_settings.groups.<group> and editor.screen_settings.rows.<key> (app 0.2.90).
|
||||
export const SETTING_GROUPS = [
|
||||
{ title: "Brightness", icon: "F0599", rows: [
|
||||
{ key: "brightness", label: "Brightness", kind: "number", min: 5, max: 100, step: 5, unit: "%" },
|
||||
{ key: "dark_mode", label: "Dark mode", kind: "toggle" },
|
||||
{ key: "standby_enabled", label: "Auto standby", kind: "toggle" },
|
||||
{ key: "standby_seconds", label: "Standby after", kind: "duration", min: 60, max: 86400, needs: "standby_enabled" },
|
||||
{ key: "standby_brightness", label: "Standby brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "standby_enabled", cap: "brightness" },
|
||||
{ group: "brightness", icon: "F0599", rows: [
|
||||
{ key: "brightness", kind: "number", min: 5, max: 100, step: 5, unit: "%" },
|
||||
{ key: "dark_mode", kind: "toggle" },
|
||||
{ key: "standby_enabled", kind: "toggle" },
|
||||
{ key: "standby_seconds", kind: "duration", min: 60, max: 86400, needs: "standby_enabled" },
|
||||
{ key: "standby_brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "standby_enabled", cap: "brightness" },
|
||||
] },
|
||||
{ title: "Night", icon: "F0594", rows: [
|
||||
{ key: "night_enabled", label: "Night mode", kind: "toggle" },
|
||||
{ key: "night_start", label: "Starts", kind: "moment", needs: "night_enabled" },
|
||||
{ key: "night_end", label: "Ends", kind: "moment", needs: "night_enabled" },
|
||||
{ key: "night_brightness", label: "Night brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "night_enabled", cap: "brightness" },
|
||||
{ group: "night", icon: "F0594", rows: [
|
||||
{ key: "night_enabled", kind: "toggle" },
|
||||
{ key: "night_start", kind: "moment", needs: "night_enabled" },
|
||||
{ key: "night_end", kind: "moment", needs: "night_enabled" },
|
||||
{ key: "night_brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "night_enabled", cap: "brightness" },
|
||||
] },
|
||||
{ title: "Screen", icon: "F0379", rows: [
|
||||
{ key: "clock_24h", label: "Clock", kind: "choice", options: [[false, "12 hour"], [true, "24 hour"]] },
|
||||
{ key: "auto_home", label: "Back to page 1", kind: "toggle" },
|
||||
{ key: "auto_home_seconds", label: "After", kind: "duration", min: 30, max: 3600, needs: "auto_home" },
|
||||
{ key: "home_on_standby", label: "Also on standby", kind: "toggle" },
|
||||
{ key: "swipe_pages", label: "Swipe between pages", kind: "toggle" },
|
||||
{ key: "page_buttons", label: "Page buttons", kind: "toggle" },
|
||||
{ key: "rotation", label: "Rotation", kind: "choice", options: [[0, "0°"], [90, "90°"], [180, "180°"], [270, "270°"]] },
|
||||
{ group: "screen", icon: "F0379", rows: [
|
||||
{ key: "auto_home", kind: "toggle" },
|
||||
{ key: "auto_home_seconds", kind: "duration", min: 30, max: 3600, needs: "auto_home" },
|
||||
{ key: "home_on_standby", kind: "toggle" },
|
||||
{ key: "swipe_pages", kind: "toggle" },
|
||||
{ key: "page_buttons", kind: "toggle" },
|
||||
{ key: "rotation", kind: "choice", options: [0, 90, 180, 270] },
|
||||
] },
|
||||
] as const;
|
||||
export type SettingRow = (typeof SETTING_GROUPS)[number]["rows"][number] & { min?: number; max?: number; step?: number; unit?: string; needs?: string; cap?: string; options?: readonly (readonly [unknown, string])[] };
|
||||
export type SettingRow = (typeof SETTING_GROUPS)[number]["rows"][number] & { min?: number; max?: number; step?: number; unit?: string; needs?: string; cap?: string; options?: readonly unknown[] };
|
||||
export const settingLabel = (row: SettingRow) => t(`editor.screen_settings.rows.${row.key}`);
|
||||
// A choice in the same words in every language: the rotation's angle. The clock left this page for Settings → Language
|
||||
// & region, one choice for every screen (app 0.2.90).
|
||||
export const choiceText = (_row: SettingRow, value: unknown) => `${value}°`;
|
||||
// Page buttons and swiping both off (firmware 0.2.69+): only Go to page tiles change the page, so the editor says which
|
||||
// pages the Go to page tiles lead to and which page that leaves out, or has no way back to page 1. Empty when either
|
||||
// is on or still unknown, or every page can be reached and left.
|
||||
@@ -649,15 +658,20 @@ export function pageReachWarning(entries = liveEntries(), pages = state.layout ?
|
||||
if (pages < 2 || values.page_buttons !== false || values.swipe_pages !== false) return "";
|
||||
const { tiles, targets, unreachable, noWayBack } = strandedPages(entries, pages);
|
||||
if (!unreachable.length && !noWayBack.length) return "";
|
||||
const named = (list: number[]) => (list.length === 1 ? `page ${list[0]}` : `pages ${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`);
|
||||
// "page 2" or "pages 2, 3 and 4", in the editor's language.
|
||||
const named = (list: number[]) => t("editor.screen_settings.reach.pages", { list: andList(list) }, list.length);
|
||||
// A page that tiles lead to but only from pages that can't be reached themselves.
|
||||
const missing = unreachable.filter((page) => !targets.includes(page)), cutOff = unreachable.filter((page) => targets.includes(page));
|
||||
let text = "Page buttons and swiping are off, so only Go to page tiles change the page. ";
|
||||
text += tiles ? `You have ${tiles} Go to page ${tiles === 1 ? "tile" : "tiles"}, to ${named(targets)}` : "You have no Go to page tiles";
|
||||
text += missing.length ? `${tiles ? ", but none" : ""} to ${named(missing)}, so you can't reach ${missing.length === 1 ? "it" : "them"}.` : ".";
|
||||
if (cutOff.length) text += ` The tiles to ${named(cutOff)} are only on pages you can't reach.`;
|
||||
if (noWayBack.length) text += ` From ${named(noWayBack)} no tile leads back to page 1.`;
|
||||
return text;
|
||||
// Whole sentences: "it" or "them" follows how many pages are out of reach.
|
||||
const lost = missing.length === 1 ? "one" : "more";
|
||||
const sentences = [t("editor.screen_settings.reach.intro")];
|
||||
if (tiles && missing.length) sentences.push(t(`editor.screen_settings.reach.tiles_missing_${lost}`, { targets: named(targets), missing: named(missing) }, tiles));
|
||||
else if (tiles) sentences.push(t("editor.screen_settings.reach.tiles", { targets: named(targets) }, tiles));
|
||||
else if (missing.length) sentences.push(t(`editor.screen_settings.reach.none_missing_${lost}`, { missing: named(missing) }));
|
||||
else sentences.push(t("editor.screen_settings.reach.none"));
|
||||
if (cutOff.length) sentences.push(t("editor.screen_settings.reach.cut_off", { pages: named(cutOff) }));
|
||||
if (noWayBack.length) sentences.push(t("editor.screen_settings.reach.no_way_back", { pages: named(noWayBack) }));
|
||||
return sentences.join(" ");
|
||||
}
|
||||
// Changes made here that the screen has not reported back yet win over what Home Assistant still shows for a
|
||||
// few seconds, so a value never flicks back while it travels.
|
||||
@@ -683,14 +697,17 @@ export function steppedSetting(row: SettingRow, value: number, direction: number
|
||||
return Math.min(max, Math.max(row.min!, value + direction * step));
|
||||
}
|
||||
export function durationText(seconds: number) {
|
||||
if (seconds < 60) return `${seconds} sec`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} min`;
|
||||
if (seconds < 60) return t("editor.screen_settings.duration.seconds", { n: seconds });
|
||||
if (seconds < 3600) return t("editor.screen_settings.duration.minutes", { n: Math.floor(seconds / 60) });
|
||||
const hours = Math.floor(seconds / 3600), minutes = Math.floor((seconds % 3600) / 60);
|
||||
return minutes ? `${hours} h ${String(minutes).padStart(2, "0")}` : `${hours} h`;
|
||||
return minutes
|
||||
? t("editor.screen_settings.duration.hours_minutes", { h: hours, m: String(minutes).padStart(2, "0") })
|
||||
: t("editor.screen_settings.duration.hours", { n: hours });
|
||||
}
|
||||
export function momentText(minutes: number, clock24: boolean) {
|
||||
const hour = Math.floor(minutes / 60), minute = String(minutes % 60).padStart(2, "0");
|
||||
return clock24 ? `${String(hour).padStart(2, "0")}:${minute}` : `${hour % 12 || 12}:${minute} ${hour < 12 ? "AM" : "PM"}`;
|
||||
if (clock24) return `${String(hour).padStart(2, "0")}:${minute}`;
|
||||
return t(hour < 12 ? "editor.screen_settings.time.am" : "editor.screen_settings.time.pm", { time: `${hour % 12 || 12}:${minute}` });
|
||||
}
|
||||
export function settingText(row: SettingRow, values: Record<string, any>) {
|
||||
const value = values[row.key];
|
||||
@@ -698,14 +715,14 @@ export function settingText(row: SettingRow, values: Record<string, any>) {
|
||||
if (value === null || value === undefined) return "—";
|
||||
if (row.kind === "number") return `${value}${row.unit || ""}`;
|
||||
if (row.kind === "duration") return durationText(value);
|
||||
if (row.kind === "moment") return momentText(value, values.clock_24h !== false);
|
||||
if (row.kind === "moment") return momentText(value, clock24.value);
|
||||
return "";
|
||||
}
|
||||
export function setSetting(key: string, value: any, delay: number) {
|
||||
// One screen's changes at a time: the ones for the screen shown before go out first.
|
||||
if (settingTarget && settingTarget !== state.selected && Object.keys(settingQueue).length) {
|
||||
flushSettings();
|
||||
toast("Still saving the other screen's settings. Try again in a moment.");
|
||||
toast(t("editor.screen_settings.other_screen_busy"));
|
||||
return;
|
||||
}
|
||||
settingTarget = state.selected;
|
||||
@@ -761,11 +778,9 @@ export function settleSettings() {
|
||||
}
|
||||
|
||||
// ---- Updates ----
|
||||
export const PHASES: Record<string, string> = {
|
||||
install: "Building and installing…",
|
||||
verify: "Waiting for the screen to come back…",
|
||||
settle: "Checking that it stays stable…",
|
||||
};
|
||||
// What a running update is doing, by its phase.
|
||||
export const phaseText = (phase: string | undefined) =>
|
||||
["install", "verify", "settle"].includes(phase || "") ? t(`editor.update.phases.${phase}`) : t("editor.update.starting");
|
||||
export async function startUpdate(screen: Screen, host?: string) {
|
||||
state.updating.push(screen.id);
|
||||
try {
|
||||
@@ -788,7 +803,7 @@ export async function setAutoUpdate(auto: boolean) {
|
||||
try {
|
||||
await send("updates", "PUT", { auto });
|
||||
if (state.inventory.updates) state.inventory.updates.auto = auto;
|
||||
toast(auto ? "Screens will now update automatically at night." : "Automatic updates are off.");
|
||||
toast(t(auto ? "editor.settings.updates.auto_on" : "editor.settings.updates.auto_off"));
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
@@ -796,14 +811,51 @@ export async function setAutoUpdate(auto: boolean) {
|
||||
export async function installClaudeSkill() {
|
||||
try {
|
||||
state.inventory.claude_skill = await send("claude-skill", "POST");
|
||||
toast(state.inventory.claude_skill?.restart
|
||||
? "Skill installed. Restart Claude Code once so it finds the new skills folder."
|
||||
: "Skill installed. Claude Code picks it up right away.");
|
||||
toast(t(state.inventory.claude_skill?.restart ? "editor.settings.claude.installed_restart" : "editor.settings.claude.installed"));
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Languages (app 0.2.90) ----
|
||||
// The editor speaks the language of the user's Home Assistant profile (i18n.ts). The screens have one language for all
|
||||
// of them, Home Assistant's unless the setting says another; the mockup draws their words in it, and in English until
|
||||
// the add-on tells which one it is.
|
||||
export const screenLanguage = computed(() => pickLanguage(state.inventory.language?.effective));
|
||||
watch(screenLanguage, (code) => loadLanguage(code), { immediate: true });
|
||||
/** A text as the screens show it: in their language, not the editor's. */
|
||||
export const screenText = (key: string, named: Record<string, unknown> = {}) => t(key, named, { locale: screenLanguage.value });
|
||||
/** A language by its own name ("Nederlands"), as the add-on lists it. */
|
||||
export const languageName = (code: string | null | undefined) =>
|
||||
state.inventory.language?.languages?.find((l) => l.code === code)?.name || languageMeta(code || "")?.name || code || "";
|
||||
// A screen that doesn't run the chosen language yet needs its update as well.
|
||||
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.
|
||||
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];
|
||||
});
|
||||
/** 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 {
|
||||
const answer = await send("language", "PUT", changes);
|
||||
if (answer?.language) state.inventory.language = answer.language;
|
||||
// A language is built into the firmware; the time and number format are not.
|
||||
toast(t(changes.setting === undefined ? "editor.settings.language.saved" : "editor.settings.language.saved_language"));
|
||||
// Every screen now wants an update, which the inventory reports.
|
||||
await refresh();
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Inventory: full catalogue, light polls, and the live stream ----
|
||||
export async function refresh(full = true) {
|
||||
try {
|
||||
@@ -828,7 +880,8 @@ function applyLive(data: Partial<Inventory>) {
|
||||
let pollTimer = 0, lastFull = Date.now(), live = false, stream: EventSource | null = null;
|
||||
function listen() {
|
||||
if (stream || typeof EventSource === "undefined") return;
|
||||
stream = new EventSource("api/events");
|
||||
// An EventSource sends no headers of its own: the editor's language goes along in the address (app 0.2.90).
|
||||
stream = new EventSource(`api/events?language=${encodeURIComponent(editorLanguage())}`);
|
||||
stream.onopen = () => { live = true; poll(); };
|
||||
stream.onmessage = (e) => { if (!document.hidden) applyLive(JSON.parse(e.data)); };
|
||||
stream.onerror = () => { live = false; poll(); };
|
||||
|
||||
@@ -23,6 +23,8 @@ export type Layout = {
|
||||
export type UpdateInfo = {
|
||||
available?: boolean; target?: string; state?: string; phase?: string; host?: string; profile?: string;
|
||||
result?: { state: string; message: string; time: number };
|
||||
// The screen still runs another language than the one chosen for the screens (app 0.2.90).
|
||||
language?: boolean;
|
||||
};
|
||||
export type SettingsView = { owner: string; keys: string[]; values: Record<string, any>; unavailable: string[] };
|
||||
export type Screen = {
|
||||
@@ -32,6 +34,17 @@ export type Screen = {
|
||||
// What the add-on reads from the firmware (app 0.2.78): its X.Y.Z (null when unknown), how many tiles it holds,
|
||||
// whether it draws full-page tiles, and whether it takes several tiles that go to the same page.
|
||||
firmware_known?: string | null; tile_limit?: number; full_page?: boolean; page_tiles_repeat?: boolean;
|
||||
// The language its firmware was built in (app 0.2.90); null for older firmware, which is English.
|
||||
language?: string | null;
|
||||
};
|
||||
// Language & region of the screens (app 0.2.90): the language setting ("auto" follows Home Assistant), the language that
|
||||
// gives, Home Assistant's own, and every language there is, by its own name; the time and number format, each "auto"
|
||||
// (as the language writes it) or a choice, and what that gives.
|
||||
export type LanguageInfo = { code: string; name: string; english: string; checked: boolean };
|
||||
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";
|
||||
};
|
||||
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 };
|
||||
@@ -62,6 +75,8 @@ export type Inventory = {
|
||||
suggestions?: Record<string, { item: HeaderItem; label: string; name?: string; area?: string; icon?: string }[]>;
|
||||
};
|
||||
alerts?: any;
|
||||
// Missing from an add-on before 0.2.90: English everywhere, and no Language card.
|
||||
language?: Languages;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type Capability = { toggle: boolean; inline: boolean; controls: string[]; displays: string[] };
|
||||
|
||||
@@ -67,7 +67,8 @@ describe("TileCard", () => {
|
||||
it("draws the large value, the wide card's toggle and an unavailable entity in grey", () => {
|
||||
state.liveStates["sensor.t"] = { state: "1249", word: null, a: { unit_of_measurement: "W" } };
|
||||
const big = placed({ entity: "sensor.t", name: "Power", slot: 0, options: { display: "watch" } });
|
||||
expect(big.find(".big").text()).toBe("1249W");
|
||||
// Numbers as the screens write them (app 0.2.90): "1,234.5" until the add-on names another format.
|
||||
expect(big.find(".big").text()).toBe("1,249W");
|
||||
state.liveStates["light.a"] = { state: "off", word: "Off", a: {} };
|
||||
const wide = placed({ entity: "light.a", name: "", slot: 2, options: { size: "wide" } });
|
||||
expect(wide.classes()).toContain("wide");
|
||||
@@ -82,12 +83,12 @@ describe("TileCard", () => {
|
||||
const plain = placed({ entity: "sensor.t", name: "Power", slot: 0 });
|
||||
expect(plain.find(".head > .ic").exists()).toBe(true);
|
||||
expect(plain.find(".head > .tx > .nm").text()).toBe("Power");
|
||||
expect(plain.find(".head > .tx > .st").text()).toBe("1249 W");
|
||||
expect(plain.find(".head > .tx > .st").text()).toBe("1,249 W");
|
||||
// A watch card keeps the name next to the icon and puts the big value underneath.
|
||||
const watch = placed({ entity: "sensor.t", name: "Power", slot: 1, options: { display: "watch" } });
|
||||
expect(watch.find(".head").classes()).toContain("top");
|
||||
expect(watch.find(".head .big").exists()).toBe(false);
|
||||
expect(watch.find(".head + .big").text()).toBe("1249W");
|
||||
expect(watch.find(".head + .big").text()).toBe("1,249W");
|
||||
state.liveStates["light.a"] = { state: "on", word: "On", a: { brightness: 255 } };
|
||||
const lamp = placed({ entity: "light.a", name: "", slot: 2, options: { inline: "slider" } });
|
||||
expect(lamp.find(".head + .mini-slider").exists()).toBe(true);
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// The editor's texts (app 0.2.90): English from en.json with the page, another language when it is needed, the plural
|
||||
// rules of the screens, and which language the page, the add-on's answers and the mockup speak.
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import en from "../../screen_manager/translations/en.json";
|
||||
import { send } from "../src/api";
|
||||
import TileCard from "../src/components/TileCard.vue";
|
||||
import {
|
||||
addLanguage, andList, haProfile, i18n, languageMarks, languageMeta, languages, loadLanguage, matchLanguage, numberText, pickLanguage,
|
||||
PLURAL_RULES, requestedLanguage, setEditorLanguage, STYLE_MARKS, t,
|
||||
} from "../src/i18n";
|
||||
import { agoText, dateText } from "../src/model/topbar";
|
||||
import { state } from "../src/store";
|
||||
import { pageTexts, TRANSLATIONS } from "../translations";
|
||||
|
||||
const meta = (plural: string) => ({ name: "Test", english: "Test", script: "latin", plural, checked: false });
|
||||
// Every text of a part as [key, text]; a list's items get .0, .1, ... as in tools/i18n.py.
|
||||
function flat(node: unknown, path: string): [string, unknown][] {
|
||||
if (node && typeof node === "object") return Object.entries(node).flatMap(([key, value]) => flat(value, `${path}.${key}`));
|
||||
return [[path, node]];
|
||||
}
|
||||
// en.json as it is, and the part of it the page takes.
|
||||
const file = JSON.parse(readFileSync(join(TRANSLATIONS, "en.json"), "utf8"));
|
||||
const page = pageTexts(file);
|
||||
const texts = [...flat(page.editor, "editor"), ...flat(page.screen, "screen")];
|
||||
|
||||
afterEach(() => {
|
||||
i18n.global.locale.value = "en";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("English", () => {
|
||||
it("comes with the page: the editor's texts and the screens' words the mockup draws, and every language's _meta", () => {
|
||||
// The build keeps the rest of the screens' texts and the add-on's out (vite.config.ts); the tests import it the same way.
|
||||
expect(Object.keys(en).sort()).toEqual(["editor", "screen"]);
|
||||
expect(Object.keys((en as any).screen).sort()).toEqual(["date", "ha", "number", "tile", "time"]);
|
||||
expect(Object.keys((en as any).screen.tile)).toEqual(["page"]);
|
||||
expect(languageMeta("en")?.plural).toBe("one_other");
|
||||
expect(languages()[0]).toBe("en");
|
||||
// Every file in the folder is a language the editor can load.
|
||||
const files = readdirSync(TRANSLATIONS).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -5)).sort();
|
||||
expect([...languages()].sort()).toEqual(files);
|
||||
});
|
||||
it("leaves out empty texts, keeps a space, and takes nothing else from a file", () => {
|
||||
const part = pageTexts({
|
||||
_meta: meta("one_other"), addon: { x: "y" },
|
||||
screen: { number: { decimal: ",", group: " ", group_min: "" }, status: { home: "Thuis" }, tile: { page: "Pagina {n}", refused: "Geweigerd" }, time: { just_now: "" } },
|
||||
editor: { common: { close: "Sluiten", cancel: "" }, empty: {} },
|
||||
});
|
||||
expect(part).toEqual({ editor: { common: { close: "Sluiten" } }, screen: { number: { decimal: ",", group: " " }, tile: { page: "Pagina {n}" } } });
|
||||
});
|
||||
it("has a readable text for every key, and no text vue-i18n reads as something else", () => {
|
||||
for (const [key, text] of texts) {
|
||||
expect(typeof text, key).toBe("string");
|
||||
expect(t(key), key).not.toBe(key);
|
||||
// @ links another text and | separates plural forms: a text uses them only as that.
|
||||
expect(text as string, key).not.toMatch(/@|\$|\|\|/);
|
||||
if ((text as string).includes("|")) expect(text as string, key).toMatch(/ \| /);
|
||||
}
|
||||
});
|
||||
it("has every key the code asks for, and no editor key the code doesn't use", () => {
|
||||
const files = (dir: string): string[] => readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
return statSync(path).isDirectory() ? files(path) : /\.(ts|vue)$/.test(name) ? [path] : [];
|
||||
});
|
||||
const code = files(join(__dirname, "..", "src")).map((path) => readFileSync(path, "utf8")).join("\n");
|
||||
const key = String.raw`(?:editor|screen\.(?:ha|time|date|number|tile))\.[a-z0-9_.]*`;
|
||||
const keys = new Set([...code.matchAll(new RegExp(`["'\`](${key}[a-z0-9_])["'\`]`, "g"))].map((m) => m[1]));
|
||||
// A key built at run time, such as `editor.library.filters.${value}`, counts for every text under its start.
|
||||
const starts = new Set([...code.matchAll(new RegExp(`\`(${key})\\$\\{`, "g"))].map((m) => m[1]));
|
||||
const known = new Set(texts.map(([key]) => key));
|
||||
for (const key of keys) expect(known.has(key) || texts.some(([other]) => other.startsWith(`${key}.`)), key).toBe(true);
|
||||
for (const start of starts) expect(texts.some(([key]) => key.startsWith(start)), start).toBe(true);
|
||||
// The screens' words come as whole parts (screen.ha, time, date, number); the editor's own texts are all in use.
|
||||
const unused = texts.map(([key]) => key)
|
||||
.filter((key) => key.startsWith("editor.") && !keys.has(key) && ![...starts].some((start) => key.startsWith(start)));
|
||||
expect(unused).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("choosing a language", () => {
|
||||
const available = ["en", "nl", "pt-BR", "zh-Hans"];
|
||||
it("takes the same code, else its base language, else English", () => {
|
||||
expect(matchLanguage("nl", available)).toBe("nl");
|
||||
expect(matchLanguage("PT-br", available)).toBe("pt-BR");
|
||||
expect(matchLanguage("zh-hans", available)).toBe("zh-Hans");
|
||||
expect(matchLanguage("nl-BE", available)).toBe("nl");
|
||||
expect(matchLanguage("fr", available)).toBeUndefined();
|
||||
expect(pickLanguage("fr", available)).toBe("en");
|
||||
expect(pickLanguage("pt", available)).toBe("en");
|
||||
expect(pickLanguage(null, available)).toBe("en");
|
||||
expect(pickLanguage("", available)).toBe("en");
|
||||
});
|
||||
it("reads Home Assistant's <html lang> inside its iframe, else the browser's language", () => {
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
const inner = frame.contentWindow!;
|
||||
document.documentElement.lang = "nl";
|
||||
expect(requestedLanguage(inner)).toBe("nl");
|
||||
document.documentElement.lang = "";
|
||||
expect(requestedLanguage(inner)).toBe(inner.navigator.language);
|
||||
expect(requestedLanguage(window)).toBe(navigator.language);
|
||||
// A parent on another origin throws on its document.
|
||||
const foreign = { get parent(): Window { throw new Error("cross-origin"); }, navigator: { language: "de-DE" } } as unknown as Window;
|
||||
expect(requestedLanguage(foreign)).toBe("de-DE");
|
||||
frame.remove();
|
||||
});
|
||||
it("sets the page's own language, loads a language from its file, and keeps English for one it doesn't have", async () => {
|
||||
addLanguage("tt", { editor: { common: { close: "Sluiten" } } }, meta("one_other"));
|
||||
expect(await setEditorLanguage("tt")).toBe("tt");
|
||||
expect(document.documentElement.lang).toBe("tt");
|
||||
expect(t("editor.common.close")).toBe("Sluiten");
|
||||
// A text the language doesn't have yet shows in English.
|
||||
expect(t("editor.common.cancel")).toBe("Cancel");
|
||||
expect(await setEditorLanguage("xx")).toBe("en");
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
expect(await loadLanguage("xx")).toBe(false);
|
||||
// A language with a file comes from its own chunk, compiled like English.
|
||||
expect(await loadLanguage("nl")).toBe(true);
|
||||
expect(t("screen.tile.page", { n: 2 }, { locale: "nl" })).toBe("Pagina 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plural forms", () => {
|
||||
it("follow the rules of the screens", () => {
|
||||
const forms = (rule: string, numbers: number[]) => numbers.map(PLURAL_RULES[rule]);
|
||||
expect(forms("one_other", [0, 1, 2, 11])).toEqual([1, 0, 1, 1]);
|
||||
expect(forms("one_upto_1", [0, 1, 2, 11])).toEqual([0, 0, 1, 1]);
|
||||
expect(forms("slavic_pl", [1, 2, 4, 5, 12, 14, 21, 22, 25, 102])).toEqual([0, 1, 1, 2, 2, 2, 2, 1, 2, 1]);
|
||||
expect(forms("east_slavic", [1, 2, 5, 11, 12, 21, 22, 25, 111])).toEqual([0, 1, 2, 2, 2, 0, 1, 2, 2]);
|
||||
expect(forms("none", [0, 1, 2, 5])).toEqual([0, 0, 0, 0]);
|
||||
});
|
||||
it("pick the form of a text by its language's rule, with {n}, and the last form when a text has fewer", () => {
|
||||
expect(t("editor.library.entities", 1)).toBe("1 entity");
|
||||
expect(t("editor.library.entities", 5)).toBe("5 entities");
|
||||
addLanguage("tp", { editor: { library: { entities: "{n} encja | {n} encje | {n} encji" }, common: { results: "{n} wynik | {n} wyników" } } }, meta("slavic_pl"));
|
||||
const tp = (key: string, n: number) => t(key, n, { locale: "tp" });
|
||||
expect([1, 3, 5, 22].map((n) => tp("editor.library.entities", n))).toEqual(["1 encja", "3 encje", "5 encji", "22 encje"]);
|
||||
expect(tp("editor.common.results", 3)).toBe("3 wyników");
|
||||
// One form: every number takes it.
|
||||
addLanguage("tz", { editor: { library: { entities: "{n} 个实体" } } }, meta("none"));
|
||||
expect(t("editor.library.entities", 7, { locale: "tz" })).toBe("7 个实体");
|
||||
// French counts 0 as one.
|
||||
addLanguage("tf", { editor: { library: { entities: "{n} entité | {n} entités" } } }, meta("one_upto_1"));
|
||||
expect([0, 1, 2].map((n) => t("editor.library.entities", n, { locale: "tf" }))).toEqual(["0 entité", "1 entité", "2 entités"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the add-on hears the editor's language", () => {
|
||||
it("in a header on every request", async () => {
|
||||
const fetch = vi.fn(async () => new Response("", { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await send("screens/x/identify", "POST");
|
||||
addLanguage("tt", { editor: {} }, meta("one_other"));
|
||||
i18n.global.locale.value = "tt";
|
||||
await send("inventory", "GET");
|
||||
const headers = fetch.mock.calls.map((call: any[]) => call[1].headers["X-ESP-Screens-Language"]);
|
||||
expect(headers).toEqual(["en", "tt"]);
|
||||
});
|
||||
it("and a failed answer without the add-on's own words says so in the editor's language", async () => {
|
||||
addLanguage("tu", { editor: { api: { failed: "Dat lukte niet." } } }, meta("one_other"));
|
||||
i18n.global.locale.value = "tu";
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response("<html>502</html>", { status: 502 })));
|
||||
await expect(send("screens/x", "PUT", {})).rejects.toThrow("Dat lukte niet.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the mockup speaks the screens' language", () => {
|
||||
it("draws the screens' own words in their language while the editor keeps its own", async () => {
|
||||
addLanguage("td", {
|
||||
editor: { tile_card: { remove: "Kachel entfernen" } },
|
||||
screen: {
|
||||
ha: { unavailable: "Nicht verfügbar", on: "An", weather: { windy: "Windig" }, binary: { door_on: "Offen" }, button: { run: "Ausführen" } },
|
||||
tile: { page: "Seite {n}" },
|
||||
time: { hours_ago: "Vor 1 Stunde | Vor {n} Stunden", just_now: "Gerade eben" },
|
||||
date: { weekdays_min: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"], months_short: ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez."], top_bar: "{weekday}., {day}. {month}" },
|
||||
number: { decimal: ",", group: ".", group_min: "2" },
|
||||
},
|
||||
}, 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" },
|
||||
controls: { script: { default: "run", choices: [{ key: "run", label: "Run" }, { key: "none", label: "None" }] } },
|
||||
} as any;
|
||||
state.layout = { title: "Living room", tiles: [] };
|
||||
state.liveStates = {
|
||||
"light.c": { state: "on", word: null, a: {} },
|
||||
"weather.home": { state: "windy-variant", word: null, a: { temperature: 12.5 } },
|
||||
"binary_sensor.door": { state: "on", word: null, a: { device_class: "door" } },
|
||||
"sensor.power": { state: "1249", word: null, a: { unit_of_measurement: "W" } },
|
||||
"sensor.energy": { state: "12345.5", word: null, a: { unit_of_measurement: "kWh" } },
|
||||
};
|
||||
const card = (entity: string, slot: number, options?: Record<string, unknown>) => {
|
||||
const tile = { entity, name: "", slot, ...(options ? { options } : {}) };
|
||||
state.layout!.tiles.push(tile);
|
||||
return mount(TileCard, { props: { tile, slot } });
|
||||
};
|
||||
const gone = card("light.b", 0);
|
||||
expect(gone.find(".st").text()).toBe("Nicht verfügbar");
|
||||
// The editor's own words stay in the editor's language.
|
||||
expect(gone.find(".remove").attributes("title")).toBe("Remove tile");
|
||||
expect(card("screen.page_2", 1).find(".goto").text()).toBe("Seite 2 ›");
|
||||
expect(card("light.c", 2).find(".st").text()).toBe("An");
|
||||
expect(card("weather.home", 3).find(".st").text()).toBe("Windig · 12,5°");
|
||||
expect(card("binary_sensor.door", 4).find(".st").text()).toBe("Offen");
|
||||
expect(card("script.movie", 5, { size: "wide", controls: "run" }).find(".run").text()).toBe("Ausführen");
|
||||
// 1249 has too few digits for a mark in this language; 12345 gets one, as the screen writes them.
|
||||
expect(card("sensor.power", 6).find(".st").text()).toBe("1249 W");
|
||||
expect(card("sensor.energy", 7).find(".st").text()).toBe("12.345,5 kWh");
|
||||
expect(agoText(1_000_000 - 7200, 1_000_000, "td")).toBe("Vor 2 Stunden");
|
||||
expect(agoText(1_000_000 - 7200, 1_000_000)).toBe("2 hours ago");
|
||||
expect(dateText(new Date(2026, 8, 15), "td")).toBe("Di., 15. Sept.");
|
||||
// A word the language doesn't have yet is English, as on the screen.
|
||||
expect(agoText(1_000_000 - 90000, 1_000_000, "td")).toBe("Yesterday");
|
||||
});
|
||||
});
|
||||
|
||||
describe("numbers, lists and the profile", () => {
|
||||
it("writes numbers as the screens do", () => {
|
||||
expect(["1249", "21.4", "-1234567.25", "0", "999"].map((n) => numberText(n, STYLE_MARKS.point))).toEqual(["1,249", "21.4", "-1,234,567.25", "0", "999"]);
|
||||
expect(["1249", "21.4", "-1234567.25"].map((n) => numberText(n, STYLE_MARKS.comma))).toEqual(["1.249", "21,4", "-1.234.567,25"]);
|
||||
expect(["1249", "21.4"].map((n) => numberText(n, STYLE_MARKS.space))).toEqual(["1 249", "21,4"]);
|
||||
// Anything that isn't a plain number stays as it is.
|
||||
expect(["on", "1e5", "12:30", "", ".5", "5."].map((n) => numberText(n, STYLE_MARKS.comma))).toEqual(["on", "1e5", "12:30", "", ".5", "5."]);
|
||||
expect(numberText(1234.5, STYLE_MARKS.comma)).toBe("1.234,5");
|
||||
// A language's own marks: English and Dutch from four digits, Italian from five.
|
||||
expect(numberText("1234.5", languageMarks("en"))).toBe("1,234.5");
|
||||
addLanguage("ti", { screen: { number: { decimal: ",", group: ".", group_min: "2" } } }, meta("one_other"));
|
||||
expect(["1234.5", "12345.5"].map((n) => numberText(n, languageMarks("ti")))).toEqual(["1234,5", "12.345,5"]);
|
||||
});
|
||||
it("writes a list the way the language does, the English one without a comma before and", () => {
|
||||
expect(andList([1, 2, 3])).toBe("1, 2 and 3");
|
||||
expect(andList([2, 3], "nl")).toBe("2 en 3");
|
||||
});
|
||||
it("reads the clock and number format of the user's Home Assistant profile, where it names one", () => {
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
const inner = frame.contentWindow!;
|
||||
const ha = document.createElement("home-assistant") as any;
|
||||
document.body.append(ha);
|
||||
ha.hass = { locale: { language: "nl", time_format: "24", number_format: "decimal_comma" } };
|
||||
expect(haProfile(inner)).toEqual({ clock: "24", numbers: "comma" });
|
||||
ha.hass = { locale: { time_format: "12", number_format: "comma_decimal" } };
|
||||
expect(haProfile(inner)).toEqual({ clock: "12", numbers: "point" });
|
||||
ha.hass = { locale: { time_format: "language", number_format: "space_comma" } };
|
||||
expect(haProfile(inner)).toEqual({ numbers: "space" });
|
||||
ha.hass = { locale: { time_format: "system", number_format: "none" } };
|
||||
expect(haProfile(inner)).toEqual({});
|
||||
// Outside Home Assistant's page there is no profile.
|
||||
expect(haProfile(window)).toEqual({});
|
||||
ha.remove();
|
||||
frame.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
// Language & region (app 0.2.90): one place for every screen's language, clock and numbers, the update it takes, and
|
||||
// the top bar that points there.
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AppSettingsView from "../src/components/AppSettingsView.vue";
|
||||
import Sidebar from "../src/components/Sidebar.vue";
|
||||
import TopbarInspector from "../src/components/TopbarInspector.vue";
|
||||
import { loadLanguage } from "../src/i18n";
|
||||
import { state } from "../src/store";
|
||||
import type { Languages } from "../src/types";
|
||||
|
||||
const GUIDE = "https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/TRANSLATING.md";
|
||||
function language(extra: Partial<Languages> = {}): Languages {
|
||||
return {
|
||||
setting: "auto", effective: "nl", ha: "nl", clock: "auto", clock_effective: "24", numbers: "auto", numbers_effective: "comma",
|
||||
languages: [
|
||||
{ code: "en", name: "English (US)", english: "English (US)", checked: true },
|
||||
{ code: "en-GB", name: "English (UK)", english: "English (UK)", checked: true },
|
||||
{ code: "nl", name: "Nederlands", english: "Dutch", checked: true },
|
||||
{ code: "de", name: "Deutsch", english: "German", checked: false },
|
||||
],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
// The add-on: PUT api/language answers with the new state, or refuses; the inventory keeps what it has.
|
||||
function addOn(answer: (body: any) => Response) {
|
||||
const fetch = vi.fn(async (url: string, options: RequestInit = {}) => {
|
||||
if (url === "api/language") return answer(JSON.parse(String(options.body)));
|
||||
if (url.startsWith("api/inventory")) return new Response(JSON.stringify(state.inventory), { status: 200 });
|
||||
return new Response("{}", { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
return fetch;
|
||||
}
|
||||
const options = (view: ReturnType<typeof mount>, id: string) => view.findAll(`#${id} option`).map((o) => o.text());
|
||||
|
||||
// The screens' language here is Dutch: its own number marks come with its file.
|
||||
beforeAll(() => loadLanguage("nl"));
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
state.inventory = { screens: [], entities: [], updates: { target: "0.2.80", pending: 0 }, language: language() } as any;
|
||||
state.toast = null;
|
||||
});
|
||||
|
||||
describe("the Language & region card", () => {
|
||||
it("is missing while the add-on has no languages", () => {
|
||||
delete state.inventory.language;
|
||||
expect(mount(AppSettingsView).find("#language").exists()).toBe(false);
|
||||
});
|
||||
it("offers Home Assistant's language by its own name, every language, and marks the unchecked ones", () => {
|
||||
const view = mount(AppSettingsView);
|
||||
expect(view.find("#language h2").text()).toBe("Language & region");
|
||||
expect(options(view, "screen-language")).toEqual([
|
||||
"Home Assistant's language (Nederlands)", "English (US)", "English (UK)", "Nederlands", "Deutsch · not checked yet, help welcome",
|
||||
]);
|
||||
expect((view.find("#screen-language").element as HTMLSelectElement).value).toBe("auto");
|
||||
expect(view.find("#language-ha-missing").exists()).toBe(false);
|
||||
expect(view.find("#language-help").exists()).toBe(false);
|
||||
expect(view.find("#language").text()).toContain("The editor itself follows the language of your Home Assistant profile.");
|
||||
expect(options(view, "time-format")).toEqual(["Automatic, follows the language (24 hour)", "24 hour", "12 hour"]);
|
||||
expect(options(view, "number-format")).toEqual(["Automatic, follows the language (1.234,5)", "1,234.5", "1.234,5", "1 234,5"]);
|
||||
});
|
||||
it("says when Home Assistant's language isn't there yet, and asks for help with an unchecked one", () => {
|
||||
state.inventory.language = language({ ha: "fy", effective: "en" });
|
||||
let view = mount(AppSettingsView);
|
||||
expect(options(view, "screen-language")[0]).toBe("Home Assistant's language");
|
||||
expect(view.find("#language-ha-missing").text()).toBe("The screens use English until Home Assistant's language is available.");
|
||||
// A regional variant takes its base language, as the editor does.
|
||||
state.inventory.language = language({ ha: "nl-BE" });
|
||||
expect(options(mount(AppSettingsView), "screen-language")[0]).toBe("Home Assistant's language (Nederlands)");
|
||||
state.inventory.language = language({ setting: "de", effective: "de" });
|
||||
view = mount(AppSettingsView);
|
||||
expect(view.find("#language-help").text()).toBe("Deutsch is not checked yet, help welcome: how to translate");
|
||||
expect(view.find("#language-help a").attributes("href")).toBe(GUIDE);
|
||||
});
|
||||
it("saves a choice, and every screen takes the language with its next update", async () => {
|
||||
const fetch = addOn((body) => new Response(JSON.stringify({ language: language({ setting: body.setting, effective: body.setting }) }), { status: 200 }));
|
||||
const view = mount(AppSettingsView);
|
||||
await view.find("#screen-language").setValue("de");
|
||||
await flushPromises();
|
||||
const put = fetch.mock.calls.find(([url]) => url === "api/language")!;
|
||||
expect(put[1]!.method).toBe("PUT");
|
||||
expect(JSON.parse(String(put[1]!.body))).toEqual({ setting: "de" });
|
||||
expect(state.inventory.language!.setting).toBe("de");
|
||||
expect(state.toast?.message).toBe("Saved. Every screen takes the new language with its next update.");
|
||||
// The screens' update state comes with the inventory again.
|
||||
expect(fetch.mock.calls.some(([url]) => url === "api/inventory")).toBe(true);
|
||||
await view.find("#time-format").setValue("12");
|
||||
await flushPromises();
|
||||
expect(JSON.parse(String(fetch.mock.calls.filter(([url]) => url === "api/language").pop()![1]!.body))).toEqual({ clock: "12" });
|
||||
expect(state.toast?.message).toBe("Saved.");
|
||||
});
|
||||
it("shows the add-on's refusal and keeps the stored choice", async () => {
|
||||
addOn(() => new Response(JSON.stringify({ error: "That language isn't there." }), { status: 400 }));
|
||||
const view = mount(AppSettingsView);
|
||||
await view.find("#number-format").setValue("space");
|
||||
await flushPromises();
|
||||
expect(state.toast?.message).toBe("That language isn't there.");
|
||||
expect((view.find("#number-format").element as HTMLSelectElement).value).toBe("auto");
|
||||
expect(state.inventory.language!.numbers).toBe("auto");
|
||||
});
|
||||
it("offers the clock and numbers of the user's Home Assistant profile when they differ", async () => {
|
||||
const hass = { locale: { language: "nl", time_format: "12", number_format: "comma_decimal" } };
|
||||
const parent = { document: { documentElement: { lang: "nl" }, querySelector: (tag: string) => (tag === "home-assistant" ? { hass } : null) } };
|
||||
vi.stubGlobal("parent", parent);
|
||||
const fetch = addOn((body) => new Response(JSON.stringify({ language: language({ clock: body.clock, clock_effective: body.clock, numbers: body.numbers, numbers_effective: body.numbers }) }), { status: 200 }));
|
||||
let view = mount(AppSettingsView);
|
||||
expect(view.find("#language-profile small").text()).toBe("Your Home Assistant profile: 12 hour · 1,234.5");
|
||||
await view.find("#language-profile button").trigger("click");
|
||||
await flushPromises();
|
||||
expect(JSON.parse(String(fetch.mock.calls.find(([url]) => url === "api/language")![1]!.body))).toEqual({ clock: "12", numbers: "point" });
|
||||
expect(view.find("#language-profile").exists()).toBe(false);
|
||||
// A profile that follows the language names nothing.
|
||||
hass.locale = { language: "nl", time_format: "language", number_format: "language" };
|
||||
view = mount(AppSettingsView);
|
||||
expect(view.find("#language-profile").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the update a new language takes", () => {
|
||||
const screen = (update: object, firmware = "0.2.80") => ({ id: "living", name: "Living room", online: true, firmware, layout: { title: "", tiles: [] }, update } as any);
|
||||
it("says the new language when the firmware stays the same", () => {
|
||||
state.inventory.screens = [screen({ language: true, target: "0.2.80", profile: "living.yaml", host: "10.0.0.2" })];
|
||||
const sidebar = mount(Sidebar);
|
||||
expect(sidebar.find(".pill").text()).toBe("Update");
|
||||
expect(sidebar.find(".screen-update small").text()).toBe("New language: Nederlands");
|
||||
expect(sidebar.find(".whatsnew").exists()).toBe(false);
|
||||
});
|
||||
it("puts it first in What's new when the firmware is new too", () => {
|
||||
state.inventory.screens = [screen({ available: true, language: true, target: "0.2.81", profile: "living.yaml" })];
|
||||
state.inventory.updates = { target: "0.2.81", pending: 1 };
|
||||
state.inventory.changelog = [{ app: "0.2.91", firmware: "0.2.81", lines: ["Faster."] }];
|
||||
const sidebar = mount(Sidebar);
|
||||
expect(sidebar.find(".screen-update small").text()).toBe("Update 0.2.81");
|
||||
expect(sidebar.findAll(".whatsnew li").map((li) => li.text())).toEqual(["New language: Nederlands", "Faster."]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the top bar's clock", () => {
|
||||
it("points to Language & region instead of a clock of its own", () => {
|
||||
state.layout = { title: "Living room", tiles: [], header: { items: [{ type: "clock" }] } };
|
||||
let drawer = mount(TopbarInspector, { props: { index: 0 } });
|
||||
expect(drawer.find(".seg").exists()).toBe(false);
|
||||
expect(drawer.find("#topbar-clock").text()).toBe("24 hour clock · change under Settings → Language & region");
|
||||
expect(drawer.find("#topbar-clock a").attributes("href")).toBe("#settings");
|
||||
state.inventory.language = language({ clock_effective: "12" });
|
||||
drawer = mount(TopbarInspector, { props: { index: 0 } });
|
||||
expect(drawer.find("#topbar-clock").text()).toBe("12 hour clock · change under Settings → Language & region");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
// Every mounted component gets the editor's texts, as main.ts gives the page (app 0.2.90).
|
||||
import { config } from "@vue/test-utils";
|
||||
import { i18n } from "../src/i18n";
|
||||
|
||||
config.global.plugins = [i18n];
|
||||
@@ -1,7 +1,7 @@
|
||||
// The store: selecting a screen, editing its layout, what's new, progress, copy and import.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addTile, canAlert, copyLayoutFrom, fullPage, importLayout, layoutJson, liveOf, moveTileToPage, pageReachWarning, pageTilesRepeat, PHASES, removeTile,
|
||||
addTile, canAlert, copyLayoutFrom, fullPage, importLayout, layoutJson, liveOf, moveTileToPage, pageReachWarning, pageTilesRepeat, phaseText, removeTile,
|
||||
retargetPageTile, save, select, setTileOption, state, supports, tileLimit, topbarItems, topbarView, updateProgress, whatsNew,
|
||||
} from "../src/store";
|
||||
import type { Inventory, Screen } from "../src/types";
|
||||
@@ -141,7 +141,8 @@ describe("updates with content", () => {
|
||||
expect(updateProgress(living)).toBeNull();
|
||||
living.update!.state = "running";
|
||||
living.update!.phase = "install";
|
||||
expect(updateProgress(living)).toEqual({ percent: 12, text: PHASES.install });
|
||||
expect(updateProgress(living)).toEqual({ percent: 12, text: phaseText("install") });
|
||||
expect(phaseText("install")).toBe("Building and installing…");
|
||||
state.firmwareJob = { job: { stage: "compile" }, logs: [] };
|
||||
expect(updateProgress(living)!.percent).toBe(40);
|
||||
state.firmwareJob = { job: { stage: "upload" }, logs: [] };
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// What the page takes from the translations (app 0.2.90), for vite.config.ts and the tests. Every language is one file
|
||||
// in screen_manager/translations, shared with the firmware and the add-on.
|
||||
import { join } from "node:path";
|
||||
|
||||
export const TRANSLATIONS = join(__dirname, "..", "screen_manager", "translations");
|
||||
// Of the screens' texts the page takes the ones its mockup draws: Home Assistant's words, times, dates, numbers and the
|
||||
// page label. The rest of `screen` and all of `addon` stay out.
|
||||
const SCREEN_PARTS = ["ha", "time", "date", "number"];
|
||||
|
||||
// An empty text is one the language doesn't have yet: it stays out, so English shows, as on the screens. A space is
|
||||
// a text (French writes 1 234,5).
|
||||
function filled(node: unknown): unknown {
|
||||
if (typeof node === "string") return node === "" ? undefined : node;
|
||||
if (Array.isArray(node)) return node.every((item) => typeof item === "string" && item !== "") ? node : undefined;
|
||||
if (!node || typeof node !== "object") return undefined;
|
||||
const kept = Object.entries(node).map(([key, value]) => [key, filled(value)]).filter(([, value]) => value !== undefined);
|
||||
return kept.length ? Object.fromEntries(kept) : undefined;
|
||||
}
|
||||
/** The part of a language's file the page takes: the editor's texts and the screens' words its mockup draws. */
|
||||
export function pageTexts(data: any) {
|
||||
const screen = data?.screen || {};
|
||||
const words: Record<string, unknown> = Object.fromEntries(SCREEN_PARTS.filter((part) => part in screen).map((part) => [part, screen[part]]));
|
||||
if (screen.tile?.page !== undefined) words.tile = { page: screen.tile.page };
|
||||
return (filled({ editor: data?.editor, screen: words }) || {}) as Record<string, unknown>;
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"noUnusedLocals": false,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"jsx": "preserve",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
|
||||
+40
-2
@@ -1,5 +1,39 @@
|
||||
import VueI18n from "@intlify/unplugin-vue-i18n/vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import { pageTexts, TRANSLATIONS } from "./translations";
|
||||
|
||||
// Before vue-i18n's plugin compiles a file into the page's messages: only its page part goes in. Every language's
|
||||
// `_meta` (its name, plural rule, whether it is checked) comes with the page as virtual:esp-screens-languages, so the
|
||||
// editor knows the languages before it loads one.
|
||||
const LANGUAGES_ID = "virtual:esp-screens-languages";
|
||||
export function editorTexts(): Plugin {
|
||||
return {
|
||||
name: "esp-screens-editor-texts",
|
||||
enforce: "pre",
|
||||
resolveId: (id) => (id === LANGUAGES_ID ? `\0${LANGUAGES_ID}` : null),
|
||||
load(id) {
|
||||
if (id !== `\0${LANGUAGES_ID}`) return null;
|
||||
const languages: Record<string, unknown> = {};
|
||||
for (const name of readdirSync(TRANSLATIONS).filter((file) => file.endsWith(".json")).sort()) {
|
||||
this.addWatchFile(join(TRANSLATIONS, name));
|
||||
languages[name.slice(0, -".json".length)] = JSON.parse(readFileSync(join(TRANSLATIONS, name), "utf8"))._meta;
|
||||
}
|
||||
return `export default ${JSON.stringify(languages)};`;
|
||||
},
|
||||
transform(code, id) {
|
||||
if (!/[\\/]screen_manager[\\/]translations[\\/][^\\/?]+\.json$/.test(id)) return null;
|
||||
return { code: JSON.stringify(pageTexts(JSON.parse(code))), map: null };
|
||||
},
|
||||
};
|
||||
}
|
||||
// vue-i18n compiles every text while building (app 0.2.90), so the page runs its runtime-only build without the message
|
||||
// compiler, and no text is compiled in the browser. A text it can't read stops the build (tools/i18n.py check says so
|
||||
// first). `runtimeOnly: false` for the tests, which add texts of their own.
|
||||
export const translations = (runtimeOnly = true) =>
|
||||
VueI18n({ include: [join(TRANSLATIONS, "*.json")], runtimeOnly, compositionOnly: true, fullInstall: true, dropMessageCompiler: runtimeOnly });
|
||||
|
||||
// The add-on serves the built page from screen_manager/app/static behind Home Assistant ingress,
|
||||
// which lives on a sub path with a token. Everything is relative so the page works there and on
|
||||
@@ -7,7 +41,9 @@ import { defineConfig } from "vite";
|
||||
// files change. `npm run dev` proxies /api to a running server (the demo home or SCREEN_DEV).
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [vue()],
|
||||
plugins: [editorTexts(), translations(), vue()],
|
||||
// No vue-i18n devtools in the build; its other switches come from the plugin above.
|
||||
define: { __INTLIFY_PROD_DEVTOOLS__: false },
|
||||
build: {
|
||||
outDir: "../screen_manager/app/static",
|
||||
emptyOutDir: false,
|
||||
@@ -16,5 +52,7 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: { "/api": { target: "http://127.0.0.1:8099", changeOrigin: false } },
|
||||
// The translations live beside the add-on, outside web/.
|
||||
fs: { allow: [".", "../screen_manager/translations"] },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { editorTexts, translations } from "./vite.config";
|
||||
|
||||
// Unit and component tests of the editor: the grid rules, the top bar rules, the store and the components,
|
||||
// in jsdom. `npm test` runs them; `npm run test:watch` while developing.
|
||||
// in jsdom. `npm test` runs them; `npm run test:watch` while developing. The components mount with the
|
||||
// editor's texts (tests/setup.ts), in English, compiled as for the page; vue-i18n keeps its compiler here, for the
|
||||
// texts the tests add themselves.
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [editorTexts(), translations(false), vue()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
setupFiles: ["tests/setup.ts"],
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user