9 files
This commit is contained in:
@@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.loader import async_get_integration
|
from homeassistant.loader import async_get_integration
|
||||||
|
|
||||||
from .api import ImageProxyView, MAQueueView, MusicAssistantBrowseView, MusicAssistantLibraryView, MusicAssistantProvidersView, MusicAssistantRecommendationsView, MusicAssistantSearchView, MusicAssistantSubitemsView, PlayerGroupView, PlayerQueueJumpView, PlayerQueueView
|
from .api import ImageProxyView, MAThumbnailView, MAQueueView, MusicAssistantBrowseView, MusicAssistantLibraryView, MusicAssistantProvidersView, MusicAssistantRecommendationsView, MusicAssistantSearchView, MusicAssistantSubitemsView, PlayerGroupView, PlayerQueueJumpView, PlayerQueueView
|
||||||
from .const import CARD_JS_FILENAME, CARD_URL, CONF_DEBUG_MODE, CONF_EXCLUDED_PLAYERS, CONF_MA_URL, DOMAIN, ICON_URL, MUSIC_ASSISTANT_DOMAIN, WS_CONFIG_COMMAND
|
from .const import CARD_JS_FILENAME, CARD_URL, CONF_DEBUG_MODE, CONF_EXCLUDED_PLAYERS, CONF_MA_URL, DOMAIN, ICON_URL, MUSIC_ASSISTANT_DOMAIN, WS_CONFIG_COMMAND
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -110,6 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.http.register_view(MusicAssistantRecommendationsView)
|
hass.http.register_view(MusicAssistantRecommendationsView)
|
||||||
hass.http.register_view(MusicAssistantProvidersView)
|
hass.http.register_view(MusicAssistantProvidersView)
|
||||||
hass.http.register_view(ImageProxyView)
|
hass.http.register_view(ImageProxyView)
|
||||||
|
hass.http.register_view(MAThumbnailView)
|
||||||
|
|
||||||
# Register WebSocket command so the card can fetch its config
|
# Register WebSocket command so the card can fetch its config
|
||||||
_register_websocket_commands(hass)
|
_register_websocket_commands(hass)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,7 @@ import enum
|
|||||||
import logging
|
import logging
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@@ -69,6 +70,18 @@ def _extract_thumbnail(item: dict) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _make_thumb_url(raw_path: str) -> str:
|
||||||
|
"""Wrap a raw image path in our thumbnail proxy endpoint.
|
||||||
|
|
||||||
|
Instead of exposing raw image URLs (which may require provider auth
|
||||||
|
or be unreachable from the browser), route everything through our
|
||||||
|
server-side proxy that resolves via the MA server.
|
||||||
|
"""
|
||||||
|
if not raw_path:
|
||||||
|
return ""
|
||||||
|
return f"/my_music_library/thumb?path={quote(raw_path, safe='')}"
|
||||||
|
|
||||||
|
|
||||||
def _serialize_search_results(results: Any) -> dict:
|
def _serialize_search_results(results: Any) -> dict:
|
||||||
"""Convert a MA SearchResults object (or dict) to a plain dict."""
|
"""Convert a MA SearchResults object (or dict) to a plain dict."""
|
||||||
if results is None:
|
if results is None:
|
||||||
@@ -77,10 +90,9 @@ def _serialize_search_results(results: Any) -> dict:
|
|||||||
if isinstance(safe, dict):
|
if isinstance(safe, dict):
|
||||||
for key in ("tracks", "artists", "albums", "playlists", "radios"):
|
for key in ("tracks", "artists", "albums", "playlists", "radios"):
|
||||||
for item in safe.get(key) or []:
|
for item in safe.get(key) or []:
|
||||||
if isinstance(item, dict) and not item.get("thumbnail"):
|
if isinstance(item, dict):
|
||||||
thumb = _extract_thumbnail(item)
|
raw = item.get("thumbnail") or _extract_thumbnail(item)
|
||||||
if thumb:
|
item["thumbnail"] = _make_thumb_url(raw)
|
||||||
item["thumbnail"] = thumb
|
|
||||||
return safe
|
return safe
|
||||||
return {"tracks": [], "artists": [], "albums": [], "playlists": [], "raw": str(safe)}
|
return {"tracks": [], "artists": [], "albums": [], "playlists": [], "raw": str(safe)}
|
||||||
|
|
||||||
@@ -242,7 +254,7 @@ def _normalize_library_item(item: dict) -> dict:
|
|||||||
if isinstance(media_type, dict):
|
if isinstance(media_type, dict):
|
||||||
media_type = media_type.get("value", "")
|
media_type = media_type.get("value", "")
|
||||||
|
|
||||||
thumbnail = _extract_thumbnail(item)
|
thumbnail = _make_thumb_url(_extract_thumbnail(item))
|
||||||
|
|
||||||
artist = item.get("media_artist") or ""
|
artist = item.get("media_artist") or ""
|
||||||
if not artist:
|
if not artist:
|
||||||
@@ -554,7 +566,7 @@ def _normalize_browse_item(item: dict) -> dict:
|
|||||||
if _path.startswith("folder/"):
|
if _path.startswith("folder/"):
|
||||||
uri = f"{_scheme}://{_path[len('folder/'):]}"
|
uri = f"{_scheme}://{_path[len('folder/'):]}"
|
||||||
|
|
||||||
thumbnail = _extract_thumbnail(item)
|
thumbnail = _make_thumb_url(_extract_thumbnail(item))
|
||||||
|
|
||||||
artist = item.get("media_artist") or ""
|
artist = item.get("media_artist") or ""
|
||||||
if not artist:
|
if not artist:
|
||||||
@@ -680,6 +692,45 @@ def _parse_ma_uri(uri: str) -> tuple[str, str]:
|
|||||||
return item_id, scheme
|
return item_id, scheme
|
||||||
|
|
||||||
|
|
||||||
|
_SUBITEM_REST_PATHS: dict[str, list[str]] = {
|
||||||
|
"artist_albums": ["/api/music/artists/{id}/albums", "/api/artists/{id}/albums"],
|
||||||
|
"album_tracks": ["/api/music/albums/{id}/tracks", "/api/albums/{id}/tracks"],
|
||||||
|
"playlist_tracks": ["/api/music/playlists/{id}/tracks", "/api/playlists/{id}/tracks"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_subitems_via_rest(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
action: str,
|
||||||
|
item_id: str,
|
||||||
|
limit: int,
|
||||||
|
) -> list | None:
|
||||||
|
"""Fallback: fetch sub-items via MA REST API."""
|
||||||
|
ma_url = _get_mass_url(hass)
|
||||||
|
if not ma_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session = async_get_clientsession(hass)
|
||||||
|
paths = _SUBITEM_REST_PATHS.get(action, [])
|
||||||
|
for path_tpl in paths:
|
||||||
|
url = f"{ma_url}{path_tpl.format(id=item_id)}"
|
||||||
|
try:
|
||||||
|
async with session.get(
|
||||||
|
url,
|
||||||
|
timeout=aiohttp.ClientTimeout(total=10),
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
_LOGGER.debug("Subitems REST %s → HTTP %s", url, resp.status)
|
||||||
|
continue
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
items_raw = data if isinstance(data, list) else (data.get("items") or [])
|
||||||
|
_LOGGER.info("Subitems REST %s → %d items", url, len(items_raw))
|
||||||
|
return [_normalize_library_item(i) for i in items_raw[:limit]]
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
_LOGGER.debug("Subitems REST %s failed: %s", url, err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _get_subitems(
|
async def _get_subitems(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
action: str,
|
action: str,
|
||||||
@@ -690,11 +741,11 @@ async def _get_subitems(
|
|||||||
mass = _get_mass_client(hass)
|
mass = _get_mass_client(hass)
|
||||||
if mass is None:
|
if mass is None:
|
||||||
_LOGGER.warning("No Music Assistant (mass) client available for subitems")
|
_LOGGER.warning("No Music Assistant (mass) client available for subitems")
|
||||||
return None
|
return await _get_subitems_via_rest(hass, action, uri, limit)
|
||||||
|
|
||||||
music = getattr(mass, "music", None)
|
music = getattr(mass, "music", None)
|
||||||
if not music:
|
if not music:
|
||||||
return None
|
return await _get_subitems_via_rest(hass, action, uri, limit)
|
||||||
|
|
||||||
methods = _SUBITEM_METHODS.get(action, [])
|
methods = _SUBITEM_METHODS.get(action, [])
|
||||||
if not methods:
|
if not methods:
|
||||||
@@ -723,10 +774,11 @@ async def _get_subitems(
|
|||||||
_LOGGER.info("Subitems %s: %d items (args=%s kwargs=%s)", action, len(items), call_args, call_kwargs)
|
_LOGGER.info("Subitems %s: %d items (args=%s kwargs=%s)", action, len(items), call_args, call_kwargs)
|
||||||
return [_normalize_library_item(_to_json_safe(i)) for i in items[:limit]]
|
return [_normalize_library_item(_to_json_safe(i)) for i in items[:limit]]
|
||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
_LOGGER.debug("Subitems %s args=%s kwargs=%s → %s: %s", method_name, call_args, call_kwargs, type(err).__name__, err)
|
_LOGGER.warning("Subitems %s args=%s kwargs=%s → %s: %s", method_name, call_args, call_kwargs, type(err).__name__, err)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return None
|
_LOGGER.warning("Subitems %s: all MA client attempts failed for %r, trying REST fallback", action, uri)
|
||||||
|
return await _get_subitems_via_rest(hass, action, item_id, limit)
|
||||||
|
|
||||||
|
|
||||||
# ── Providers ─────────────────────────────────────────────────────────────────
|
# ── Providers ─────────────────────────────────────────────────────────────────
|
||||||
@@ -853,9 +905,10 @@ def _normalize_queue_item(item: dict) -> dict:
|
|||||||
queue_item_id = item.get("queue_item_id") or ""
|
queue_item_id = item.get("queue_item_id") or ""
|
||||||
duration = item.get("duration") or 0
|
duration = item.get("duration") or 0
|
||||||
|
|
||||||
thumbnail = _extract_thumbnail(item)
|
raw_thumb = _extract_thumbnail(item)
|
||||||
if not thumbnail:
|
if not raw_thumb:
|
||||||
thumbnail = _extract_thumbnail(item.get("media_item") or {})
|
raw_thumb = _extract_thumbnail(item.get("media_item") or {})
|
||||||
|
thumbnail = _make_thumb_url(raw_thumb)
|
||||||
|
|
||||||
media_item = item.get("media_item") or {}
|
media_item = item.get("media_item") or {}
|
||||||
uri = media_item.get("uri") or ""
|
uri = media_item.get("uri") or ""
|
||||||
@@ -1361,3 +1414,64 @@ class ImageProxyView(HomeAssistantView):
|
|||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
return web.Response(status=HTTPStatus.BAD_GATEWAY)
|
return web.Response(status=HTTPStatus.BAD_GATEWAY)
|
||||||
|
|
||||||
|
|
||||||
|
class MAThumbnailView(HomeAssistantView):
|
||||||
|
"""Proxy thumbnail requests through the MA server.
|
||||||
|
|
||||||
|
GET /my_music_library/thumb?path=<raw_image_path>
|
||||||
|
|
||||||
|
Resolves image paths server-side via the Music Assistant server,
|
||||||
|
handling provider-specific auth (Plex tokens, etc.) and internal
|
||||||
|
MA references that browsers cannot access directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = "/my_music_library/thumb"
|
||||||
|
name = "my_music_library:thumb"
|
||||||
|
requires_auth = False
|
||||||
|
|
||||||
|
async def get(self, request: web.Request) -> web.Response:
|
||||||
|
"""Resolve an image path via MA and return the image bytes."""
|
||||||
|
hass: HomeAssistant = request.app["hass"]
|
||||||
|
raw_path = request.query.get("path", "").strip()
|
||||||
|
if not raw_path:
|
||||||
|
return web.Response(status=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
ma_url = _get_mass_url(hass)
|
||||||
|
session = async_get_clientsession(hass)
|
||||||
|
|
||||||
|
urls_to_try: list[str] = []
|
||||||
|
if raw_path.startswith("http"):
|
||||||
|
urls_to_try.append(raw_path)
|
||||||
|
if ma_url:
|
||||||
|
urls_to_try.append(
|
||||||
|
f"{ma_url}/api/image/{quote(raw_path, safe='')}"
|
||||||
|
)
|
||||||
|
elif ma_url:
|
||||||
|
urls_to_try.append(
|
||||||
|
f"{ma_url}/api/image/{quote(raw_path, safe='')}"
|
||||||
|
)
|
||||||
|
if raw_path.startswith("/"):
|
||||||
|
urls_to_try.append(f"{ma_url}{raw_path}")
|
||||||
|
|
||||||
|
for image_url in urls_to_try:
|
||||||
|
try:
|
||||||
|
async with session.get(
|
||||||
|
image_url,
|
||||||
|
timeout=aiohttp.ClientTimeout(total=10),
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
continue
|
||||||
|
ct = resp.content_type or "image/jpeg"
|
||||||
|
if not ct.startswith("image"):
|
||||||
|
continue
|
||||||
|
body = await resp.read()
|
||||||
|
return web.Response(
|
||||||
|
body=body,
|
||||||
|
content_type=ct,
|
||||||
|
headers={"Cache-Control": "public, max-age=3600"},
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
|
||||||
|
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||||
|
|||||||
@@ -9,5 +9,5 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"issue_tracker": "https://github.com/Patafoin/ha-my-music-library/issues",
|
"issue_tracker": "https://github.com/Patafoin/ha-my-music-library/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "3.10.4"
|
"version": "3.12.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CARD_VERSION = "3.10.4";
|
const CARD_VERSION = "3.12.4";
|
||||||
|
|
||||||
/* ─── Icons (inline SVG strings) ─────────────────────────── */
|
/* ─── Icons (inline SVG strings) ─────────────────────────── */
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
@@ -169,6 +169,32 @@ const TRANSLATIONS = {
|
|||||||
search_layout_rows: "Rows",
|
search_layout_rows: "Rows",
|
||||||
search_layout_columns: "Columns",
|
search_layout_columns: "Columns",
|
||||||
show_device_select: "Show device selection",
|
show_device_select: "Show device selection",
|
||||||
|
type_custom_element: "Custom element",
|
||||||
|
btn_element_name: "Element tag",
|
||||||
|
btn_element_config: "Config (JSON)",
|
||||||
|
action_mml_navigate_tab: "Show tab (MML)",
|
||||||
|
action_mml_navigate_section: "Show section (MML)",
|
||||||
|
action_mml_control: "Player control (MML)",
|
||||||
|
btn_mml_tab: "Tab",
|
||||||
|
btn_mml_section: "Section",
|
||||||
|
btn_mml_command: "Command",
|
||||||
|
mml_cmd_play_pause: "Play / Pause",
|
||||||
|
mml_cmd_next: "Next",
|
||||||
|
mml_cmd_prev: "Previous",
|
||||||
|
mml_cmd_shuffle: "Shuffle",
|
||||||
|
mml_cmd_repeat: "Repeat",
|
||||||
|
mml_cmd_mute: "Mute",
|
||||||
|
nav_bar_section: "Navigation bar",
|
||||||
|
nav_bar_position: "Position",
|
||||||
|
nav_bar_pos_top: "Top",
|
||||||
|
nav_bar_pos_bottom: "Bottom",
|
||||||
|
nav_bar_pos_left: "Left",
|
||||||
|
nav_bar_pos_right: "Right",
|
||||||
|
nav_bar_align: "Alignment",
|
||||||
|
nav_bar_align_start: "Start",
|
||||||
|
nav_bar_align_center: "Center",
|
||||||
|
nav_bar_align_end: "End",
|
||||||
|
nav_bar_align_space_between: "Space between",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
@@ -290,6 +316,32 @@ const TRANSLATIONS = {
|
|||||||
search_layout_rows: "Lignes",
|
search_layout_rows: "Lignes",
|
||||||
search_layout_columns: "Colonnes",
|
search_layout_columns: "Colonnes",
|
||||||
show_device_select: "Afficher la sélection de l'appareil",
|
show_device_select: "Afficher la sélection de l'appareil",
|
||||||
|
type_custom_element: "Élément custom",
|
||||||
|
btn_element_name: "Balise élément",
|
||||||
|
btn_element_config: "Config (JSON)",
|
||||||
|
action_mml_navigate_tab: "Afficher un onglet (MML)",
|
||||||
|
action_mml_navigate_section: "Afficher une section (MML)",
|
||||||
|
action_mml_control: "Contrôle lecteur (MML)",
|
||||||
|
btn_mml_tab: "Onglet",
|
||||||
|
btn_mml_section: "Section",
|
||||||
|
btn_mml_command: "Commande",
|
||||||
|
mml_cmd_play_pause: "Lecture / Pause",
|
||||||
|
mml_cmd_next: "Suivant",
|
||||||
|
mml_cmd_prev: "Précédent",
|
||||||
|
mml_cmd_shuffle: "Aléatoire",
|
||||||
|
mml_cmd_repeat: "Répéter",
|
||||||
|
mml_cmd_mute: "Muet",
|
||||||
|
nav_bar_section: "Barre de navigation",
|
||||||
|
nav_bar_position: "Position",
|
||||||
|
nav_bar_pos_top: "Haut",
|
||||||
|
nav_bar_pos_bottom: "Bas",
|
||||||
|
nav_bar_pos_left: "Gauche",
|
||||||
|
nav_bar_pos_right: "Droite",
|
||||||
|
nav_bar_align: "Alignement",
|
||||||
|
nav_bar_align_start: "Début",
|
||||||
|
nav_bar_align_center: "Centre",
|
||||||
|
nav_bar_align_end: "Fin",
|
||||||
|
nav_bar_align_space_between: "Réparti",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
@@ -411,10 +463,120 @@ const TRANSLATIONS = {
|
|||||||
search_layout_rows: "Zeilen",
|
search_layout_rows: "Zeilen",
|
||||||
search_layout_columns: "Spalten",
|
search_layout_columns: "Spalten",
|
||||||
show_device_select: "Geräteauswahl anzeigen",
|
show_device_select: "Geräteauswahl anzeigen",
|
||||||
|
type_custom_element: "Benutzerelement",
|
||||||
|
btn_element_name: "Element-Tag",
|
||||||
|
btn_element_config: "Konfiguration (JSON)",
|
||||||
|
action_mml_navigate_tab: "Tab anzeigen (MML)",
|
||||||
|
action_mml_navigate_section: "Abschnitt anzeigen (MML)",
|
||||||
|
action_mml_control: "Player-Steuerung (MML)",
|
||||||
|
btn_mml_tab: "Tab",
|
||||||
|
btn_mml_section: "Abschnitt",
|
||||||
|
btn_mml_command: "Befehl",
|
||||||
|
mml_cmd_play_pause: "Wiedergabe / Pause",
|
||||||
|
mml_cmd_next: "Weiter",
|
||||||
|
mml_cmd_prev: "Zurück",
|
||||||
|
mml_cmd_shuffle: "Zufällig",
|
||||||
|
mml_cmd_repeat: "Wiederholen",
|
||||||
|
mml_cmd_mute: "Stummschalten",
|
||||||
|
nav_bar_section: "Navigationsleiste",
|
||||||
|
nav_bar_position: "Position",
|
||||||
|
nav_bar_pos_top: "Oben",
|
||||||
|
nav_bar_pos_bottom: "Unten",
|
||||||
|
nav_bar_pos_left: "Links",
|
||||||
|
nav_bar_pos_right: "Rechts",
|
||||||
|
nav_bar_align: "Ausrichtung",
|
||||||
|
nav_bar_align_start: "Anfang",
|
||||||
|
nav_bar_align_center: "Mitte",
|
||||||
|
nav_bar_align_end: "Ende",
|
||||||
|
nav_bar_align_space_between: "Verteilt",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ─── YAML utilities (no external deps) ──────────────────── */
|
||||||
|
function _yamlDump(obj, indent = 0) {
|
||||||
|
if (obj === null || obj === undefined) return "null";
|
||||||
|
if (typeof obj === "boolean" || typeof obj === "number") return String(obj);
|
||||||
|
if (typeof obj === "string") {
|
||||||
|
if (!obj || /[\r\n:#\[\]{},&*?|<>=!%@`"']/.test(obj) || /^\s|\s$/.test(obj) ||
|
||||||
|
/^(true|false|yes|no|on|off|null|~)$/i.test(obj) || /^\d/.test(obj)) {
|
||||||
|
return `"${obj.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
if (!obj.length) return "[]";
|
||||||
|
const pad = " ".repeat(indent);
|
||||||
|
return obj.map(v => `${pad}- ${_yamlDump(v, indent + 2)}`).join("\n");
|
||||||
|
}
|
||||||
|
if (typeof obj === "object") {
|
||||||
|
const keys = Object.keys(obj);
|
||||||
|
if (!keys.length) return "";
|
||||||
|
const pad = " ".repeat(indent);
|
||||||
|
return keys.map(k => {
|
||||||
|
const v = obj[k];
|
||||||
|
if (v !== null && typeof v === "object") {
|
||||||
|
const nested = _yamlDump(v, indent + 2);
|
||||||
|
return nested ? `${pad}${k}:\n${nested}` : `${pad}${k}: {}`;
|
||||||
|
}
|
||||||
|
return `${pad}${k}: ${_yamlDump(v, indent)}`;
|
||||||
|
}).join("\n");
|
||||||
|
}
|
||||||
|
return String(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseYamlScalar(v) {
|
||||||
|
if (v === "true" || v === "yes" || v === "on") return true;
|
||||||
|
if (v === "false" || v === "no" || v === "off") return false;
|
||||||
|
if (v === "null" || v === "~" || v === "") return null;
|
||||||
|
if (/^-?\d+$/.test(v)) return parseInt(v, 10);
|
||||||
|
if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v);
|
||||||
|
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
||||||
|
return v.slice(1, -1).replace(/\\n/g, "\n").replace(/\\"/g, '"');
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _yamlLoad(text) {
|
||||||
|
if (window.jsyaml?.load) return window.jsyaml.load(text);
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t || t === "{}") return {};
|
||||||
|
// Basic line-by-line parser for HA card config subset
|
||||||
|
const lines = t.split("\n");
|
||||||
|
const stack = [{ obj: {}, indent: -1 }];
|
||||||
|
const arrKeys = new Map(); // tracks which keys hold arrays
|
||||||
|
for (const raw of lines) {
|
||||||
|
const trimEnd = raw.trimEnd();
|
||||||
|
if (!trimEnd || trimEnd.trimStart().startsWith("#")) continue;
|
||||||
|
const il = trimEnd.length - trimEnd.trimStart().length;
|
||||||
|
const content = trimEnd.trimStart();
|
||||||
|
while (stack.length > 1 && stack[stack.length - 1].indent >= il) stack.pop();
|
||||||
|
const top = stack[stack.length - 1];
|
||||||
|
if (content.startsWith("- ")) {
|
||||||
|
const val = _parseYamlScalar(content.slice(2).trim());
|
||||||
|
const arrKey = arrKeys.get(top);
|
||||||
|
if (arrKey !== undefined && Array.isArray(top.obj[arrKey])) top.obj[arrKey].push(val);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ci = content.indexOf(": ");
|
||||||
|
const colonEnd = content === content.replace(/:$/, "") ? -1 : content.length - 1;
|
||||||
|
if (ci === -1 && colonEnd === -1) continue;
|
||||||
|
const key = ci >= 0 ? content.slice(0, ci).trim() : content.slice(0, colonEnd).trim();
|
||||||
|
const val = ci >= 0 ? content.slice(ci + 2).trim() : "";
|
||||||
|
if (!val) {
|
||||||
|
const newObj = {};
|
||||||
|
top.obj[key] = newObj;
|
||||||
|
stack.push({ obj: newObj, indent: il });
|
||||||
|
} else if (val === "[]") {
|
||||||
|
top.obj[key] = [];
|
||||||
|
arrKeys.set(stack[stack.length - 1], key);
|
||||||
|
} else {
|
||||||
|
top.obj[key] = _parseYamlScalar(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stack[0].obj;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── CSS ─────────────────────────────────────────────────── */
|
/* ─── CSS ─────────────────────────────────────────────────── */
|
||||||
const STYLES = `
|
const STYLES = `
|
||||||
:host {
|
:host {
|
||||||
@@ -545,6 +707,56 @@ const STYLES = `
|
|||||||
.nav-btn ha-icon { --mdc-icon-size: 20px; display: block; pointer-events: none; }
|
.nav-btn ha-icon { --mdc-icon-size: 20px; display: block; pointer-events: none; }
|
||||||
.nav-btn svg { width: 20px; height: 20px; fill: currentColor; flex-shrink: 0; }
|
.nav-btn svg { width: 20px; height: 20px; fill: currentColor; flex-shrink: 0; }
|
||||||
.nav-btn-label { font-size: 10px; font-weight: 500; line-height: 1; pointer-events: none; }
|
.nav-btn-label { font-size: 10px; font-weight: 500; line-height: 1; pointer-events: none; }
|
||||||
|
|
||||||
|
/* ── CUSTOM ELEMENT SLOT ── */
|
||||||
|
.nav-btn-custom { padding: 0; min-width: 36px; min-height: 44px; overflow: hidden; flex-shrink: 0; }
|
||||||
|
.nav-btn-custom > * { pointer-events: none; display: block; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
/* ── NAV BAR POSITION VARIANTS ── */
|
||||||
|
.card-root[data-nav-pos="bottom"] { flex-direction: column-reverse; }
|
||||||
|
.card-root[data-nav-pos="left"],
|
||||||
|
.card-root[data-nav-pos="right"] { flex-direction: row; }
|
||||||
|
.card-root[data-nav-pos="right"] { flex-direction: row-reverse; }
|
||||||
|
|
||||||
|
.card-root[data-nav-pos="left"] .nav,
|
||||||
|
.card-root[data-nav-pos="right"] .nav {
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto; overflow-x: hidden;
|
||||||
|
border-bottom: none; border-right: 1px solid var(--border);
|
||||||
|
scroll-snap-type: y mandatory;
|
||||||
|
width: auto; height: 100%;
|
||||||
|
}
|
||||||
|
.card-root[data-nav-pos="right"] .nav { border-right: none; border-left: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.card-root[data-nav-pos="left"] .nav-tabs,
|
||||||
|
.card-root[data-nav-pos="right"] .nav-tabs { flex-direction: column; flex: none; width: 100%; }
|
||||||
|
|
||||||
|
.card-root[data-nav-pos="left"] .nav-tab,
|
||||||
|
.card-root[data-nav-pos="right"] .nav-tab {
|
||||||
|
border-right: none; border-bottom: none;
|
||||||
|
justify-content: flex-start; padding: 10px 14px;
|
||||||
|
border-left: 3px solid transparent; flex: none;
|
||||||
|
}
|
||||||
|
.card-root[data-nav-pos="right"] .nav-tab { border-left: none; border-right: 3px solid transparent; }
|
||||||
|
|
||||||
|
.card-root[data-nav-pos="left"] .nav-tab.active {
|
||||||
|
border-left-color: var(--accent); border-bottom: none;
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
.card-root[data-nav-pos="right"] .nav-tab.active {
|
||||||
|
border-right-color: var(--accent); border-bottom: none;
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
.card-root[data-nav-pos="left"] .nav-fade-left,
|
||||||
|
.card-root[data-nav-pos="left"] .nav-fade-right,
|
||||||
|
.card-root[data-nav-pos="right"] .nav-fade-left,
|
||||||
|
.card-root[data-nav-pos="right"] .nav-fade-right { display: none; }
|
||||||
|
|
||||||
|
/* ── NAV TABS ALIGNMENT ── */
|
||||||
|
.nav-tabs[data-align="center"] { justify-content: center; }
|
||||||
|
.nav-tabs[data-align="end"] { justify-content: flex-end; }
|
||||||
|
.nav-tabs[data-align="space-between"] { justify-content: space-between; }
|
||||||
|
|
||||||
/* ── CONTENT AREA ── */
|
/* ── CONTENT AREA ── */
|
||||||
/* position:relative + inset:0 on children is the most reliable way to
|
/* position:relative + inset:0 on children is the most reliable way to
|
||||||
give tab panels a definite pixel height without relying on flex cross-axis
|
give tab panels a definite pixel height without relying on flex cross-axis
|
||||||
@@ -1146,7 +1358,7 @@ const STYLES = `
|
|||||||
/* ══════════════════════════════════════════
|
/* ══════════════════════════════════════════
|
||||||
LIBRARY TAB
|
LIBRARY TAB
|
||||||
══════════════════════════════════════════ */
|
══════════════════════════════════════════ */
|
||||||
.library-panel { flex: 1; overflow-y: auto; padding: 0 0 16px; display: flex; flex-direction: column; }
|
.library-panel { flex: 1; overflow: hidden; padding: 0 0 16px; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
.lib-filters {
|
.lib-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1197,7 +1409,7 @@ const STYLES = `
|
|||||||
.lib-filter-fav.active { background: var(--accent); color: #000; border-color: var(--accent); }
|
.lib-filter-fav.active { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||||
.lib-filter-fav svg { width: 14px; height: 14px; fill: currentColor; }
|
.lib-filter-fav svg { width: 14px; height: 14px; fill: currentColor; }
|
||||||
|
|
||||||
.lib-content { flex: 1; overflow-y: auto; }
|
.lib-content { flex: 1; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
||||||
|
|
||||||
.lib-section { margin-bottom: 8px; }
|
.lib-section { margin-bottom: 8px; }
|
||||||
.lib-section-header {
|
.lib-section-header {
|
||||||
@@ -1219,6 +1431,7 @@ const STYLES = `
|
|||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.lib-scroll.scroll-locked { overflow-x: hidden !important; }
|
||||||
.lib-scroll::-webkit-scrollbar { display: none; }
|
.lib-scroll::-webkit-scrollbar { display: none; }
|
||||||
@media (hover: hover) and (pointer: fine) {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
.lib-scroll { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.2) transparent; }
|
.lib-scroll { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.2) transparent; }
|
||||||
@@ -1815,6 +2028,12 @@ class MyMusicLibraryCard extends HTMLElement {
|
|||||||
const typeCounts = {};
|
const typeCounts = {};
|
||||||
return config.tabs.map(t => {
|
return config.tabs.map(t => {
|
||||||
const type = t.type || "button";
|
const type = t.type || "button";
|
||||||
|
if (type === "custom_element") {
|
||||||
|
return { type: "custom_element", id: `ce-${idx++}`, element: t.element || "",
|
||||||
|
element_config: t.element_config || {}, name: t.name || "",
|
||||||
|
tap_action: t.tap_action, hold_action: t.hold_action, double_tap_action: t.double_tap_action,
|
||||||
|
width: t.width, height: t.height };
|
||||||
|
}
|
||||||
if (type === "button") {
|
if (type === "button") {
|
||||||
return { type: "button", id: `btn-${idx++}`, icon: t.icon, name: t.name || "", entity: t.entity,
|
return { type: "button", id: `btn-${idx++}`, icon: t.icon, name: t.name || "", entity: t.entity,
|
||||||
tap_action: t.tap_action, hold_action: t.hold_action, double_tap_action: t.double_tap_action,
|
tap_action: t.tap_action, hold_action: t.hold_action, double_tap_action: t.double_tap_action,
|
||||||
@@ -1823,7 +2042,7 @@ class MyMusicLibraryCard extends HTMLElement {
|
|||||||
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
||||||
const id = type === "settings" ? "settings" : (typeCounts[type] > 1 ? `${type}-${typeCounts[type] - 1}` : type);
|
const id = type === "settings" ? "settings" : (typeCounts[type] > 1 ? `${type}-${typeCounts[type] - 1}` : type);
|
||||||
const tab = { type, id, label: t.label || null, iconOverride: t.icon || null,
|
const tab = { type, id, label: t.label || null, iconOverride: t.icon || null,
|
||||||
defaultIcon: TAB_ICONS[type] || null };
|
defaultIcon: TAB_ICONS[type] || null, show_in_nav: t.show_in_nav !== false };
|
||||||
if (type === "library") {
|
if (type === "library") {
|
||||||
const sections = Array.isArray(t.sections) ? t.sections.filter(s => VALID_SECTIONS.includes(s)) : null;
|
const sections = Array.isArray(t.sections) ? t.sections.filter(s => VALID_SECTIONS.includes(s)) : null;
|
||||||
tab.sections = sections && sections.length ? sections : DEFAULT_SECTIONS;
|
tab.sections = sections && sections.length ? sections : DEFAULT_SECTIONS;
|
||||||
@@ -2068,8 +2287,10 @@ class MyMusicLibraryCard extends HTMLElement {
|
|||||||
|
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = `card-root${this._isMobile ? " mml-mobile" : ""}`;
|
card.className = `card-root${this._isMobile ? " mml-mobile" : ""}`;
|
||||||
|
const navPos = this._config.nav_bar?.position || "top";
|
||||||
|
if (navPos !== "top") card.dataset.navPos = navPos;
|
||||||
|
|
||||||
const panels = this._resolvedTabs.filter(t => t.type !== "button");
|
const panels = this._resolvedTabs.filter(t => t.type !== "button" && t.type !== "custom_element");
|
||||||
const panelRenderers = {
|
const panelRenderers = {
|
||||||
player: (t) => this._renderPlayerTab(),
|
player: (t) => this._renderPlayerTab(),
|
||||||
search: (t) => this._renderSearchTab(),
|
search: (t) => this._renderSearchTab(),
|
||||||
@@ -2107,8 +2328,20 @@ class MyMusicLibraryCard extends HTMLElement {
|
|||||||
this._updatePlayerContent(card);
|
this._updatePlayerContent(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_renderCustomElementSlot(tab) {
|
||||||
|
const sizeParts = [];
|
||||||
|
if (tab.width) sizeParts.push(`width:${typeof tab.width === "number" ? tab.width + "px" : tab.width}`);
|
||||||
|
if (tab.height) sizeParts.push(`height:${typeof tab.height === "number" ? tab.height + "px" : tab.height}`);
|
||||||
|
const sizeStyle = sizeParts.length ? ` style="${sizeParts.join(";")}"` : "";
|
||||||
|
return `<div class="nav-btn nav-btn-custom" data-tab-btn="${tab.id}" data-ce-slot="${tab.id}"${sizeStyle}></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
_renderNav() {
|
_renderNav() {
|
||||||
const items = this._resolvedTabs.map(t => {
|
const align = this._config.nav_bar?.align || "start";
|
||||||
|
const items = this._resolvedTabs.filter(t => t.show_in_nav !== false).map(t => {
|
||||||
|
if (t.type === "custom_element") {
|
||||||
|
return this._renderCustomElementSlot(t);
|
||||||
|
}
|
||||||
if (t.type === "button") {
|
if (t.type === "button") {
|
||||||
return this._renderNavButton(t);
|
return this._renderNavButton(t);
|
||||||
}
|
}
|
||||||
@@ -2131,12 +2364,14 @@ class MyMusicLibraryCard extends HTMLElement {
|
|||||||
</button>`;
|
</button>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
|
const alignAttr = align !== "start" ? ` data-align="${align}"` : "";
|
||||||
|
const navStyle = this._config.nav_bar?.style ? ` style="${this._esc(this._config.nav_bar.style)}"` : "";
|
||||||
return `
|
return `
|
||||||
<div class="nav-wrapper">
|
<div class="nav-wrapper">
|
||||||
<div class="nav-fade-left"></div>
|
<div class="nav-fade-left"></div>
|
||||||
<div class="nav-fade-right"></div>
|
<div class="nav-fade-right"></div>
|
||||||
<nav class="nav">
|
<nav class="nav"${navStyle}>
|
||||||
<div class="nav-tabs">${items}</div>
|
<div class="nav-tabs"${alignAttr}>${items}</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -2511,6 +2746,27 @@ class MyMusicLibraryCard extends HTMLElement {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._mountCustomElements(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
_mountCustomElements(card) {
|
||||||
|
card.querySelectorAll("[data-ce-slot]").forEach(slot => {
|
||||||
|
if (slot._ceMounted) return;
|
||||||
|
const id = slot.dataset.ceSlot;
|
||||||
|
const tab = this._resolvedTabs.find(t => t.id === id);
|
||||||
|
if (!tab?.element) return;
|
||||||
|
// Strip Lovelace "custom:" prefix — the actual DOM tag name never has it
|
||||||
|
const tagName = tab.element.replace(/^custom:/, "");
|
||||||
|
const el = document.createElement(tagName);
|
||||||
|
if (typeof el.setConfig === "function") {
|
||||||
|
try { el.setConfig(tab.element_config || {}); } catch(e) {
|
||||||
|
console.warn(`[mml] custom_element "${tagName}" setConfig error:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this._hass) el.hass = this._hass;
|
||||||
|
slot.appendChild(el);
|
||||||
|
slot._ceMounted = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Nav button action handler ── */
|
/* ── Nav button action handler ── */
|
||||||
@@ -2577,6 +2833,75 @@ class MyMusicLibraryCard extends HTMLElement {
|
|||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "mml_navigate_tab": {
|
||||||
|
const tabTypeOrId = action.tab;
|
||||||
|
if (!tabTypeOrId) break;
|
||||||
|
const card = this.shadowRoot?.querySelector(".card-root");
|
||||||
|
if (!card) break;
|
||||||
|
const tabDef = this._resolvedTabs.find(t => t.id === tabTypeOrId) ||
|
||||||
|
this._resolvedTabs.find(t => t.type === tabTypeOrId);
|
||||||
|
if (!tabDef) break;
|
||||||
|
if (tabDef.type === "settings") {
|
||||||
|
this._openSettings(card);
|
||||||
|
} else {
|
||||||
|
this._setActiveTab(tabDef.id, card);
|
||||||
|
if (tabDef.type === "library" && !this._libLoadedTabs.has(tabDef.id)) this._loadLibrary();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mml_navigate_section": {
|
||||||
|
const section = action.section;
|
||||||
|
if (!section) break;
|
||||||
|
const card = this.shadowRoot?.querySelector(".card-root");
|
||||||
|
if (!card) break;
|
||||||
|
const libTab = this._resolvedTabs.find(t => t.type === "library" && t.sections?.includes(section)) ||
|
||||||
|
this._resolvedTabs.find(t => t.type === "library");
|
||||||
|
if (!libTab) break;
|
||||||
|
const alreadyLoaded = this._libLoadedTabs.has(libTab.id);
|
||||||
|
this._setActiveTab(libTab.id, card);
|
||||||
|
if (!alreadyLoaded) this._loadLibrary();
|
||||||
|
setTimeout(() => {
|
||||||
|
const panel = card.querySelector(`[data-panel="${libTab.id}"]`);
|
||||||
|
const secEl = panel?.querySelector(`#lib-sec-${section}`);
|
||||||
|
if (secEl) secEl.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}, alreadyLoaded ? 50 : 600);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mml_control": {
|
||||||
|
const cmd = action.command;
|
||||||
|
const player = this._activePlayer;
|
||||||
|
if (!cmd || !player) break;
|
||||||
|
switch (cmd) {
|
||||||
|
case "play_pause": {
|
||||||
|
const state = this._hass?.states[player]?.state;
|
||||||
|
this._hass?.callService("media_player", state === "playing" ? "media_pause" : "media_play", {}, { entity_id: player });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "next":
|
||||||
|
this._hass?.callService("media_player", "media_next_track", {}, { entity_id: player });
|
||||||
|
break;
|
||||||
|
case "prev":
|
||||||
|
this._hass?.callService("media_player", "media_previous_track", {}, { entity_id: player });
|
||||||
|
break;
|
||||||
|
case "shuffle": {
|
||||||
|
const shuffleOn = this._hass?.states[player]?.attributes?.shuffle;
|
||||||
|
this._hass?.callService("media_player", "shuffle_set", { shuffle: !shuffleOn }, { entity_id: player });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "repeat": {
|
||||||
|
const cur = this._hass?.states[player]?.attributes?.repeat || "off";
|
||||||
|
const next = cur === "off" ? "all" : cur === "all" ? "one" : "off";
|
||||||
|
this._hass?.callService("media_player", "repeat_set", { repeat: next }, { entity_id: player });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mute": {
|
||||||
|
const isMuted = this._hass?.states[player]?.attributes?.is_volume_muted;
|
||||||
|
this._hass?.callService("media_player", "volume_mute", { is_volume_muted: !isMuted }, { entity_id: player });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2601,12 +2926,17 @@ class MyMusicLibraryCard extends HTMLElement {
|
|||||||
|
|
||||||
_updateNavButtons(card) {
|
_updateNavButtons(card) {
|
||||||
for (const tab of this._resolvedTabs) {
|
for (const tab of this._resolvedTabs) {
|
||||||
if (tab.type !== "button" || !tab.entity) continue;
|
if (tab.type === "button" && tab.entity) {
|
||||||
const st = this._hass?.states[tab.entity];
|
const st = this._hass?.states[tab.entity];
|
||||||
const isActive = st ? ["on", "playing", "active", "home"].includes(st.state) : false;
|
const isActive = st ? ["on", "playing", "active", "home"].includes(st.state) : false;
|
||||||
const el = card.querySelector(`[data-tab-btn="${tab.id}"]`);
|
const el = card.querySelector(`[data-tab-btn="${tab.id}"]`);
|
||||||
if (el) el.classList.toggle("active", isActive);
|
if (el) el.classList.toggle("active", isActive);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Propagate hass updates to mounted custom elements
|
||||||
|
card.querySelectorAll("[data-ce-slot] > *").forEach(el => {
|
||||||
|
if (this._hass) el.hass = this._hass;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_resolveImageUrl(url) {
|
_resolveImageUrl(url) {
|
||||||
@@ -3183,6 +3513,12 @@ class MyMusicLibraryCard extends HTMLElement {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_makeThumbUrl(rawPath) {
|
||||||
|
if (!rawPath) return null;
|
||||||
|
if (rawPath.startsWith("/my_music_library/thumb")) return rawPath;
|
||||||
|
return `/my_music_library/thumb?path=${encodeURIComponent(rawPath)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/* Parse the result of music_assistant/search WebSocket command.
|
/* Parse the result of music_assistant/search WebSocket command.
|
||||||
MA returns either a flat {tracks,artists,albums,playlists} or
|
MA returns either a flat {tracks,artists,albums,playlists} or
|
||||||
a nested {results: {tracks,...}}. */
|
a nested {results: {tracks,...}}. */
|
||||||
@@ -3193,12 +3529,13 @@ class MyMusicLibraryCard extends HTMLElement {
|
|||||||
for (const [key, type] of Object.entries(map)) {
|
for (const [key, type] of Object.entries(map)) {
|
||||||
const items = root[key] || [];
|
const items = root[key] || [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
const rawThumb = item.thumbnail || item.metadata?.images?.[0]?.path || item.image?.path || (typeof item.image === "string" ? item.image : null) || null;
|
||||||
out[key].push({
|
out[key].push({
|
||||||
id: item.uri || item.item_id || "",
|
id: item.uri || item.item_id || "",
|
||||||
type,
|
type,
|
||||||
title: item.name || item.title || "",
|
title: item.name || item.title || "",
|
||||||
subtitle: item.artists?.[0]?.name || item.artist?.name || item.owner_name || "",
|
subtitle: item.artists?.[0]?.name || item.artist?.name || item.owner_name || "",
|
||||||
thumbnail: item.thumbnail || item.metadata?.images?.[0]?.path || item.image?.path || (typeof item.image === "string" ? item.image : null) || null,
|
thumbnail: this._makeThumbUrl(rawThumb),
|
||||||
can_play: true,
|
can_play: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3831,6 +4168,38 @@ class MyMusicLibraryCard extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._attachLibInfiniteScroll(libEl);
|
this._attachLibInfiniteScroll(libEl);
|
||||||
|
this._attachLibDirectionLock(libEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
_attachLibDirectionLock(libEl) {
|
||||||
|
if (!libEl || libEl._dirLockBound) return;
|
||||||
|
libEl._dirLockBound = true;
|
||||||
|
const THRESHOLD = 8;
|
||||||
|
let startX = 0, startY = 0, locked = null;
|
||||||
|
|
||||||
|
libEl.addEventListener("touchstart", (e) => {
|
||||||
|
startX = e.touches[0].pageX;
|
||||||
|
startY = e.touches[0].pageY;
|
||||||
|
locked = null;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
libEl.addEventListener("touchmove", (e) => {
|
||||||
|
if (locked) return;
|
||||||
|
const dx = Math.abs(e.touches[0].pageX - startX);
|
||||||
|
const dy = Math.abs(e.touches[0].pageY - startY);
|
||||||
|
if (dx < THRESHOLD && dy < THRESHOLD) return;
|
||||||
|
locked = dy >= dx ? "v" : "h";
|
||||||
|
if (locked === "v") {
|
||||||
|
libEl.querySelectorAll(".lib-scroll").forEach(el => el.classList.add("scroll-locked"));
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
libEl.addEventListener("touchend", () => {
|
||||||
|
if (locked === "v") {
|
||||||
|
libEl.querySelectorAll(".lib-scroll").forEach(el => el.classList.remove("scroll-locked"));
|
||||||
|
}
|
||||||
|
locked = null;
|
||||||
|
}, { passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
_attachLibInfiniteScroll(libEl) {
|
_attachLibInfiniteScroll(libEl) {
|
||||||
@@ -4573,6 +4942,14 @@ const EDITOR_STYLES = `
|
|||||||
.add-tab-menu button:hover { border-color: var(--primary-color, #03a9f4); background: rgba(3,169,244,0.04); }
|
.add-tab-menu button:hover { border-color: var(--primary-color, #03a9f4); background: rgba(3,169,244,0.04); }
|
||||||
.expand-chevron { transition: transform 0.2s; font-size: 12px; }
|
.expand-chevron { transition: transform 0.2s; font-size: 12px; }
|
||||||
.expand-chevron.open { transform: rotate(90deg); }
|
.expand-chevron.open { transform: rotate(90deg); }
|
||||||
|
.tab-item-type.custom_element { background: #9c27b0; }
|
||||||
|
.editor-row textarea {
|
||||||
|
flex: 1; padding: 8px; border: 1px solid var(--divider-color, #e0e0e0);
|
||||||
|
border-radius: 4px; font-size: 12px; font-family: monospace;
|
||||||
|
background: var(--card-background-color, #fff);
|
||||||
|
color: var(--primary-text-color, #212121); min-width: 0; resize: vertical;
|
||||||
|
}
|
||||||
|
.editor-row textarea:focus { outline: none; border-color: var(--primary-color, #03a9f4); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
class MyMusicLibraryCardEditor extends HTMLElement {
|
class MyMusicLibraryCardEditor extends HTMLElement {
|
||||||
@@ -4640,6 +5017,7 @@ class MyMusicLibraryCardEditor extends HTMLElement {
|
|||||||
_tabDisplayLabel(tab) {
|
_tabDisplayLabel(tab) {
|
||||||
if (tab.label) return tab.label;
|
if (tab.label) return tab.label;
|
||||||
if (tab.name) return tab.name;
|
if (tab.name) return tab.name;
|
||||||
|
if (tab.type === "custom_element") return tab.element || this._t("editor.type_custom_element");
|
||||||
if (tab.type === "button") return tab.icon || "Button";
|
if (tab.type === "button") return tab.icon || "Button";
|
||||||
return this._t(`tabs.${tab.type}`) || tab.type;
|
return this._t(`tabs.${tab.type}`) || tab.type;
|
||||||
}
|
}
|
||||||
@@ -4667,7 +5045,7 @@ class MyMusicLibraryCardEditor extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
${this._showAddMenu ? `
|
${this._showAddMenu ? `
|
||||||
<div class="add-tab-menu">
|
<div class="add-tab-menu">
|
||||||
${["player","search","library","settings","button"].map(type => `
|
${["player","search","library","settings","button","custom_element"].map(type => `
|
||||||
<button data-add-type="${type}">${this._tabTypeLabel(type)}</button>
|
<button data-add-type="${type}">${this._tabTypeLabel(type)}</button>
|
||||||
`).join("")}
|
`).join("")}
|
||||||
</div>` : `
|
</div>` : `
|
||||||
@@ -4682,6 +5060,8 @@ class MyMusicLibraryCardEditor extends HTMLElement {
|
|||||||
|
|
||||||
_renderBasicFields(defaultTabOptions) {
|
_renderBasicFields(defaultTabOptions) {
|
||||||
const cfg = this._config;
|
const cfg = this._config;
|
||||||
|
const navPos = cfg.nav_bar?.position || "top";
|
||||||
|
const navAlign = cfg.nav_bar?.align || "start";
|
||||||
return `
|
return `
|
||||||
<div class="editor-section">
|
<div class="editor-section">
|
||||||
<div class="editor-row">
|
<div class="editor-row">
|
||||||
@@ -4702,12 +5082,31 @@ class MyMusicLibraryCardEditor extends HTMLElement {
|
|||||||
<label>${this._t("editor.show_device_select")}</label>
|
<label>${this._t("editor.show_device_select")}</label>
|
||||||
<input id="ed-show-device" type="checkbox" ${cfg.show_device_select !== false ? "checked" : ""}>
|
<input id="ed-show-device" type="checkbox" ${cfg.show_device_select !== false ? "checked" : ""}>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-section">
|
||||||
|
<div class="editor-section-title">${this._t("editor.nav_bar_section")}</div>
|
||||||
|
<div class="editor-row">
|
||||||
|
<label>${this._t("editor.nav_bar_position")}</label>
|
||||||
|
<select id="ed-nav-pos">
|
||||||
|
${["top","bottom","left","right"].map(p =>
|
||||||
|
`<option value="${p}" ${navPos === p ? "selected" : ""}>${this._t(`editor.nav_bar_pos_${p}`)}</option>`
|
||||||
|
).join("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="editor-row">
|
||||||
|
<label>${this._t("editor.nav_bar_align")}</label>
|
||||||
|
<select id="ed-nav-align">
|
||||||
|
${["start","center","end","space-between"].map(a =>
|
||||||
|
`<option value="${a}" ${navAlign === a ? "selected" : ""}>${this._t(`editor.nav_bar_align_${a.replace("-","_")}`)}</option>`
|
||||||
|
).join("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderTabItem(tab, index, total) {
|
_renderTabItem(tab, index, total) {
|
||||||
const isExpanded = this._expandedTab === index;
|
const isExpanded = this._expandedTab === index;
|
||||||
const typeClass = tab.type === "button" ? " button" : "";
|
const typeClass = tab.type === "button" ? " button" : tab.type === "custom_element" ? " custom_element" : "";
|
||||||
return `
|
return `
|
||||||
<div class="tab-item" data-tab-idx="${index}">
|
<div class="tab-item" data-tab-idx="${index}">
|
||||||
<div class="tab-item-header" data-toggle-idx="${index}">
|
<div class="tab-item-header" data-toggle-idx="${index}">
|
||||||
@@ -4724,8 +5123,55 @@ class MyMusicLibraryCardEditor extends HTMLElement {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_renderActionSelect(tab, index) {
|
||||||
|
const actionType = tab.tap_action?.action || "none";
|
||||||
|
const allActions = ["none","toggle","more-info","navigate","url","call-service","assist",
|
||||||
|
"mml_navigate_tab","mml_navigate_section","mml_control"];
|
||||||
|
let actionFields = "";
|
||||||
|
if (actionType === "navigate") {
|
||||||
|
actionFields = `<div class="editor-row"><label>${this._t("editor.btn_nav_path")}</label><input data-btn-field="navigation_path" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.navigation_path || "")}"></div>`;
|
||||||
|
} else if (actionType === "url") {
|
||||||
|
actionFields = `<div class="editor-row"><label>${this._t("editor.btn_url")}</label><input data-btn-field="url_path" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.url_path || "")}"></div>`;
|
||||||
|
} else if (actionType === "call-service" || actionType === "perform-action") {
|
||||||
|
actionFields = `<div class="editor-row"><label>${this._t("editor.btn_service")}</label><input data-btn-field="perform_action" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.perform_action || tab.tap_action?.service || "")}"></div>`;
|
||||||
|
} else if (actionType === "mml_navigate_tab") {
|
||||||
|
const panelTabs = this._getResolvedTabs().filter(t => t.type && !["button","custom_element"].includes(t.type));
|
||||||
|
actionFields = `<div class="editor-row"><label>${this._t("editor.btn_mml_tab")}</label><select data-btn-field="tab" data-idx="${index}">${panelTabs.map(t => `<option value="${t.type}" ${tab.tap_action?.tab === t.type ? "selected" : ""}>${this._t(`tabs.${t.type}`) || t.type}</option>`).join("")}</select></div>`;
|
||||||
|
} else if (actionType === "mml_navigate_section") {
|
||||||
|
const sections = ["artists","albums","playlists","tracks","radios","recently_played","recently_added","recommended","flows"];
|
||||||
|
actionFields = `<div class="editor-row"><label>${this._t("editor.btn_mml_section")}</label><select data-btn-field="section" data-idx="${index}">${sections.map(s => `<option value="${s}" ${tab.tap_action?.section === s ? "selected" : ""}>${this._t(`lib.${s}`) || s}</option>`).join("")}</select></div>`;
|
||||||
|
} else if (actionType === "mml_control") {
|
||||||
|
const cmds = ["play_pause","next","prev","shuffle","repeat","mute"];
|
||||||
|
actionFields = `<div class="editor-row"><label>${this._t("editor.btn_mml_command")}</label><select data-btn-field="command" data-idx="${index}">${cmds.map(c => `<option value="${c}" ${tab.tap_action?.command === c ? "selected" : ""}>${this._t(`editor.mml_cmd_${c}`) || c}</option>`).join("")}</select></div>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="editor-row">
|
||||||
|
<label>${this._t("editor.btn_action_type")}</label>
|
||||||
|
<select data-btn-action-type data-idx="${index}">
|
||||||
|
${allActions.map(a => `<option value="${a}" ${actionType === a ? "selected" : ""}>${this._t(`editor.action_${a.replace(/-/g,"_")}`) || a}</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
${actionFields}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderCustomElementBody(tab, index) {
|
||||||
|
return `
|
||||||
|
<div class="tab-item-body">
|
||||||
|
<div class="editor-row">
|
||||||
|
<label>${this._t("editor.btn_element_name")}</label>
|
||||||
|
<input data-field="element" data-idx="${index}" type="text" value="${this._esc(tab.element || "")}" placeholder="button-card">
|
||||||
|
</div>
|
||||||
|
<div class="editor-row" style="align-items:flex-start">
|
||||||
|
<label style="padding-top:6px">${this._t("editor.btn_element_config")}</label>
|
||||||
|
<textarea data-ce-config data-idx="${index}" rows="4">${this._esc(_yamlDump(tab.element_config || {}))}</textarea>
|
||||||
|
</div>
|
||||||
|
${this._renderActionSelect(tab, index)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
_renderTabBody(tab, index) {
|
_renderTabBody(tab, index) {
|
||||||
if (tab.type === "button") return this._renderButtonBody(tab, index);
|
if (tab.type === "button") return this._renderButtonBody(tab, index);
|
||||||
|
if (tab.type === "custom_element") return this._renderCustomElementBody(tab, index);
|
||||||
let body = `
|
let body = `
|
||||||
<div class="tab-item-body">
|
<div class="tab-item-body">
|
||||||
<div class="editor-row">
|
<div class="editor-row">
|
||||||
@@ -4797,27 +5243,6 @@ class MyMusicLibraryCardEditor extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_renderButtonBody(tab, index) {
|
_renderButtonBody(tab, index) {
|
||||||
const actionType = tab.tap_action?.action || "none";
|
|
||||||
let actionFields = "";
|
|
||||||
if (actionType === "navigate") {
|
|
||||||
actionFields = `
|
|
||||||
<div class="editor-row">
|
|
||||||
<label>${this._t("editor.btn_nav_path")}</label>
|
|
||||||
<input data-btn-field="navigation_path" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.navigation_path || "")}">
|
|
||||||
</div>`;
|
|
||||||
} else if (actionType === "url") {
|
|
||||||
actionFields = `
|
|
||||||
<div class="editor-row">
|
|
||||||
<label>${this._t("editor.btn_url")}</label>
|
|
||||||
<input data-btn-field="url_path" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.url_path || "")}">
|
|
||||||
</div>`;
|
|
||||||
} else if (actionType === "call-service" || actionType === "perform-action") {
|
|
||||||
actionFields = `
|
|
||||||
<div class="editor-row">
|
|
||||||
<label>${this._t("editor.btn_service")}</label>
|
|
||||||
<input data-btn-field="perform_action" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.perform_action || tab.tap_action?.service || "")}">
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
return `
|
return `
|
||||||
<div class="tab-item-body">
|
<div class="tab-item-body">
|
||||||
<div class="editor-row">
|
<div class="editor-row">
|
||||||
@@ -4832,15 +5257,7 @@ class MyMusicLibraryCardEditor extends HTMLElement {
|
|||||||
<label>${this._t("editor.btn_entity")}</label>
|
<label>${this._t("editor.btn_entity")}</label>
|
||||||
<input data-field="entity" data-idx="${index}" type="text" value="${this._esc(tab.entity || "")}" placeholder="light.living_room">
|
<input data-field="entity" data-idx="${index}" type="text" value="${this._esc(tab.entity || "")}" placeholder="light.living_room">
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-row">
|
${this._renderActionSelect(tab, index)}
|
||||||
<label>${this._t("editor.btn_action_type")}</label>
|
|
||||||
<select data-btn-action-type data-idx="${index}">
|
|
||||||
${["none","toggle","more-info","navigate","url","call-service","assist"].map(a =>
|
|
||||||
`<option value="${a}" ${actionType === a ? "selected" : ""}>${this._t(`editor.action_${a.replace("-","_").replace("-","_")}`) || a}</option>`
|
|
||||||
).join("")}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
${actionFields}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4871,6 +5288,17 @@ class MyMusicLibraryCardEditor extends HTMLElement {
|
|||||||
this._fireChanged();
|
this._fireChanged();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const _setNavBar = (key, val, defaultVal) => {
|
||||||
|
const navBar = { ...(this._config.nav_bar || {}) };
|
||||||
|
if (val === defaultVal) delete navBar[key]; else navBar[key] = val;
|
||||||
|
this._config = { ...this._config };
|
||||||
|
if (Object.keys(navBar).length) this._config.nav_bar = navBar;
|
||||||
|
else delete this._config.nav_bar;
|
||||||
|
this._fireChanged();
|
||||||
|
};
|
||||||
|
wrap.querySelector("#ed-nav-pos")?.addEventListener("change", (e) => _setNavBar("position", e.target.value, "top"));
|
||||||
|
wrap.querySelector("#ed-nav-align")?.addEventListener("change", (e) => _setNavBar("align", e.target.value, "start"));
|
||||||
|
|
||||||
// Toggle expand
|
// Toggle expand
|
||||||
wrap.querySelectorAll("[data-toggle-idx]").forEach(el => {
|
wrap.querySelectorAll("[data-toggle-idx]").forEach(el => {
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
@@ -4918,6 +5346,7 @@ class MyMusicLibraryCardEditor extends HTMLElement {
|
|||||||
const newTab = { type };
|
const newTab = { type };
|
||||||
if (type === "library") newTab.sections = ["artists", "albums", "playlists", "tracks"];
|
if (type === "library") newTab.sections = ["artists", "albums", "playlists", "tracks"];
|
||||||
if (type === "button") { newTab.icon = "mdi:gesture-tap"; newTab.tap_action = { action: "none" }; }
|
if (type === "button") { newTab.icon = "mdi:gesture-tap"; newTab.tap_action = { action: "none" }; }
|
||||||
|
if (type === "custom_element") { newTab.element = ""; newTab.element_config = {}; newTab.tap_action = { action: "none" }; }
|
||||||
t.push(newTab);
|
t.push(newTab);
|
||||||
this._showAddMenu = false;
|
this._showAddMenu = false;
|
||||||
this._expandedTab = t.length - 1;
|
this._expandedTab = t.length - 1;
|
||||||
@@ -5018,6 +5447,19 @@ class MyMusicLibraryCardEditor extends HTMLElement {
|
|||||||
this._updateTabs(t);
|
this._updateTabs(t);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Custom element JSON config textarea
|
||||||
|
wrap.querySelectorAll("[data-ce-config]").forEach(ta => {
|
||||||
|
ta.addEventListener("change", () => {
|
||||||
|
const idx = parseInt(ta.dataset.idx);
|
||||||
|
try {
|
||||||
|
const cfg = _yamlLoad(ta.value.trim() || "{}");
|
||||||
|
const t = [...tabs];
|
||||||
|
t[idx] = { ...t[idx], element_config: cfg };
|
||||||
|
this._updateTabs(t);
|
||||||
|
} catch(_) { /* invalid YAML — ignore, keep previous value */ }
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_esc(str) {
|
_esc(str) {
|
||||||
|
|||||||
+25
-25
@@ -4,7 +4,7 @@
|
|||||||
"state": "ON",
|
"state": "ON",
|
||||||
"led_brightness": 100,
|
"led_brightness": 100,
|
||||||
"countdown_to_turn_off": 0,
|
"countdown_to_turn_off": 0,
|
||||||
"voltage": 121.2,
|
"voltage": 121.7,
|
||||||
"countdown_to_turn_on": 0,
|
"countdown_to_turn_on": 0,
|
||||||
"ac_frequency": 60,
|
"ac_frequency": 60,
|
||||||
"power_factor": 0.14,
|
"power_factor": 0.14,
|
||||||
@@ -15,22 +15,22 @@
|
|||||||
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
||||||
"latest_release_notes": null
|
"latest_release_notes": null
|
||||||
},
|
},
|
||||||
"power": 0.5,
|
"power": 0.4,
|
||||||
"linkquality": 102,
|
"linkquality": 102,
|
||||||
"current": 0.03,
|
"current": 0.03,
|
||||||
"power_on_behavior": "on"
|
"power_on_behavior": "on"
|
||||||
},
|
},
|
||||||
"0xffffb40e0607af27": {
|
"0xffffb40e0607af27": {
|
||||||
"state": "ON",
|
"state": "ON",
|
||||||
"voltage": 121,
|
"voltage": 121.1,
|
||||||
"ac_frequency": 60,
|
"ac_frequency": 60,
|
||||||
"led_brightness": 100,
|
"led_brightness": 100,
|
||||||
"countdown_to_turn_off": 0,
|
"countdown_to_turn_off": 0,
|
||||||
"countdown_to_turn_on": 0,
|
"countdown_to_turn_on": 0,
|
||||||
"power": 2.2,
|
"power": 1.9,
|
||||||
"current": 0.1,
|
"current": 0.1,
|
||||||
"energy": 32.61,
|
"energy": 32.61,
|
||||||
"power_factor": 0.16,
|
"power_factor": 0.17,
|
||||||
"update": {
|
"update": {
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
"installed_version": 268513381,
|
"installed_version": 268513381,
|
||||||
@@ -45,9 +45,9 @@
|
|||||||
"state": "ON",
|
"state": "ON",
|
||||||
"led_brightness": 100,
|
"led_brightness": 100,
|
||||||
"countdown_to_turn_off": 0,
|
"countdown_to_turn_off": 0,
|
||||||
"voltage": 120.8,
|
"voltage": 120.6,
|
||||||
"countdown_to_turn_on": 0,
|
"countdown_to_turn_on": 0,
|
||||||
"energy": 65.52,
|
"energy": 65.54,
|
||||||
"power_factor": 0.2,
|
"power_factor": 0.2,
|
||||||
"ac_frequency": 60,
|
"ac_frequency": 60,
|
||||||
"update": {
|
"update": {
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"latest_release_notes": null
|
"latest_release_notes": null
|
||||||
},
|
},
|
||||||
"linkquality": 138,
|
"linkquality": 138,
|
||||||
"power": 45.4,
|
"power": 0.2,
|
||||||
"current": 0.01,
|
"current": 0.01,
|
||||||
"power_on_behavior": "on"
|
"power_on_behavior": "on"
|
||||||
},
|
},
|
||||||
@@ -74,13 +74,13 @@
|
|||||||
"led_brightness": 100,
|
"led_brightness": 100,
|
||||||
"countdown_to_turn_off": 0,
|
"countdown_to_turn_off": 0,
|
||||||
"countdown_to_turn_on": 0,
|
"countdown_to_turn_on": 0,
|
||||||
"voltage": 119.3,
|
"voltage": 120.1,
|
||||||
"state": "ON",
|
"state": "ON",
|
||||||
"ac_frequency": 60,
|
"ac_frequency": 60,
|
||||||
"energy": 132.57,
|
"energy": 132.59,
|
||||||
"power": 95.9,
|
"power": 101.8,
|
||||||
"current": 0.88,
|
"current": 0.96,
|
||||||
"power_factor": 0.93,
|
"power_factor": 0.94,
|
||||||
"update": {
|
"update": {
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
"installed_version": 268513381,
|
"installed_version": 268513381,
|
||||||
@@ -95,11 +95,11 @@
|
|||||||
"led_brightness": 100,
|
"led_brightness": 100,
|
||||||
"countdown_to_turn_off": 0,
|
"countdown_to_turn_off": 0,
|
||||||
"countdown_to_turn_on": 0,
|
"countdown_to_turn_on": 0,
|
||||||
"voltage": 121,
|
"voltage": 121.5,
|
||||||
"energy": 63.35,
|
"energy": 63.37,
|
||||||
"state": "ON",
|
"state": "ON",
|
||||||
"power": 40.7,
|
"power": 48.4,
|
||||||
"current": 0.47,
|
"current": 0.62,
|
||||||
"ac_frequency": 60,
|
"ac_frequency": 60,
|
||||||
"power_factor": 0.69,
|
"power_factor": 0.69,
|
||||||
"update": {
|
"update": {
|
||||||
@@ -114,12 +114,12 @@
|
|||||||
},
|
},
|
||||||
"0xffffb40e060895b3": {
|
"0xffffb40e060895b3": {
|
||||||
"state": "ON",
|
"state": "ON",
|
||||||
"voltage": 120.3,
|
"voltage": 120.8,
|
||||||
"ac_frequency": 60,
|
"ac_frequency": 60,
|
||||||
"energy": 8.79,
|
"energy": 8.79,
|
||||||
"current": 0.01,
|
"current": 0.01,
|
||||||
"power": 0.1,
|
"power": 0,
|
||||||
"power_factor": 0.1,
|
"power_factor": 0.11,
|
||||||
"linkquality": 109,
|
"linkquality": 109,
|
||||||
"update": {
|
"update": {
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
"0xffffb40e0608864e": {
|
"0xffffb40e0608864e": {
|
||||||
"led_brightness": 100,
|
"led_brightness": 100,
|
||||||
"countdown_to_turn_off": 0,
|
"countdown_to_turn_off": 0,
|
||||||
"voltage": 121.3,
|
"voltage": 121.8,
|
||||||
"energy": 20.09,
|
"energy": 20.09,
|
||||||
"countdown_to_turn_on": 0,
|
"countdown_to_turn_on": 0,
|
||||||
"state": "ON",
|
"state": "ON",
|
||||||
@@ -172,10 +172,10 @@
|
|||||||
"0xffffb40e060893d8": {
|
"0xffffb40e060893d8": {
|
||||||
"state": "ON",
|
"state": "ON",
|
||||||
"led_brightness": 100,
|
"led_brightness": 100,
|
||||||
"voltage": 121.3,
|
"voltage": 121.8,
|
||||||
"countdown_to_turn_off": 0,
|
"countdown_to_turn_off": 0,
|
||||||
"countdown_to_turn_on": 0,
|
"countdown_to_turn_on": 0,
|
||||||
"energy": 3.68,
|
"energy": 3.69,
|
||||||
"power_on_behavior": "on",
|
"power_on_behavior": "on",
|
||||||
"linkquality": 142,
|
"linkquality": 142,
|
||||||
"current": 0.09,
|
"current": 0.09,
|
||||||
@@ -187,12 +187,12 @@
|
|||||||
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
||||||
"latest_release_notes": null
|
"latest_release_notes": null
|
||||||
},
|
},
|
||||||
"power_factor": 0.84,
|
"power_factor": 0.86,
|
||||||
"power": 9.6
|
"power": 9.6
|
||||||
},
|
},
|
||||||
"0xa4c1380d0679ffff": {
|
"0xa4c1380d0679ffff": {
|
||||||
"battery": 100,
|
"battery": 100,
|
||||||
"temperature": 27.4,
|
"temperature": 27.3,
|
||||||
"temperature_units": "celsius",
|
"temperature_units": "celsius",
|
||||||
"temperature_calibration": 0,
|
"temperature_calibration": 0,
|
||||||
"update": {
|
"update": {
|
||||||
|
|||||||
Reference in New Issue
Block a user