New apps Added
This commit is contained in:
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,17 @@
|
||||
from homeassistant import config_entries # type: ignore[reportMissingImports]
|
||||
from .const import DOMAIN
|
||||
|
||||
class ThermostatTimelineConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[misc]
|
||||
async def async_step_user(self, user_input=None):
|
||||
# Kun én instans
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="Thermostat Timeline", data={})
|
||||
return self.async_show_form(step_id="user")
|
||||
|
||||
async def async_step_import(self, user_input=None):
|
||||
"""Understøt YAML: thermostat_timeline: i configuration.yaml."""
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
return self.async_create_entry(title="Thermostat Timeline", data={})
|
||||
@@ -0,0 +1,5 @@
|
||||
DOMAIN = "thermostat_timeline"
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
SIGNAL_UPDATED = f"{DOMAIN}_updated"
|
||||
BACKUP_STORAGE_KEY = f"{DOMAIN}_backup"
|
||||
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from homeassistant.core import HomeAssistant # type: ignore[reportMissingImports]
|
||||
from homeassistant.helpers.storage import Store # type: ignore[reportMissingImports]
|
||||
from homeassistant.helpers.event import async_call_later # type: ignore[reportMissingImports]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The JS file shipped with this integration under ./www/
|
||||
JS_FILENAME = "thermostat-pro-timeline.js"
|
||||
|
||||
# Where Home Assistant serves files from config/www/ as /local/
|
||||
RESOURCE_URL = f"/local/{JS_FILENAME}"
|
||||
RESOURCE_TYPE = "module" # modern custom cards should be loaded as ES modules
|
||||
|
||||
|
||||
def _strip_query(url: str) -> str:
|
||||
try:
|
||||
return str(url).split("?", 1)[0]
|
||||
except Exception:
|
||||
return str(url)
|
||||
|
||||
|
||||
def _token_from_hash(sha_hex: str) -> str:
|
||||
"""Make a stable, numeric-ish token (similar vibe to HACS hacstag)."""
|
||||
try:
|
||||
# 48-bit is plenty; keeps token reasonably short and numeric.
|
||||
return str(int(sha_hex[:12], 16))
|
||||
except Exception:
|
||||
return sha_hex[:12] or "0"
|
||||
|
||||
|
||||
def _get_lovelace_resources(hass: HomeAssistant):
|
||||
"""Return the Lovelace ResourceStorageCollection when in storage mode, else None."""
|
||||
try:
|
||||
lovelace_data = hass.data.get("lovelace")
|
||||
if lovelace_data is None:
|
||||
return None
|
||||
|
||||
resources = None
|
||||
if hasattr(lovelace_data, "resources"):
|
||||
resources = lovelace_data.resources
|
||||
elif isinstance(lovelace_data, dict):
|
||||
resources = lovelace_data.get("resources")
|
||||
|
||||
if resources is None:
|
||||
return None
|
||||
|
||||
# YAML mode: store is missing/None.
|
||||
if not hasattr(resources, "store") or resources.store is None:
|
||||
return None
|
||||
|
||||
# Basic capability check.
|
||||
for attr in ("async_items", "async_create_item", "async_update_item"):
|
||||
if not hasattr(resources, attr):
|
||||
return None
|
||||
|
||||
return resources
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _sha256_sync(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 256), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
async def _sha256(hass: HomeAssistant, path: Path) -> str:
|
||||
return await hass.async_add_executor_job(_sha256_sync, path)
|
||||
|
||||
|
||||
async def ensure_frontend(hass: HomeAssistant) -> None:
|
||||
"""Best-effort:
|
||||
|
||||
1) Copy bundled JS from the integration package to /config/www/
|
||||
2) Register the JS as a Lovelace resource (storage mode)
|
||||
|
||||
This is intentionally best-effort: failures must not block the integration
|
||||
(and must not break config flow loading).
|
||||
"""
|
||||
try:
|
||||
await _ensure_js_in_www(hass)
|
||||
except Exception:
|
||||
_LOGGER.debug("ensure_frontend: failed copying JS to /config/www", exc_info=True)
|
||||
|
||||
try:
|
||||
await _ensure_lovelace_resource_retry(hass, attempts_left=6)
|
||||
except Exception:
|
||||
_LOGGER.debug("ensure_frontend: failed registering Lovelace resource", exc_info=True)
|
||||
|
||||
|
||||
async def _ensure_lovelace_resource_retry(hass: HomeAssistant, attempts_left: int) -> None:
|
||||
"""Retry resource registration if Lovelace isn't fully ready yet."""
|
||||
if attempts_left <= 0:
|
||||
return
|
||||
|
||||
resources = _get_lovelace_resources(hass)
|
||||
if resources is None:
|
||||
async_call_later(
|
||||
hass,
|
||||
5,
|
||||
lambda _now: hass.async_create_task(
|
||||
_ensure_lovelace_resource_retry(hass, attempts_left - 1)
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
await _ensure_lovelace_resource(hass)
|
||||
|
||||
|
||||
async def _ensure_js_in_www(hass: HomeAssistant) -> None:
|
||||
src = Path(__file__).parent / "www" / JS_FILENAME
|
||||
dst = Path(hass.config.path("www", JS_FILENAME))
|
||||
|
||||
if not src.exists():
|
||||
# If the file isn't shipped, do nothing.
|
||||
_LOGGER.debug("ensure_frontend: bundled JS not found at %s", src)
|
||||
return
|
||||
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy only if missing or changed
|
||||
if dst.exists():
|
||||
try:
|
||||
if await _sha256(hass, src) == await _sha256(hass, dst):
|
||||
return
|
||||
except Exception:
|
||||
# If hashing fails for any reason, overwrite.
|
||||
pass
|
||||
|
||||
await hass.async_add_executor_job(shutil.copyfile, src, dst)
|
||||
|
||||
|
||||
async def _ensure_lovelace_resource(hass: HomeAssistant) -> None:
|
||||
"""Add/update /local/... as a Lovelace resource (storage mode) with cache-busting."""
|
||||
|
||||
# Prefer HA's resource collection API (what the UI uses).
|
||||
resources = _get_lovelace_resources(hass)
|
||||
if resources is not None:
|
||||
if not getattr(resources, "loaded", True):
|
||||
await resources.async_load()
|
||||
|
||||
deployed = Path(hass.config.path("www", JS_FILENAME))
|
||||
token = "0"
|
||||
if deployed.exists():
|
||||
try:
|
||||
token = _token_from_hash(await _sha256(hass, deployed))
|
||||
except Exception:
|
||||
token = "0"
|
||||
url = f"{RESOURCE_URL}?v={token}"
|
||||
|
||||
matches = []
|
||||
for entry in resources.async_items():
|
||||
try:
|
||||
if _strip_query(entry.get("url", "")) == RESOURCE_URL:
|
||||
matches.append(entry)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if matches:
|
||||
entry = matches[0]
|
||||
entry_url = str(entry.get("url", ""))
|
||||
if entry_url != url or entry.get("res_type") != RESOURCE_TYPE:
|
||||
await resources.async_update_item(
|
||||
entry["id"],
|
||||
{"res_type": RESOURCE_TYPE, "url": url},
|
||||
)
|
||||
_LOGGER.info("Updated Lovelace resource: %s", url)
|
||||
|
||||
# Remove duplicates (keep first)
|
||||
for dup in matches[1:]:
|
||||
try:
|
||||
await resources.async_delete_item(dup["id"])
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
await resources.async_create_item({"res_type": RESOURCE_TYPE, "url": url})
|
||||
_LOGGER.info("Added Lovelace resource: %s", url)
|
||||
return
|
||||
|
||||
# Fallback: older setups / YAML mode. This may not show up in the UI, but keep it
|
||||
# as a best-effort compatibility path.
|
||||
try:
|
||||
from homeassistant.components.lovelace.resources import ( # type: ignore[reportMissingImports]
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
)
|
||||
|
||||
store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
except Exception:
|
||||
store = Store(hass, 1, "lovelace.resources")
|
||||
|
||||
resources = await store.async_load()
|
||||
if resources is None:
|
||||
resources = []
|
||||
|
||||
# Some older / custom setups could theoretically store dicts; keep it safe.
|
||||
if isinstance(resources, dict):
|
||||
resources = resources.get("resources", [])
|
||||
|
||||
deployed = Path(hass.config.path("www", JS_FILENAME))
|
||||
token = "0"
|
||||
if deployed.exists():
|
||||
try:
|
||||
token = _token_from_hash(await _sha256(hass, deployed))
|
||||
except Exception:
|
||||
token = "0"
|
||||
url = f"{RESOURCE_URL}?v={token}"
|
||||
|
||||
for r in resources:
|
||||
if isinstance(r, dict) and _strip_query(r.get("url", "")) == RESOURCE_URL:
|
||||
if r.get("url") != url or r.get("type") != RESOURCE_TYPE:
|
||||
r["url"] = url
|
||||
r["type"] = RESOURCE_TYPE
|
||||
await store.async_save(resources)
|
||||
_LOGGER.info("Updated Lovelace resource (fallback): %s", url)
|
||||
return
|
||||
|
||||
resources.append(
|
||||
{
|
||||
"id": str(uuid4()),
|
||||
"type": RESOURCE_TYPE,
|
||||
"url": url,
|
||||
}
|
||||
)
|
||||
|
||||
await store.async_save(resources)
|
||||
_LOGGER.info("Added Lovelace resource (fallback): %s", url)
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"domain": "thermostat_timeline",
|
||||
"name": "Thermostat Pro Timeline",
|
||||
"after_dependencies": [
|
||||
"lovelace"
|
||||
],
|
||||
"codeowners": [
|
||||
"@qlerup"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"http",
|
||||
"frontend"
|
||||
],
|
||||
"documentation": "https://github.com/qlerup/thermostat-pro-timeline",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/qlerup/thermostat-pro-timeline/issues",
|
||||
"requirements": [],
|
||||
"version": "3.2.3"
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
set_store:
|
||||
name: Set full store
|
||||
description: Replace the entire schedules store and bump version.
|
||||
fields:
|
||||
instance_id:
|
||||
name: Instance ID
|
||||
description: Optional. When provided, writes into that instance (and activates it by default).
|
||||
required: false
|
||||
example: default
|
||||
selector:
|
||||
text: {}
|
||||
activate:
|
||||
name: Activate instance
|
||||
description: Optional. When instance_id is provided, whether to make it the active instance. Defaults to true.
|
||||
required: false
|
||||
selector:
|
||||
boolean: {}
|
||||
schedules:
|
||||
name: Schedules
|
||||
description: Object mapping entity_id -> {defaultTemp, blocks:[...]}.
|
||||
required: false
|
||||
example: >
|
||||
{"climate.kitchen":{"defaultTemp":20,"blocks":[{"from":"06:00","to":"08:00","temp":21}]}}
|
||||
selector:
|
||||
object: {}
|
||||
settings:
|
||||
name: Settings (optional)
|
||||
description: Extra shared settings used by UI and background auto-apply.
|
||||
required: false
|
||||
example: >
|
||||
{"min_temp":5,"max_temp":25,"away":{"enabled":true,"persons":["person.me"],"target_c":17},"merges":{"climate.living":["climate.living_2"]},"auto_apply_enabled":true}
|
||||
selector:
|
||||
object: {}
|
||||
|
||||
select_instance:
|
||||
name: Select active instance
|
||||
description: Switch which instance is used for background control and default API /state.
|
||||
fields:
|
||||
instance_id:
|
||||
name: Instance ID
|
||||
required: true
|
||||
example: winter
|
||||
selector:
|
||||
text: {}
|
||||
create_if_missing:
|
||||
name: Create if missing
|
||||
description: Create the instance if it does not exist.
|
||||
required: false
|
||||
selector:
|
||||
boolean: {}
|
||||
copy_from_active:
|
||||
name: Copy from current active
|
||||
description: When creating, copy schedules/settings from current active instance.
|
||||
required: false
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
rename_instance:
|
||||
name: Rename instance
|
||||
description: Rename an instance id (moves all data).
|
||||
fields:
|
||||
old_instance_id:
|
||||
name: Old instance ID
|
||||
required: true
|
||||
example: old_name
|
||||
selector:
|
||||
text: {}
|
||||
new_instance_id:
|
||||
name: New instance ID
|
||||
required: true
|
||||
example: new_name
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
patch_entity:
|
||||
name: Patch single entity
|
||||
description: Merge given data into one entity and bump version.
|
||||
fields:
|
||||
entity_id:
|
||||
name: Entity
|
||||
description: The climate entity id to patch.
|
||||
required: true
|
||||
example: climate.kitchen
|
||||
selector:
|
||||
entity:
|
||||
domain: climate
|
||||
data:
|
||||
name: Data
|
||||
description: Fields to merge (e.g., defaultTemp, blocks).
|
||||
required: true
|
||||
example: >
|
||||
{"defaultTemp":21,"blocks":[{"from":"17:00","to":"22:00","temp":22}]}
|
||||
selector:
|
||||
object: {}
|
||||
|
||||
clear:
|
||||
name: Clear store
|
||||
description: Clear all schedules and bump version.
|
||||
|
||||
factory_reset:
|
||||
name: Factory reset
|
||||
description: Delete thermostat_timeline storage files and recreate them empty (removes all instances, schedules, settings, and backups).
|
||||
|
||||
backup_now:
|
||||
name: Backup now
|
||||
description: Create a backup of schedules/settings. Supports selecting sections.
|
||||
fields:
|
||||
main:
|
||||
name: Main schedules
|
||||
description: Include base daily schedules (defaultTemp, blocks, profiles).
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
weekday:
|
||||
name: Weekday schedules
|
||||
description: Include weekday schedules (weekly, weekly_modes).
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
presence:
|
||||
name: Presence schedules
|
||||
description: Include presence schedules (advanced away per room).
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
settings:
|
||||
name: Editor settings
|
||||
description: Include editor/global settings (excluding colors).
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
holiday:
|
||||
name: Holiday schedules
|
||||
description: Include holiday schedules per room.
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
colors:
|
||||
name: Color blocks
|
||||
description: Include color ranges and color mode.
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
restore_now:
|
||||
name: Restore now
|
||||
description: Restore from backup. Can merge only selected sections.
|
||||
fields:
|
||||
mode:
|
||||
name: Mode
|
||||
description: Use 'merge' to merge selected sections, or 'replace' to fully overwrite.
|
||||
required: false
|
||||
example: merge
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- merge
|
||||
- replace
|
||||
mode: dropdown
|
||||
main:
|
||||
name: Main schedules
|
||||
description: Restore base daily schedules (defaultTemp, blocks, profiles).
|
||||
required: false
|
||||
selector:
|
||||
boolean: {}
|
||||
weekday:
|
||||
name: Weekday schedules
|
||||
description: Restore weekday schedules (weekly, weekly_modes).
|
||||
required: false
|
||||
selector:
|
||||
boolean: {}
|
||||
presence:
|
||||
name: Presence schedules
|
||||
description: Restore presence schedules.
|
||||
required: false
|
||||
selector:
|
||||
boolean: {}
|
||||
settings:
|
||||
name: Editor settings
|
||||
description: Restore editor/global settings (excluding colors).
|
||||
required: false
|
||||
selector:
|
||||
boolean: {}
|
||||
holiday:
|
||||
name: Holiday schedules
|
||||
description: Restore holiday schedules per room.
|
||||
required: false
|
||||
selector:
|
||||
boolean: {}
|
||||
colors:
|
||||
name: Color blocks
|
||||
description: Restore color ranges and color mode.
|
||||
required: false
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"title": "Thermostat Pro Timeline Sync",
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Only a single instance is allowed."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up Thermostat Pro Timeline Sync"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user