From ce341b18ab42fa1c3887d289b7e78ffccd58a3d5 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Tue, 13 May 2025 13:20:46 -0700 Subject: [PATCH] homeassistant: Add the most popular app for home automation - Use docker container via registry.freedombox.org to obtain the package. Specify this in the description. - Mark the app as experimental. - Show information that a dedicated domain is required to host Home Assistant. - Use special YAML loader/dumper to deal with custom YAML tags in configuration file. - Obtain logo file from a test file in code repository with Apache license as the actual logo files are freely licensed. - Write functional tests without accessing the website as a dedicated domain is necessary. Tests: - Functional tests work. - Add a domain 'mydomain.example' using the Names app. Assign this domain in Home Assistant app configuration. In /etc/hosts on the host machine add a mapping from mydomain.example to the IP address of the container/VM. Access the web interface using https://mydomain.example. Home Assistant web interface is available and functional. - After install of the app the configuration.yaml file contains the proxy related lines are expected. - Diagnostics work (expect the URL access). - Re-run setup works. - 'Launch web client' and frontpage shortcut work as expected. - Non-admin users can't connect on port 8123. - Home Assistant is able to establish websocket connection in its web UI. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- debian/copyright | 6 + plinth/modules/homeassistant/__init__.py | 141 +++++++++++++ .../includes/home-assistant-freedombox.conf | 11 + .../freedombox/modules-enabled/homeassistant | 1 + plinth/modules/homeassistant/manifest.py | 68 +++++++ plinth/modules/homeassistant/privileged.py | 104 ++++++++++ .../static/icons/homeassistant.png | Bin 0 -> 12757 bytes .../static/icons/homeassistant.svg | 189 ++++++++++++++++++ .../templates/homeassistant.html | 43 ++++ .../modules/homeassistant/tests/__init__.py | 0 .../homeassistant/tests/test_functional.py | 28 +++ plinth/modules/homeassistant/urls.py | 11 + plinth/modules/homeassistant/views.py | 55 +++++ 13 files changed, 657 insertions(+) create mode 100644 plinth/modules/homeassistant/__init__.py create mode 100644 plinth/modules/homeassistant/data/usr/share/freedombox/etc/apache2/includes/home-assistant-freedombox.conf create mode 100644 plinth/modules/homeassistant/data/usr/share/freedombox/modules-enabled/homeassistant create mode 100644 plinth/modules/homeassistant/manifest.py create mode 100644 plinth/modules/homeassistant/privileged.py create mode 100644 plinth/modules/homeassistant/static/icons/homeassistant.png create mode 100644 plinth/modules/homeassistant/static/icons/homeassistant.svg create mode 100644 plinth/modules/homeassistant/templates/homeassistant.html create mode 100644 plinth/modules/homeassistant/tests/__init__.py create mode 100644 plinth/modules/homeassistant/tests/test_functional.py create mode 100644 plinth/modules/homeassistant/urls.py create mode 100644 plinth/modules/homeassistant/views.py diff --git a/debian/copyright b/debian/copyright index 06eea8862..671e391dd 100644 --- a/debian/copyright +++ b/debian/copyright @@ -124,6 +124,12 @@ Files: static/themes/default/icons/gnu-linux.png Copyright: 2017 Cowemoji License: CC0-1.0 +Files: plinth/modules/homeassistant/static/icons/homeassistant.png + plinth/modules/homeassistant/static/icons/homeassistant.svg +Copyright: Home Assistant Core Developers +Comment: https://github.com/home-assistant/core/blob/dev/tests/components/image_upload/logo.png +License: Apache-2.0 + Files: plinth/modules/ikiwiki/static/icons/ikiwiki.png plinth/modules/ikiwiki/static/icons/ikiwiki.svg Copyright: 2006 Recai Oktaş diff --git a/plinth/modules/homeassistant/__init__.py b/plinth/modules/homeassistant/__init__.py new file mode 100644 index 000000000..148202ceb --- /dev/null +++ b/plinth/modules/homeassistant/__init__.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""FreedomBox app to configure Home Assistant.""" + +from django.utils.functional import lazy +from django.utils.translation import gettext_lazy as _ + +from plinth import app as app_module +from plinth import cfg, frontpage, menu +from plinth.config import DropinConfigs +from plinth.container import Container +from plinth.modules.apache.components import WebserverRoot +from plinth.modules.backups.components import BackupRestore +from plinth.modules.firewall.components import (Firewall, + FirewallLocalProtection) +from plinth.package import Packages +from plinth.utils import format_lazy + +from . import manifest, privileged + +_alert = ''' + +''' + +_description = [ + _('Home Assistant is a home automation hub with emphasis on local control ' + 'and privacy. It integrates with thousands of devices including smart ' + 'bulbs, alarms, presense sensors, door bells, thermostats, irrigation ' + 'timers, energy monitors, etc.'), + _('Home Assistant can detect, configure, and use various devices on the ' + 'local network. For devices using other protocols such as ZigBee, it ' + 'typically requires additional hardware such as a ZigBee USB dongle. ' + 'You need to re-run setup if such hardware is added or removed.'), + _('Home Assistant web interface must be setup soon after the app is ' + 'installed. An administrator account is created at this time. Home ' + 'Assistant maintains its own user accounts.'), + format_lazy( + _('Please note that Home Assistant is installed and run inside a ' + 'container provided by the Home Assistant project. Security, ' + 'quality, privacy and legal reviews are done by the upstream ' + 'project and not by Debian/{box_name}. Updates are performed ' + 'following an independent cycle.'), box_name=_(cfg.box_name)), + format_lazy(_alert, _('Caution:'), _('This app is experimental.')), +] + + +class HomeAssistnatApp(app_module.App): + """FreedomBox app for Home Assistant.""" + + app_id = 'homeassistant' + + _version = 1 + + def __init__(self): + """Create components for the app.""" + super().__init__() + + info = app_module.Info(app_id=self.app_id, version=self._version, + name=_('Home Assistant'), + icon_filename='homeassistant', + description=_description, + manual_page='HomeAssistant', + clients=manifest.clients, tags=manifest.tags) + self.add(info) + + menu_item = menu.Menu('menu-homeassistant', info.name, + info.icon_filename, info.tags, + 'homeassistant:index', parent_url_name='apps') + self.add(menu_item) + + shortcut = frontpage.Shortcut('shortcut-homeassistant', info.name, + icon=info.icon_filename, url='#', + clients=info.clients, tags=info.tags, + login_required=True) + self.add(shortcut) + + packages = Packages('packages-homeassistant', ['podman'], + conflicts=['libpam-tmpdir'], + conflicts_action=Packages.ConflictsAction.REMOVE) + self.add(packages) + + dropin_configs = DropinConfigs('dropin-configs-homeassistant', [ + '/etc/apache2/includes/home-assistant-freedombox.conf', + ]) + self.add(dropin_configs) + + firewall = Firewall('firewall-homeassistant', info.name, + ports=['http', 'https'], is_external=True) + self.add(firewall) + + firewall_local_protection = FirewallLocalProtection( + 'firewall-local-protection-homeassistant', ['8123']) + self.add(firewall_local_protection) + + webserver = WebserverRoot('webserverroot-homeassistant', + 'home-assistant-freedombox') + self.add(webserver) + + image_name = 'registry.freedombox.org/' \ + 'homeassistant/home-assistant:stable' + volume_name = 'home-assistant-freedombox' + volumes = { + '/run/dbus': '/run/dbus', + volume_name: '/config', + } + devices = { + f'/dev/ttyUSB{number}': f'/dev/ttyUSB{number}' + for number in range(8) + } + container = Container( + 'container-homeassistant', 'home-assistant-freedombox', + image_name=image_name, volume_name=volume_name, + volume_path='/var/lib/home-assistant-freedombox/config/', + volumes=volumes, devices=devices, listen_ports=[(8123, 'tcp4')]) + self.add(container) + + backup_restore = BackupRestore('backup-restore-homeassistant', + **manifest.backup) + self.add(backup_restore) + + def post_init(self): + """Perform post initialization operations.""" + root = self.get_component('webserverroot-homeassistant') + + def get_url(): + return f'https://{root.domain_get()}' + + url = lazy(get_url, str)() + self.get_component('shortcut-homeassistant').url = url + self.info.clients[0]['platforms'][0]['url'] = url + + def setup(self, old_version): + """Install and configure the app.""" + super().setup(old_version) + + privileged.setup() diff --git a/plinth/modules/homeassistant/data/usr/share/freedombox/etc/apache2/includes/home-assistant-freedombox.conf b/plinth/modules/homeassistant/data/usr/share/freedombox/etc/apache2/includes/home-assistant-freedombox.conf new file mode 100644 index 000000000..7b6ce4fdd --- /dev/null +++ b/plinth/modules/homeassistant/data/usr/share/freedombox/etc/apache2/includes/home-assistant-freedombox.conf @@ -0,0 +1,11 @@ +## +## On one site, provide Home Assistant on a default path: / +## + +# Handle WebSocket connections using websocket proxy +RewriteEngine On +RewriteCond %{HTTP:Upgrade} websocket [NC] +RewriteRule ^/(api/websocket)$ "ws://127.0.0.1:8123/$1" [P,L] + +# Proxy all requests to Home Assistant +ProxyPass / http://127.0.0.1:8123/ diff --git a/plinth/modules/homeassistant/data/usr/share/freedombox/modules-enabled/homeassistant b/plinth/modules/homeassistant/data/usr/share/freedombox/modules-enabled/homeassistant new file mode 100644 index 000000000..88c710d73 --- /dev/null +++ b/plinth/modules/homeassistant/data/usr/share/freedombox/modules-enabled/homeassistant @@ -0,0 +1 @@ +plinth.modules.homeassistant diff --git a/plinth/modules/homeassistant/manifest.py b/plinth/modules/homeassistant/manifest.py new file mode 100644 index 000000000..1588717dc --- /dev/null +++ b/plinth/modules/homeassistant/manifest.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Application manifest for Home Assistant.""" + +from django.utils.translation import gettext_lazy as _ + +from plinth.clients import store_url + +_android_package_id = 'io.homeassistant.companion.android' + +clients = [ + { + 'name': _('Home Assistant'), + 'platforms': [{ + 'type': 'web', + 'url': '#' # Filled in later + }] + }, + { + 'name': + _('Home Assistant'), + 'platforms': [{ + 'type': 'download', + 'os': 'macos', + 'url': 'https://apps.apple.com/us/app/home-assistant/id1099568401' + }, { + 'type': 'store', + 'os': 'ios', + 'store_name': 'app-store', + 'url': 'https://apps.apple.com/us/app/home-assistant/id1099568401' + '?platform=iphone' + }, { + 'type': + 'store', + 'os': + 'android', + 'store_name': + 'google-play', + 'url': + store_url('google-play', 'io.homeassistant.companion.android') + }, { + 'type': + 'store', + 'os': + 'android', + 'store_name': + 'f-droid', + 'url': + store_url('f-droid', + 'io.homeassistant.companion.android.minimal') + }] + } +] + +backup = { + 'data': { + 'directories': ['/var/lib/home-assistant-freedombox/'] + }, + 'services': ['home-assistant-freedombox'] +} + +tags = [ + _('Home Automation'), + _('IoT'), + _('Wi-Fi'), + _('ZigBee'), + _('Z-Wave'), + _('Thread') +] diff --git a/plinth/modules/homeassistant/privileged.py b/plinth/modules/homeassistant/privileged.py new file mode 100644 index 000000000..f1abd0e8d --- /dev/null +++ b/plinth/modules/homeassistant/privileged.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Configure Home Assistant.""" + +import functools +import pathlib +import time +import traceback +from dataclasses import dataclass + +import yaml + +from plinth.actions import privileged + +_settings_file = pathlib.Path( + '/var/lib/home-assistant-freedombox/config/configuration.yaml') + + +@dataclass +class YAMLUnknownTag: + """Object used to represent an unknown tag in YAML.""" + tag: str + value: str + + +class YAMLLoader(yaml.SafeLoader): + """Custom YAML loader to handle !include etc. tags.""" + pass + + +def yaml_unknown_constructor(loader, node, tag): + """Create an object when a unknown tag is encountered.""" + value = loader.construct_scalar(node) + return YAMLUnknownTag(tag, value) + + +class YAMLDumper(yaml.Dumper): + """Custom YAML dumper to handle !include etc. tags.""" + pass + + +def yaml_unknown_representor(dumper, data): + """Dump original tag from an object representing an unknown tag.""" + return dumper.represent_scalar(data.tag, data.value) + + +def yaml_add_handlers(): + """Add special handlers to YAML loader and dumper.""" + tags = [ + '!include', '!env_var', '!secret', '!include_dir_list', + '!include_dir_merge_list', '!include_dir_named', + '!include_dir_merge_named', '!input' + ] + for tag in tags: + YAMLLoader.add_constructor( + tag, functools.partial(yaml_unknown_constructor, tag=tag)) + + YAMLDumper.add_representer(YAMLUnknownTag, yaml_unknown_representor) + + +yaml_add_handlers() + + +@privileged +def setup() -> None: + """Setup basic Home Assistant configuration.""" + pathlib.Path('/var/lib/home-assistant-freedombox/').chmod(0o700) + + try: + _wait_for_configuration_file() + + settings = _read_settings() + if 'http' not in settings: + settings['http'] = {} + + settings['http']['server_host'] = '127.0.0.1' + settings['http']['use_x_forwarded_for'] = True + settings['http']['trusted_proxies'] = '127.0.0.1' + _write_settings(settings) + except Exception as exception: + raise Exception( + traceback.format_tb(exception.__traceback__) + + [_settings_file.read_text()]) + + +def _wait_for_configuration_file() -> None: + """Wait until the Home Assistant daemon creates a configuration file.""" + start_time = time.time() + while time.time() < start_time + 300: + if _settings_file.exists(): + break + + time.sleep(1) + + +def _read_settings() -> dict: + """Load settings as dictionary from YAML config file.""" + with _settings_file.open('rb') as settings_file: + return yaml.load(settings_file, Loader=YAMLLoader) + + +def _write_settings(settings: dict): + """Write settings from dictionary to YAML config file.""" + with _settings_file.open('w', encoding='utf-8') as settings_file: + yaml.dump(settings, settings_file, Dumper=YAMLDumper) diff --git a/plinth/modules/homeassistant/static/icons/homeassistant.png b/plinth/modules/homeassistant/static/icons/homeassistant.png new file mode 100644 index 0000000000000000000000000000000000000000..ce83ccdbc92b0d7c2eaf7648d470951008733d8a GIT binary patch literal 12757 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 literal 0 HcmV?d00001 diff --git a/plinth/modules/homeassistant/static/icons/homeassistant.svg b/plinth/modules/homeassistant/static/icons/homeassistant.svg new file mode 100644 index 000000000..aeb4faced --- /dev/null +++ b/plinth/modules/homeassistant/static/icons/homeassistant.svg @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plinth/modules/homeassistant/templates/homeassistant.html b/plinth/modules/homeassistant/templates/homeassistant.html new file mode 100644 index 000000000..6f0acc283 --- /dev/null +++ b/plinth/modules/homeassistant/templates/homeassistant.html @@ -0,0 +1,43 @@ +{% extends "app.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block configuration %} + {% if form %} +

{% trans "Configuration" %}

+ +
+ {% csrf_token %} + +

+ {% blocktrans trimmed %} + Home Assistant requires a dedicated domain to work with and cannot + work on a URL path. Please select the domain on which Home Assistant + will be available. Home Assistant will not be available on other + domains. + {% endblocktrans %} +

+ +

+ {% url 'names:domain-add' as names_url %} + {% url 'dynamicdns:index' as dynamic_dns_url %} + {% blocktrans trimmed %} + A separate domain can be made available for Home Assistant by + configurating a subdomain such as homeassistant.mydomain.example. See + Names app and + Dynamic DNS app for configuring + subdomains. + {% endblocktrans %} +

+ + {{ form|bootstrap }} + + +
+ {% endif %} +{% endblock %} diff --git a/plinth/modules/homeassistant/tests/__init__.py b/plinth/modules/homeassistant/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/homeassistant/tests/test_functional.py b/plinth/modules/homeassistant/tests/test_functional.py new file mode 100644 index 000000000..97c42d69d --- /dev/null +++ b/plinth/modules/homeassistant/tests/test_functional.py @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Functional, browser based tests for Home Assistant app.""" + +import pytest + +from plinth.tests import functional + +pytestmark = [pytest.mark.apps, pytest.mark.homeassistant] + + +class TestHomeAssitantApp(functional.BaseAppTests): + """Basic tests for Home Assistant app.""" + app_name = 'homeassistant' + has_service = True + has_web = False # Can't yet check separate sub-domain + diagnostics_delay = 5 + + def install_and_setup(self, session_browser): + """Set the domain to freedombox.local so that it can tested.""" + super().install_and_setup(session_browser) + _domain_set(session_browser, 'freedombox.local') + + +def _domain_set(browser, domain): + """Set the domain in the domain selection drop down.""" + functional.nav_to_module(browser, 'homeassistant') + browser.select('domain_name', domain) + functional.submit(browser, form_class='form-configuration') diff --git a/plinth/modules/homeassistant/urls.py b/plinth/modules/homeassistant/urls.py new file mode 100644 index 000000000..e458184a5 --- /dev/null +++ b/plinth/modules/homeassistant/urls.py @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""URLs for the Home Assistant module.""" + +from django.urls import re_path + +from .views import HomeAssistantAppView + +urlpatterns = [ + re_path(r'^apps/homeassistant/$', HomeAssistantAppView.as_view(), + name='index') +] diff --git a/plinth/modules/homeassistant/views.py b/plinth/modules/homeassistant/views.py new file mode 100644 index 000000000..32d875c7c --- /dev/null +++ b/plinth/modules/homeassistant/views.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Django views for Home Assistant app.""" + +import logging + +from django.contrib import messages +from django.utils.translation import gettext_lazy as _ + +from plinth.forms import DomainSelectionForm +from plinth.views import AppView + +logger = logging.getLogger(__name__) + + +class HomeAssistantAppView(AppView): + """Show Home Assistant app main view.""" + + app_id = 'homeassistant' + template_name = 'homeassistant.html' + form_class = DomainSelectionForm + + def get_initial(self): + """Return the values to fill in the form.""" + initial = super().get_initial() + component = self.app.get_component('webserverroot-homeassistant') + initial.update({ + 'domain_name': component.domain_get() or '', + }) + return initial + + def get_form_kwargs(self): + """Return the arguments to instantiate form with.""" + kwargs = super().get_form_kwargs() + kwargs['show_none'] = True + return kwargs + + def form_valid(self, form): + """Apply the changes submitted in the form.""" + old_config = self.get_initial() + new_config = form.cleaned_data + + is_changed = False + + def _value_changed(key): + return old_config.get(key) != new_config.get(key) + + if _value_changed('domain_name'): + component = self.app.get_component('webserverroot-homeassistant') + component.domain_set(new_config['domain_name'] or None) + is_changed = True + + if is_changed: + messages.success(self.request, _('Configuration updated.')) + + return super().form_valid(form)