Initial commit

This commit is contained in:
Striker72rus 2026-03-25 13:48:26 +03:00
commit 5f2b6b8efb
89 changed files with 23475 additions and 0 deletions

12
.dockerignore Executable file
View File

@ -0,0 +1,12 @@
.git
.gitignore
.DS_Store
ha-addon
custom_components
node_modules
storage/@eaDir
config/@eaDir
config/config.json
lib/@eaDir
assets/@eaDir
storage/*.json

3
.gitignore vendored Executable file
View File

@ -0,0 +1,3 @@
config/config.json
storage/
@eaDir/

BIN
@eaDir/.git@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/README.md@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/README.md@SynoResource Executable file

Binary file not shown.

BIN
@eaDir/api.php@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/api.php@SynoResource Executable file

Binary file not shown.

BIN
@eaDir/assets@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/config@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/favicon.ico@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/favicon.ico@SynoResource Executable file

Binary file not shown.

BIN
@eaDir/index.php@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/index.php@SynoResource Executable file

Binary file not shown.

BIN
@eaDir/lib@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/storage@SynoEAStream Executable file

Binary file not shown.

Binary file not shown.

19
Dockerfile Executable file
View File

@ -0,0 +1,19 @@
FROM php:8.2-cli-alpine
RUN apk add --no-cache curl-dev && docker-php-ext-install curl
WORKDIR /app
COPY index.php /app/index.php
COPY api.php /app/api.php
COPY favicon.ico /app/favicon.ico
COPY assets /app/assets
COPY lib /app/lib
COPY config/addon-default-config.json /app/config/config.json
COPY run.sh /run.sh
RUN chmod a+x /run.sh
EXPOSE 8099
CMD ["/run.sh"]

162
README.md Executable file
View File

@ -0,0 +1,162 @@
# Wall Panel
Таблет-ориентированная панель для Home Assistant на `PHP + HTML + JS`.
Основной HA-способ запуска теперь `Home Assistant Add-on` с ingress и отдельным портом.
## Запуск
```bash
php -S 0.0.0.0:8080
```
Откройте `http://localhost:8080`.
## Конфиг
Основной файл:
- [`config/config.json`](config/config.json)
В него кладутся:
- `home_assistant.base_url`
- `home_assistant.token`
- `camera.rtsp_url`
- `camera.stream_url`
- `camera.poster_url`
- `rooms`
Если `base_url` и `token` пустые, панель работает в demo mode с тестовыми карточками.
## Home Assistant Add-on
Для HA теперь рекомендован add-on-режим:
- запускает тот же PHP-интерфейс внутри Supervisor;
- открывается через ingress;
- доступен через отдельный порт;
- хранит конфиг в add-on volume;
- не зависит от `custom_components`.
Файлы add-on лежат в папке:
- [`wall_panel/config.yaml`](/Volumes/web/wallpanell/wall_panel/config.yaml)
- [`wall_panel/Dockerfile`](/Volumes/web/wallpanell/wall_panel/Dockerfile)
- [`wall_panel/run.sh`](/Volumes/web/wallpanell/wall_panel/run.sh)
- [`repository.yaml`](/Volumes/web/wallpanell/repository.yaml)
### Как использовать
1. Добавьте Git URL репозитория в Home Assistant как add-on repository.
2. Установите add-on.
3. Запустите его.
4. Откройте панель через ingress или через проброшенный порт `8099/tcp`.
### Конфигурация
Add-on использует persistent config file:
- `/config/config.json` внутри контейнера
Этот файл является runtime source of truth для панели в HA-режиме и переживает рестарт add-on.
Частые настройки можно менять прямо из UI add-on в Home Assistant:
- `app.title`
- `app.poll_interval_ms`
- `app.main_room_name`
- `app.main_room_icon`
- `app.edit_mode`
- `app.battery_history_hours`
- `home_assistant.base_url`
- `home_assistant.token`
- `home_assistant.verify_ssl`
- `home_assistant.weather_entity_id`
- `camera.rtsp_url`
- `camera.stream_url`
- `camera.stream_mode`
- `camera.poster_url`
- `camera.popup_timeout_minutes`
Сложные структуры, вроде `rooms`, по-прежнему удобнее держать в JSON.
### Старый embed-режим
Отдельный PHP-доступ по-прежнему работает:
`http://YOUR_PANEL_HOST/index.php?embed=1`
Это полезно, если вы запускаете панель вне HA или хотите iframe-карточку вручную.
## Popup камеры
Для браузера нужен не прямой `rtsp://`, а bridge, который отдаёт `HLS` или `WebRTC`.
Popup открывается через endpoint:
```bash
POST /api.php?action=popup
{
"sensor_entity_id": "binary_sensor.doorbell_all_occupancy",
"state": "on"
}
```
Закрытие:
```bash
POST /api.php?action=popup
{
"sensor_entity_id": "binary_sensor.doorbell_all_occupancy",
"state": "off"
}
```
## Room overrides
Для комнаты можно сохранять overrides через:
```bash
POST /api.php?action=save-entity-override
{
"room_id": "living_room",
"entity_id": "light.living_room_main",
"visible": true,
"order": 10,
"card_type": "toggle",
"title": "Основной свет",
"icon": "mdi:ceiling-light"
}
```
## Empty room slots
Для desktop-раскладки можно создавать пустые слоты:
```bash
POST /api.php?action=create-room-layout-item
{
"room_id": "living_room"
}
```
Перемещение:
```bash
POST /api.php?action=save-room-layout-item
{
"room_id": "living_room",
"layout_item_id": "slot_xxx",
"order": 120
}
```
Удаление:
```bash
POST /api.php?action=delete-room-layout-item
{
"room_id": "living_room",
"layout_item_id": "slot_xxx"
}
```

254
api.php Executable file
View File

@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/lib/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
function api_json(array $payload, int $status = 200): never
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function api_input(): array
{
$raw = file_get_contents('php://input');
if ($raw === false || trim($raw) === '') {
return $_POST ?: [];
}
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
return $_POST ?: [];
}
function api_mirror_config_change(array $config, string $action, array $payload): array
{
$response = app_sync_remote_action($config, $action, $payload);
if (is_array($response) && is_array($response['config'] ?? null)) {
$config = app_merge_synced_config($config, $response['config']);
app_save_config($config);
}
return $config;
}
try {
$config = app_load_config();
$client = new HomeAssistantClient($config);
$action = strtolower((string)($_GET['action'] ?? 'snapshot'));
if ($action === 'bootstrap') {
api_json(app_build_snapshot($config, $client, 'main'));
}
if ($action === 'snapshot') {
$spaceId = (string)($_GET['space_id'] ?? ($_GET['room_id'] ?? 'main'));
api_json(app_build_snapshot($config, $client, $spaceId));
}
if ($action === 'history') {
$entityId = trim((string)($_GET['entity_id'] ?? ''));
$hours = max(1, (int)($_GET['hours'] ?? 24));
if ($entityId === '') {
api_json(['ok' => false, 'error' => 'entity_id is required'], 400);
}
api_json([
'ok' => true,
'entity_id' => $entityId,
'hours' => $hours,
'history' => $client->fetchEntityHistory($entityId, $hours),
]);
}
if ($action === 'service') {
$payload = api_input();
$entityId = trim((string)($payload['entity_id'] ?? ''));
$command = trim((string)($payload['command'] ?? 'toggle'));
$value = $payload['value'] ?? null;
if ($entityId === '') {
api_json(['ok' => false, 'error' => 'entity_id is required'], 400);
}
[$domain, $service, $serviceData] = app_service_for_entity($entityId, $command);
if ($command === 'set_temperature' && $value !== null) {
$serviceData['temperature'] = $value;
}
if ($command === 'set_hvac_mode' && $value !== null) {
$serviceData['hvac_mode'] = $value;
}
if ($command === 'set_fan_mode' && $value !== null) {
$serviceData['fan_mode'] = $value;
}
if ($command === 'set_swing_mode' && $value !== null) {
$serviceData['swing_mode'] = $value;
}
if ($command === 'set_preset_mode' && $value !== null) {
$serviceData['preset_mode'] = $value;
}
if ($command === 'set_position' && $value !== null) {
$serviceData['position'] = $value;
}
$result = $client->callService($domain, $service, $serviceData);
api_json(['ok' => true, 'result' => $result]);
}
if ($action === 'save-entity-override') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
$entityId = trim((string)($payload['entity_id'] ?? ''));
if ($roomId === '' || $entityId === '') {
api_json(['ok' => false, 'error' => 'room_id and entity_id are required'], 400);
}
$patch = [
'visible' => array_key_exists('visible', $payload) ? (bool)$payload['visible'] : null,
'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null,
'card_type' => array_key_exists('card_type', $payload) ? (string)$payload['card_type'] : null,
'title' => array_key_exists('title', $payload) ? (string)$payload['title'] : null,
'icon' => array_key_exists('icon', $payload) ? (string)$payload['icon'] : null,
];
$config = app_update_entity_override($config, $roomId, $entityId, $patch);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'save-space-override') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
if ($roomId === '') {
api_json(['ok' => false, 'error' => 'room_id is required'], 400);
}
$patch = [
'visible' => array_key_exists('visible', $payload) ? (bool)$payload['visible'] : null,
'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null,
'name' => array_key_exists('name', $payload) ? (string)$payload['name'] : null,
'icon' => array_key_exists('icon', $payload) ? (string)$payload['icon'] : null,
];
if (array_key_exists('temperature_sensor_entity_id', $payload)) {
$patch['temperature_sensor_entity_id'] = (string)$payload['temperature_sensor_entity_id'];
}
$config = app_update_room_override($config, $roomId, $patch);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'create-room-layout-item') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
if ($roomId === '') {
api_json(['ok' => false, 'error' => 'room_id is required'], 400);
}
$layoutItemId = trim((string)($payload['layout_item_id'] ?? ''));
if ($layoutItemId === '') {
$layoutItemId = 'slot_' . str_replace('.', '', uniqid('', true));
}
$order = array_key_exists('order', $payload) ? (int)$payload['order'] : null;
$config = app_update_room_layout_item($config, $roomId, $layoutItemId, [
'order' => $order,
]);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json([
'ok' => true,
'layout_item_id' => $layoutItemId,
'config' => ['rooms' => $config['rooms']],
]);
}
if ($action === 'save-room-layout-item') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
$layoutItemId = trim((string)($payload['layout_item_id'] ?? ''));
if ($roomId === '' || $layoutItemId === '') {
api_json(['ok' => false, 'error' => 'room_id and layout_item_id are required'], 400);
}
$patch = [
'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null,
];
$config = app_update_room_layout_item($config, $roomId, $layoutItemId, $patch);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'delete-room-layout-item') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
$layoutItemId = trim((string)($payload['layout_item_id'] ?? ''));
if ($roomId === '' || $layoutItemId === '') {
api_json(['ok' => false, 'error' => 'room_id and layout_item_id are required'], 400);
}
$config = app_delete_room_layout_item($config, $roomId, $layoutItemId);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'reorder-room-grid') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
$entries = $payload['entries'] ?? [];
if ($roomId === '' || !is_array($entries)) {
api_json(['ok' => false, 'error' => 'room_id and entries are required'], 400);
}
$config = app_reorder_room_grid($config, $roomId, $entries);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'save-settings') {
$payload = api_input();
if (array_key_exists('edit_mode', $payload)) {
$config['app']['edit_mode'] = (bool)$payload['edit_mode'];
}
if (array_key_exists('title', $payload) && trim((string)$payload['title']) !== '') {
$config['app']['title'] = trim((string)$payload['title']);
}
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'settings' => $config['app']]);
}
if ($action === 'popup') {
$payload = api_input();
$popup = app_handle_popup_event($config, $payload);
api_json(['ok' => true, 'popup' => $popup]);
}
api_json(['ok' => false, 'error' => 'Unknown action'], 404);
} catch (Throwable $e) {
api_json([
'ok' => false,
'error' => $e->getMessage(),
], 500);
}

Binary file not shown.

Binary file not shown.

BIN
assets/@eaDir/app.js@SynoEAStream Executable file

Binary file not shown.

BIN
assets/@eaDir/app.js@SynoResource Executable file

Binary file not shown.

2540
assets/app.css Executable file

File diff suppressed because it is too large Load Diff

4553
assets/app.js Executable file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,32 @@
{
"app": {
"title": "Wall Panel",
"poll_interval_ms": 5000,
"main_room_name": "Главная",
"main_room_icon": "mdi:home",
"edit_mode": false,
"battery_history_hours": 4320
},
"home_assistant": {
"base_url": "",
"token": "",
"verify_ssl": true,
"sync_url": "",
"sync_token": "",
"sync_timeout": 10,
"sync_verify_ssl": true,
"sync_cache_seconds": 30,
"weather_entity_id": "",
"auto_label": "auto",
"auto_entity_ids": []
},
"camera": {
"rtsp_url": "",
"stream_url": "",
"stream_mode": "hls",
"poster_url": "",
"popup_timeout_minutes": 3,
"trigger_entities": []
},
"rooms": []
}

View File

@ -0,0 +1,48 @@
"""Wall Panel integration."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from .const import CONF_CONFIG, DOMAIN
from .frontend import async_setup_frontend
from .helpers import current_entry_config
from .views import WallPanelConfigView
async def async_setup_entry(hass: HomeAssistant, entry) -> bool:
"""Set up Wall Panel from a config entry."""
state = hass.data.setdefault(DOMAIN, {})
state[entry.entry_id] = {
"entry": entry,
"config": current_entry_config(entry),
}
panel_url_path = await async_setup_frontend(hass, entry)
state[entry.entry_id]["panel_url_path"] = panel_url_path
if not state.get("_config_view_registered"):
hass.http.register_view(WallPanelConfigView)
state["_config_view_registered"] = True
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
return True
async def async_unload_entry(hass: HomeAssistant, entry) -> bool:
"""Unload Wall Panel."""
from homeassistant.components.frontend import async_remove_panel
state = hass.data.get(DOMAIN, {})
panel_url_path = str(state.get(entry.entry_id, {}).get("panel_url_path") or entry.options.get("frontend_url_path", "wall-panel") or "wall-panel").strip()
async_remove_panel(hass, panel_url_path)
state.pop(entry.entry_id, None)
return True
async def _async_options_updated(hass: HomeAssistant, entry) -> None:
"""Reload the integration when options change."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@ -0,0 +1,134 @@
"""Config flow for Wall Panel."""
from __future__ import annotations
import json
import secrets
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_NAME
from homeassistant.helpers.selector import TextSelector, TextSelectorConfig, TextSelectorType
from .const import (
CONF_CONFIG,
CONF_FRONTEND_URL_PATH,
CONF_PANEL_URL,
CONF_REQUIRE_ADMIN,
CONF_SIDEBAR_ICON,
CONF_SIDEBAR_TITLE,
CONF_SYNC_TOKEN,
DEFAULT_FRONTEND_URL_PATH,
DEFAULT_PANEL_URL,
DEFAULT_SIDEBAR_ICON,
DEFAULT_SIDEBAR_TITLE,
DOMAIN,
)
from .helpers import config_to_json, normalize_config, parse_config_json
def _schema(defaults: dict[str, Any]) -> vol.Schema:
return vol.Schema({
vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME, "Wall Panel")): str,
vol.Optional(CONF_PANEL_URL, default=defaults.get(CONF_PANEL_URL, DEFAULT_PANEL_URL)): str,
vol.Optional(CONF_SIDEBAR_TITLE, default=defaults.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE)): str,
vol.Optional(CONF_SIDEBAR_ICON, default=defaults.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON)): str,
vol.Optional(CONF_FRONTEND_URL_PATH, default=defaults.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH)): str,
vol.Optional(CONF_REQUIRE_ADMIN, default=bool(defaults.get(CONF_REQUIRE_ADMIN, False))): bool,
vol.Optional(CONF_SYNC_TOKEN, default=defaults.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24))): str,
vol.Optional(
CONF_CONFIG,
default=defaults.get(CONF_CONFIG, config_to_json(normalize_config({}))),
): TextSelector(TextSelectorConfig(multiline=True, type=TextSelectorType.TEXT)),
})
class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Wall Panel."""
VERSION = 1
async def async_step_user(self, user_input: dict[str, Any] | None = None):
errors: dict[str, str] = {}
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
if user_input is not None:
try:
config = parse_config_json(user_input[CONF_CONFIG])
except (json.JSONDecodeError, ValueError):
errors[CONF_CONFIG] = "invalid_json"
else:
data = {
CONF_NAME: user_input.get(CONF_NAME, "Wall Panel"),
CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""),
CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE),
CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON),
CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH),
CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)),
CONF_SYNC_TOKEN: str(user_input.get(CONF_SYNC_TOKEN, "") or ""),
CONF_CONFIG: config,
}
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=data[CONF_SIDEBAR_TITLE], data={}, options=data)
defaults = {
CONF_NAME: "Wall Panel",
CONF_PANEL_URL: DEFAULT_PANEL_URL,
CONF_SIDEBAR_TITLE: DEFAULT_SIDEBAR_TITLE,
CONF_SIDEBAR_ICON: DEFAULT_SIDEBAR_ICON,
CONF_FRONTEND_URL_PATH: DEFAULT_FRONTEND_URL_PATH,
CONF_REQUIRE_ADMIN: False,
CONF_SYNC_TOKEN: secrets.token_urlsafe(24),
CONF_CONFIG: config_to_json(normalize_config({})),
}
return self.async_show_form(step_id="user", data_schema=_schema(defaults), errors=errors)
async def async_step_import(self, user_input: dict[str, Any]):
return await self.async_step_user(user_input)
class WallPanelOptionsFlow(config_entries.OptionsFlow):
"""Handle options for Wall Panel."""
def __init__(self, config_entry):
self.config_entry = config_entry
async def async_step_init(self, user_input: dict[str, Any] | None = None):
errors: dict[str, str] = {}
if user_input is not None:
try:
config = parse_config_json(user_input[CONF_CONFIG])
except (json.JSONDecodeError, ValueError):
errors[CONF_CONFIG] = "invalid_json"
else:
data = dict(self.config_entry.options)
data.update({
CONF_NAME: user_input.get(CONF_NAME, data.get(CONF_NAME, "Wall Panel")),
CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""),
CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE),
CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON),
CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH),
CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)),
CONF_SYNC_TOKEN: str(user_input.get(CONF_SYNC_TOKEN, "") or ""),
CONF_CONFIG: config,
})
return self.async_create_entry(title="", data=data)
defaults = {
CONF_NAME: self.config_entry.options.get(CONF_NAME, "Wall Panel"),
CONF_PANEL_URL: self.config_entry.options.get(CONF_PANEL_URL, DEFAULT_PANEL_URL),
CONF_SIDEBAR_TITLE: self.config_entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE),
CONF_SIDEBAR_ICON: self.config_entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON),
CONF_FRONTEND_URL_PATH: self.config_entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH),
CONF_REQUIRE_ADMIN: self.config_entry.options.get(CONF_REQUIRE_ADMIN, False),
CONF_SYNC_TOKEN: self.config_entry.options.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24)),
CONF_CONFIG: config_to_json(normalize_config(self.config_entry.options.get(CONF_CONFIG, {}))),
}
return self.async_show_form(step_id="init", data_schema=_schema(defaults), errors=errors)
def async_get_options_flow(config_entry):
return WallPanelOptionsFlow(config_entry)

View File

@ -0,0 +1,17 @@
"""Constants for Wall Panel."""
DOMAIN = "wall_panel"
CONF_PANEL_URL = "panel_url"
CONF_CONFIG = "config"
CONF_SYNC_TOKEN = "sync_token"
CONF_SIDEBAR_TITLE = "sidebar_title"
CONF_SIDEBAR_ICON = "sidebar_icon"
CONF_FRONTEND_URL_PATH = "frontend_url_path"
CONF_REQUIRE_ADMIN = "require_admin"
DEFAULT_NAME = "Wall Panel"
DEFAULT_PANEL_URL = ""
DEFAULT_SIDEBAR_TITLE = "Wall Panel"
DEFAULT_SIDEBAR_ICON = "mdi:view-dashboard"
DEFAULT_FRONTEND_URL_PATH = "wall-panel"

View File

@ -0,0 +1,68 @@
"""Frontend registration for Wall Panel."""
from __future__ import annotations
from pathlib import Path
from homeassistant.components.frontend import async_register_built_in_panel
from homeassistant.components.http import StaticPathConfig
from homeassistant.core import HomeAssistant
from .const import (
CONF_FRONTEND_URL_PATH,
CONF_PANEL_URL,
CONF_REQUIRE_ADMIN,
CONF_SIDEBAR_ICON,
CONF_SIDEBAR_TITLE,
DEFAULT_FRONTEND_URL_PATH,
DEFAULT_SIDEBAR_ICON,
DEFAULT_SIDEBAR_TITLE,
DOMAIN,
)
async def async_setup_frontend(hass: HomeAssistant, entry) -> str:
"""Register the custom panel and static frontend assets."""
frontend_dir = Path(__file__).parent / "frontend"
state = hass.data.setdefault(DOMAIN, {})
if not state.get("_static_paths_registered"):
await hass.http.async_register_static_paths([
StaticPathConfig(
f"/api/{DOMAIN}/frontend",
str(frontend_dir),
cache_headers=False,
),
])
state["_static_paths_registered"] = True
panel_url_path = str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH).strip()
sidebar_title = str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE).strip()
sidebar_icon = str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON).strip()
require_admin = bool(entry.options.get(CONF_REQUIRE_ADMIN, False))
panel_url = str(entry.options.get(CONF_PANEL_URL, "") or "").strip()
async_register_built_in_panel(
hass,
component_name="custom",
sidebar_title=sidebar_title,
sidebar_icon=sidebar_icon,
frontend_url_path=panel_url_path,
config={
"_panel_custom": {
"name": "wall-panel-panel",
"module_url": f"/api/{DOMAIN}/frontend/panel.js",
"embed_iframe": False,
"trust_external": False,
"config": {
"panel_url": panel_url,
"panel_url_path": panel_url_path,
"entry_id": entry.entry_id,
},
}
},
require_admin=require_admin,
update=True,
)
return panel_url_path

View File

@ -0,0 +1,133 @@
class WallPanelPanel extends HTMLElement {
constructor() {
super();
this._hass = null;
this._panel = null;
this._narrow = false;
this.attachShadow({ mode: 'open' });
}
set hass(hass) {
this._hass = hass;
this._render();
}
set panel(panel) {
this._panel = panel;
this._render();
}
set narrow(narrow) {
this._narrow = Boolean(narrow);
this._render();
}
connectedCallback() {
this._render();
}
_resolveUrl(rawUrl) {
const value = String(rawUrl || '').trim();
if (!value) {
return '';
}
try {
const url = new URL(value, window.location.href);
if (!url.searchParams.has('embed')) {
url.searchParams.set('embed', '1');
}
if (!url.searchParams.has('mode')) {
url.searchParams.set('mode', 'ha');
}
return url.toString();
} catch (error) {
return value;
}
}
_render() {
if (!this.shadowRoot) {
return;
}
const config = this._panel?.config || {};
const customConfig = config._panel_custom || {};
const payload = customConfig.config || customConfig || config;
const panelUrl = this._resolveUrl(
payload.panel_url || config.panel_url || customConfig.panel_url || ''
);
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
width: 100%;
height: 100%;
min-height: 100vh;
background: var(--primary-background-color, #0d0f14);
color: var(--primary-text-color, #fff);
}
.wrap {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
background: var(--primary-background-color, #0d0f14);
}
iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
background: transparent;
}
.empty {
display: grid;
place-items: center;
height: 100%;
padding: 24px;
box-sizing: border-box;
text-align: center;
font-family: var(--primary-font-family, sans-serif);
color: var(--secondary-text-color, #b3b8c2);
}
.empty strong {
display: block;
color: var(--primary-text-color, #fff);
margin-bottom: 8px;
font-size: 1.1rem;
}
</style>
<div class="wrap"></div>
`;
const wrap = this.shadowRoot.querySelector('.wrap');
if (!wrap) {
return;
}
if (!panelUrl) {
wrap.innerHTML = `
<div class="empty">
<div>
<strong>Wall Panel is not configured</strong>
<div>Set the PHP panel URL in the integration options.</div>
</div>
</div>
`;
return;
}
const iframe = document.createElement('iframe');
iframe.src = panelUrl;
iframe.loading = 'eager';
iframe.referrerPolicy = 'no-referrer';
iframe.allow = 'autoplay; fullscreen; picture-in-picture';
wrap.replaceChildren(iframe);
}
}
if (!customElements.get('wall-panel-panel')) {
customElements.define('wall-panel-panel', WallPanelPanel);
}

View File

@ -0,0 +1,283 @@
"""Helper functions for Wall Panel."""
from __future__ import annotations
import json
from copy import deepcopy
from typing import Any
from .const import (
CONF_CONFIG,
CONF_FRONTEND_URL_PATH,
CONF_PANEL_URL,
CONF_REQUIRE_ADMIN,
CONF_SIDEBAR_ICON,
CONF_SIDEBAR_TITLE,
CONF_SYNC_TOKEN,
DEFAULT_FRONTEND_URL_PATH,
DEFAULT_PANEL_URL,
DEFAULT_SIDEBAR_ICON,
DEFAULT_SIDEBAR_TITLE,
)
def default_config() -> dict[str, Any]:
return {
"app": {
"title": "Wall Panel",
"poll_interval_ms": 5000,
"main_room_name": "Главная",
"main_room_icon": "mdi:home",
"edit_mode": False,
"battery_history_hours": 4320,
},
"home_assistant": {
"base_url": "",
"token": "",
"verify_ssl": True,
"sync_url": "",
"sync_token": "",
"sync_timeout": 10,
"sync_verify_ssl": True,
"sync_cache_seconds": 30,
"weather_entity_id": "",
"auto_label": "auto",
"auto_entity_ids": [],
},
"camera": {
"rtsp_url": "",
"stream_url": "",
"stream_mode": "hls",
"poster_url": "",
"popup_timeout_minutes": 3,
"trigger_entities": [],
},
"rooms": [],
}
def normalize_config(value: Any) -> dict[str, Any]:
config = deepcopy(default_config())
if not isinstance(value, dict):
return config
_deep_merge(config, value)
if not isinstance(config.get("rooms"), list):
config["rooms"] = []
return config
def config_to_json(config: dict[str, Any]) -> str:
return json.dumps(config, ensure_ascii=False, indent=2, sort_keys=False)
def parse_config_json(raw: str) -> dict[str, Any]:
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError("Config JSON must be an object")
return normalize_config(data)
def current_entry_config(entry) -> dict[str, Any]:
return normalize_config(entry.options.get(CONF_CONFIG))
def current_entry_panel(entry) -> dict[str, Any]:
return {
CONF_PANEL_URL: str(entry.options.get(CONF_PANEL_URL, DEFAULT_PANEL_URL) or ""),
CONF_SYNC_TOKEN: str(entry.options.get(CONF_SYNC_TOKEN, "") or ""),
CONF_SIDEBAR_TITLE: str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE),
CONF_SIDEBAR_ICON: str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON),
CONF_FRONTEND_URL_PATH: str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH),
CONF_REQUIRE_ADMIN: bool(entry.options.get(CONF_REQUIRE_ADMIN, False)),
}
def save_settings(config: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
app = config.setdefault("app", {})
if "edit_mode" in payload:
app["edit_mode"] = bool(payload["edit_mode"])
if isinstance(payload.get("title"), str) and payload["title"].strip():
app["title"] = payload["title"].strip()
return config
def update_entity_override(config: dict[str, Any], room_id: str, entity_id: str, patch: dict[str, Any]) -> dict[str, Any]:
room = _ensure_room(config, room_id)
overrides = room.setdefault("entity_overrides", {})
current = overrides.get(entity_id, {})
if not isinstance(current, dict):
current = {}
merged = deepcopy(current)
for key, value in patch.items():
if value is not None:
merged[key] = value
overrides[entity_id] = merged
return config
def update_room_override(config: dict[str, Any], room_id: str, patch: dict[str, Any]) -> dict[str, Any]:
room = _ensure_room(config, room_id)
for key, value in patch.items():
if value is not None:
room[key] = value
return config
def update_room_layout_item(config: dict[str, Any], room_id: str, layout_item_id: str, patch: dict[str, Any]) -> dict[str, Any]:
room = _ensure_room(config, room_id)
items = room.setdefault("layout_items", [])
if not isinstance(items, list):
items = []
room["layout_items"] = items
current = None
for item in items:
if isinstance(item, dict) and str(item.get("id", "")) == layout_item_id:
current = item
break
if current is None:
current = {
"id": layout_item_id,
"type": "ghost",
}
items.append(current)
for key, value in patch.items():
if value is not None:
current[key] = value
_sort_room_layout_items(room)
return config
def create_room_layout_item(config: dict[str, Any], room_id: str, layout_item_id: str, order: int | None = None) -> dict[str, Any]:
return update_room_layout_item(config, room_id, layout_item_id, {
"order": order,
"type": "ghost",
})
def delete_room_layout_item(config: dict[str, Any], room_id: str, layout_item_id: str) -> dict[str, Any]:
room = _ensure_room(config, room_id)
items = room.get("layout_items", [])
if not isinstance(items, list):
room["layout_items"] = []
return config
room["layout_items"] = [
item for item in items
if not isinstance(item, dict) or str(item.get("id", "")) != layout_item_id
]
return config
def reorder_room_grid(config: dict[str, Any], room_id: str, entries: list[Any]) -> dict[str, Any]:
room = _ensure_room(config, room_id)
normalized: list[dict[str, str]] = []
for entry in entries:
if not isinstance(entry, dict):
continue
kind = str(entry.get("kind", "")).strip()
item_id = str(entry.get("id", "")).strip()
if kind not in {"entity", "layout"} or not item_id:
continue
normalized.append({"kind": kind, "id": item_id})
entity_overrides = room.setdefault("entity_overrides", {})
if not isinstance(entity_overrides, dict):
entity_overrides = {}
room["entity_overrides"] = entity_overrides
layout_items = room.setdefault("layout_items", [])
if not isinstance(layout_items, list):
layout_items = []
room["layout_items"] = layout_items
layout_by_id = {
str(item.get("id", "")): item
for item in layout_items
if isinstance(item, dict) and str(item.get("id", "")).strip()
}
order = 10000
for entry in normalized:
if entry["kind"] == "entity":
current = entity_overrides.get(entry["id"], {})
if not isinstance(current, dict):
current = {}
merged = deepcopy(current)
merged["order"] = order
entity_overrides[entry["id"]] = merged
else:
current = layout_by_id.get(entry["id"], {
"id": entry["id"],
"type": "ghost",
})
merged = deepcopy(current)
merged["id"] = entry["id"]
merged["type"] = "ghost"
merged["order"] = order
layout_by_id[entry["id"]] = merged
order += 10
room["layout_items"] = sorted(
layout_by_id.values(),
key=lambda item: (int(item.get("order", 9999) or 9999), str(item.get("id", ""))),
)
return config
def build_patch_payload(payload: dict[str, Any], keys: list[str]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key in keys:
if key in payload:
result[key] = payload[key]
return result
def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None:
for key, value in source.items():
if isinstance(value, dict) and isinstance(target.get(key), dict):
_deep_merge(target[key], value)
else:
target[key] = deepcopy(value)
def _ensure_room(config: dict[str, Any], room_id: str) -> dict[str, Any]:
rooms = config.setdefault("rooms", [])
if not isinstance(rooms, list):
rooms = []
config["rooms"] = rooms
for room in rooms:
if isinstance(room, dict) and str(room.get("id", "")) == room_id:
room.setdefault("visible", True)
room.setdefault("entity_ids", [])
room.setdefault("entity_overrides", {})
room.setdefault("layout_items", [])
return room
room = {
"id": room_id,
"visible": True,
"entity_ids": [],
"entity_overrides": {},
"layout_items": [],
}
rooms.append(room)
return room
def _sort_room_layout_items(room: dict[str, Any]) -> None:
items = room.get("layout_items", [])
if not isinstance(items, list):
room["layout_items"] = []
return
room["layout_items"] = sorted(
[item for item in items if isinstance(item, dict)],
key=lambda item: (int(item.get("order", 9999) or 9999), str(item.get("id", ""))),
)

View File

@ -0,0 +1,10 @@
{
"domain": "wall_panel",
"name": "Wall Panel",
"version": "1.0.0",
"documentation": "https://example.invalid/wall-panel",
"codeowners": [],
"config_flow": true,
"iot_class": "local_polling",
"requirements": []
}

View File

@ -0,0 +1,23 @@
{
"config": {
"step": {
"user": {
"title": "Wall Panel",
"description": "Connect Wall Panel to Home Assistant.",
"data": {
"name": "Name",
"panel_url": "PHP panel URL",
"sidebar_title": "Sidebar title",
"sidebar_icon": "Sidebar icon",
"frontend_url_path": "Frontend URL path",
"require_admin": "Require admin",
"sync_token": "Sync token",
"config": "Canonical config JSON"
}
}
},
"error": {
"invalid_json": "Invalid JSON"
}
}
}

View File

@ -0,0 +1,23 @@
{
"config": {
"step": {
"user": {
"title": "Wall Panel",
"description": "Connect Wall Panel to Home Assistant.",
"data": {
"name": "Name",
"panel_url": "PHP panel URL",
"sidebar_title": "Sidebar title",
"sidebar_icon": "Sidebar icon",
"frontend_url_path": "Frontend URL path",
"require_admin": "Require admin",
"sync_token": "Sync token",
"config": "Canonical config JSON"
}
}
},
"error": {
"invalid_json": "Invalid JSON"
}
}
}

View File

@ -0,0 +1,160 @@
"""HTTP views for Wall Panel."""
from __future__ import annotations
import secrets
from typing import Any
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from .const import CONF_CONFIG, CONF_SYNC_TOKEN, DOMAIN
from .helpers import (
build_patch_payload,
config_to_json,
create_room_layout_item,
current_entry_config,
delete_room_layout_item,
normalize_config,
reorder_room_grid,
save_settings,
update_entity_override,
update_room_layout_item,
update_room_override,
)
def _entry_from_hass(hass: HomeAssistant, entry_id: str):
return hass.data.get(DOMAIN, {}).get(entry_id, {}).get("entry")
def _request_token(request: web.Request) -> str:
header = request.headers.get("X-Wall-Panel-Token", "").strip()
if header:
return header
auth = request.headers.get("Authorization", "").strip()
if auth.lower().startswith("bearer "):
return auth[7:].strip()
return request.query.get("token", "").strip()
def _authorized(entry, request: web.Request) -> bool:
expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip()
if not expected:
return False
return secrets.compare_digest(_request_token(request), expected)
def _response(data: Any, status: int = 200) -> web.Response:
if isinstance(data, str):
return web.Response(text=data, status=status, content_type="text/plain; charset=utf-8")
return web.json_response(data, status=status)
def _save_entry_config(hass: HomeAssistant, entry, config: dict[str, Any]) -> None:
options = dict(entry.options)
options[CONF_CONFIG] = config
hass.config_entries.async_update_entry(entry, options=options)
hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})["config"] = config
class WallPanelConfigView(HomeAssistantView):
"""Serve and update the canonical Wall Panel config."""
url = "/api/wall_panel/config/{entry_id}"
name = "api:wall_panel:config"
requires_auth = False
async def async_get(self, request: web.Request, entry_id: str) -> web.Response:
hass = request.app["hass"]
entry = _entry_from_hass(hass, entry_id)
if entry is None:
return _response({"ok": False, "error": "Unknown entry"}, 404)
if not _authorized(entry, request):
return _response({"ok": False, "error": "Unauthorized"}, 401)
config = current_entry_config(entry)
return _response(config)
async def async_post(self, request: web.Request, entry_id: str) -> web.Response:
hass = request.app["hass"]
entry = _entry_from_hass(hass, entry_id)
if entry is None:
return _response({"ok": False, "error": "Unknown entry"}, 404)
if not _authorized(entry, request):
return _response({"ok": False, "error": "Unauthorized"}, 401)
payload = await request.json()
if not isinstance(payload, dict):
return _response({"ok": False, "error": "Invalid payload"}, 400)
config = current_entry_config(entry)
action = str(payload.get("action", "") or "").strip().lower()
action_payload = payload.get("payload")
if not isinstance(action_payload, dict):
action_payload = payload
if action == "save-settings":
config = save_settings(config, action_payload)
elif action == "save-entity-override":
room_id = str(action_payload.get("room_id", "") or "").strip()
entity_id = str(action_payload.get("entity_id", "") or "").strip()
if not room_id or not entity_id:
return _response({"ok": False, "error": "room_id and entity_id are required"}, 400)
patch = build_patch_payload(action_payload, ["visible", "order", "card_type", "title", "icon"])
config = update_entity_override(config, room_id, entity_id, patch)
elif action == "save-space-override":
room_id = str(action_payload.get("room_id", "") or "").strip()
if not room_id:
return _response({"ok": False, "error": "room_id is required"}, 400)
patch = build_patch_payload(action_payload, ["visible", "order", "name", "icon", "temperature_sensor_entity_id"])
config = update_room_override(config, room_id, patch)
elif action == "create-room-layout-item":
room_id = str(action_payload.get("room_id", "") or "").strip()
if not room_id:
return _response({"ok": False, "error": "room_id is required"}, 400)
layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip()
if not layout_item_id:
layout_item_id = f"slot_{secrets.token_hex(12)}"
order = action_payload.get("order")
config = create_room_layout_item(config, room_id, layout_item_id, int(order) if order is not None else None)
_save_entry_config(hass, entry, config)
return _response({
"ok": True,
"layout_item_id": layout_item_id,
"config": config,
})
elif action == "save-room-layout-item":
room_id = str(action_payload.get("room_id", "") or "").strip()
layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip()
if not room_id or not layout_item_id:
return _response({"ok": False, "error": "room_id and layout_item_id are required"}, 400)
patch = build_patch_payload(action_payload, ["order"])
config = update_room_layout_item(config, room_id, layout_item_id, patch)
elif action == "delete-room-layout-item":
room_id = str(action_payload.get("room_id", "") or "").strip()
layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip()
if not room_id or not layout_item_id:
return _response({"ok": False, "error": "room_id and layout_item_id are required"}, 400)
config = delete_room_layout_item(config, room_id, layout_item_id)
elif action == "reorder-room-grid":
room_id = str(action_payload.get("room_id", "") or "").strip()
entries = action_payload.get("entries", [])
if not room_id or not isinstance(entries, list):
return _response({"ok": False, "error": "room_id and entries are required"}, 400)
config = reorder_room_grid(config, room_id, entries)
elif isinstance(payload.get("config"), dict):
config = normalize_config(payload["config"])
else:
return _response({"ok": False, "error": "Unknown action"}, 404)
_save_entry_config(hass, entry, config)
return _response({
"ok": True,
"config": config,
})

BIN
favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

164
index.php Executable file
View File

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/lib/bootstrap.php';
function app_is_embed_request(): bool
{
$value = strtolower(trim((string)($_GET['embed'] ?? '')));
if (in_array($value, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
$mode = strtolower(trim((string)($_GET['mode'] ?? '')));
return in_array($mode, ['embed', 'panel', 'lovelace', 'ha'], true);
}
$config = app_load_config();
$client = new HomeAssistantClient($config);
$bootstrap = app_build_snapshot($config, $client, 'main');
$embedMode = app_is_embed_request();
$runtimeMode = trim((string)getenv('WALL_PANEL_RUNTIME_MODE'));
if ($runtimeMode === '') {
$runtimeMode = $embedMode ? 'ha' : 'standalone';
}
$bootstrap['ui'] = [
'embed' => $embedMode,
'mode' => $runtimeMode,
'shell' => $embedMode ? 'embed' : 'standalone',
'config_source' => app_remote_sync_enabled($config) ? 'ha' : 'file',
];
$appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0d0f14">
<title><?= $appTitle ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css">
<script src="https://home.striker72rus.ru/local/community/custom-brand-icons/custom-brand-icons.js" defer></script>
<script>
window.APP_BOOTSTRAP = <?= json_encode($bootstrap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
</script>
<link rel="stylesheet" href="assets/app.css?v=0.28">
<script src="assets/app.js?v=0.28" defer></script>
</head>
<body class="<?= $embedMode ? 'is-embedded' : '' ?>" data-ui-mode="<?= htmlspecialchars($runtimeMode, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
<div class="app-shell<?= $embedMode ? ' app-shell--embed' : '' ?>">
<aside class="sidebar">
<section class="clock-panel">
<div class="clock-panel__time" id="clock-time">--:--</div>
<div class="clock-panel__date" id="clock-date">---</div>
</section>
<section class="rooms-panel">
<div class="panel-header">
<div>
<div class="panel-header__label">Пространства</div>
<div class="panel-header__sub" id="rooms-count">0</div>
</div>
<button class="icon-button" id="edit-mode-toggle" type="button" aria-label="Edit mode">
<i class="mdi mdi-cog-outline"></i>
</button>
</div>
<div class="room-list" id="room-list"></div>
</section>
</aside>
<main class="content">
<div class="content-top" id="content-top">
<div class="main-print-strip-slot" id="main-print-strip-slot"></div>
</div>
<header class="content-header">
<button class="icon-button icon-button--ghost content-header__back" id="selected-room-back" type="button" aria-label="Back" hidden>
<i class="mdi mdi-arrow-left"></i>
</button>
<div>
<div class="content-header__eyebrow" id="selected-room-eyebrow"></div>
<h1 class="content-header__title" id="selected-room-title">Загрузка</h1>
<div class="content-header__meta" id="selected-room-meta"></div>
</div>
<div class="content-header__actions" id="selected-room-actions"></div>
</header>
<section class="dashboard-grid" id="dashboard-grid">
<div class="grid-surface" id="dashboard-surface">
<div class="loading-card">Загрузка панели...</div>
</div>
</section>
</main>
</div>
<div class="modal-backdrop" id="camera-modal" aria-hidden="true">
<div class="camera-modal" id="camera-modal-panel">
<button class="icon-button icon-button--ghost camera-modal__close" id="camera-modal-close" type="button" aria-label="Close">
<i class="mdi mdi-close"></i>
</button>
<div class="camera-modal__body">
<div class="camera-stage" id="camera-stage">
<img class="camera-stage__poster" id="camera-poster" alt="Camera poster">
<div class="camera-stage__placeholder" id="camera-placeholder">
<div class="camera-stage__placeholder-icon"><i class="mdi mdi-cctv"></i></div>
<div class="camera-stage__placeholder-title">Поток загружается</div>
<div class="camera-stage__placeholder-subtitle">Показываем poster, пока не доступен video bridge</div>
</div>
</div>
<div class="camera-modal__footer">
<div class="camera-modal__countdown" id="camera-countdown"></div>
</div>
</div>
</div>
</div>
<div class="modal-backdrop" id="entity-modal" aria-hidden="true">
<div class="entity-modal" id="entity-modal-panel" role="dialog" aria-modal="true" aria-labelledby="entity-modal-title">
<div class="entity-modal__header">
<div>
<div class="entity-modal__eyebrow" id="entity-modal-eyebrow"></div>
<div class="entity-modal__title" id="entity-modal-title">Устройство</div>
</div>
<button class="icon-button icon-button--ghost" id="entity-modal-close" type="button" aria-label="Close">
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="entity-modal__body" id="entity-modal-body"></div>
</div>
</div>
<div class="modal-backdrop" id="temperature-sensor-modal" aria-hidden="true">
<div class="temperature-sensor-modal" id="temperature-sensor-modal-panel" role="dialog" aria-modal="true" aria-labelledby="temperature-sensor-modal-title">
<div class="temperature-sensor-modal__header">
<div>
<div class="temperature-sensor-modal__eyebrow">Настройка комнаты</div>
<div class="temperature-sensor-modal__title" id="temperature-sensor-modal-title">Выбрать датчик температуры</div>
</div>
<button class="icon-button icon-button--ghost" id="temperature-sensor-modal-close" type="button" aria-label="Close">
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="temperature-sensor-modal__body" id="temperature-sensor-modal-body"></div>
</div>
</div>
<div class="modal-backdrop" id="confirm-modal" aria-hidden="true">
<div class="confirm-modal" id="confirm-modal-panel" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
<div class="confirm-modal__header">
<div>
<div class="confirm-modal__eyebrow">Подтверждение</div>
<div class="confirm-modal__title" id="confirm-modal-title">Хотите закрыть?</div>
</div>
</div>
<div class="confirm-modal__body" id="confirm-modal-message">Это действие отправит команду закрытия.</div>
<div class="confirm-modal__footer">
<button class="mushroom-button mushroom-button--small" id="confirm-modal-no" type="button">Нет</button>
<button class="mushroom-button mushroom-button--small is-on" id="confirm-modal-yes" type="button">Да</button>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

9
lib/bootstrap.php Executable file
View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
define('APP_ROOT', dirname(__DIR__));
require_once APP_ROOT . '/lib/config.php';
require_once APP_ROOT . '/lib/ha_client.php';
require_once APP_ROOT . '/lib/ha_sync.php';
require_once APP_ROOT . '/lib/dashboard.php';

348
lib/config.php Executable file
View File

@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
function app_default_config(): array
{
return [
'app' => [
'title' => 'Wall Panel',
'poll_interval_ms' => 5000,
'main_room_name' => 'Главная',
'main_room_icon' => 'mdi:home',
'edit_mode' => false,
'battery_history_hours' => 4320,
],
'home_assistant' => [
'base_url' => '',
'token' => '',
'verify_ssl' => true,
'sync_url' => '',
'sync_token' => '',
'sync_timeout' => 10,
'sync_verify_ssl' => true,
'sync_cache_seconds' => 30,
'weather_entity_id' => '',
'auto_label' => 'auto',
'auto_entity_ids' => [],
],
'camera' => [
'rtsp_url' => 'rtsp://10.0.6.110:45321/feff99fa45f317e7',
'stream_url' => '',
'stream_mode' => 'hls',
'poster_url' => 'http://10.0.6.110:5000/api/doorbell/latest.jpg',
'popup_timeout_minutes' => 3,
'trigger_entities' => [
'binary_sensor.door_all_occupancy',
'binary_sensor.barn_all_occupancy',
'binary_sensor.doorbell_all_occupancy',
],
],
'rooms' => [],
];
}
function app_config_path(): string
{
$override = trim((string)getenv('WALL_PANEL_CONFIG_PATH'));
if ($override !== '') {
return $override;
}
return APP_ROOT . '/config/config.json';
}
function app_runtime_mode(): string
{
return strtolower(trim((string)getenv('WALL_PANEL_RUNTIME_MODE')));
}
function app_addon_options_path(): string
{
$override = trim((string)getenv('WALL_PANEL_OPTIONS_PATH'));
if ($override !== '') {
return $override;
}
return '/data/options.json';
}
function app_storage_path(string $file): string
{
$override = trim((string)getenv('WALL_PANEL_STORAGE_DIR'));
$base = $override !== '' ? rtrim($override, '/') : APP_ROOT . '/storage';
return $base . '/' . ltrim($file, '/');
}
function app_ensure_directory(string $path): void
{
if (!is_dir($path)) {
mkdir($path, 0775, true);
}
}
function app_load_config(): array
{
$path = app_config_path();
app_ensure_directory(dirname($path));
$config = app_default_config();
$storedConfig = $config;
if (!file_exists($path)) {
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Failed to encode default config');
}
file_put_contents($path, $json . PHP_EOL, LOCK_EX);
} else {
$raw = file_get_contents($path);
if ($raw === false || trim($raw) !== '') {
$decoded = json_decode((string)$raw, true);
if (!is_array($decoded)) {
throw new RuntimeException('Invalid JSON in config/config.json');
}
$config = array_replace_recursive($config, $decoded);
$storedConfig = $config;
}
}
$optionsPath = app_addon_options_path();
if (is_file($optionsPath)) {
$options = app_load_json_file($optionsPath, []);
if (is_array($options) && $options !== []) {
$config = array_replace_recursive($config, $options);
if (app_runtime_mode() === 'addon' && $config !== $storedConfig) {
app_save_config($config);
}
}
}
return $config;
}
function app_save_config(array $config): void
{
$path = app_config_path();
app_ensure_directory(dirname($path));
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Failed to encode config');
}
file_put_contents($path, $json . PHP_EOL, LOCK_EX);
}
function app_load_json_file(string $path, array $fallback = []): array
{
if (!file_exists($path)) {
return $fallback;
}
$raw = file_get_contents($path);
if ($raw === false || trim($raw) === '') {
return $fallback;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : $fallback;
}
function app_save_json_file(string $path, array $data): void
{
app_ensure_directory(dirname($path));
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Failed to encode JSON');
}
file_put_contents($path, $json . PHP_EOL, LOCK_EX);
}
function app_remote_sync_enabled(array $config): bool
{
$syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? ''));
$syncToken = trim((string)($config['home_assistant']['sync_token'] ?? ''));
return $syncUrl !== '' && $syncToken !== '';
}
function app_remote_sync_cache_path(): string
{
return app_storage_path('ha_sync_cache.json');
}
function app_remote_sync_cache_ttl(array $config): int
{
return max(5, (int)($config['home_assistant']['sync_cache_seconds'] ?? 30));
}
function app_http_json_request(
string $method,
string $url,
array $headers = [],
?array $body = null,
int $timeout = 10,
bool $verifySsl = true,
bool $throwOnFailure = false
): array|null {
$ch = curl_init($url);
if ($ch === false) {
if ($throwOnFailure) {
throw new RuntimeException('Failed to initialize HTTP client');
}
return null;
}
$requestHeaders = array_merge([
'Accept: application/json',
], $headers);
if ($body !== null) {
$requestHeaders[] = 'Content-Type: application/json';
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HTTPHEADER => $requestHeaders,
CURLOPT_TIMEOUT => max(1, $timeout),
CURLOPT_CONNECTTIMEOUT => max(1, min(7, $timeout)),
CURLOPT_SSL_VERIFYPEER => $verifySsl,
CURLOPT_SSL_VERIFYHOST => $verifySsl ? 2 : 0,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno !== 0) {
if ($throwOnFailure) {
throw new RuntimeException('HTTP request failed: ' . $error);
}
return null;
}
if ($status >= 400) {
if ($throwOnFailure) {
throw new RuntimeException('HTTP request returned status ' . $status);
}
return null;
}
$decoded = json_decode((string)$raw, true);
return is_array($decoded) ? $decoded : [];
}
function app_merge_synced_config(array $baseConfig, array $syncedConfig): array
{
$syncFields = [
'sync_url' => (string)($baseConfig['home_assistant']['sync_url'] ?? ''),
'sync_token' => (string)($baseConfig['home_assistant']['sync_token'] ?? ''),
'sync_timeout' => (int)($baseConfig['home_assistant']['sync_timeout'] ?? 10),
'sync_verify_ssl' => (bool)($baseConfig['home_assistant']['sync_verify_ssl'] ?? true),
'sync_cache_seconds' => (int)($baseConfig['home_assistant']['sync_cache_seconds'] ?? 30),
];
$merged = array_replace_recursive($baseConfig, $syncedConfig);
foreach ($syncFields as $key => $value) {
$merged['home_assistant'][$key] = $value;
}
return $merged;
}
function app_load_remote_config(array $config, bool $refresh = false): array|null
{
if (!app_remote_sync_enabled($config)) {
return null;
}
$cachePath = app_remote_sync_cache_path();
$cache = app_load_json_file($cachePath, []);
$cachedConfig = is_array($cache['config'] ?? null) ? $cache['config'] : null;
$cachedAt = (int)($cache['fetched_at'] ?? 0);
$ttl = app_remote_sync_cache_ttl($config);
if (!$refresh && $cachedConfig !== null && $cachedAt > 0 && (time() - $cachedAt) < $ttl) {
return $cachedConfig;
}
$syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? ''));
$syncToken = trim((string)($config['home_assistant']['sync_token'] ?? ''));
$timeout = max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10));
$verifySsl = (bool)($config['home_assistant']['sync_verify_ssl'] ?? true);
$response = app_http_json_request('GET', $syncUrl, [
'X-Wall-Panel-Token: ' . $syncToken,
], null, $timeout, $verifySsl, false);
$remoteConfig = null;
if (is_array($response)) {
if (is_array($response['config'] ?? null)) {
$remoteConfig = $response['config'];
} elseif (!isset($response['ok']) || (bool)$response['ok'] !== false) {
$remoteConfig = $response;
}
}
if (is_array($remoteConfig)) {
app_save_json_file($cachePath, [
'fetched_at' => time(),
'config' => $remoteConfig,
]);
return $remoteConfig;
}
if ($cachedConfig !== null) {
return $cachedConfig;
}
return null;
}
function app_clear_remote_config_cache(): void
{
$path = app_remote_sync_cache_path();
if (file_exists($path)) {
@unlink($path);
}
}
function app_sync_remote_action(array $config, string $action, array $payload): array|null
{
if (!app_remote_sync_enabled($config)) {
return null;
}
$syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? ''));
$syncToken = trim((string)($config['home_assistant']['sync_token'] ?? ''));
$timeout = max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10));
$verifySsl = (bool)($config['home_assistant']['sync_verify_ssl'] ?? true);
$response = app_http_json_request('POST', $syncUrl, [
'X-Wall-Panel-Token: ' . $syncToken,
], [
'action' => $action,
'payload' => $payload,
], $timeout, $verifySsl, false);
if (!is_array($response)) {
return null;
}
if (is_array($response['config'] ?? null)) {
app_save_json_file(app_remote_sync_cache_path(), [
'fetched_at' => time(),
'config' => $response['config'],
]);
}
return $response;
}

2039
lib/dashboard.php Executable file

File diff suppressed because it is too large Load Diff

658
lib/ha_client.php Executable file
View File

@ -0,0 +1,658 @@
<?php
declare(strict_types=1);
final class HomeAssistantClient
{
private array $config;
private bool $demoMode;
public function __construct(array $config)
{
$this->config = $config;
$baseUrl = trim((string)($config['home_assistant']['base_url'] ?? ''));
$token = trim((string)($config['home_assistant']['token'] ?? ''));
$this->demoMode = $baseUrl === '' || $token === '' || str_contains($baseUrl, 'example.com') || str_contains($token, 'PASTE_') || str_contains($token, 'YOUR_');
}
public function isDemo(): bool
{
return $this->demoMode;
}
public function fetchSnapshotData(): array
{
if ($this->demoMode) {
return $this->demoData();
}
$states = $this->request('GET', '/api/states');
$areas = $this->fetchWsList('config/area_registry/list') ?? $this->tryRequest('GET', '/api/areas') ?? [];
$floors = $this->fetchWsList('config/floor_registry/list') ?? [];
$entities = $this->fetchWsList('config/entity_registry/list_for_display') ?? $this->tryRequest('GET', '/api/config/entity_registry/list') ?? [];
$devices = $this->fetchWsList('config/device_registry/list') ?? $this->tryRequest('GET', '/api/config/device_registry/list') ?? [];
return [
'states' => is_array($states) ? $states : [],
'areas' => is_array($areas) ? $areas : [],
'floors' => is_array($floors) ? $floors : [],
'entity_registry' => is_array($entities) ? $entities : [],
'device_registry' => is_array($devices) ? $devices : [],
];
}
public function fetchEntityHistory(string $entityId, int $hours = 24): array
{
$entityId = trim($entityId);
$hours = max(1, min(168, $hours));
if ($this->demoMode) {
return $this->demoHistory($entityId, $hours);
}
if ($entityId === '') {
return [];
}
$start = (new DateTimeImmutable('now', new DateTimeZone('UTC')))
->modify('-' . $hours . ' hours')
->format(DATE_ATOM);
$path = '/api/history/period/' . rawurlencode($start) . '?' . http_build_query([
'filter_entity_id' => $entityId,
'minimal_response' => 1,
'no_attributes' => 1,
]);
$response = $this->request('GET', $path);
return is_array($response) ? $response : [];
}
public function callService(string $domain, string $service, array $data): array
{
if ($this->demoMode) {
return [
'ok' => true,
'demo' => true,
'domain' => $domain,
'service' => $service,
'data' => $data,
];
}
return $this->request('POST', '/api/services/' . rawurlencode($domain) . '/' . rawurlencode($service), $data);
}
private function request(string $method, string $path, ?array $body = null): array
{
$response = $this->tryRequest($method, $path, $body, true);
if (!is_array($response)) {
throw new RuntimeException('Unexpected response from Home Assistant');
}
return $response;
}
private function tryRequest(string $method, string $path, ?array $body = null, bool $throwOnHttpError = false): array|null
{
$baseUrl = rtrim((string)$this->config['home_assistant']['base_url'], '/');
$url = $baseUrl . $path;
$token = trim((string)$this->config['home_assistant']['token']);
$ch = curl_init($url);
if ($ch === false) {
throw new RuntimeException('Failed to initialize HTTP client');
}
$headers = [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
'Accept: application/json',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 15,
CURLOPT_CONNECTTIMEOUT => 7,
CURLOPT_SSL_VERIFYPEER => (bool)($this->config['home_assistant']['verify_ssl'] ?? true),
CURLOPT_SSL_VERIFYHOST => (bool)($this->config['home_assistant']['verify_ssl'] ?? true) ? 2 : 0,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno !== 0) {
if ($throwOnHttpError) {
throw new RuntimeException('Home Assistant request failed: ' . $error);
}
return null;
}
if ($status >= 400) {
if ($throwOnHttpError) {
throw new RuntimeException('Home Assistant returned HTTP ' . $status);
}
return null;
}
$decoded = json_decode((string)$raw, true);
return is_array($decoded) ? $decoded : [];
}
private function fetchWsList(string $type): array|null
{
$response = $this->wsCall($type);
if (!is_array($response)) {
return null;
}
if (isset($response['result']) && is_array($response['result'])) {
return $this->normalizeWsResultList($type, $response['result']);
}
return null;
}
private function normalizeWsResultList(string $type, array $result): array
{
if (array_is_list($result)) {
return match ($type) {
'config/entity_registry/list_for_display' => $this->normalizeEntityRegistryForDisplay($result),
'config/area_registry/list' => $this->normalizeAreaRegistry($result),
'config/floor_registry/list' => $this->normalizeFloorRegistry($result),
'config/device_registry/list' => $this->normalizeDeviceRegistryForDisplay($result),
default => $result,
};
}
return match ($type) {
'config/entity_registry/list_for_display' => $this->normalizeEntityRegistryForDisplay($result['entities'] ?? []),
'config/area_registry/list' => $this->normalizeAreaRegistry($result['areas'] ?? []),
'config/floor_registry/list' => $this->normalizeFloorRegistry($result['floors'] ?? []),
'config/device_registry/list' => $this->normalizeDeviceRegistryForDisplay($result['devices'] ?? $result['device_registry'] ?? $result['items'] ?? []),
default => $result,
};
}
private function wsCall(string $type): array|null
{
$baseUrl = rtrim((string)$this->config['home_assistant']['base_url'], '/');
$parts = parse_url($baseUrl);
if (!is_array($parts) || empty($parts['host'])) {
return null;
}
$scheme = strtolower((string)($parts['scheme'] ?? 'http'));
$secure = in_array($scheme, ['https', 'wss'], true);
$host = (string)$parts['host'];
$port = (int)($parts['port'] ?? ($secure ? 443 : 80));
$path = '/api/websocket';
$target = ($secure ? 'tls' : 'tcp') . '://' . $host . ':' . $port;
$contextOptions = [
'ssl' => [
'verify_peer' => (bool)($this->config['home_assistant']['verify_ssl'] ?? true),
'verify_peer_name' => (bool)($this->config['home_assistant']['verify_ssl'] ?? true),
'allow_self_signed' => !(bool)($this->config['home_assistant']['verify_ssl'] ?? true),
'SNI_enabled' => true,
'peer_name' => $host,
],
];
$stream = @stream_socket_client(
$target,
$errno,
$error,
15,
STREAM_CLIENT_CONNECT,
stream_context_create($contextOptions)
);
if ($stream === false) {
return null;
}
stream_set_timeout($stream, 15);
$key = base64_encode(random_bytes(16));
$request = implode("\r\n", [
'GET ' . $path . ' HTTP/1.1',
'Host: ' . $host . ':' . $port,
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Key: ' . $key,
'Sec-WebSocket-Version: 13',
'',
'',
]);
fwrite($stream, $request);
$status = $this->readHttpHeader($stream);
if ($status === null || !str_contains($status, ' 101 ')) {
fclose($stream);
return null;
}
$auth = $this->readWsJson($stream);
if (!is_array($auth) || ($auth['type'] ?? '') !== 'auth_required') {
fclose($stream);
return null;
}
$this->writeWsJson($stream, [
'type' => 'auth',
'access_token' => (string)$this->config['home_assistant']['token'],
]);
$authResult = $this->readWsJson($stream);
if (!is_array($authResult) || ($authResult['type'] ?? '') !== 'auth_ok') {
fclose($stream);
return null;
}
$requestId = random_int(1, 1_000_000);
$this->writeWsJson($stream, [
'id' => $requestId,
'type' => $type,
]);
while (($message = $this->readWsJson($stream)) !== null) {
if (($message['id'] ?? null) === $requestId) {
fclose($stream);
if (($message['success'] ?? false) !== true) {
return null;
}
return is_array($message) ? $message : null;
}
}
fclose($stream);
return null;
}
private function normalizeEntityRegistryForDisplay(array $result): array
{
$entityCategories = is_array($result['entity_categories'] ?? null) ? $result['entity_categories'] : [];
$entities = $result;
if (!array_is_list($entities)) {
$entities = $result['entities'] ?? [];
}
if (!is_array($entities)) {
return [];
}
$normalized = [];
foreach ($entities as $entity) {
if (!is_array($entity)) {
continue;
}
$normalized[] = [
'entity_id' => $entity['ei'] ?? null,
'area_id' => $entity['ai'] ?? null,
'labels' => $this->normalizeWsLabels($entity['lb'] ?? null),
'device_id' => $entity['di'] ?? null,
'icon' => $entity['ic'] ?? null,
'translation_key' => $entity['tk'] ?? null,
'entity_category' => isset($entity['ec']) ? ($entityCategories[$entity['ec']] ?? null) : null,
'hidden_by' => !empty($entity['hb']) ? 'true' : null,
'name' => $entity['en'] ?? null,
'has_entity_name' => !empty($entity['hn']),
'platform' => $entity['pl'] ?? null,
];
}
return $normalized;
}
private function normalizeAreaRegistry(array $areas): array
{
$normalized = [];
foreach ($areas as $area) {
if (!is_array($area)) {
continue;
}
$normalized[] = [
'area_id' => $area['area_id'] ?? $area['id'] ?? null,
'name' => $area['name'] ?? $area['en'] ?? null,
'icon' => $area['icon'] ?? $area['ic'] ?? null,
'picture' => $area['picture'] ?? $area['pi'] ?? null,
'floor_id' => $area['floor_id'] ?? $area['fi'] ?? $area['floor'] ?? null,
];
}
return $normalized;
}
private function normalizeFloorRegistry(array $floors): array
{
$normalized = [];
foreach ($floors as $floor) {
if (!is_array($floor)) {
continue;
}
$normalized[] = [
'floor_id' => $floor['floor_id'] ?? $floor['id'] ?? null,
'name' => $floor['name'] ?? $floor['en'] ?? null,
'icon' => $floor['icon'] ?? $floor['ic'] ?? null,
'level' => $floor['level'] ?? $floor['lv'] ?? null,
];
}
return $normalized;
}
private function normalizeDeviceRegistryForDisplay(array $devices): array
{
$normalized = [];
foreach ($devices as $device) {
if (!is_array($device)) {
continue;
}
$normalized[] = [
'device_id' => $device['device_id'] ?? $device['id'] ?? $device['di'] ?? null,
'area_id' => $device['area_id'] ?? $device['ai'] ?? null,
'labels' => $this->normalizeWsLabels($device['labels'] ?? null),
'name' => $device['name'] ?? $device['en'] ?? null,
'manufacturer' => $device['manufacturer'] ?? $device['mf'] ?? null,
'model' => $device['model'] ?? $device['md'] ?? null,
];
}
return $normalized;
}
private function normalizeWsLabels(mixed $labels): array
{
if ($labels === null) {
return [];
}
if (is_string($labels) || is_numeric($labels)) {
return [(string)$labels];
}
if (!is_array($labels)) {
return [];
}
return array_values(array_unique(array_map('strval', $labels)));
}
private function readHttpHeader($stream): ?string
{
$buffer = '';
while (!feof($stream)) {
$chunk = fgets($stream);
if ($chunk === false) {
break;
}
$buffer .= $chunk;
if (str_contains($buffer, "\r\n\r\n")) {
break;
}
}
return $buffer !== '' ? $buffer : null;
}
private function writeWsJson($stream, array $payload): void
{
fwrite($stream, $this->encodeWsFrame(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'));
}
private function readWsJson($stream): array|null
{
$payload = $this->readWsFrame($stream);
if ($payload === null || $payload === '') {
return null;
}
$decoded = json_decode($payload, true);
return is_array($decoded) ? $decoded : null;
}
private function encodeWsFrame(string $payload): string
{
$finOpcode = chr(0x81);
$len = strlen($payload);
$maskBit = 0x80;
$mask = random_bytes(4);
$header = '';
if ($len <= 125) {
$header = chr($maskBit | $len);
} elseif ($len <= 65535) {
$header = chr($maskBit | 126) . pack('n', $len);
} else {
$header = chr($maskBit | 127) . pack('J', $len);
}
$masked = '';
for ($i = 0; $i < $len; $i++) {
$masked .= $payload[$i] ^ $mask[$i % 4];
}
return $finOpcode . $header . $mask . $masked;
}
private function readWsFrame($stream): ?string
{
$first = fread($stream, 2);
if ($first === false || strlen($first) < 2) {
return null;
}
$bytes = array_values(unpack('C2', $first));
$opcode = $bytes[0] & 0x0f;
$isMasked = (bool)($bytes[1] & 0x80);
$len = $bytes[1] & 0x7f;
if ($len === 126) {
$extended = fread($stream, 2);
if ($extended === false || strlen($extended) < 2) {
return null;
}
$len = unpack('n', $extended)[1];
} elseif ($len === 127) {
$extended = fread($stream, 8);
if ($extended === false || strlen($extended) < 8) {
return null;
}
$parts = unpack('N2', $extended);
$len = ((int)$parts[1] << 32) | (int)$parts[2];
}
$mask = '';
if ($isMasked) {
$mask = fread($stream, 4);
if ($mask === false || strlen($mask) < 4) {
return null;
}
}
$payload = '';
while (strlen($payload) < $len && !feof($stream)) {
$chunk = fread($stream, $len - strlen($payload));
if ($chunk === false || $chunk === '') {
break;
}
$payload .= $chunk;
}
if ($isMasked && $mask !== '') {
$unmasked = '';
for ($i = 0, $payloadLen = strlen($payload); $i < $payloadLen; $i++) {
$unmasked .= $payload[$i] ^ $mask[$i % 4];
}
$payload = $unmasked;
}
if ($opcode === 0x9) {
$this->writeWsPong($stream, $payload);
return $this->readWsFrame($stream);
}
if ($opcode === 0x8) {
return null;
}
return $payload;
}
private function writeWsPong($stream, string $payload): void
{
$len = strlen($payload);
$header = chr(0x8A);
if ($len <= 125) {
$header .= chr($len);
} elseif ($len <= 65535) {
$header .= chr(126) . pack('n', $len);
} else {
$header .= chr(127) . pack('J', $len);
}
fwrite($stream, $header . $payload);
}
private function demoData(): array
{
return [
'states' => [
[
'entity_id' => 'light.living_room_main',
'state' => 'on',
'attributes' => [
'friendly_name' => 'Основной свет',
'icon' => 'mdi:ceiling-light',
'labels' => ['auto'],
],
],
[
'entity_id' => 'switch.tv_power',
'state' => 'off',
'attributes' => [
'friendly_name' => 'ТВ',
'icon' => 'mdi:television',
'labels' => ['auto'],
],
],
[
'entity_id' => 'cover.living_room_curtain',
'state' => 'open',
'attributes' => [
'friendly_name' => 'Штора',
'icon' => 'mdi:curtains',
'current_position' => 82,
],
],
[
'entity_id' => 'climate.living_room_ac',
'state' => 'cool',
'attributes' => [
'friendly_name' => 'Кондиционер',
'icon' => 'mdi:air-conditioner',
'temperature' => 22,
'current_temperature' => 24.5,
'hvac_action' => 'cooling',
'fan_mode' => 'auto',
],
],
[
'entity_id' => 'light.kitchen_counter',
'state' => 'off',
'attributes' => [
'friendly_name' => 'Подсветка',
'icon' => 'mdi:lightbulb',
'labels' => ['auto'],
],
],
[
'entity_id' => 'switch.coffee_machine',
'state' => 'on',
'attributes' => [
'friendly_name' => 'Кофемашина',
'icon' => 'mdi:coffee-maker',
'labels' => ['auto'],
],
],
[
'entity_id' => 'weather.yandex_weather',
'state' => 'sunny',
'attributes' => [
'friendly_name' => 'Yandex Weather',
'temperature' => 18.3,
'humidity' => 56,
'wind_speed' => 4.8,
],
],
[
'entity_id' => 'sensor.weather_temperature',
'state' => '17.8',
'attributes' => [
'friendly_name' => 'Weather temperature',
],
],
[
'entity_id' => 'sensor.modeco_2_temperature',
'state' => '57.0',
'attributes' => [
'friendly_name' => 'ModEco 2 Temperature',
],
],
],
'areas' => [
['area_id' => 'living_room', 'name' => 'Гостиная', 'icon' => 'mdi:sofa'],
['area_id' => 'kitchen', 'name' => 'Кухня', 'icon' => 'mdi:stove'],
],
'entity_registry' => [
['entity_id' => 'light.living_room_main', 'area_id' => 'living_room', 'labels' => ['auto']],
['entity_id' => 'switch.tv_power', 'area_id' => 'living_room', 'labels' => ['auto']],
['entity_id' => 'cover.living_room_curtain', 'area_id' => 'living_room', 'labels' => []],
['entity_id' => 'climate.living_room_ac', 'area_id' => 'living_room', 'labels' => []],
['entity_id' => 'light.kitchen_counter', 'area_id' => 'kitchen', 'labels' => ['auto']],
['entity_id' => 'switch.coffee_machine', 'area_id' => 'kitchen', 'labels' => ['auto']],
['entity_id' => 'weather.yandex_weather', 'area_id' => null, 'labels' => []],
['entity_id' => 'sensor.weather_temperature', 'area_id' => null, 'labels' => []],
['entity_id' => 'sensor.modeco_2_temperature', 'area_id' => null, 'labels' => []],
],
'device_registry' => [],
];
}
private function demoHistory(string $entityId, int $hours): array
{
if ($entityId === '') {
return [];
}
$now = time();
$start = $now - ($hours * 3600);
$steps = max(24, min(96, $hours * 4));
$points = [];
$base = 57.0;
for ($index = 0; $index <= $steps; $index++) {
$ratio = $steps > 0 ? ($index / $steps) : 1;
$timestamp = $start + (int)(($now - $start) * $ratio);
$drift = sin($ratio * M_PI * 5.5) * 0.8 + cos($ratio * M_PI * 11) * 0.35;
$value = $base + $drift;
$points[] = [
'entity_id' => $entityId,
'state' => number_format($value, 1, '.', ''),
'last_changed' => gmdate(DATE_ATOM, $timestamp),
'last_updated' => gmdate(DATE_ATOM, $timestamp),
];
}
return [$points];
}
}

4
lib/ha_sync.php Executable file
View File

@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
// Sync helpers are defined in lib/config.php to keep the PHP runtime self-contained.

3
repository.yaml Executable file
View File

@ -0,0 +1,3 @@
name: Wall Panel Add-ons
url: https://git.striker72rus.ru/PHP/wallpanell.git
maintainer: Striker72rus

19
run.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/sh
set -eu
DOCROOT="${WALL_PANEL_DOCROOT:-/app}"
PORT="${WALL_PANEL_PORT:-8099}"
CONFIG_PATH="${WALL_PANEL_CONFIG_PATH:-/config/config.json}"
STORAGE_DIR="${WALL_PANEL_STORAGE_DIR:-/config/storage}"
mkdir -p "$(dirname "$CONFIG_PATH")" "$STORAGE_DIR"
if [ ! -f "$CONFIG_PATH" ]; then
cp "${DOCROOT}/config/config.json" "$CONFIG_PATH"
fi
export WALL_PANEL_CONFIG_PATH="$CONFIG_PATH"
export WALL_PANEL_STORAGE_DIR="$STORAGE_DIR"
export WALL_PANEL_RUNTIME_MODE="addon"
exec php -S "0.0.0.0:${PORT}" -t "$DOCROOT"

Binary file not shown.

Binary file not shown.

Binary file not shown.

884
storage/battery_cache.json Executable file
View File

@ -0,0 +1,884 @@
{
"items": {
"sensor.garage_motion_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 100
},
{
"timestamp": 1773668185,
"value": 100
},
{
"timestamp": 1773668401,
"value": 100
},
{
"timestamp": 1773669214,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 100
},
"sensor.garage_light_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 100
},
{
"timestamp": 1773668185,
"value": 100
},
{
"timestamp": 1773668401,
"value": 100
},
{
"timestamp": 1773669214,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 100
},
"sensor.garage_door_motion_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 100
},
{
"timestamp": 1773668185,
"value": 100
},
{
"timestamp": 1773668401,
"value": 100
},
{
"timestamp": 1773669214,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 100
},
"sensor.stair_up_motion_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 100
},
{
"timestamp": 1773668185,
"value": 100
},
{
"timestamp": 1773668367,
"value": 100
},
{
"timestamp": 1773669214,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 100
},
"sensor.stair_down_motion_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 100
},
{
"timestamp": 1773668185,
"value": 100
},
{
"timestamp": 1773668401,
"value": 100
},
{
"timestamp": 1773669214,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 100
},
"sensor.stair_light_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 100
},
{
"timestamp": 1773668185,
"value": 100
},
{
"timestamp": 1773668401,
"value": 100
},
{
"timestamp": 1773669214,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 100
},
"sensor.door_sensor_2_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 90
},
{
"timestamp": 1773668166,
"value": 90
},
{
"timestamp": 1773668401,
"value": 90
},
{
"timestamp": 1773669214,
"value": 90
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 90
},
"sensor.wleak_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 100
},
{
"timestamp": 1773668185,
"value": 100
},
{
"timestamp": 1773668401,
"value": 100
},
{
"timestamp": 1773669214,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 100
},
"sensor.0xa4c138433d675809_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 1
},
{
"timestamp": 1773668185,
"value": 1
},
{
"timestamp": 1773668401,
"value": 1
},
{
"timestamp": 1773669214,
"value": 1
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 1
},
"sensor.0xa4c138997cb4fdd1_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 100
},
{
"timestamp": 1773668158,
"value": 100
},
{
"timestamp": 1773668379,
"value": 100
},
{
"timestamp": 1773669193,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 100
},
"sensor.printer_knopka_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 74
},
{
"timestamp": 1773668185,
"value": 74
},
{
"timestamp": 1773668401,
"value": 74
},
{
"timestamp": 1773669214,
"value": 74
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 74
},
"sensor.lestnitsa_dvizhenie_2_etazh_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 100
},
{
"timestamp": 1773668185,
"value": 100
},
{
"timestamp": 1773668401,
"value": 100
},
{
"timestamp": 1773669214,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 100
},
"sensor.spalnia_knopka_girliand_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 29
},
{
"timestamp": 1773668185,
"value": 29
},
{
"timestamp": 1773668401,
"value": 29
},
{
"timestamp": 1773669214,
"value": 29
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 29
},
"sensor.ulitsa_temperatura_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 63
},
{
"timestamp": 1773668185,
"value": 63
},
{
"timestamp": 1773668401,
"value": 63
},
{
"timestamp": 1773669214,
"value": 63
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 62
},
"sensor.0x44e2f8fffeb65d8e_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 55
},
{
"timestamp": 1773664955,
"value": 60
},
{
"timestamp": 1773664975,
"value": 50
},
{
"timestamp": 1773668185,
"value": 50
},
{
"timestamp": 1773668401,
"value": 50
},
{
"timestamp": 1773669214,
"value": 50
},
{
"timestamp": 1773680612,
"value": 60
},
{
"timestamp": 1773680632,
"value": 55
},
{
"timestamp": 1773726922,
"value": 60
},
{
"timestamp": 1773726944,
"value": 50
},
{
"timestamp": 1773730991,
"value": 60
},
{
"timestamp": 1773739180,
"value": 55
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0.1639,
"forecast_reason": "Заряд не падает",
"percent": 45
},
"sensor.0x54ef4410009a6a11_battery": {
"loaded_at": 1774247175,
"history_hours": 4320,
"points": [
{
"timestamp": 1773642375,
"value": 95
},
{
"timestamp": 1773646522,
"value": 95
},
{
"timestamp": 1773647796,
"value": 95
},
{
"timestamp": 1773648329,
"value": 93
},
{
"timestamp": 1773651401,
"value": 94
},
{
"timestamp": 1773654742,
"value": 95
},
{
"timestamp": 1773663946,
"value": 94
},
{
"timestamp": 1773668185,
"value": 94
},
{
"timestamp": 1773668401,
"value": 94
},
{
"timestamp": 1773669214,
"value": 94
},
{
"timestamp": 1773673180,
"value": 95
},
{
"timestamp": 1773680014,
"value": 93
},
{
"timestamp": 1773683229,
"value": 95
},
{
"timestamp": 1773686431,
"value": 96
},
{
"timestamp": 1773689837,
"value": 93
},
{
"timestamp": 1773692920,
"value": 91
},
{
"timestamp": 1773696266,
"value": 93
},
{
"timestamp": 1773699456,
"value": 94
},
{
"timestamp": 1773702777,
"value": 96
},
{
"timestamp": 1773706218,
"value": 95
},
{
"timestamp": 1773709493,
"value": 91
},
{
"timestamp": 1773712864,
"value": 92
},
{
"timestamp": 1773716073,
"value": 95
},
{
"timestamp": 1773719369,
"value": 93
}
],
"forecast_minutes_left": 85442,
"forecast_text": "≈ 59д 8ч до разряда",
"forecast_slope_per_hour": -0.0646,
"forecast_reason": null,
"percent": 92
},
"sensor.0x00124b0035558456_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 82
},
{
"timestamp": 1773668185,
"value": 82
},
{
"timestamp": 1773668401,
"value": 82
},
{
"timestamp": 1773669214,
"value": 82
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 82
},
"sensor.0xa4c13874f5fdfd2a_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 91.5
},
{
"timestamp": 1773668185,
"value": 91.5
},
{
"timestamp": 1773668401,
"value": 91.5
},
{
"timestamp": 1773669204,
"value": 91.5
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 93.5
},
"sensor.0x54ef44100119db20_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 100
},
{
"timestamp": 1773668185,
"value": 100
},
{
"timestamp": 1773668401,
"value": 100
},
{
"timestamp": 1773669214,
"value": 100
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 100
},
"sensor.door_sensor_spalnya_battery": {
"loaded_at": 1774008710,
"history_hours": 4320,
"points": [],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": null,
"forecast_reason": "Недостаточно истории",
"percent": 100
},
"sensor.0x0ceff6fffe6cffc4_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 50
},
{
"timestamp": 1773664975,
"value": 45
},
{
"timestamp": 1773668185,
"value": 45
},
{
"timestamp": 1773668401,
"value": 45
},
{
"timestamp": 1773669214,
"value": 45
},
{
"timestamp": 1773680611,
"value": 50
},
{
"timestamp": 1773680632,
"value": 45
},
{
"timestamp": 1773730991,
"value": 50
},
{
"timestamp": 1773731013,
"value": 45
},
{
"timestamp": 1773735636,
"value": 50
},
{
"timestamp": 1773735657,
"value": 45
},
{
"timestamp": 1773739159,
"value": 50
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0.0887,
"forecast_reason": "Заряд не падает",
"percent": 55
},
"sensor.0x0ceff6fffe6cdee0_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 0
},
{
"timestamp": 1773668185,
"value": 0
},
{
"timestamp": 1773668401,
"value": 0
},
{
"timestamp": 1773669214,
"value": 0
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 60
},
"sensor.0x705464fffe43dee0_battery": {
"loaded_at": 1774247175,
"history_hours": 4320,
"points": [
{
"timestamp": 1773642375,
"value": 45
},
{
"timestamp": 1773646522,
"value": 45
},
{
"timestamp": 1773647796,
"value": 45
},
{
"timestamp": 1773668185,
"value": 45
},
{
"timestamp": 1773668401,
"value": 45
},
{
"timestamp": 1773669214,
"value": 45
},
{
"timestamp": 1773726943,
"value": 40
}
],
"forecast_minutes_left": 9734,
"forecast_text": "≈ 6д 18ч до разряда",
"forecast_slope_per_hour": -0.2157,
"forecast_reason": null,
"percent": 35
},
"sensor.0xa4c138259d164c22_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 88.5
},
{
"timestamp": 1773668171,
"value": 88.5
},
{
"timestamp": 1773668401,
"value": 88.5
},
{
"timestamp": 1773669214,
"value": 88.5
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 87
},
"sensor.0xa4c138fe1cdd2a21_battery": {
"loaded_at": 1774247175,
"history_hours": 4320,
"points": [
{
"timestamp": 1773642375,
"value": 87.5
},
{
"timestamp": 1773646492,
"value": 87.5
},
{
"timestamp": 1773647796,
"value": 87.5
},
{
"timestamp": 1773668172,
"value": 87.5
},
{
"timestamp": 1773668401,
"value": 87.5
},
{
"timestamp": 1773669214,
"value": 87.5
},
{
"timestamp": 1773711258,
"value": 86
}
],
"forecast_minutes_left": 67706,
"forecast_text": "≈ 47д до разряда",
"forecast_slope_per_hour": -0.0753,
"forecast_reason": null,
"percent": 85
},
"sensor.spalnya_temp_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 3
},
{
"timestamp": 1773668185,
"value": 3
},
{
"timestamp": 1773668401,
"value": 3
},
{
"timestamp": 1773669214,
"value": 3
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": null,
"forecast_reason": "Батарея уже разряжена",
"percent": 0
},
"sensor.kukhnia_temperatura_battery": {
"loaded_at": 1774264856,
"history_hours": 4320,
"points": [
{
"timestamp": 1773660056,
"value": 90
},
{
"timestamp": 1773668185,
"value": 90
},
{
"timestamp": 1773668401,
"value": 90
},
{
"timestamp": 1773669214,
"value": 90
}
],
"forecast_minutes_left": null,
"forecast_text": null,
"forecast_slope_per_hour": 0,
"forecast_reason": "Нет заметного разряда",
"percent": 90
}
}
}

6
storage/popup_state.json Executable file
View File

@ -0,0 +1,6 @@
{
"active": false,
"sensor_entity_id": "binary_sensor.barn_all_occupancy",
"opened_at": 1774435265,
"expires_at": null
}

12
wall_panel/.dockerignore Executable file
View File

@ -0,0 +1,12 @@
.git
.gitignore
.DS_Store
ha-addon
custom_components
node_modules
storage/@eaDir
config/@eaDir
config/config.json
lib/@eaDir
assets/@eaDir
storage/*.json

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

19
wall_panel/Dockerfile Executable file
View File

@ -0,0 +1,19 @@
FROM php:8.2-cli-alpine
RUN apk add --no-cache curl-dev && docker-php-ext-install curl
WORKDIR /app
COPY index.php /app/index.php
COPY api.php /app/api.php
COPY favicon.ico /app/favicon.ico
COPY assets /app/assets
COPY lib /app/lib
COPY config/addon-default-config.json /app/config/config.json
COPY run.sh /run.sh
RUN chmod a+x /run.sh
EXPOSE 8099
CMD ["/run.sh"]

161
wall_panel/README.md Executable file
View File

@ -0,0 +1,161 @@
# Wall Panel
Таблет-ориентированная панель для Home Assistant на `PHP + HTML + JS`.
Основной HA-способ запуска теперь `Home Assistant Add-on` с ingress и отдельным портом.
## Запуск
```bash
php -S 0.0.0.0:8080
```
Откройте `http://localhost:8080`.
## Конфиг
Основной файл:
- [`config/config.json`](config/config.json)
В него кладутся:
- `home_assistant.base_url`
- `home_assistant.token`
- `camera.rtsp_url`
- `camera.stream_url`
- `camera.poster_url`
- `rooms`
Если `base_url` и `token` пустые, панель работает в demo mode с тестовыми карточками.
## Home Assistant Add-on
Для HA теперь рекомендован add-on-режим:
- запускает тот же PHP-интерфейс внутри Supervisor;
- открывается через ingress;
- доступен через отдельный порт;
- хранит конфиг в add-on volume;
- не зависит от `custom_components`.
Файлы add-on лежат в этой папке:
- [`config.yaml`](/Volumes/web/wallpanell/wall_panel/config.yaml)
- [`Dockerfile`](/Volumes/web/wallpanell/wall_panel/Dockerfile)
- [`run.sh`](/Volumes/web/wallpanell/wall_panel/run.sh)
### Как использовать
1. Добавьте Git URL репозитория в Home Assistant как add-on repository.
2. Установите add-on.
3. Запустите его.
4. Откройте панель через ingress или через проброшенный порт `8099/tcp`.
### Конфигурация
Add-on использует persistent config file:
- `/config/config.json` внутри контейнера
Этот файл является runtime source of truth для панели в HA-режиме и переживает рестарт add-on.
Частые настройки можно менять прямо из UI add-on в Home Assistant:
- `app.title`
- `app.poll_interval_ms`
- `app.main_room_name`
- `app.main_room_icon`
- `app.edit_mode`
- `app.battery_history_hours`
- `home_assistant.base_url`
- `home_assistant.token`
- `home_assistant.verify_ssl`
- `home_assistant.weather_entity_id`
- `camera.rtsp_url`
- `camera.stream_url`
- `camera.stream_mode`
- `camera.poster_url`
- `camera.popup_timeout_minutes`
Сложные структуры, вроде `rooms`, по-прежнему удобнее держать в JSON.
### Старый embed-режим
Отдельный PHP-доступ по-прежнему работает:
`http://YOUR_PANEL_HOST/index.php?embed=1`
Это полезно, если вы запускаете панель вне HA или хотите iframe-карточку вручную.
## Popup камеры
Для браузера нужен не прямой `rtsp://`, а bridge, который отдаёт `HLS` или `WebRTC`.
Popup открывается через endpoint:
```bash
POST /api.php?action=popup
{
"sensor_entity_id": "binary_sensor.doorbell_all_occupancy",
"state": "on"
}
```
Закрытие:
```bash
POST /api.php?action=popup
{
"sensor_entity_id": "binary_sensor.doorbell_all_occupancy",
"state": "off"
}
```
## Room overrides
Для комнаты можно сохранять overrides через:
```bash
POST /api.php?action=save-entity-override
{
"room_id": "living_room",
"entity_id": "light.living_room_main",
"visible": true,
"order": 10,
"card_type": "toggle",
"title": "Основной свет",
"icon": "mdi:ceiling-light"
}
```
## Empty room slots
Для desktop-раскладки можно создавать пустые слоты:
```bash
POST /api.php?action=create-room-layout-item
{
"room_id": "living_room"
}
```
Перемещение:
```bash
POST /api.php?action=save-room-layout-item
{
"room_id": "living_room",
"layout_item_id": "slot_xxx",
"order": 120
}
```
Удаление:
```bash
POST /api.php?action=delete-room-layout-item
{
"room_id": "living_room",
"layout_item_id": "slot_xxx"
}
```

254
wall_panel/api.php Executable file
View File

@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/lib/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
function api_json(array $payload, int $status = 200): never
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function api_input(): array
{
$raw = file_get_contents('php://input');
if ($raw === false || trim($raw) === '') {
return $_POST ?: [];
}
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
return $_POST ?: [];
}
function api_mirror_config_change(array $config, string $action, array $payload): array
{
$response = app_sync_remote_action($config, $action, $payload);
if (is_array($response) && is_array($response['config'] ?? null)) {
$config = app_merge_synced_config($config, $response['config']);
app_save_config($config);
}
return $config;
}
try {
$config = app_load_config();
$client = new HomeAssistantClient($config);
$action = strtolower((string)($_GET['action'] ?? 'snapshot'));
if ($action === 'bootstrap') {
api_json(app_build_snapshot($config, $client, 'main'));
}
if ($action === 'snapshot') {
$spaceId = (string)($_GET['space_id'] ?? ($_GET['room_id'] ?? 'main'));
api_json(app_build_snapshot($config, $client, $spaceId));
}
if ($action === 'history') {
$entityId = trim((string)($_GET['entity_id'] ?? ''));
$hours = max(1, (int)($_GET['hours'] ?? 24));
if ($entityId === '') {
api_json(['ok' => false, 'error' => 'entity_id is required'], 400);
}
api_json([
'ok' => true,
'entity_id' => $entityId,
'hours' => $hours,
'history' => $client->fetchEntityHistory($entityId, $hours),
]);
}
if ($action === 'service') {
$payload = api_input();
$entityId = trim((string)($payload['entity_id'] ?? ''));
$command = trim((string)($payload['command'] ?? 'toggle'));
$value = $payload['value'] ?? null;
if ($entityId === '') {
api_json(['ok' => false, 'error' => 'entity_id is required'], 400);
}
[$domain, $service, $serviceData] = app_service_for_entity($entityId, $command);
if ($command === 'set_temperature' && $value !== null) {
$serviceData['temperature'] = $value;
}
if ($command === 'set_hvac_mode' && $value !== null) {
$serviceData['hvac_mode'] = $value;
}
if ($command === 'set_fan_mode' && $value !== null) {
$serviceData['fan_mode'] = $value;
}
if ($command === 'set_swing_mode' && $value !== null) {
$serviceData['swing_mode'] = $value;
}
if ($command === 'set_preset_mode' && $value !== null) {
$serviceData['preset_mode'] = $value;
}
if ($command === 'set_position' && $value !== null) {
$serviceData['position'] = $value;
}
$result = $client->callService($domain, $service, $serviceData);
api_json(['ok' => true, 'result' => $result]);
}
if ($action === 'save-entity-override') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
$entityId = trim((string)($payload['entity_id'] ?? ''));
if ($roomId === '' || $entityId === '') {
api_json(['ok' => false, 'error' => 'room_id and entity_id are required'], 400);
}
$patch = [
'visible' => array_key_exists('visible', $payload) ? (bool)$payload['visible'] : null,
'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null,
'card_type' => array_key_exists('card_type', $payload) ? (string)$payload['card_type'] : null,
'title' => array_key_exists('title', $payload) ? (string)$payload['title'] : null,
'icon' => array_key_exists('icon', $payload) ? (string)$payload['icon'] : null,
];
$config = app_update_entity_override($config, $roomId, $entityId, $patch);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'save-space-override') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
if ($roomId === '') {
api_json(['ok' => false, 'error' => 'room_id is required'], 400);
}
$patch = [
'visible' => array_key_exists('visible', $payload) ? (bool)$payload['visible'] : null,
'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null,
'name' => array_key_exists('name', $payload) ? (string)$payload['name'] : null,
'icon' => array_key_exists('icon', $payload) ? (string)$payload['icon'] : null,
];
if (array_key_exists('temperature_sensor_entity_id', $payload)) {
$patch['temperature_sensor_entity_id'] = (string)$payload['temperature_sensor_entity_id'];
}
$config = app_update_room_override($config, $roomId, $patch);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'create-room-layout-item') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
if ($roomId === '') {
api_json(['ok' => false, 'error' => 'room_id is required'], 400);
}
$layoutItemId = trim((string)($payload['layout_item_id'] ?? ''));
if ($layoutItemId === '') {
$layoutItemId = 'slot_' . str_replace('.', '', uniqid('', true));
}
$order = array_key_exists('order', $payload) ? (int)$payload['order'] : null;
$config = app_update_room_layout_item($config, $roomId, $layoutItemId, [
'order' => $order,
]);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json([
'ok' => true,
'layout_item_id' => $layoutItemId,
'config' => ['rooms' => $config['rooms']],
]);
}
if ($action === 'save-room-layout-item') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
$layoutItemId = trim((string)($payload['layout_item_id'] ?? ''));
if ($roomId === '' || $layoutItemId === '') {
api_json(['ok' => false, 'error' => 'room_id and layout_item_id are required'], 400);
}
$patch = [
'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null,
];
$config = app_update_room_layout_item($config, $roomId, $layoutItemId, $patch);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'delete-room-layout-item') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
$layoutItemId = trim((string)($payload['layout_item_id'] ?? ''));
if ($roomId === '' || $layoutItemId === '') {
api_json(['ok' => false, 'error' => 'room_id and layout_item_id are required'], 400);
}
$config = app_delete_room_layout_item($config, $roomId, $layoutItemId);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'reorder-room-grid') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
$entries = $payload['entries'] ?? [];
if ($roomId === '' || !is_array($entries)) {
api_json(['ok' => false, 'error' => 'room_id and entries are required'], 400);
}
$config = app_reorder_room_grid($config, $roomId, $entries);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'save-settings') {
$payload = api_input();
if (array_key_exists('edit_mode', $payload)) {
$config['app']['edit_mode'] = (bool)$payload['edit_mode'];
}
if (array_key_exists('title', $payload) && trim((string)$payload['title']) !== '') {
$config['app']['title'] = trim((string)$payload['title']);
}
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'settings' => $config['app']]);
}
if ($action === 'popup') {
$payload = api_input();
$popup = app_handle_popup_event($config, $payload);
api_json(['ok' => true, 'popup' => $popup]);
}
api_json(['ok' => false, 'error' => 'Unknown action'], 404);
} catch (Throwable $e) {
api_json([
'ok' => false,
'error' => $e->getMessage(),
], 500);
}

Binary file not shown.

Binary file not shown.

2540
wall_panel/assets/app.css Executable file

File diff suppressed because it is too large Load Diff

4553
wall_panel/assets/app.js Executable file

File diff suppressed because it is too large Load Diff

62
wall_panel/config.yaml Executable file
View File

@ -0,0 +1,62 @@
name: Wall Panel
description: Wall Panel PHP interface as a Home Assistant add-on
version: "1.0.1"
slug: wall_panel
url: https://git.striker72rus.ru/PHP/wallpanell.git
init: false
arch:
- aarch64
- amd64
- armhf
- armv7
- i386
startup: services
ingress: true
ingress_port: 8099
panel_title: Wall Panel
panel_icon: mdi:view-dashboard
ports:
8099/tcp: 8099
ports_description:
8099/tcp: Wall Panel web UI
map:
- addon_config:rw
homeassistant_api: true
options:
app:
title: Wall Panel
poll_interval_ms: 5000
main_room_name: Главная
main_room_icon: mdi:home
edit_mode: false
battery_history_hours: 4320
home_assistant:
base_url: ""
token: ""
verify_ssl: true
weather_entity_id: ""
camera:
rtsp_url: rtsp://10.0.6.110:45321/feff99fa45f317e7
stream_url: ""
stream_mode: hls
poster_url: http://10.0.6.110:5000/api/doorbell/latest.jpg
popup_timeout_minutes: 3
schema:
app:
title: str
poll_interval_ms: int
main_room_name: str
main_room_icon: str
edit_mode: bool
battery_history_hours: int
home_assistant:
base_url: str
token: str
verify_ssl: bool
weather_entity_id: str
camera:
rtsp_url: str
stream_url: str
stream_mode: str
poster_url: str
popup_timeout_minutes: int

View File

@ -0,0 +1,32 @@
{
"app": {
"title": "Wall Panel",
"poll_interval_ms": 5000,
"main_room_name": "Главная",
"main_room_icon": "mdi:home",
"edit_mode": false,
"battery_history_hours": 4320
},
"home_assistant": {
"base_url": "",
"token": "",
"verify_ssl": true,
"sync_url": "",
"sync_token": "",
"sync_timeout": 10,
"sync_verify_ssl": true,
"sync_cache_seconds": 30,
"weather_entity_id": "",
"auto_label": "auto",
"auto_entity_ids": []
},
"camera": {
"rtsp_url": "",
"stream_url": "",
"stream_mode": "hls",
"poster_url": "",
"popup_timeout_minutes": 3,
"trigger_entities": []
},
"rooms": []
}

BIN
wall_panel/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

164
wall_panel/index.php Executable file
View File

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/lib/bootstrap.php';
function app_is_embed_request(): bool
{
$value = strtolower(trim((string)($_GET['embed'] ?? '')));
if (in_array($value, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
$mode = strtolower(trim((string)($_GET['mode'] ?? '')));
return in_array($mode, ['embed', 'panel', 'lovelace', 'ha'], true);
}
$config = app_load_config();
$client = new HomeAssistantClient($config);
$bootstrap = app_build_snapshot($config, $client, 'main');
$embedMode = app_is_embed_request();
$runtimeMode = trim((string)getenv('WALL_PANEL_RUNTIME_MODE'));
if ($runtimeMode === '') {
$runtimeMode = $embedMode ? 'ha' : 'standalone';
}
$bootstrap['ui'] = [
'embed' => $embedMode,
'mode' => $runtimeMode,
'shell' => $embedMode ? 'embed' : 'standalone',
'config_source' => app_remote_sync_enabled($config) ? 'ha' : 'file',
];
$appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0d0f14">
<title><?= $appTitle ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css">
<script src="https://home.striker72rus.ru/local/community/custom-brand-icons/custom-brand-icons.js" defer></script>
<script>
window.APP_BOOTSTRAP = <?= json_encode($bootstrap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
</script>
<link rel="stylesheet" href="assets/app.css?v=0.28">
<script src="assets/app.js?v=0.28" defer></script>
</head>
<body class="<?= $embedMode ? 'is-embedded' : '' ?>" data-ui-mode="<?= htmlspecialchars($runtimeMode, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
<div class="app-shell<?= $embedMode ? ' app-shell--embed' : '' ?>">
<aside class="sidebar">
<section class="clock-panel">
<div class="clock-panel__time" id="clock-time">--:--</div>
<div class="clock-panel__date" id="clock-date">---</div>
</section>
<section class="rooms-panel">
<div class="panel-header">
<div>
<div class="panel-header__label">Пространства</div>
<div class="panel-header__sub" id="rooms-count">0</div>
</div>
<button class="icon-button" id="edit-mode-toggle" type="button" aria-label="Edit mode">
<i class="mdi mdi-cog-outline"></i>
</button>
</div>
<div class="room-list" id="room-list"></div>
</section>
</aside>
<main class="content">
<div class="content-top" id="content-top">
<div class="main-print-strip-slot" id="main-print-strip-slot"></div>
</div>
<header class="content-header">
<button class="icon-button icon-button--ghost content-header__back" id="selected-room-back" type="button" aria-label="Back" hidden>
<i class="mdi mdi-arrow-left"></i>
</button>
<div>
<div class="content-header__eyebrow" id="selected-room-eyebrow"></div>
<h1 class="content-header__title" id="selected-room-title">Загрузка</h1>
<div class="content-header__meta" id="selected-room-meta"></div>
</div>
<div class="content-header__actions" id="selected-room-actions"></div>
</header>
<section class="dashboard-grid" id="dashboard-grid">
<div class="grid-surface" id="dashboard-surface">
<div class="loading-card">Загрузка панели...</div>
</div>
</section>
</main>
</div>
<div class="modal-backdrop" id="camera-modal" aria-hidden="true">
<div class="camera-modal" id="camera-modal-panel">
<button class="icon-button icon-button--ghost camera-modal__close" id="camera-modal-close" type="button" aria-label="Close">
<i class="mdi mdi-close"></i>
</button>
<div class="camera-modal__body">
<div class="camera-stage" id="camera-stage">
<img class="camera-stage__poster" id="camera-poster" alt="Camera poster">
<div class="camera-stage__placeholder" id="camera-placeholder">
<div class="camera-stage__placeholder-icon"><i class="mdi mdi-cctv"></i></div>
<div class="camera-stage__placeholder-title">Поток загружается</div>
<div class="camera-stage__placeholder-subtitle">Показываем poster, пока не доступен video bridge</div>
</div>
</div>
<div class="camera-modal__footer">
<div class="camera-modal__countdown" id="camera-countdown"></div>
</div>
</div>
</div>
</div>
<div class="modal-backdrop" id="entity-modal" aria-hidden="true">
<div class="entity-modal" id="entity-modal-panel" role="dialog" aria-modal="true" aria-labelledby="entity-modal-title">
<div class="entity-modal__header">
<div>
<div class="entity-modal__eyebrow" id="entity-modal-eyebrow"></div>
<div class="entity-modal__title" id="entity-modal-title">Устройство</div>
</div>
<button class="icon-button icon-button--ghost" id="entity-modal-close" type="button" aria-label="Close">
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="entity-modal__body" id="entity-modal-body"></div>
</div>
</div>
<div class="modal-backdrop" id="temperature-sensor-modal" aria-hidden="true">
<div class="temperature-sensor-modal" id="temperature-sensor-modal-panel" role="dialog" aria-modal="true" aria-labelledby="temperature-sensor-modal-title">
<div class="temperature-sensor-modal__header">
<div>
<div class="temperature-sensor-modal__eyebrow">Настройка комнаты</div>
<div class="temperature-sensor-modal__title" id="temperature-sensor-modal-title">Выбрать датчик температуры</div>
</div>
<button class="icon-button icon-button--ghost" id="temperature-sensor-modal-close" type="button" aria-label="Close">
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="temperature-sensor-modal__body" id="temperature-sensor-modal-body"></div>
</div>
</div>
<div class="modal-backdrop" id="confirm-modal" aria-hidden="true">
<div class="confirm-modal" id="confirm-modal-panel" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
<div class="confirm-modal__header">
<div>
<div class="confirm-modal__eyebrow">Подтверждение</div>
<div class="confirm-modal__title" id="confirm-modal-title">Хотите закрыть?</div>
</div>
</div>
<div class="confirm-modal__body" id="confirm-modal-message">Это действие отправит команду закрытия.</div>
<div class="confirm-modal__footer">
<button class="mushroom-button mushroom-button--small" id="confirm-modal-no" type="button">Нет</button>
<button class="mushroom-button mushroom-button--small is-on" id="confirm-modal-yes" type="button">Да</button>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

9
wall_panel/lib/bootstrap.php Executable file
View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
define('APP_ROOT', dirname(__DIR__));
require_once APP_ROOT . '/lib/config.php';
require_once APP_ROOT . '/lib/ha_client.php';
require_once APP_ROOT . '/lib/ha_sync.php';
require_once APP_ROOT . '/lib/dashboard.php';

333
wall_panel/lib/config.php Executable file
View File

@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
function app_default_config(): array
{
return [
'app' => [
'title' => 'Wall Panel',
'poll_interval_ms' => 5000,
'main_room_name' => 'Главная',
'main_room_icon' => 'mdi:home',
'edit_mode' => false,
'battery_history_hours' => 4320,
],
'home_assistant' => [
'base_url' => '',
'token' => '',
'verify_ssl' => true,
'sync_url' => '',
'sync_token' => '',
'sync_timeout' => 10,
'sync_verify_ssl' => true,
'sync_cache_seconds' => 30,
'weather_entity_id' => '',
'auto_label' => 'auto',
'auto_entity_ids' => [],
],
'camera' => [
'rtsp_url' => 'rtsp://10.0.6.110:45321/feff99fa45f317e7',
'stream_url' => '',
'stream_mode' => 'hls',
'poster_url' => 'http://10.0.6.110:5000/api/doorbell/latest.jpg',
'popup_timeout_minutes' => 3,
'trigger_entities' => [
'binary_sensor.door_all_occupancy',
'binary_sensor.barn_all_occupancy',
'binary_sensor.doorbell_all_occupancy',
],
],
'rooms' => [],
];
}
function app_config_path(): string
{
$override = trim((string)getenv('WALL_PANEL_CONFIG_PATH'));
if ($override !== '') {
return $override;
}
return APP_ROOT . '/config/config.json';
}
function app_storage_path(string $file): string
{
$override = trim((string)getenv('WALL_PANEL_STORAGE_DIR'));
$base = $override !== '' ? rtrim($override, '/') : APP_ROOT . '/storage';
return $base . '/' . ltrim($file, '/');
}
function app_ensure_directory(string $path): void
{
if (!is_dir($path)) {
mkdir($path, 0775, true);
}
}
function app_load_config(): array
{
$path = app_config_path();
app_ensure_directory(dirname($path));
$config = app_default_config();
$storedConfig = $config;
if (!file_exists($path)) {
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Failed to encode default config');
}
file_put_contents($path, $json . PHP_EOL, LOCK_EX);
} else {
$raw = file_get_contents($path);
if ($raw === false || trim($raw) !== '') {
$decoded = json_decode((string)$raw, true);
if (!is_array($decoded)) {
throw new RuntimeException('Invalid JSON in config/config.json');
}
$config = array_replace_recursive($config, $decoded);
$storedConfig = $config;
}
}
$optionsPath = app_addon_options_path();
if (is_file($optionsPath)) {
$options = app_load_json_file($optionsPath, []);
if (is_array($options) && $options !== []) {
$config = array_replace_recursive($config, $options);
if (app_runtime_mode() === 'addon' && $config !== $storedConfig) {
app_save_config($config);
}
}
}
return $config;
}
function app_save_config(array $config): void
{
$path = app_config_path();
app_ensure_directory(dirname($path));
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Failed to encode config');
}
file_put_contents($path, $json . PHP_EOL, LOCK_EX);
}
function app_load_json_file(string $path, array $fallback = []): array
{
if (!file_exists($path)) {
return $fallback;
}
$raw = file_get_contents($path);
if ($raw === false || trim($raw) === '') {
return $fallback;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : $fallback;
}
function app_save_json_file(string $path, array $data): void
{
app_ensure_directory(dirname($path));
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Failed to encode JSON');
}
file_put_contents($path, $json . PHP_EOL, LOCK_EX);
}
function app_remote_sync_enabled(array $config): bool
{
$syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? ''));
$syncToken = trim((string)($config['home_assistant']['sync_token'] ?? ''));
return $syncUrl !== '' && $syncToken !== '';
}
function app_remote_sync_cache_path(): string
{
return app_storage_path('ha_sync_cache.json');
}
function app_remote_sync_cache_ttl(array $config): int
{
return max(5, (int)($config['home_assistant']['sync_cache_seconds'] ?? 30));
}
function app_http_json_request(
string $method,
string $url,
array $headers = [],
?array $body = null,
int $timeout = 10,
bool $verifySsl = true,
bool $throwOnFailure = false
): array|null {
$ch = curl_init($url);
if ($ch === false) {
if ($throwOnFailure) {
throw new RuntimeException('Failed to initialize HTTP client');
}
return null;
}
$requestHeaders = array_merge([
'Accept: application/json',
], $headers);
if ($body !== null) {
$requestHeaders[] = 'Content-Type: application/json';
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HTTPHEADER => $requestHeaders,
CURLOPT_TIMEOUT => max(1, $timeout),
CURLOPT_CONNECTTIMEOUT => max(1, min(7, $timeout)),
CURLOPT_SSL_VERIFYPEER => $verifySsl,
CURLOPT_SSL_VERIFYHOST => $verifySsl ? 2 : 0,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno !== 0) {
if ($throwOnFailure) {
throw new RuntimeException('HTTP request failed: ' . $error);
}
return null;
}
if ($status >= 400) {
if ($throwOnFailure) {
throw new RuntimeException('HTTP request returned status ' . $status);
}
return null;
}
$decoded = json_decode((string)$raw, true);
return is_array($decoded) ? $decoded : [];
}
function app_merge_synced_config(array $baseConfig, array $syncedConfig): array
{
$syncFields = [
'sync_url' => (string)($baseConfig['home_assistant']['sync_url'] ?? ''),
'sync_token' => (string)($baseConfig['home_assistant']['sync_token'] ?? ''),
'sync_timeout' => (int)($baseConfig['home_assistant']['sync_timeout'] ?? 10),
'sync_verify_ssl' => (bool)($baseConfig['home_assistant']['sync_verify_ssl'] ?? true),
'sync_cache_seconds' => (int)($baseConfig['home_assistant']['sync_cache_seconds'] ?? 30),
];
$merged = array_replace_recursive($baseConfig, $syncedConfig);
foreach ($syncFields as $key => $value) {
$merged['home_assistant'][$key] = $value;
}
return $merged;
}
function app_load_remote_config(array $config, bool $refresh = false): array|null
{
if (!app_remote_sync_enabled($config)) {
return null;
}
$cachePath = app_remote_sync_cache_path();
$cache = app_load_json_file($cachePath, []);
$cachedConfig = is_array($cache['config'] ?? null) ? $cache['config'] : null;
$cachedAt = (int)($cache['fetched_at'] ?? 0);
$ttl = app_remote_sync_cache_ttl($config);
if (!$refresh && $cachedConfig !== null && $cachedAt > 0 && (time() - $cachedAt) < $ttl) {
return $cachedConfig;
}
$syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? ''));
$syncToken = trim((string)($config['home_assistant']['sync_token'] ?? ''));
$timeout = max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10));
$verifySsl = (bool)($config['home_assistant']['sync_verify_ssl'] ?? true);
$response = app_http_json_request('GET', $syncUrl, [
'X-Wall-Panel-Token: ' . $syncToken,
], null, $timeout, $verifySsl, false);
$remoteConfig = null;
if (is_array($response)) {
if (is_array($response['config'] ?? null)) {
$remoteConfig = $response['config'];
} elseif (!isset($response['ok']) || (bool)$response['ok'] !== false) {
$remoteConfig = $response;
}
}
if (is_array($remoteConfig)) {
app_save_json_file($cachePath, [
'fetched_at' => time(),
'config' => $remoteConfig,
]);
return $remoteConfig;
}
if ($cachedConfig !== null) {
return $cachedConfig;
}
return null;
}
function app_clear_remote_config_cache(): void
{
$path = app_remote_sync_cache_path();
if (file_exists($path)) {
@unlink($path);
}
}
function app_sync_remote_action(array $config, string $action, array $payload): array|null
{
if (!app_remote_sync_enabled($config)) {
return null;
}
$syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? ''));
$syncToken = trim((string)($config['home_assistant']['sync_token'] ?? ''));
$timeout = max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10));
$verifySsl = (bool)($config['home_assistant']['sync_verify_ssl'] ?? true);
$response = app_http_json_request('POST', $syncUrl, [
'X-Wall-Panel-Token: ' . $syncToken,
], [
'action' => $action,
'payload' => $payload,
], $timeout, $verifySsl, false);
if (!is_array($response)) {
return null;
}
if (is_array($response['config'] ?? null)) {
app_save_json_file(app_remote_sync_cache_path(), [
'fetched_at' => time(),
'config' => $response['config'],
]);
}
return $response;
}

2039
wall_panel/lib/dashboard.php Executable file

File diff suppressed because it is too large Load Diff

658
wall_panel/lib/ha_client.php Executable file
View File

@ -0,0 +1,658 @@
<?php
declare(strict_types=1);
final class HomeAssistantClient
{
private array $config;
private bool $demoMode;
public function __construct(array $config)
{
$this->config = $config;
$baseUrl = trim((string)($config['home_assistant']['base_url'] ?? ''));
$token = trim((string)($config['home_assistant']['token'] ?? ''));
$this->demoMode = $baseUrl === '' || $token === '' || str_contains($baseUrl, 'example.com') || str_contains($token, 'PASTE_') || str_contains($token, 'YOUR_');
}
public function isDemo(): bool
{
return $this->demoMode;
}
public function fetchSnapshotData(): array
{
if ($this->demoMode) {
return $this->demoData();
}
$states = $this->request('GET', '/api/states');
$areas = $this->fetchWsList('config/area_registry/list') ?? $this->tryRequest('GET', '/api/areas') ?? [];
$floors = $this->fetchWsList('config/floor_registry/list') ?? [];
$entities = $this->fetchWsList('config/entity_registry/list_for_display') ?? $this->tryRequest('GET', '/api/config/entity_registry/list') ?? [];
$devices = $this->fetchWsList('config/device_registry/list') ?? $this->tryRequest('GET', '/api/config/device_registry/list') ?? [];
return [
'states' => is_array($states) ? $states : [],
'areas' => is_array($areas) ? $areas : [],
'floors' => is_array($floors) ? $floors : [],
'entity_registry' => is_array($entities) ? $entities : [],
'device_registry' => is_array($devices) ? $devices : [],
];
}
public function fetchEntityHistory(string $entityId, int $hours = 24): array
{
$entityId = trim($entityId);
$hours = max(1, min(168, $hours));
if ($this->demoMode) {
return $this->demoHistory($entityId, $hours);
}
if ($entityId === '') {
return [];
}
$start = (new DateTimeImmutable('now', new DateTimeZone('UTC')))
->modify('-' . $hours . ' hours')
->format(DATE_ATOM);
$path = '/api/history/period/' . rawurlencode($start) . '?' . http_build_query([
'filter_entity_id' => $entityId,
'minimal_response' => 1,
'no_attributes' => 1,
]);
$response = $this->request('GET', $path);
return is_array($response) ? $response : [];
}
public function callService(string $domain, string $service, array $data): array
{
if ($this->demoMode) {
return [
'ok' => true,
'demo' => true,
'domain' => $domain,
'service' => $service,
'data' => $data,
];
}
return $this->request('POST', '/api/services/' . rawurlencode($domain) . '/' . rawurlencode($service), $data);
}
private function request(string $method, string $path, ?array $body = null): array
{
$response = $this->tryRequest($method, $path, $body, true);
if (!is_array($response)) {
throw new RuntimeException('Unexpected response from Home Assistant');
}
return $response;
}
private function tryRequest(string $method, string $path, ?array $body = null, bool $throwOnHttpError = false): array|null
{
$baseUrl = rtrim((string)$this->config['home_assistant']['base_url'], '/');
$url = $baseUrl . $path;
$token = trim((string)$this->config['home_assistant']['token']);
$ch = curl_init($url);
if ($ch === false) {
throw new RuntimeException('Failed to initialize HTTP client');
}
$headers = [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
'Accept: application/json',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 15,
CURLOPT_CONNECTTIMEOUT => 7,
CURLOPT_SSL_VERIFYPEER => (bool)($this->config['home_assistant']['verify_ssl'] ?? true),
CURLOPT_SSL_VERIFYHOST => (bool)($this->config['home_assistant']['verify_ssl'] ?? true) ? 2 : 0,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno !== 0) {
if ($throwOnHttpError) {
throw new RuntimeException('Home Assistant request failed: ' . $error);
}
return null;
}
if ($status >= 400) {
if ($throwOnHttpError) {
throw new RuntimeException('Home Assistant returned HTTP ' . $status);
}
return null;
}
$decoded = json_decode((string)$raw, true);
return is_array($decoded) ? $decoded : [];
}
private function fetchWsList(string $type): array|null
{
$response = $this->wsCall($type);
if (!is_array($response)) {
return null;
}
if (isset($response['result']) && is_array($response['result'])) {
return $this->normalizeWsResultList($type, $response['result']);
}
return null;
}
private function normalizeWsResultList(string $type, array $result): array
{
if (array_is_list($result)) {
return match ($type) {
'config/entity_registry/list_for_display' => $this->normalizeEntityRegistryForDisplay($result),
'config/area_registry/list' => $this->normalizeAreaRegistry($result),
'config/floor_registry/list' => $this->normalizeFloorRegistry($result),
'config/device_registry/list' => $this->normalizeDeviceRegistryForDisplay($result),
default => $result,
};
}
return match ($type) {
'config/entity_registry/list_for_display' => $this->normalizeEntityRegistryForDisplay($result['entities'] ?? []),
'config/area_registry/list' => $this->normalizeAreaRegistry($result['areas'] ?? []),
'config/floor_registry/list' => $this->normalizeFloorRegistry($result['floors'] ?? []),
'config/device_registry/list' => $this->normalizeDeviceRegistryForDisplay($result['devices'] ?? $result['device_registry'] ?? $result['items'] ?? []),
default => $result,
};
}
private function wsCall(string $type): array|null
{
$baseUrl = rtrim((string)$this->config['home_assistant']['base_url'], '/');
$parts = parse_url($baseUrl);
if (!is_array($parts) || empty($parts['host'])) {
return null;
}
$scheme = strtolower((string)($parts['scheme'] ?? 'http'));
$secure = in_array($scheme, ['https', 'wss'], true);
$host = (string)$parts['host'];
$port = (int)($parts['port'] ?? ($secure ? 443 : 80));
$path = '/api/websocket';
$target = ($secure ? 'tls' : 'tcp') . '://' . $host . ':' . $port;
$contextOptions = [
'ssl' => [
'verify_peer' => (bool)($this->config['home_assistant']['verify_ssl'] ?? true),
'verify_peer_name' => (bool)($this->config['home_assistant']['verify_ssl'] ?? true),
'allow_self_signed' => !(bool)($this->config['home_assistant']['verify_ssl'] ?? true),
'SNI_enabled' => true,
'peer_name' => $host,
],
];
$stream = @stream_socket_client(
$target,
$errno,
$error,
15,
STREAM_CLIENT_CONNECT,
stream_context_create($contextOptions)
);
if ($stream === false) {
return null;
}
stream_set_timeout($stream, 15);
$key = base64_encode(random_bytes(16));
$request = implode("\r\n", [
'GET ' . $path . ' HTTP/1.1',
'Host: ' . $host . ':' . $port,
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Key: ' . $key,
'Sec-WebSocket-Version: 13',
'',
'',
]);
fwrite($stream, $request);
$status = $this->readHttpHeader($stream);
if ($status === null || !str_contains($status, ' 101 ')) {
fclose($stream);
return null;
}
$auth = $this->readWsJson($stream);
if (!is_array($auth) || ($auth['type'] ?? '') !== 'auth_required') {
fclose($stream);
return null;
}
$this->writeWsJson($stream, [
'type' => 'auth',
'access_token' => (string)$this->config['home_assistant']['token'],
]);
$authResult = $this->readWsJson($stream);
if (!is_array($authResult) || ($authResult['type'] ?? '') !== 'auth_ok') {
fclose($stream);
return null;
}
$requestId = random_int(1, 1_000_000);
$this->writeWsJson($stream, [
'id' => $requestId,
'type' => $type,
]);
while (($message = $this->readWsJson($stream)) !== null) {
if (($message['id'] ?? null) === $requestId) {
fclose($stream);
if (($message['success'] ?? false) !== true) {
return null;
}
return is_array($message) ? $message : null;
}
}
fclose($stream);
return null;
}
private function normalizeEntityRegistryForDisplay(array $result): array
{
$entityCategories = is_array($result['entity_categories'] ?? null) ? $result['entity_categories'] : [];
$entities = $result;
if (!array_is_list($entities)) {
$entities = $result['entities'] ?? [];
}
if (!is_array($entities)) {
return [];
}
$normalized = [];
foreach ($entities as $entity) {
if (!is_array($entity)) {
continue;
}
$normalized[] = [
'entity_id' => $entity['ei'] ?? null,
'area_id' => $entity['ai'] ?? null,
'labels' => $this->normalizeWsLabels($entity['lb'] ?? null),
'device_id' => $entity['di'] ?? null,
'icon' => $entity['ic'] ?? null,
'translation_key' => $entity['tk'] ?? null,
'entity_category' => isset($entity['ec']) ? ($entityCategories[$entity['ec']] ?? null) : null,
'hidden_by' => !empty($entity['hb']) ? 'true' : null,
'name' => $entity['en'] ?? null,
'has_entity_name' => !empty($entity['hn']),
'platform' => $entity['pl'] ?? null,
];
}
return $normalized;
}
private function normalizeAreaRegistry(array $areas): array
{
$normalized = [];
foreach ($areas as $area) {
if (!is_array($area)) {
continue;
}
$normalized[] = [
'area_id' => $area['area_id'] ?? $area['id'] ?? null,
'name' => $area['name'] ?? $area['en'] ?? null,
'icon' => $area['icon'] ?? $area['ic'] ?? null,
'picture' => $area['picture'] ?? $area['pi'] ?? null,
'floor_id' => $area['floor_id'] ?? $area['fi'] ?? $area['floor'] ?? null,
];
}
return $normalized;
}
private function normalizeFloorRegistry(array $floors): array
{
$normalized = [];
foreach ($floors as $floor) {
if (!is_array($floor)) {
continue;
}
$normalized[] = [
'floor_id' => $floor['floor_id'] ?? $floor['id'] ?? null,
'name' => $floor['name'] ?? $floor['en'] ?? null,
'icon' => $floor['icon'] ?? $floor['ic'] ?? null,
'level' => $floor['level'] ?? $floor['lv'] ?? null,
];
}
return $normalized;
}
private function normalizeDeviceRegistryForDisplay(array $devices): array
{
$normalized = [];
foreach ($devices as $device) {
if (!is_array($device)) {
continue;
}
$normalized[] = [
'device_id' => $device['device_id'] ?? $device['id'] ?? $device['di'] ?? null,
'area_id' => $device['area_id'] ?? $device['ai'] ?? null,
'labels' => $this->normalizeWsLabels($device['labels'] ?? null),
'name' => $device['name'] ?? $device['en'] ?? null,
'manufacturer' => $device['manufacturer'] ?? $device['mf'] ?? null,
'model' => $device['model'] ?? $device['md'] ?? null,
];
}
return $normalized;
}
private function normalizeWsLabels(mixed $labels): array
{
if ($labels === null) {
return [];
}
if (is_string($labels) || is_numeric($labels)) {
return [(string)$labels];
}
if (!is_array($labels)) {
return [];
}
return array_values(array_unique(array_map('strval', $labels)));
}
private function readHttpHeader($stream): ?string
{
$buffer = '';
while (!feof($stream)) {
$chunk = fgets($stream);
if ($chunk === false) {
break;
}
$buffer .= $chunk;
if (str_contains($buffer, "\r\n\r\n")) {
break;
}
}
return $buffer !== '' ? $buffer : null;
}
private function writeWsJson($stream, array $payload): void
{
fwrite($stream, $this->encodeWsFrame(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'));
}
private function readWsJson($stream): array|null
{
$payload = $this->readWsFrame($stream);
if ($payload === null || $payload === '') {
return null;
}
$decoded = json_decode($payload, true);
return is_array($decoded) ? $decoded : null;
}
private function encodeWsFrame(string $payload): string
{
$finOpcode = chr(0x81);
$len = strlen($payload);
$maskBit = 0x80;
$mask = random_bytes(4);
$header = '';
if ($len <= 125) {
$header = chr($maskBit | $len);
} elseif ($len <= 65535) {
$header = chr($maskBit | 126) . pack('n', $len);
} else {
$header = chr($maskBit | 127) . pack('J', $len);
}
$masked = '';
for ($i = 0; $i < $len; $i++) {
$masked .= $payload[$i] ^ $mask[$i % 4];
}
return $finOpcode . $header . $mask . $masked;
}
private function readWsFrame($stream): ?string
{
$first = fread($stream, 2);
if ($first === false || strlen($first) < 2) {
return null;
}
$bytes = array_values(unpack('C2', $first));
$opcode = $bytes[0] & 0x0f;
$isMasked = (bool)($bytes[1] & 0x80);
$len = $bytes[1] & 0x7f;
if ($len === 126) {
$extended = fread($stream, 2);
if ($extended === false || strlen($extended) < 2) {
return null;
}
$len = unpack('n', $extended)[1];
} elseif ($len === 127) {
$extended = fread($stream, 8);
if ($extended === false || strlen($extended) < 8) {
return null;
}
$parts = unpack('N2', $extended);
$len = ((int)$parts[1] << 32) | (int)$parts[2];
}
$mask = '';
if ($isMasked) {
$mask = fread($stream, 4);
if ($mask === false || strlen($mask) < 4) {
return null;
}
}
$payload = '';
while (strlen($payload) < $len && !feof($stream)) {
$chunk = fread($stream, $len - strlen($payload));
if ($chunk === false || $chunk === '') {
break;
}
$payload .= $chunk;
}
if ($isMasked && $mask !== '') {
$unmasked = '';
for ($i = 0, $payloadLen = strlen($payload); $i < $payloadLen; $i++) {
$unmasked .= $payload[$i] ^ $mask[$i % 4];
}
$payload = $unmasked;
}
if ($opcode === 0x9) {
$this->writeWsPong($stream, $payload);
return $this->readWsFrame($stream);
}
if ($opcode === 0x8) {
return null;
}
return $payload;
}
private function writeWsPong($stream, string $payload): void
{
$len = strlen($payload);
$header = chr(0x8A);
if ($len <= 125) {
$header .= chr($len);
} elseif ($len <= 65535) {
$header .= chr(126) . pack('n', $len);
} else {
$header .= chr(127) . pack('J', $len);
}
fwrite($stream, $header . $payload);
}
private function demoData(): array
{
return [
'states' => [
[
'entity_id' => 'light.living_room_main',
'state' => 'on',
'attributes' => [
'friendly_name' => 'Основной свет',
'icon' => 'mdi:ceiling-light',
'labels' => ['auto'],
],
],
[
'entity_id' => 'switch.tv_power',
'state' => 'off',
'attributes' => [
'friendly_name' => 'ТВ',
'icon' => 'mdi:television',
'labels' => ['auto'],
],
],
[
'entity_id' => 'cover.living_room_curtain',
'state' => 'open',
'attributes' => [
'friendly_name' => 'Штора',
'icon' => 'mdi:curtains',
'current_position' => 82,
],
],
[
'entity_id' => 'climate.living_room_ac',
'state' => 'cool',
'attributes' => [
'friendly_name' => 'Кондиционер',
'icon' => 'mdi:air-conditioner',
'temperature' => 22,
'current_temperature' => 24.5,
'hvac_action' => 'cooling',
'fan_mode' => 'auto',
],
],
[
'entity_id' => 'light.kitchen_counter',
'state' => 'off',
'attributes' => [
'friendly_name' => 'Подсветка',
'icon' => 'mdi:lightbulb',
'labels' => ['auto'],
],
],
[
'entity_id' => 'switch.coffee_machine',
'state' => 'on',
'attributes' => [
'friendly_name' => 'Кофемашина',
'icon' => 'mdi:coffee-maker',
'labels' => ['auto'],
],
],
[
'entity_id' => 'weather.yandex_weather',
'state' => 'sunny',
'attributes' => [
'friendly_name' => 'Yandex Weather',
'temperature' => 18.3,
'humidity' => 56,
'wind_speed' => 4.8,
],
],
[
'entity_id' => 'sensor.weather_temperature',
'state' => '17.8',
'attributes' => [
'friendly_name' => 'Weather temperature',
],
],
[
'entity_id' => 'sensor.modeco_2_temperature',
'state' => '57.0',
'attributes' => [
'friendly_name' => 'ModEco 2 Temperature',
],
],
],
'areas' => [
['area_id' => 'living_room', 'name' => 'Гостиная', 'icon' => 'mdi:sofa'],
['area_id' => 'kitchen', 'name' => 'Кухня', 'icon' => 'mdi:stove'],
],
'entity_registry' => [
['entity_id' => 'light.living_room_main', 'area_id' => 'living_room', 'labels' => ['auto']],
['entity_id' => 'switch.tv_power', 'area_id' => 'living_room', 'labels' => ['auto']],
['entity_id' => 'cover.living_room_curtain', 'area_id' => 'living_room', 'labels' => []],
['entity_id' => 'climate.living_room_ac', 'area_id' => 'living_room', 'labels' => []],
['entity_id' => 'light.kitchen_counter', 'area_id' => 'kitchen', 'labels' => ['auto']],
['entity_id' => 'switch.coffee_machine', 'area_id' => 'kitchen', 'labels' => ['auto']],
['entity_id' => 'weather.yandex_weather', 'area_id' => null, 'labels' => []],
['entity_id' => 'sensor.weather_temperature', 'area_id' => null, 'labels' => []],
['entity_id' => 'sensor.modeco_2_temperature', 'area_id' => null, 'labels' => []],
],
'device_registry' => [],
];
}
private function demoHistory(string $entityId, int $hours): array
{
if ($entityId === '') {
return [];
}
$now = time();
$start = $now - ($hours * 3600);
$steps = max(24, min(96, $hours * 4));
$points = [];
$base = 57.0;
for ($index = 0; $index <= $steps; $index++) {
$ratio = $steps > 0 ? ($index / $steps) : 1;
$timestamp = $start + (int)(($now - $start) * $ratio);
$drift = sin($ratio * M_PI * 5.5) * 0.8 + cos($ratio * M_PI * 11) * 0.35;
$value = $base + $drift;
$points[] = [
'entity_id' => $entityId,
'state' => number_format($value, 1, '.', ''),
'last_changed' => gmdate(DATE_ATOM, $timestamp),
'last_updated' => gmdate(DATE_ATOM, $timestamp),
];
}
return [$points];
}
}

4
wall_panel/lib/ha_sync.php Executable file
View File

@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
// Sync helpers are defined in lib/config.php to keep the PHP runtime self-contained.

19
wall_panel/run.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/sh
set -eu
DOCROOT="${WALL_PANEL_DOCROOT:-/app}"
PORT="${WALL_PANEL_PORT:-8099}"
CONFIG_PATH="${WALL_PANEL_CONFIG_PATH:-/config/config.json}"
STORAGE_DIR="${WALL_PANEL_STORAGE_DIR:-/config/storage}"
mkdir -p "$(dirname "$CONFIG_PATH")" "$STORAGE_DIR"
if [ ! -f "$CONFIG_PATH" ]; then
cp "${DOCROOT}/config/config.json" "$CONFIG_PATH"
fi
export WALL_PANEL_CONFIG_PATH="$CONFIG_PATH"
export WALL_PANEL_STORAGE_DIR="$STORAGE_DIR"
export WALL_PANEL_RUNTIME_MODE="addon"
exec php -S "0.0.0.0:${PORT}" -t "$DOCROOT"

8
wallpanell.code-workspace Executable file
View File

@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}