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 <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2025-05-13 13:20:46 -07:00 committed by James Valleroy
parent f83485b68c
commit ce341b18ab
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
13 changed files with 657 additions and 0 deletions

6
debian/copyright vendored
View File

@ -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ş <roktas@debian.org>

View File

@ -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 = '''
<div class="alert alert-warning d-flex align-items-center" role="alert">
<div class="me-2">
<span class="fa fa-exclamation-triangle" aria-hidden="true"></span>
<span class="visually-hidden">{}</span>
</div>
<div>{}</div>
</div>
'''
_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()

View File

@ -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/

View File

@ -0,0 +1 @@
plinth.modules.homeassistant

View File

@ -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')
]

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,189 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg2"
width="512"
height="512"
viewBox="0 0 512 512"
sodipodi:docname="homeassistant.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
inkscape:export-filename="homeassistant.png"
inkscape:export-xdpi="48"
inkscape:export-ydpi="48"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs6">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect10481"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,16 @ F,0,0,1,0,7.4006832,0,16 @ F,0,0,1,0,6.8476415,0,16 @ F,0,0,1,0,0,0,16 @ F,0,0,1,0,13,0,16 @ F,0,0,1,0,0,0,16 @ F,0,0,1,0,6.8398548,0,16 @ F,0,0,1,0,7.0625983,0,16 @ F,0,0,1,0,0,0,16 @ F,0,0,1,0,5.7657568,0,16 @ F,0,0,1,0,13,0,16"
unit="px"
method="auto"
mode="C"
radius="13"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<sodipodi:namedview
id="namedview4"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="1.4142136"
inkscape:cx="109.9551"
inkscape:cy="229.1026"
inkscape:window-width="2054"
inkscape:window-height="1424"
inkscape:window-x="26"
inkscape:window-y="23"
inkscape:window-maximized="0"
inkscape:current-layer="g8" />
<g
inkscape:groupmode="layer"
inkscape:label="Image"
id="g8">
<g
id="g10867"
transform="matrix(1.4584376,0,0,1.4559519,-35.756104,-35.922376)">
<rect
style="display:inline;fill:#3db7ed;fill-opacity:1;stroke:none;stroke-width:6;stroke-dasharray:none;stroke-opacity:1"
id="rect10535"
width="351.06061"
height="351.65997"
x="24.516718"
y="24.672777"
rx="27.427588"
ry="27.427588" />
<path
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 93.540138,214.62714 -0.703144,95.63584 a 7.3637398,7.3637398 45.277884 0 0 7.346256,7.41786 l 202.50305,0.47543 a 6.8319137,6.8319137 135.06864 0 0 6.84795,-6.83156 l 0.005,-96.58845 28.86224,0.19532 a 5.2986741,5.2986741 112.56308 0 0 3.76544,-9.06234 l -33.64783,-33.34198 0.25133,-44.31303 a 6.8858444,6.8858444 45.516933 0 0 -6.80054,-6.92437 l -19.3796,-0.23979 a 6.9883849,6.9883849 135.40629 0 0 -7.07484,6.97521 l -0.0213,11.78286 -69.96618,-70.708974 a 5.7338785,5.7338785 0.14370144 0 0 -8.13135,-0.02039 L 60.681075,205.86618 a 5.3083747,5.3083747 67.196859 0 0 3.809347,9.06069 z"
id="path2919"
sodipodi:nodetypes="cccccccccccc"
inkscape:original-d="m 93.540138,214.62714 -0.757555,103.03632 216.751337,0.50889 0.005,-103.43609 41.86194,0.2833 -42.88209,-42.4923 0.29012,-51.15277 -33.28099,-0.4118 -0.0341,18.84545 L 201.4722,65.00069 51.491114,215.061 Z"
inkscape:path-effect="#path-effect10481" />
<g
id="g10604">
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326"
cx="179.24469"
cy="149.3372"
rx="7.3252597"
ry="7.3252592" />
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326-1"
cx="169.45244"
cy="190.98294"
rx="7.3252597"
ry="7.3252592" />
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326-2"
cx="138.19283"
cy="222.78395"
rx="7.3252597"
ry="7.3252592" />
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326-70"
cx="141.91083"
cy="257.76157"
rx="7.3252597"
ry="7.3252592" />
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326-9"
cx="179.11179"
cy="262.82785"
rx="7.3252597"
ry="7.3252592" />
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326-36"
cx="147.65073"
cy="294.24802"
rx="7.3252597"
ry="7.3252592" />
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326-0"
cx="255.81784"
cy="294.59372"
rx="7.3252597"
ry="7.3252592" />
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326-6"
cx="274.44196"
cy="259.56049"
rx="7.3252597"
ry="7.3252592" />
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326-26"
cx="225.69614"
cy="227.99767"
rx="7.3252597"
ry="7.3252592" />
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326-18"
cx="251.68121"
cy="165.4055"
rx="7.3252597"
ry="7.3252592" />
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326-3"
cx="223.96227"
cy="149.19836"
rx="7.3252597"
ry="7.3252592" />
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326-7"
cx="229.48242"
cy="192.50766"
rx="7.3252597"
ry="7.3252592" />
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326-3-5"
cx="274.20001"
cy="192.36882"
rx="7.3252597"
ry="7.3252592" />
<ellipse
style="fill:none;stroke:#3db7ed;stroke-width:4.9;stroke-dasharray:none;stroke-opacity:1"
id="path326-35"
cx="142.36525"
cy="164.90781"
rx="7.3252597"
ry="7.3252592" />
<path
id="path2657"
style="fill:none;stroke:#3db7ed;stroke-width:6;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 251.49286,172.81947 0.0666,61.7281 -49.7452,49.80448 m 66.78444,-86.57997 -16.96839,16.54133 -15.64318,-15.88293 m 30.5797,60.84935 -40.48194,0.22401 -0.14847,-22.94487 m 23.03301,58.54101 -24.716,0.0115 -22.8132,22.58867 m 17.48042,-163.52317 -17.1326,16.68666 -0.37143,145.80773 -54.16934,-54.41329 m 32.00458,8.05924 -0.0525,23.92802 -23.86244,-0.18309 m 45.55819,-40.76105 -58.50429,-57.16861 -0.0519,-22.53777 m 27.22317,25.92054 0.24132,23.39815 -24.00093,0.0152 m 39.09799,-67.98579 16.75747,16.12059"
sodipodi:nodetypes="ccccccccccccccccccccccccccc" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -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 %}
<h3>{% trans "Configuration" %}</h3>
<form id="app-form" class="form form-configuration" method="post">
{% csrf_token %}
<p>
{% 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 %}
</p>
<p>
{% 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
<a href="{{ names_url }}">Names</a> app and
<a href="{{ dynamic_dns_url }}">Dynamic DNS</a> app for configuring
subdomains.
{% endblocktrans %}
</p>
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary"
value="{% trans "Update setup" %}"/>
</form>
{% endif %}
{% endblock %}

View File

@ -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')

View File

@ -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')
]

View File

@ -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)