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 000000000..ce83ccdbc Binary files /dev/null and b/plinth/modules/homeassistant/static/icons/homeassistant.png differ 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)