Source code for pybragerone.models.menu
"""Menu models with validation and automatic prefix cleanup.
This module provides Pydantic models for BragerOne menu structure with automatic
cleanup of prefixes (A.*, e.*, a.*) and normalization of parameters.
"""
from __future__ import annotations
import re
from typing import Any, ClassVar
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from .api.common import Permission
# Obfuscated bundles keep the public name as a string index: ``_0xabc['DISPLAY_MENU_DHW']``.
# Require the ``_0x…`` receiver so ``arr['map']`` / ``Math['floor']`` stay leftover source.
# The computed-key brackets must balance: ``(?(1)\])`` demands the closing one only when
# the leading ``[`` matched, so ``_0xabc['NAME']]`` is not normalized.
_JS_SUBSCRIPT_PUBLIC_RE = re.compile(r"^(\[)?_0x[0-9a-fA-F]*\[(['\"])(?P<name>[A-Za-z_]\w*)\2\](?(1)\])$")
# Index-inline builders also leave readable enum leftovers such as
# ``ParameterStatus['INVISIBLE']``. Require a PascalCase receiver that looks like
# an enum namespace and a SCREAMING_SNAKE member so ``Math['floor']`` /
# ``Object['DEFINE']`` / ``PARAMS['P11_1']`` stay untouched.
_JS_PASCAL_ENUM_SUBSCRIPT_RE = re.compile(r"^(\[)?(?P<recv>[A-Z][\w$]*)\[(['\"])(?P<name>[A-Z][A-Z0-9_]*)\3\](?(1)\])$")
_JS_ENUM_RECEIVER_EXACT: frozenset[str] = frozenset({"Permissions", "IconsList"})
_JS_ENUM_RECEIVER_SUFFIXES: tuple[str, ...] = (
"Status",
"Operation",
"Property",
"Type",
"State",
"List",
"Commands",
"Component",
)
def _is_js_enum_receiver(receiver: str) -> bool:
"""Return whether *receiver* looks like a TypeScript/JS enum namespace."""
return receiver in _JS_ENUM_RECEIVER_EXACT or any(receiver.endswith(suffix) for suffix in _JS_ENUM_RECEIVER_SUFFIXES)
[docs]
def js_public_member_name(value: str) -> str | None:
"""Return the public identifier inside an obfuscated member/subscript leftover.
Live menu and PARAM chunks store enums as ``_0x521864['DISPLAY_PARAMETER_LEVEL_1']``
(sometimes wrapped in ``[…]`` for computed keys). Index-inline parameter factories
keep readable enum receivers such as ``ParameterStatus['INVISIBLE']``. The REST API
still emits the inner name as a plain string, so catalog matching has to recover it.
Args:
value: Raw leftover text from ``_node_to_python`` or a permission field.
Returns:
The public name, or ``None`` when ``value`` is not that leftover shape.
"""
text = value.strip()
match = _JS_SUBSCRIPT_PUBLIC_RE.match(text)
if match is not None:
return match.group("name")
match = _JS_PASCAL_ENUM_SUBSCRIPT_RE.match(text)
if match is not None and _is_js_enum_receiver(match.group("recv")):
return match.group("name")
return None
[docs]
class MenuParameter(BaseModel):
"""Single parameter in a menu route with automatic cleanup.
Automatically extracts token from parameter expressions like:
- e(E.WRITE,"PARAM_123") → "PARAM_123"
- E(A.READ,"TEMP_SENSOR") → "TEMP_SENSOR"
And normalizes permissions by removing detected prefixes:
- A.DISPLAY_PARAMETER_LEVEL_1 → DISPLAY_PARAMETER_LEVEL_1
- e.HeaterManagement → HeaterManagement
- a.DISPLAY_MENU_DHW → DISPLAY_MENU_DHW
"""
token: str = Field(description="Clean parameter token extracted from parameter expression")
permission: Permission | None = Field(None, description="Required permission (prefix-normalized)")
raw_parameter: str = Field(..., alias="parameter", description="Original parameter expression")
raw_permission: str | None = Field(None, alias="permissionModule", description="Original permission with prefix")
model_config = ConfigDict(extra="allow")
# Regex to extract token from parameter expressions.
# Build output may rename helper functions; do not rely on single-letter identifiers.
PARAM_REGEX: ClassVar[re.Pattern[str]] = re.compile(r"\b[A-Za-z_$][\w$]*\([^,]*?,\s*['\"]([^'\"]+)['\"]\)")
# Prefixes like "A." / "e." are build artifacts; treat any short leading segment as a prefix.
PREFIX_RE: ClassVar[re.Pattern[str]] = re.compile(r"^(?P<prefix>[A-Za-z]{1,3})\.(?P<rest>.+)$")
@classmethod
def _strip_prefix(cls, value: str) -> str:
public = js_public_member_name(value)
if public is not None:
return public
m = cls.PREFIX_RE.match(value)
return m.group("rest") if m else value
[docs]
@model_validator(mode="before")
@classmethod
def extract_fields(cls, data: Any) -> Any:
"""Extract token from parameter and normalize permission."""
if not isinstance(data, dict):
return data
result = dict(data)
# Extract token from parameter if not already present
if "token" not in result and "parameter" in result:
param_str = result["parameter"]
if isinstance(param_str, str):
match = cls.PARAM_REGEX.search(param_str)
if match:
result["token"] = match.group(1)
else:
# Fallback: use parameter string as token
result["token"] = param_str.strip()
# Set default token if still missing
if "token" not in result:
result["token"] = "" # nosec B105 - default placeholder token
# Normalize permission from permissionModule
if "permission" not in result and "permissionModule" in result:
perm_str = result["permissionModule"]
if perm_str:
result["permission"] = Permission(name=cls._strip_prefix(str(perm_str)))
else:
result["permission"] = None
return result
[docs]
@field_validator("permission", mode="before")
@classmethod
def normalize_permission(cls, v: Any) -> Permission | None:
"""Normalize permission by removing common prefixes."""
if v is None:
return None
perm_str = str(v)
if not perm_str:
return None
return Permission(name=cls._strip_prefix(perm_str))
[docs]
@model_validator(mode="after")
def validate_token_extracted(self) -> MenuParameter:
"""Ensure token was successfully extracted."""
if not self.token and self.raw_parameter:
# Try extraction one more time from raw_parameter
match = self.PARAM_REGEX.search(self.raw_parameter)
if match:
self.token = match.group(1)
else:
# Last resort: use raw parameter
self.token = self.raw_parameter
if not self.token:
raise ValueError(f"Could not extract token from parameter: {self.raw_parameter}")
return self
[docs]
class MenuParameters(BaseModel):
"""Collection of parameters organized by type (read/write/status/special)."""
read: list[MenuParameter] = Field(default_factory=list)
write: list[MenuParameter] = Field(default_factory=list)
status: list[MenuParameter] = Field(default_factory=list)
special: list[MenuParameter] = Field(default_factory=list)
[docs]
def all_tokens(self) -> set[str]:
"""Get all unique tokens from all parameter sections."""
tokens: set[str] = set()
for param_list in [self.read, self.write, self.status, self.special]:
tokens.update(param.token for param in param_list)
return tokens
[docs]
def all_permissions(self) -> set[Permission]:
"""Get all unique permissions from all parameters."""
permissions: set[Permission] = set()
for param_list in [self.read, self.write, self.status, self.special]:
for param in param_list:
if param.permission:
permissions.add(param.permission)
return permissions
[docs]
class MenuMeta(BaseModel):
"""Menu route metadata with automatic cleanup."""
display_name: str = Field(..., alias="displayName")
icon: str | None = None
permission: Permission | None = None
is_visible_on_side_menu: bool | None = Field(None, alias="isVisibleOnSideMenu")
parameters: MenuParameters = Field(default_factory=MenuParameters)
display_dropdown: str | None = Field(None, alias="displayDropdown")
# Raw fields for debugging/reference
raw_permission: str | None = Field(None, alias="permissionModule")
raw_icon: str | None = None
[docs]
@field_validator("icon", mode="before")
@classmethod
def clean_icon(cls, v: Any) -> str | None:
"""Remove build prefix from icons (commonly 'a.')."""
if v is None:
return None
icon_str = str(v)
m = MenuParameter.PREFIX_RE.match(icon_str)
if m:
return m.group("rest")
return icon_str
[docs]
@model_validator(mode="before")
@classmethod
def normalize_fields(cls, data: Any) -> Any:
"""Normalize permission and icon fields."""
if not isinstance(data, dict):
return data
result = dict(data)
# Normalize permission from permissionModule
if "permission" not in result and "permissionModule" in result:
perm_str = result["permissionModule"]
if perm_str:
result["permission"] = Permission(name=MenuParameter._strip_prefix(str(perm_str)))
else:
result["permission"] = None
return result
[docs]
@model_validator(mode="after")
def store_raw_values(self) -> MenuMeta:
"""Store raw values for reference."""
# This will be set by alias during parsing
return self
[docs]
class MenuRoute(BaseModel):
"""Single menu route with automatic cleanup and validation."""
path: str
name: str
meta: MenuMeta | None = None
component: str | None = None
children: list[MenuRoute] = Field(default_factory=list)
# Legacy parameters field (should be empty after processing)
parameters: MenuParameters = Field(default_factory=MenuParameters)
[docs]
def all_tokens(self) -> set[str]:
"""Get all tokens from this route and its children recursively."""
tokens: set[str] = set()
# Add from meta parameters
if self.meta:
tokens.update(self.meta.parameters.all_tokens())
# Add from legacy parameters
tokens.update(self.parameters.all_tokens())
# Add from children
for child in self.children:
tokens.update(child.all_tokens())
return tokens
[docs]
def all_permissions(self) -> set[Permission]:
"""Get all permissions from this route and its children recursively."""
permissions: set[Permission] = set()
# Add route permission
if self.meta and self.meta.permission:
permissions.add(self.meta.permission)
# Add parameter permissions
if self.meta:
permissions.update(self.meta.parameters.all_permissions())
permissions.update(self.parameters.all_permissions())
# Add from children
for child in self.children:
permissions.update(child.all_permissions())
return permissions
[docs]
class MenuResult(BaseModel):
"""Complete menu result with validation and statistics."""
routes: list[MenuRoute] = Field(default_factory=list)
asset_url: str | None = None
[docs]
def all_tokens(self) -> set[str]:
"""Get all unique tokens from all routes."""
tokens: set[str] = set()
for route in self.routes:
tokens.update(route.all_tokens())
return tokens
[docs]
def all_permissions(self) -> set[Permission]:
"""Get all unique permissions from all routes."""
permissions: set[Permission] = set()
for route in self.routes:
permissions.update(route.all_permissions())
return permissions
[docs]
def token_count(self) -> int:
"""Get total number of unique tokens."""
return len(self.all_tokens())
[docs]
def route_count(self) -> int:
"""Get total number of routes (including nested)."""
def count_routes(routes: list[MenuRoute]) -> int:
count = len(routes)
for route in routes:
count += count_routes(route.children)
return count
return count_routes(self.routes)
[docs]
def routes_by_path(self) -> dict[str, MenuRoute]:
"""Get flat mapping of path -> route for easy lookup."""
result: dict[str, MenuRoute] = {}
def collect_routes(routes: list[MenuRoute], prefix: str = "") -> None:
for route in routes:
# Clean path by removing leading slashes and handling double slashes
clean_path = route.path.lstrip("/")
full_path = f"{prefix}/{clean_path}" if prefix else clean_path
result[full_path] = route
collect_routes(route.children, full_path)
collect_routes(self.routes)
return result