Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions custom_components/switch_manager/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .store import SwitchManagerStore
from .helpers import load_blueprints, VERSION, deploy_blueprints, check_blueprints_folder_exists, _get_blueprint, _get_switch_config, _set_switch_config
from .view import async_setup_view, async_bind_blueprint_images
from homeassistant.components.frontend import async_remove_panel
from . import models
from .schema import BLUEPRINT_MQTT_SCHEMA, BLUEPRINT_EVENT_SCHEMA, SERVICE_SET_VARIABLES_SCHEMA
from .connections import async_setup_connections
Expand Down Expand Up @@ -77,6 +78,7 @@ async def async_unload_entry( hass: HomeAssistant, config_entry ):
"""Unload a config entry."""
for switch_id in hass.data[DOMAIN].get(CONF_MANAGED_SWITCHES, {}):
hass.data[DOMAIN][CONF_MANAGED_SWITCHES][switch_id].unload()
async_remove_panel(hass, "switch_manager", warn_if_unknown=False)
return True

async def async_migrate( hass, in_dev ):
Expand Down
671 changes: 608 additions & 63 deletions custom_components/switch_manager/assets/switch_manager_panel.js

Large diffs are not rendered by default.

60 changes: 58 additions & 2 deletions custom_components/switch_manager/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.script import Script

from .schema import SWITCH_MANAGER_CONFIG_SCHEMA
from .helpers import _get_blueprint, _get_switch_config, _remove_switch_config, _set_switch_config
from homeassistant.config import format_schema_error
from homeassistant.exceptions import HomeAssistantError

from .schema import BLUEPRINT_EVENT_SCHEMA, BLUEPRINT_MQTT_SCHEMA, BLUEPRINT_SAVE_SCHEMA, SWITCH_MANAGER_CONFIG_SCHEMA
from .helpers import _get_blueprint, _get_switch_config, _remove_switch_config, _set_switch_config, save_blueprint
from .const import DOMAIN, CONF_BLUEPRINTS, CONF_MANAGED_SWITCHES, CONF_STORE
from .models import ManagedSwitchConfig

Expand All @@ -33,6 +36,58 @@ async def websocket_blueprints(

connection.send_result( msg["id"], data )

@websocket_api.websocket_command({
vol.Required("type"): "switch_manager/blueprint/save",
vol.Required("payload"): BLUEPRINT_SAVE_SCHEMA
})
@websocket_api.async_response
async def websocket_save_blueprint(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
payload = msg["payload"]
raw_blueprint = payload["blueprint"]
try:
blueprint = (
BLUEPRINT_MQTT_SCHEMA(raw_blueprint)
if raw_blueprint.get("event_type") == "mqtt"
else BLUEPRINT_EVENT_SCHEMA(raw_blueprint)
)
if len(blueprint.get("buttons")) == 1:
button = blueprint.get("buttons")[0]
if button.get("x") or button.get("y") or button.get("width") or button.get("height") or button.get("d"):
raise HomeAssistantError("Single button blueprints must not define button shape geometry")

blueprint_id = await save_blueprint(
hass,
payload["id"],
blueprint,
payload.get("image"),
payload.get("overwrite", False),
)

from . import _init_blueprints
from .view import async_bind_blueprint_images

await _init_blueprints(hass)
await async_bind_blueprint_images(hass)
except vol.Invalid as ex:
connection.send_error(
msg["id"],
"invalid_blueprint",
format_schema_error(hass, ex, f"{DOMAIN} {CONF_BLUEPRINTS}({payload['id']})", raw_blueprint),
)
return
except HomeAssistantError as ex:
connection.send_error(msg["id"], "invalid_blueprint", str(ex))
return

connection.send_result(msg["id"], {
"blueprint_id": blueprint_id,
"blueprint": _get_blueprint(hass, blueprint_id)
})

@websocket_api.websocket_command({
vol.Required("type"): "switch_manager/blueprints/auto_discovery",
vol.Optional("blueprint_id"): cv.string
Expand Down Expand Up @@ -265,6 +320,7 @@ async def websocket_run_action(
websocket_api.async_register_command(hass, websocket_run_action)
websocket_api.async_register_command(hass, websocket_configs)
websocket_api.async_register_command(hass, websocket_blueprints)
websocket_api.async_register_command(hass, websocket_save_blueprint)
websocket_api.async_register_command(hass, websocket_blueprint_auto_discovery)
websocket_api.async_register_command(hass, websocket_monitor_config)
websocket_api.async_register_command(hass, websocket_save_config)
Expand Down
68 changes: 67 additions & 1 deletion custom_components/switch_manager/helpers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Helpers for switch_manager integration."""
import json, pathlib, os, shutil, enum
import base64, binascii, json, pathlib, os, re, shutil, enum
import yaml
from homeassistant.core import HomeAssistant
from annotatedyaml.loader import load_yaml
from .const import (
Expand All @@ -18,6 +19,10 @@
MANIFEST = json.load(f)

VERSION = MANIFEST['version']
PNG_SIGNATURE = b'\x89PNG\r\n\x1a\n'
BLUEPRINT_ID_PATTERN = re.compile(r'^[a-z0-9][a-z0-9_-]*$')
MAX_BLUEPRINT_IMAGE_WIDTH = 800
MAX_BLUEPRINT_IMAGE_HEIGHT = 500

async def check_blueprints_folder_exists( hass ):
dest_folder = pathlib.Path(hass.config.path(BLUEPRINTS_FOLDER, DOMAIN))
Expand Down Expand Up @@ -83,6 +88,67 @@ def doFiles():
await hass.async_add_executor_job(doFiles)
return results

def _component_blueprint_exists( blueprint_id: str ):
component_blueprints_path = os.path.join( COMPONENT_PATH, 'blueprints' )
return os.path.exists(os.path.join(component_blueprints_path, f"{blueprint_id}.yaml"))

def _decode_blueprint_png( image: str | None ):
if not image:
return None

if image.startswith("data:"):
header, sep, payload = image.partition(",")
if not sep or "image/png" not in header:
raise HomeAssistantError("Blueprint image must be a PNG data URL")
image = payload

try:
data = base64.b64decode(image, validate=True)
except (binascii.Error, ValueError) as ex:
raise HomeAssistantError("Blueprint image is not valid base64") from ex

if not data.startswith(PNG_SIGNATURE):
raise HomeAssistantError("Blueprint image must be a PNG")
if len(data) < 24:
raise HomeAssistantError("Blueprint image is not a valid PNG")

width = int.from_bytes(data[16:20], "big")
height = int.from_bytes(data[20:24], "big")
if width > MAX_BLUEPRINT_IMAGE_WIDTH or height > MAX_BLUEPRINT_IMAGE_HEIGHT:
raise HomeAssistantError(
f"Blueprint image must be at most {MAX_BLUEPRINT_IMAGE_WIDTH}px wide and "
f"{MAX_BLUEPRINT_IMAGE_HEIGHT}px tall"
)

return data

async def save_blueprint( hass, blueprint_id: str, blueprint: dict, image: str | None = None, overwrite: bool = False ):
if not BLUEPRINT_ID_PATTERN.match(blueprint_id):
raise HomeAssistantError("Blueprint id may only contain lowercase letters, numbers, dashes and underscores")

if _component_blueprint_exists(blueprint_id):
raise HomeAssistantError("Bundled blueprints cannot be overwritten from the editor")

dest_folder = pathlib.Path(hass.config.path(BLUEPRINTS_FOLDER, DOMAIN))
yaml_path = pathlib.Path(dest_folder, f"{blueprint_id}.yaml")
png_path = pathlib.Path(dest_folder, f"{blueprint_id}.png")
image_data = _decode_blueprint_png(image)

def doFiles():
os.makedirs(dest_folder, exist_ok=True)
if yaml_path.exists() and not overwrite:
raise HomeAssistantError("A blueprint with this id already exists")

with open(yaml_path, "w", encoding="utf-8") as f:
yaml.safe_dump(blueprint, f, sort_keys=False, allow_unicode=False)

if image_data is not None:
with open(png_path, "wb") as f:
f.write(image_data)

await hass.async_add_executor_job(doFiles)
return blueprint_id

def format_mqtt_message( message: ReceiveMessage):
try:
data = json.loads(message.payload)
Expand Down
3 changes: 2 additions & 1 deletion custom_components/switch_manager/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import time
from .const import DOMAIN, LOGGER
from .helpers import format_mqtt_message, get_val_from_str
from .helpers import format_mqtt_message, get_val_from_str, _component_blueprint_exists
from homeassistant.core import HomeAssistant, Context, callback
from homeassistant.helpers.script import Script, async_validate_actions_config
from homeassistant.helpers.condition import async_template as template_condition
Expand Down Expand Up @@ -57,6 +57,7 @@ def __init__(self, hass, _id: str, config: dict, has_image: bool):
self.id = str(_id)
self.name = config.get('name')
self.has_image = has_image
self.editable = not _component_blueprint_exists(_id)
self.service = config.get('service')
self.event_type = config.get('event_type')
self.is_mqtt = self.event_type == 'mqtt'
Expand Down
7 changes: 7 additions & 0 deletions custom_components/switch_manager/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@
vol.Optional('mqtt_topic_format'): cv.string,
vol.Optional('mqtt_sub_topics', default=False): cv.boolean
})
BLUEPRINT_SAVE_SCHEMA = vol.Schema({
vol.Required('id'): cv.string,
vol.Required('blueprint'): dict,
vol.Optional('image'): vol.Any(None, cv.string),
vol.Optional('overwrite', default=False): bool,
})

def _normalize_config_action(value):
"""Normalize raw HA action dicts into {mode, sequence} wrapper."""
if isinstance(value, dict) and 'sequence' not in value and ('action' in value or 'service' in value):
Expand Down
6 changes: 5 additions & 1 deletion custom_components/switch_manager/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from .const import DOMAIN, CONF_BLUEPRINTS, BLUEPRINTS_FOLDER, PANEL_URL, NAME
from .helpers import VERSION
from homeassistant.core import HomeAssistant
from homeassistant.components.frontend import async_register_built_in_panel
from homeassistant.components.frontend import async_register_built_in_panel, async_remove_panel
from homeassistant.components.http import StaticPathConfig

async def async_setup_view(hass: HomeAssistant):
Expand All @@ -18,6 +18,10 @@ async def async_setup_view(hass: HomeAssistant):
await hass.http.async_register_static_paths(staticJS)
await async_bind_blueprint_images(hass)

# Remove any previously registered panel so reloading the config entry
# doesn't fail with "Overwriting panel switch_manager".
async_remove_panel(hass, "switch_manager", warn_if_unknown=False)

async_register_built_in_panel(hass,
component_name="custom",
sidebar_title=NAME,
Expand Down
53 changes: 52 additions & 1 deletion frontend/src/dialogs/blueprint-selector.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { LitElement, html, css } from "lit";
import { LitElement, html, css, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import type { HomeAssistant, Blueprint, BlueprintsResponse } from "../types";
import { wsType, navigateTo, navigate, assetUrl } from "../helpers";
import "../switch-manager-dialog";

const mdiEdit = "M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z";

@customElement("switch-manager-dialog-blueprint-selector")
export class SwitchManagerDialogBlueprintSelector extends LitElement {
@state() private _params?: any;
Expand Down Expand Up @@ -60,6 +62,17 @@ export class SwitchManagerDialogBlueprintSelector extends LitElement {
@click=${() => this._selectBlueprint(bp)}
>
<div class="card-content">
${bp.editable
? html`
<button
class="edit-btn"
title="Edit blueprint"
@click=${(e: Event) => this._editBlueprint(e, bp)}
>
<ha-svg-icon .path=${mdiEdit}></ha-svg-icon>
</button>
`
: nothing}
<div class="image">
${bp.has_image
? html`<img src="${assetUrl(bp.id + ".png")}" />`
Expand All @@ -76,6 +89,7 @@ export class SwitchManagerDialogBlueprintSelector extends LitElement {
`
)}
</div>
<button slot="actions" @click=${this._createBlueprint}>Create Blueprint</button>
<button slot="actions" @click=${this.closeDialog}>Cancel</button>
</switch-manager-dialog>
`;
Expand All @@ -86,6 +100,18 @@ export class SwitchManagerDialogBlueprintSelector extends LitElement {
navigate(navigateTo(`new/${bp.id}`));
}

private _createBlueprint() {
this.closeDialog();
navigate(navigateTo("blueprint/new"));
}

private _editBlueprint(e: Event, bp: Blueprint) {
e.preventDefault();
e.stopPropagation();
this.closeDialog();
navigate(navigateTo(`blueprint/edit/${bp.id}`));
}

static styles = css`
.blueprints {
display: grid;
Expand All @@ -104,6 +130,31 @@ export class SwitchManagerDialogBlueprintSelector extends LitElement {
.card-content {
text-align: center;
padding: 8px;
position: relative;
}
.edit-btn {
position: absolute;
top: 4px;
right: 4px;
border: none;
background: var(--secondary-background-color);
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
z-index: 1;
}
.edit-btn:hover {
background: var(--primary-color);
color: var(--text-primary-color, #fff);
}
.edit-btn ha-svg-icon {
width: 18px;
height: 18px;
}
.image {
height: 80px;
Expand Down
Loading