mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-21 07:55:00 +00:00
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:
parent
f83485b68c
commit
ce341b18ab
6
debian/copyright
vendored
6
debian/copyright
vendored
@ -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>
|
||||
|
||||
141
plinth/modules/homeassistant/__init__.py
Normal file
141
plinth/modules/homeassistant/__init__.py
Normal 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()
|
||||
@ -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/
|
||||
@ -0,0 +1 @@
|
||||
plinth.modules.homeassistant
|
||||
68
plinth/modules/homeassistant/manifest.py
Normal file
68
plinth/modules/homeassistant/manifest.py
Normal 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')
|
||||
]
|
||||
104
plinth/modules/homeassistant/privileged.py
Normal file
104
plinth/modules/homeassistant/privileged.py
Normal 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)
|
||||
BIN
plinth/modules/homeassistant/static/icons/homeassistant.png
Normal file
BIN
plinth/modules/homeassistant/static/icons/homeassistant.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
189
plinth/modules/homeassistant/static/icons/homeassistant.svg
Normal file
189
plinth/modules/homeassistant/static/icons/homeassistant.svg
Normal 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 |
43
plinth/modules/homeassistant/templates/homeassistant.html
Normal file
43
plinth/modules/homeassistant/templates/homeassistant.html
Normal 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 %}
|
||||
0
plinth/modules/homeassistant/tests/__init__.py
Normal file
0
plinth/modules/homeassistant/tests/__init__.py
Normal file
28
plinth/modules/homeassistant/tests/test_functional.py
Normal file
28
plinth/modules/homeassistant/tests/test_functional.py
Normal 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')
|
||||
11
plinth/modules/homeassistant/urls.py
Normal file
11
plinth/modules/homeassistant/urls.py
Normal 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')
|
||||
]
|
||||
55
plinth/modules/homeassistant/views.py
Normal file
55
plinth/modules/homeassistant/views.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user