├── halinuxcompanion ├── __init__.py ├── resources │ ├── __init__.py │ ├── home-assistant-favicon.png │ └── halinuxcompanion.service ├── sensors │ ├── __init__.py │ ├── uptime.py │ ├── battery_state.py │ ├── battery_level.py │ ├── camera_state.py │ ├── status.py │ ├── cpu.py │ └── memory.py ├── test_main.py ├── __main__.py ├── dbus.py ├── api.py ├── sensor.py ├── companion.py └── notifier.py ├── tests ├── backup.tar.gz ├── entrypoint.sh └── config.json ├── .gitignore ├── setup.cfg ├── sonar-project.properties ├── Dockerfile ├── requirements.txt ├── tox.ini ├── docker-compose.yaml ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── build.yml ├── LICENSE ├── config.example.json └── README.md /halinuxcompanion/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /halinuxcompanion/resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/backup.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muniter/halinuxcompanion/HEAD/tests/backup.tar.gz -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .venv/ 3 | config/ 4 | my-config.json 5 | config.json 6 | ha_config/ 7 | *.xml 8 | .coverage 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | # ignore = E226, E722, W504 3 | max-line-length = 120 4 | # exclude = tests/ 5 | 6 | [yapf] 7 | column_limit = 120 8 | -------------------------------------------------------------------------------- /halinuxcompanion/resources/home-assistant-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muniter/halinuxcompanion/HEAD/halinuxcompanion/resources/home-assistant-favicon.png -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=muniter 2 | sonar.projectKey=muniter_halinuxcompanion 3 | sonar.sources=. 4 | sonar.python.coverage.reportPaths=./coverage.xml 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM homeassistant/home-assistant:stable 2 | 3 | COPY tests/entrypoint.sh /custom-entrypoint.sh 4 | RUN chmod +x /custom-entrypoint.sh 5 | RUN sed -i '5i\ sh /custom-entrypoint.sh' /init 6 | 7 | ENTRYPOINT ["/init"] 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.9.5 2 | psutil==5.8.0 3 | dbus-next==0.2.3 4 | pytest==7.4.3 5 | pytest-asyncio==0.21.1 6 | pytest-cov==4.1.0 7 | coverage==7.3.2 8 | tox==4.11.3 9 | pydantic==2.10.4 10 | greenlet==3.1.1 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py311 3 | skipsdist = True 4 | 5 | [testenv] 6 | deps = 7 | pytest 8 | pytest-cov 9 | commands = 10 | coverage run -m pytest . 11 | coverage xml 12 | 13 | [coverage:run] 14 | relative_files = True 15 | source = . 16 | branch = True 17 | -------------------------------------------------------------------------------- /tests/entrypoint.sh: -------------------------------------------------------------------------------- 1 | echo "Starting entrypoint.sh" 2 | mkdir /tmp/backup-dest 3 | echo "Extracting backup.tar.gz" 4 | tar xvf /tmp/backup.tar.gz --directory /tmp/backup-dest 5 | echo "Moving data to /config" 6 | mv /tmp/backup-dest/data/.* /tmp/backup-dest/data/* -t /config 7 | echo "Starting the original entrypoint /init" 8 | -------------------------------------------------------------------------------- /halinuxcompanion/sensors/__init__.py: -------------------------------------------------------------------------------- 1 | import os, pathlib 2 | 3 | __all__ = [] 4 | 5 | # Find all the sensor modules 6 | this_dir = pathlib.Path(__file__).parent.absolute() 7 | for root, dirs, files in os.walk(this_dir): 8 | for file in files: 9 | if file.endswith('.py') and not file.startswith('__'): 10 | sensor = os.path.splitext(os.path.basename(file))[0] 11 | __all__.append(sensor) 12 | -------------------------------------------------------------------------------- /halinuxcompanion/resources/halinuxcompanion.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Home Assistant Linux Companion 3 | Documentation=https://github.com/muniter/halinuxcompanion 4 | After=networking.target 5 | 6 | [Service] 7 | WorkingDirectory=%h/.config/halinuxcompanion 8 | ExecStart=%h/.config/halinuxcompanion/.venv/bin/python -m halinuxcompanion -c config.json 9 | Restart=always 10 | RestartSec=30 11 | 12 | [Install] 13 | WantedBy=default.target 14 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json 2 | version: '3.7' 3 | services: 4 | ha: 5 | image: custom-ha:latest 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | container_name: test_ha 10 | ports: 11 | - 9999:8123 12 | environment: 13 | - TZ=America/Panama 14 | - SECRET=the_secret 15 | volumes: 16 | - ./tests/backup.tar.gz:/tmp/backup.tar.gz 17 | # 18 | -------------------------------------------------------------------------------- /halinuxcompanion/sensors/uptime.py: -------------------------------------------------------------------------------- 1 | from halinuxcompanion.sensor import Sensor 2 | import psutil 3 | from datetime import datetime, timezone 4 | 5 | Uptime = Sensor() 6 | Uptime.config_name = "uptime" 7 | Uptime.device_class = "timestamp" 8 | Uptime.state_class = "" 9 | Uptime.icon = "mdi:clock" 10 | Uptime.name = "Uptime" 11 | Uptime.state = 0 12 | Uptime.type = "sensor" 13 | Uptime.unique_id = "uptime" 14 | Uptime.unit_of_measurement = "" 15 | Uptime.state = datetime.fromtimestamp(psutil.boot_time(), timezone.utc).isoformat() 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEAT]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Application Logs** 24 | Logs when the issue is occurring. 25 | 26 | **Home Assistant Logs** 27 | Logs if anything seems related to the issue. 28 | 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /halinuxcompanion/sensors/battery_state.py: -------------------------------------------------------------------------------- 1 | from types import MethodType 2 | from halinuxcompanion.sensor import Sensor 3 | import psutil 4 | 5 | BatteryState = Sensor() 6 | BatteryState.config_name = "battery_state" 7 | BatteryState.attributes = {} 8 | 9 | BatteryState.icon = "mdi:battery" 10 | BatteryState.name = "Battery State" 11 | BatteryState.state = "unavailable" 12 | BatteryState.type = "sensor" 13 | BatteryState.unique_id = "battery_state" 14 | 15 | 16 | def updater(self): 17 | data = psutil.sensors_battery() 18 | if data is not None: 19 | if data.power_plugged: 20 | self.state = "charging" 21 | self.icon = "mdi:battery-plus" 22 | else: 23 | self.state = "discharging" 24 | self.icon = "mdi:battery-minus" 25 | 26 | 27 | BatteryState.updater = MethodType(updater, BatteryState) 28 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | # Trigger analysis when pushing in master or pull requests, and when creating 3 | # a pull request. 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [opened, synchronize, reopened] 9 | name: Main Workflow 10 | jobs: 11 | sonarcloud: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | # Disabling shallow clone is recommended for improving relevancy of reporting 17 | fetch-depth: 0 18 | - uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.11' 21 | cache: 'pip' 22 | - run: pip install -r requirements.txt 23 | - run: coverage run --concurrency=thread,greenlet -m pytest . 24 | - run: coverage xml 25 | - name: SonarCloud Scan 26 | if: false 27 | uses: sonarsource/sonarcloud-github-action@master 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 31 | -------------------------------------------------------------------------------- /halinuxcompanion/sensors/battery_level.py: -------------------------------------------------------------------------------- 1 | from types import MethodType 2 | from halinuxcompanion.sensor import Sensor 3 | import psutil 4 | 5 | BatteryLevel = Sensor() 6 | BatteryLevel.config_name = "battery_level" 7 | BatteryLevel.attributes = { 8 | "time_left": "", 9 | } 10 | 11 | BatteryLevel.device_class = "battery" 12 | BatteryLevel.state_class = "measurement" 13 | BatteryLevel.icon = "mdi:battery" 14 | BatteryLevel.name = "Battery Level" 15 | BatteryLevel.state = "unavailable" 16 | BatteryLevel.type = "sensor" 17 | BatteryLevel.unique_id = "battery_level" 18 | BatteryLevel.unit_of_measurement = "%" 19 | 20 | 21 | def updater(self): 22 | data = psutil.sensors_battery() 23 | if data is not None: 24 | minutes, seconds = divmod(data.secsleft, 60) 25 | hours, minutes = divmod(minutes, 60) 26 | 27 | self.state = round(data.percent) 28 | self.icon = "mdi:battery-%d0" % round(data.percent / 10) 29 | self.attributes["time_left"] = "%d:%02d:%02d" % (hours, minutes, seconds) 30 | 31 | 32 | BatteryLevel.updater = MethodType(updater, BatteryLevel) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Javier Lopez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /halinuxcompanion/sensors/camera_state.py: -------------------------------------------------------------------------------- 1 | from types import MethodType 2 | from halinuxcompanion.sensor import Sensor 3 | from glob import glob 4 | from subprocess import run 5 | from logging import getLogger 6 | 7 | CameraState = Sensor() 8 | CameraState.config_name = "camera_state" 9 | CameraState.attributes = {} 10 | 11 | CameraState.icon = "mdi:video-off" 12 | CameraState.name = "Camera State" 13 | CameraState.state = "unavailable" 14 | CameraState.type = "sensor" 15 | CameraState.unique_id = "camera_state" 16 | 17 | def updater(self): 18 | logger = getLogger(__name__) 19 | 20 | ''' Get list of /dev/video* devices ''' 21 | devices = glob("/dev/video*") 22 | 23 | ''' Call fuser to check if any camera is being used ''' 24 | output = run(["fuser"] + devices, capture_output=True, check=False).stdout 25 | output = output.decode("utf-8") 26 | logger.debug(f"CameraState: {output}") 27 | if output == "": 28 | self.state = "idle" 29 | self.icon = "mdi:video-off" 30 | else: 31 | self.state = "active" 32 | self.icon = "mdi:video" 33 | 34 | CameraState.updater = MethodType(updater, CameraState) 35 | -------------------------------------------------------------------------------- /halinuxcompanion/sensors/status.py: -------------------------------------------------------------------------------- 1 | from types import MethodType 2 | from halinuxcompanion.sensor import Sensor 3 | import psutil 4 | import os 5 | 6 | import logging 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | Status = Sensor() 11 | Status.config_name = "status" 12 | Status.type = "binary_sensor" 13 | Status.device_class = "power" 14 | Status.name = "Status" 15 | Status.unique_id = "status" 16 | Status.icon = "mdi:cpu-64-bit" 17 | 18 | Status.state = True 19 | Status.attributes = {"reason": "power_on", "idle": "unknown"} 20 | 21 | IDLE = {True: {"idle": "true"}, False: {"idle": "false"}} 22 | SLEEP = {True: {"reason": "sleep"}, False: {"reason": "wake"}} 23 | SHUTDOWN = {True: {"reason": "power_off"}, False: {"reason": "power_on"}} 24 | 25 | 26 | async def on_prepare_for_sleep(self, v): 27 | """Handler for system sleep and wake up from sleep events. 28 | https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html 29 | 30 | :param v: True if going to sleep, False if waking up from it 31 | """ 32 | self.state = not v 33 | self.attributes = SLEEP[v] 34 | 35 | 36 | async def on_prepare_for_shutdown(self, v): 37 | """Handler for system shutdown/reboot. 38 | https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html 39 | 40 | :param v: True if shutting down, False if powering on. 41 | """ 42 | self.state = not v 43 | self.attributes = SHUTDOWN[v] 44 | 45 | 46 | async def screensaver_on_active_changed(self, v): 47 | """Handler for session screensaver status changes.""" 48 | self.attributes.update(IDLE[v]) 49 | 50 | 51 | def updater(self): 52 | # Updated only by signals 53 | pass 54 | 55 | 56 | Status.updater = MethodType(updater, Status) 57 | Status.signals = { 58 | "system.login_on_prepare_for_sleep": on_prepare_for_sleep, 59 | "system.login_on_prepare_for_shutdown": on_prepare_for_shutdown, 60 | "session.screensaver_on_active_changed": screensaver_on_active_changed, 61 | "session.gnome_screensaver_on_active_changed": screensaver_on_active_changed, 62 | } 63 | -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "ha_url": "http://homeassistant.local:8123/", 3 | "ha_token": "mysuperlongtoken", 4 | "device_id": "computername", 5 | "device_name": "whatever you want can be left empty", 6 | "manufacturer": "whatever you want can be left empty", 7 | "model": "Computer", 8 | "computer_ip": "192.168.1.15", 9 | "computer_port": 8400, 10 | "refresh_interval": 15, 11 | "loglevel": "INFO", 12 | "sensors": { 13 | "cpu": { 14 | "enabled": true, 15 | "name": "CPU" 16 | }, 17 | "memory": { 18 | "enabled": true, 19 | "name": "Memory Load" 20 | }, 21 | "uptime": { 22 | "enabled": true, 23 | "name": "Uptime" 24 | }, 25 | "status": { 26 | "enabled": true, 27 | "name": "Status" 28 | }, 29 | "battery_level": { 30 | "enabled": true, 31 | "name": "Battery Level" 32 | }, 33 | "battery_state": { 34 | "enabled": true, 35 | "name": "Battery State" 36 | }, 37 | "camera_state": { 38 | "enabled": true, 39 | "name": "Camera State" 40 | } 41 | }, 42 | "services": { 43 | "notifications": { 44 | "enabled": true, 45 | "url_program": "xdg-open", 46 | "commands": { 47 | "command_suspend": { 48 | "name": "Suspend", 49 | "command": ["systemctl", "suspend"] 50 | }, 51 | "command_poweroff": { 52 | "name": "Power off", 53 | "command": ["systemctl", "poweroff"] 54 | }, 55 | "command_reboot": { 56 | "name": "Reboot", 57 | "command": ["systemctl", "reboot"] 58 | }, 59 | "command_hibernate": { 60 | "name": "Hibernate", 61 | "command": ["systemctl", "hibernate"] 62 | }, 63 | "command_open_ha": { 64 | "name": "Open Home Assistant", 65 | "command": ["xdg-open", "http://homeassistant.local:8123/"] 66 | }, 67 | "command_open_spotify": { 68 | "name": "Open Spotify Flatpak", 69 | "command": ["flatpak", "run", "com.spotify.Client"] 70 | } 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /halinuxcompanion/sensors/cpu.py: -------------------------------------------------------------------------------- 1 | from types import MethodType 2 | from halinuxcompanion.sensor import Sensor 3 | import psutil 4 | import os 5 | 6 | load_average: bool = False 7 | allow_update: bool = True 8 | 9 | Cpu = Sensor() 10 | Cpu.config_name = "cpu" 11 | Cpu.attributes = { 12 | "cpu_count": psutil.cpu_count(logical=False), 13 | "cpu_logical_count": psutil.cpu_count(), 14 | } 15 | 16 | if os.name == "posix": 17 | load_average = True 18 | 19 | Cpu.device_class = "power_factor" 20 | Cpu.state_class = "measurement" 21 | Cpu.icon = "mdi:cpu-64-bit" 22 | Cpu.name = "CPU Load" 23 | Cpu.state = 0 24 | Cpu.type = "sensor" 25 | Cpu.unique_id = "cpu_load" 26 | Cpu.unit_of_measurement = "%" 27 | 28 | 29 | async def on_prepare_for_sleep(self, v): 30 | """Handler for system sleep and wake up from sleep events. 31 | https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html 32 | 33 | :param v: True if going to sleep, False if waking up from it 34 | """ 35 | global allow_update 36 | if v: 37 | allow_update = False 38 | self.state = "unavailable" 39 | else: 40 | allow_update = True 41 | 42 | 43 | async def on_prepare_for_shutdown(self, v): 44 | """Handler for system shutdown/reboot. 45 | https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html 46 | 47 | :param v: True if shutting down, False if powering on. 48 | """ 49 | global allow_update 50 | if v: 51 | allow_update = False 52 | self.state = "unavailable" 53 | else: 54 | allow_update = True 55 | 56 | 57 | def updater(self): 58 | if not allow_update: 59 | return 60 | 61 | self.state = psutil.cpu_percent() 62 | if load_average: 63 | data = psutil.getloadavg() 64 | self.attributes["load_1"] = data[0] 65 | self.attributes["load_5"] = data[1] 66 | self.attributes["load_15"] = data[2] 67 | 68 | 69 | Cpu.updater = MethodType(updater, Cpu) 70 | Cpu.signals = { 71 | "system.login_on_prepare_for_sleep": on_prepare_for_sleep, 72 | "system.login_on_prepare_for_shutdown": on_prepare_for_shutdown, 73 | } 74 | -------------------------------------------------------------------------------- /halinuxcompanion/sensors/memory.py: -------------------------------------------------------------------------------- 1 | from types import MethodType 2 | from halinuxcompanion.sensor import Sensor 3 | import psutil 4 | 5 | allow_update: bool = True 6 | 7 | Memory = Sensor() 8 | Memory.config_name = "memory" 9 | Memory.attributes = { 10 | "total": 0, 11 | "available": 0, 12 | "used": 0, 13 | "free": 0, 14 | } 15 | 16 | Memory.device_class = "power_factor" 17 | Memory.state_class = "measurement" 18 | Memory.icon = "mdi:memory" 19 | Memory.name = "Memory Load" 20 | Memory.state = 0 21 | Memory.type = "sensor" 22 | Memory.unique_id = "memory_usage" 23 | Memory.unit_of_measurement = "%" 24 | 25 | 26 | async def on_prepare_for_sleep(self, v): 27 | """Handler for system sleep and wake up from sleep events. 28 | https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html 29 | 30 | :param v: True if going to sleep, False if waking up from it 31 | """ 32 | global allow_update 33 | if v: 34 | allow_update = False 35 | self.state = "unavailable" 36 | else: 37 | allow_update = True 38 | 39 | 40 | async def on_prepare_for_shutdown(self, v): 41 | """Handler for system shutdown/reboot. 42 | https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html 43 | 44 | :param v: True if shutting down, False if powering on. 45 | """ 46 | global allow_update 47 | if v: 48 | allow_update = False 49 | self.state = "unavailable" 50 | else: 51 | allow_update = True 52 | 53 | 54 | def updater(self): 55 | if not allow_update: 56 | return 57 | 58 | data = psutil.virtual_memory() 59 | self.state = round((data.total - data.available) / data.total * 100, 1) 60 | self.attributes["total"] = data.total / 1024 61 | self.attributes["available"] = data.available / 1024 62 | self.attributes["used"] = data.used / 1024 63 | self.attributes["free"] = data.free / 1024 64 | 65 | 66 | Memory.updater = MethodType(updater, Memory) 67 | Memory.signals = { 68 | "system.login_on_prepare_for_sleep": on_prepare_for_sleep, 69 | "system.login_on_prepare_for_shutdown": on_prepare_for_shutdown, 70 | } 71 | -------------------------------------------------------------------------------- /tests/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ha_url": "http://localhost:9999/", 3 | "ha_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIxOWM5NmNkNTNjOGM0Yzg3YmFjOGI1NDc5MDM5N2M1NiIsImlhdCI6MTY5OTEyOTE2NiwiZXhwIjoyMDE0NDg5MTY2fQ.dHoNoOhR77ZpZEkdmolWzLNp6IgAwvCybOxBCG8D2i8", 4 | "device_id": "testpc", 5 | "device_name": "test", 6 | "manufacturer": "test", 7 | "model": "Computer", 8 | "computer_ip": "localhost", 9 | "computer_port": 8400, 10 | "refresh_interval": 15, 11 | "loglevel": "INFO", 12 | "sensors": { 13 | "cpu": { 14 | "enabled": true, 15 | "name": "CPU" 16 | }, 17 | "memory": { 18 | "enabled": true, 19 | "name": "Memory Load" 20 | }, 21 | "uptime": { 22 | "enabled": true, 23 | "name": "Uptime" 24 | }, 25 | "status": { 26 | "enabled": true, 27 | "name": "Status" 28 | }, 29 | "battery_level": { 30 | "enabled": false, 31 | "name": "Battery Level" 32 | }, 33 | "battery_state": { 34 | "enabled": false, 35 | "name": "Battery State" 36 | }, 37 | "camera_state": { 38 | "enabled": false, 39 | "name": "Camera State" 40 | } 41 | }, 42 | "services": { 43 | "notifications": { 44 | "enabled": true, 45 | "url_program": "xdg-open", 46 | "commands": { 47 | "command_suspend": { 48 | "name": "Suspend", 49 | "command": ["systemctl", "suspend"] 50 | }, 51 | "command_poweroff": { 52 | "name": "Power off", 53 | "command": ["systemctl", "poweroff"] 54 | }, 55 | "command_reboot": { 56 | "name": "Reboot", 57 | "command": ["systemctl", "reboot"] 58 | }, 59 | "command_hibernate": { 60 | "name": "Hibernate", 61 | "command": ["systemctl", "hibernate"] 62 | }, 63 | "command_open_ha": { 64 | "name": "Open Home Assistant", 65 | "command": ["xdg-open", "http://homeassistant.local:8123/"] 66 | }, 67 | "command_open_spotify": { 68 | "name": "Open Spotify Flatpak", 69 | "command": ["flatpak", "run", "com.spotify.Client"] 70 | } 71 | } 72 | } 73 | } 74 | } 75 | 76 | -------------------------------------------------------------------------------- /halinuxcompanion/test_main.py: -------------------------------------------------------------------------------- 1 | from halinuxcompanion.api import Server 2 | from halinuxcompanion.notifier import Notifier 3 | from halinuxcompanion.sensors.status import Status 4 | import json 5 | from halinuxcompanion.companion import CommandConfig, Companion 6 | import pytest 7 | 8 | 9 | def get_config() -> dict: 10 | with open("tests/config.json") as f: 11 | data = json.load(f) 12 | return data 13 | 14 | 15 | class RequestStub: 16 | def __init__(self, json): 17 | self.__json = json 18 | 19 | async def json(self): 20 | return self.__json 21 | 22 | 23 | def setup_companion() -> Companion: 24 | data = get_config() 25 | companion = Companion(data) 26 | return companion 27 | 28 | 29 | def setup_notifier() -> Notifier: 30 | notifier = Notifier() 31 | return notifier 32 | 33 | 34 | def test_status_updater(): 35 | Status.updater() 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_notifier(): 40 | notifier = setup_notifier() 41 | notifier.push_token = "d0f7bd90-7b23-11ee-852f-0 0d861ab3a9c" 42 | notifier.commands = { 43 | "command_suspend": CommandConfig(name="Suspend", command=["ls"]), 44 | } 45 | 46 | # Existing command 47 | payload = { 48 | "message": "command_suspend", 49 | "push_token": notifier.push_token, 50 | "registration_info": { 51 | "app_id": "Linux_Companion0.0.1", 52 | "app_version": "0.0.1", 53 | "webhook_id": "fd0e8af0183a1445e029436995286479a57d5a455b4d6ce3e40b743c3969b 505", 54 | "os_version": "6.5.9-arch2-1", 55 | }, 56 | } 57 | result = await notifier.on_ha_notification(RequestStub(payload)) 58 | assert result is not None 59 | 60 | # Non existing command 61 | payload = { 62 | "message": "suspend", 63 | "push_token": notifier.push_token, 64 | "registration_info": { 65 | "app_id": "Linux_Companion0.0.1", 66 | "app_version": "0.0.1", 67 | "webhook_id": "fd0e8af0183a1445e029436995286479a57d5a455b4d6ce3e40b743c3969b 505", 68 | "os_version": "6.5.9-arch2-1", 69 | }, 70 | } 71 | result = await notifier.on_ha_notification(RequestStub(payload)) 72 | assert result is not None 73 | 74 | 75 | def test_setup(): 76 | assert True 77 | 78 | 79 | def test_companion_init(): 80 | companion = setup_companion() 81 | assert companion is not None 82 | -------------------------------------------------------------------------------- /halinuxcompanion/__main__.py: -------------------------------------------------------------------------------- 1 | from halinuxcompanion.api import API, Server 2 | from halinuxcompanion.dbus import Dbus 3 | from halinuxcompanion.notifier import Notifier 4 | from halinuxcompanion.companion import Companion 5 | from halinuxcompanion.sensor import Sensor, SensorManager 6 | from halinuxcompanion.sensors import * 7 | 8 | import asyncio 9 | import json 10 | import logging 11 | import argparse 12 | # set logging level using and environment variable 13 | logger = logging.getLogger("halinuxcompanion") 14 | 15 | 16 | def load_config(file="config.json") -> dict: 17 | logger.info("Reading configuration file %s", file) 18 | try: 19 | with open(file, "r") as f: 20 | return json.load(f) 21 | except FileNotFoundError: 22 | logger.critical("Config file not found %s, exiting now", file) 23 | exit(1) 24 | 25 | 26 | def commandline() -> argparse.Namespace: 27 | parser = argparse.ArgumentParser(description="Home Assistan Linux Companion") 28 | parser.add_argument( 29 | "-c", 30 | "--config", 31 | help="Path to config file", 32 | default="config.json", 33 | ) 34 | parser.add_argument( 35 | "-l", 36 | "--loglevel", 37 | help="Log level", 38 | default="", 39 | ) 40 | args = parser.parse_args() 41 | return args 42 | 43 | 44 | async def main(): 45 | """ Main function 46 | The program is fairly simple, data is sent and received to/from Home Assistant over HTTP 47 | Sensors: 48 | - Data is collected from sensors and sent to Home Assistant. 49 | Notifications: 50 | - Sent from Home Assistant to the application via embeded webserver, this are sent to the desktop using Dbus. 51 | - Actions are triggered in dbus listened by the application. Some are handled locally others are handled by Home 52 | Assistant, events are relayed to it as expected (closed and action). 53 | """ 54 | args = commandline() 55 | logging.basicConfig(level="INFO") 56 | config = load_config(args.config) 57 | 58 | # Command line loglevel takes precedence 59 | if args.loglevel != "": 60 | logger.setLevel(args.loglevel) 61 | elif "loglevel" in config: 62 | logger.setLevel(config["loglevel"]) 63 | 64 | companion = Companion(config) # Companion objet where configuration is stored 65 | api = API(companion) # API client to send data to Home Assistant 66 | server = Server(companion) # HTTP server that handles notifications 67 | # Initialize dbus connections 68 | bus = Dbus() 69 | await bus.init() 70 | # Register sensors 71 | sensors = list(filter(lambda x: x.config_name in companion.sensors, Sensor.instances)) 72 | sensor_manager = SensorManager(api, sensors, bus) 73 | 74 | # If the device can't be registered exit immidiately, nothing to do. 75 | ok, reg_data = await companion.load_or_register(api) 76 | if not ok: 77 | logger.critical("Device registration failed, exiting now") 78 | exit(1) 79 | 80 | api.process_registration_data(reg_data) 81 | 82 | # If sensors can't be registered exit immidiately, nothing to do. 83 | if not await sensor_manager.register_sensors(): 84 | logger.critical("Sensor registration failed, exiting now") 85 | exit(1) 86 | 87 | # Initialize the notifier which implies the webserver and the dbus interface 88 | if companion.notifier: 89 | # TODO: Session bus is initialized already. 90 | # DBus session client to send desktop notifications and listen to signals 91 | # Notifier behavior: HA -> Webserver -> dbus ... dbus -> event_handler -> HA 92 | notifier = Notifier() 93 | await notifier.init(bus, api, server, companion) 94 | await server.start() 95 | 96 | interval = companion.refresh_interval 97 | # Loop forever updating sensors. 98 | while True: 99 | await sensor_manager.update_sensors() 100 | await asyncio.sleep(interval) 101 | 102 | 103 | loop = asyncio.new_event_loop() 104 | loop.run_until_complete(main()) 105 | -------------------------------------------------------------------------------- /halinuxcompanion/dbus.py: -------------------------------------------------------------------------------- 1 | from dbus_next.aio import MessageBus, ProxyInterface 2 | from dbus_next import BusType 3 | from dbus_next.errors import DBusError 4 | from typing import Callable, Optional 5 | import logging 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | NOTIFICATIONS_INTERFACE = "org.freedesktop.Notifications" 10 | LOGIN_INTERFACE = "org.freedesktop.login1.Manager" 11 | SCREENSAVER_INTERFACE = "org.freedesktop.ScreenSaver" 12 | SCREENSAVER_GNOME_INTERFACE = "org.gnome.ScreenSaver" 13 | 14 | SIGNALS = { 15 | "session.notification_on_action_invoked": { 16 | "name": "on_action_invoked", 17 | "interface": NOTIFICATIONS_INTERFACE, 18 | }, 19 | "session.notification_on_notification_closed": { 20 | "name": "on_notification_closed", 21 | "interface": NOTIFICATIONS_INTERFACE, 22 | }, 23 | "session.screensaver_on_active_changed": { 24 | "name": "on_active_changed", 25 | "interface": SCREENSAVER_INTERFACE, 26 | }, 27 | "session.gnome_screensaver_on_active_changed": { 28 | "name": "on_active_changed", 29 | "interface": SCREENSAVER_GNOME_INTERFACE, 30 | }, 31 | "system.login_on_prepare_for_sleep": { 32 | "name": "on_prepare_for_sleep", 33 | "interface": LOGIN_INTERFACE, 34 | }, 35 | "system.login_on_prepare_for_shutdown": { 36 | "name": "on_prepare_for_shutdown", 37 | "interface": LOGIN_INTERFACE, 38 | }, 39 | "subscribed": [], 40 | } 41 | 42 | INTERFACES = { 43 | LOGIN_INTERFACE: { 44 | "type": "system", 45 | "service": "org.freedesktop.login1", 46 | "path": "/org/freedesktop/login1", 47 | "interface": LOGIN_INTERFACE, 48 | }, 49 | SCREENSAVER_INTERFACE: { 50 | "type": "session", 51 | "service": SCREENSAVER_INTERFACE, 52 | "path": "/org/freedesktop/ScreenSaver", 53 | "interface": SCREENSAVER_INTERFACE, 54 | }, 55 | SCREENSAVER_GNOME_INTERFACE: { 56 | "type": "session", 57 | "service": SCREENSAVER_GNOME_INTERFACE, 58 | "path": "/org/gnome/ScreenSaver", 59 | "interface": SCREENSAVER_GNOME_INTERFACE, 60 | }, 61 | NOTIFICATIONS_INTERFACE: { 62 | "type": "session", 63 | "service": NOTIFICATIONS_INTERFACE, 64 | "path": "/org/freedesktop/Notifications", 65 | "interface": NOTIFICATIONS_INTERFACE, 66 | }, 67 | } 68 | 69 | 70 | async def get_interface(bus, service, path, interface) -> Optional[ProxyInterface]: 71 | try: 72 | introspection = await bus.introspect(service, path) 73 | proxy = bus.get_proxy_object(service, path, introspection) 74 | return proxy.get_interface(interface) 75 | except DBusError: 76 | return None 77 | 78 | 79 | class Dbus: 80 | session: MessageBus 81 | system: MessageBus 82 | interfaces: dict[str, ProxyInterface] = {} 83 | 84 | async def init(self) -> None: 85 | self.system = await MessageBus(bus_type=BusType.SYSTEM).connect() 86 | self.session = await MessageBus(bus_type=BusType.SESSION).connect() 87 | 88 | async def get_interface(self, name: str) -> Optional[ProxyInterface]: 89 | i = INTERFACES[name] 90 | bus_type, service, path, interface = i["type"], i["service"], i["path"], i["interface"] 91 | iface = self.interfaces.get(name) 92 | if iface is None: 93 | if bus_type == "system": 94 | bus = self.system 95 | else: 96 | bus = self.session 97 | iface = await get_interface(bus, service, path, interface) 98 | if iface is not None: 99 | self.interfaces[name] = iface 100 | 101 | return iface 102 | 103 | async def register_signal(self, signal_alias: str, callback: Callable) -> None: 104 | """Register a signal handler""" 105 | iface_name, signal_name = SIGNALS[signal_alias]["interface"], SIGNALS[signal_alias]["name"] 106 | iface = await self.get_interface(iface_name) 107 | if iface is not None: 108 | getattr(iface, signal_name)(callback) 109 | logger.info("Registered signal callback for interface:%s, signal:%s", iface_name, signal_name) 110 | SIGNALS["subscribed"].append((signal_alias, callback)) 111 | else: 112 | logger.warning("Could not register signal callback for interface:%s, signal:%s", iface_name, signal_name) 113 | -------------------------------------------------------------------------------- /halinuxcompanion/api.py: -------------------------------------------------------------------------------- 1 | from .companion import Companion 2 | 3 | import logging 4 | from aiohttp import (web, ClientSession, ClientResponse) 5 | from typing import Optional 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | SC_INVALID_JSON = 400 10 | SC_MOBILE_COMPONENT_NOT_LOADED = 404 11 | SC_INTEGRATION_DELETED = 410 12 | SESSION: Optional[ClientSession] = None 13 | 14 | 15 | class API: 16 | """Class that handles Home Assisntat HTTP API calls""" 17 | instance_url: str 18 | token: str 19 | headers: dict 20 | register_payload: dict 21 | # Returned by register_device 22 | cloudhook_url: str or None 23 | remote_ui_url: str or None 24 | secret: str 25 | webhook_id: str 26 | webhook_url: str 27 | counter: int = 0 28 | session: ClientSession 29 | 30 | def __init__(self, companion: Companion) -> None: 31 | global SESSION 32 | if SESSION is None: 33 | SESSION = ClientSession() 34 | self.session = SESSION 35 | self.token = companion.ha_token 36 | self.headers = {'Authorization': 'Bearer ' + self.token} 37 | self.instance_url = companion.ha_url 38 | self.register_payload = companion.registration_payload() 39 | 40 | async def webhook_post(self, type: str, data: str) -> ClientResponse: 41 | """Send a POST request to the webhook endpoint with the given type and data 42 | Simple wrapper that handles and logs response status, should be wrapped to handle clinet errors. 43 | :param type: Whats being posted, ussed for logging 44 | :param data: The data to send in the body of the request (json serialized) 45 | """ 46 | 47 | self.counter += 1 48 | logger.debug('Sending webhook POST %s type:%s ', self.counter, type) 49 | 50 | async with self.session.post(self.webhook_url, data=data) as res: 51 | logger.debug('Recived response %s to request %s', res.status, self.counter) 52 | 53 | if logger.level == logging.DEBUG: 54 | if res.status == SC_INVALID_JSON: 55 | logger.error('Invalid JSON %s', self.webhook_url) 56 | if res.status == SC_MOBILE_COMPONENT_NOT_LOADED: 57 | logger.error('The mobile_app component has not ben loaded %s', self.webhook_url) 58 | elif res.status == SC_INTEGRATION_DELETED: 59 | logger.error('The integration has been deleted, need to register again %s', self.webhook_url) 60 | 61 | return res 62 | 63 | async def post(self, endpoint: str, data: str) -> ClientResponse: 64 | """Send a POST request to the given Home Assisntat endpoint 65 | Headers are set to the token and the body is set to the data 66 | 67 | :param endpoint: The endpoint to send the request to (must have a leading /) 68 | :param data: The data to send in the body of the request (json serialized) 69 | :return: The response from Home Assisntat 70 | """ 71 | return await self.session.post(self.instance_url + endpoint, headers=self.headers, data=data) 72 | 73 | async def get(self, endpoint: str) -> ClientResponse: 74 | """Send a GET request to the given Home Assisntat endpoint 75 | Headers are set to the token 76 | 77 | :param endpoint: The endpoint to send the request to (must have a leading /) 78 | :return: The response from Home Assisntat 79 | """ 80 | return await self.session.get(self.instance_url + endpoint, headers=self.headers) 81 | 82 | def process_registration_data(self, data: dict) -> None: 83 | """Process the data returned from the registration endpoint 84 | :param data: The data returned from the registration endpoint 85 | """ 86 | self.secret = data['secret'] 87 | self.webhook_id = data['webhook_id'] 88 | self.webhook_url = self.instance_url + '/api/webhook/' + self.webhook_id 89 | self.cloudhook_url = data.get('cloudhook_url', "") 90 | self.remote_ui_url = data.get('remote_ui_url', "") 91 | 92 | 93 | class Server: 94 | """Class that runs an http server and handles requests in the route /notify""" 95 | app: web.Application 96 | host: str 97 | port: int 98 | 99 | def __init__(self, companion: Companion) -> None: 100 | self.app = web.Application() 101 | self.host = companion.computer_ip 102 | self.port = companion.computer_port 103 | 104 | async def start(self) -> None: 105 | logger.info('Starting http server on %s:%s', self.host, self.port) 106 | runner = web.AppRunner(self.app) 107 | await runner.setup() 108 | site = web.TCPSite(runner, self.host, self.port) 109 | await site.start() 110 | logger.info('Server started on %s:%s', self.host, self.port) 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant Linux Companion 2 | 3 | Application to run on Linux desktop computer to provide sensor data to Home Assistant, and get notifications as if it was a mobile device. 4 | 5 | ## How To 6 | 7 | ### Requirements 8 | 9 | Python 3.10+ and the related `dev` dependencies (usually `python3-dev` or `python3-devel` on your package manager) 10 | 11 | ### Instructions 12 | 13 | 1. [Get a long-lived access token from your Home Assistant user](https://www.home-assistant.io/docs/authentication/#your-account-profile) 14 | 1. Clone this repository in a subfolder from your home directory (unless you don't want to run the service from `systemd`) 15 | 1. Create a Python virtual environment and install all the requirements: 16 | 17 | ```shell 18 | cd halinuxcompanion # this is the root of the cloned project 19 | python3 -m venv .venv 20 | source .venv/bin/activate 21 | pip install -r requirements.txt 22 | ``` 23 | 24 | 1. Copy `config.example.json` to `config.json`. 25 | 1. Modify `config.json` to match your setup and desired options. 26 | 1. Run the application, either from: 27 | 1. the virtual environment directly: `python -m halinuxcompanion --config config.json`. In this case, you'll need to run it again when you restart. 28 | 1. or setting up a systemd service (don't use `sudo` for any of the commands below; if you need it, something is probably wrong with your setup): 29 | 1. Copy the sample unit file from `halinuxcompanion/resources/halinuxcompanion.service` to `~/.config/systemd/user/` 30 | 1. Modify it to match your setup - mainly, the installation paths at `WorkingDirectory` and `ExecStart` 31 | 1. (Re)Load it with `systemctl --user daemon-reload` 32 | 1. Start it with `systemctl --user start halinuxcompanion` 33 | 1. You can check if it went well with `systemctl --user status halinuxcompanion`. If it errored, you can check logs with `journalctl --user -u halinuxcompanion` 34 | 1. If all went well, you can enable it permanently with `systemctl --user enable halinuxcompanion` 35 | 36 | Now in your Home Assistant you will see a new device in the **"mobile_app"** integration, and there will be a new service to notify your Linux desktop. Notification actions work and the expected events will be fired in Home Assistant. 37 | 38 | ## [Example configuration file](config.example.json) 39 | 40 | ```json 41 | { 42 | "ha_url": "http://homeassistant.local:8123/", 43 | "ha_token": "mysuperlongtoken", 44 | "device_id": "computername", 45 | "device_name": "whatever you want can be left empty", 46 | "manufacturer": "whatever you want can be left empty", 47 | "model": "Computer", 48 | "computer_ip": "192.168.1.15", 49 | "computer_port": 8400, 50 | "refresh_interval": 15, 51 | "loglevel": "INFO", 52 | "sensors": { 53 | "cpu": { 54 | "enabled": true, 55 | "name": "CPU" 56 | }, 57 | "memory": { 58 | "enabled": true, 59 | "name": "Memory Load" 60 | }, 61 | "uptime": { 62 | "enabled": true, 63 | "name": "Uptime" 64 | }, 65 | "status": { 66 | "enabled": true, 67 | "name": "Status" 68 | }, 69 | "battery_level": { 70 | "enabled": true, 71 | "name": "Battery Level" 72 | }, 73 | "battery_state": { 74 | "enabled": true, 75 | "name": "Battery State" 76 | }, 77 | "camera_state": { 78 | "enabled": true, 79 | "name": "Camera State" 80 | } 81 | }, 82 | "services": { 83 | "notifications": { 84 | "enabled": true, 85 | "url_program": "xdg-open", 86 | "commands": { 87 | "command_suspend": { 88 | "name": "Suspend", 89 | "command": ["systemctl", "suspend"] 90 | }, 91 | "command_poweroff": { 92 | "name": "Power off", 93 | "command": ["systemctl", "poweroff"] 94 | }, 95 | "command_reboot": { 96 | "name": "Reboot", 97 | "command": ["systemctl", "reboot"] 98 | }, 99 | "command_hibernate": { 100 | "name": "Hibernate", 101 | "command": ["systemctl", "hibernate"] 102 | }, 103 | "command_open_ha": { 104 | "name": "Open Home Assistant", 105 | "command": ["xdg-open", "http://homeassistant.local:8123/"] 106 | }, 107 | "command_open_spotify": { 108 | "name": "Open Spotify Flatpak", 109 | "command": ["flatpak", "run", "com.spotify.Client"] 110 | } 111 | } 112 | } 113 | } 114 | } 115 | ``` 116 | 117 | ## Technical 118 | 119 | - [Home Assistant Native App Integration](https://developers.home-assistant.io/docs/api/native-app-integration) 120 | - [Home Assistant REST API](https://developers.home-assistant.io/docs/api/rest) 121 | - Asynchronous (because why not :smile:) 122 | - HTTP Server ([aiohttp](https://docs.aiohttp.org/en/stable/)): Listen to POST notification service call from Home Assistant 123 | - Client ([aiohttp](https://docs.aiohttp.org/en/stable/)): POST to Home Assistant api, sensors, events, etc 124 | - [Dbus](https://www.freedesktop.org/wiki/Software/dbus/) interface ([dbus_next](https://python-dbus-next.readthedocs.io/en/latest/index.html)): Sending notifications and listening to notification actions from the desktop, also listens to sleep, shutdown to update the status sensor 125 | 126 | ## To-do 127 | 128 | - [ ] [Implement encryption](https://developers.home-assistant.io/docs/api/native-app-integration/sending-data) 129 | - [ ] Move sensors to MQTT 130 | The reasoning for the change is the limitations of the API, naturally is expected that desktop and laptops would go offline and I would like for the sensors to reflect this new state. But if for some reason the application is unable to send this new state to Home Assistant the values of the sensors would be stuck. But if the app uses MQTT it can set will topics for the sensors to be updated when the client can't communicate with the server. 131 | - [ ] One day make it work with remote and local instance, for laptops roaming networks 132 | - [x] Status sensors that listens to sleep, wakeup, shutdown, power_on 133 | - [ ] Add more sensors 134 | - [ ] Finish notifications functionality 135 | - [x] Add notification commands 136 | - [x] [Notifications Clearing](https://companion.home-assistant.io/docs/notifications/notifications-basic/#clearing) 137 | - [ ] [Notification Icon](https://companion.home-assistant.io/docs/notifications/notifications-basic/#notification-icon) 138 | 139 | ## Features 140 | 141 | - Sensors: 142 | - CPU 143 | - Memory 144 | - Uptime 145 | - Status: Computer status, reflects if the computer went to sleep, wakes up, shutdown, turned on. The sensor is updated right before any of these events happen by listening to dbus signals. 146 | - Battery Level 147 | - Batter State 148 | - Notifications: 149 | - [Actionable Notifications](https://companion.home-assistant.io/docs/notifications/actionable-notifications#building-actionable-notifications) (Triggers event in Home Assistant) 150 | - [Local action handler using URI](https://companion.home-assistant.io/docs/notifications/actionable-notifications#uri-values): only relative style `/lovelace/myviwew` and `http(s)` uri supported so far. 151 | - [Notification cleared/dismissed](https://companion.home-assistant.io/docs/notifications/notification-cleared/) (Triggers event in Home Assistant) 152 | - [Timeout](https://companion.home-assistant.io/docs/notifications/notifications-basic#notification-timeout) 153 | - [Commands](https://companion.home-assistant.io/docs/notifications/notification-commands/) 154 | - [Replacing](https://companion.home-assistant.io/docs/notifications/notifications-basic/#replacing) 155 | - [Clearing](https://companion.home-assistant.io/docs/notifications/notifications-basic/#clearing) 156 | - [Icon](https://companion.home-assistant.io/docs/notifications/notifications-basic/#notification-icon) **TODO** 157 | - Default commands (example config): 158 | - Suspend 159 | - Power off 160 | - Reboot 161 | - Hibernate 162 | -------------------------------------------------------------------------------- /halinuxcompanion/sensor.py: -------------------------------------------------------------------------------- 1 | from types import MethodType 2 | from halinuxcompanion.api import API 3 | from halinuxcompanion.dbus import Dbus 4 | from aiohttp import ClientError 5 | from typing import Union, List, Dict, Callable 6 | from functools import partial, update_wrapper 7 | import json 8 | import logging 9 | import asyncio 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | SC_REGISTER_SENSOR = 301 14 | 15 | 16 | class Sensor: 17 | """Standard sensor class""" 18 | 19 | instances = [] 20 | 21 | def __init__(self): 22 | self.config_name: str 23 | self.attributes: dict = {} 24 | self.device_class: str = "" 25 | self.state_class: str = "" 26 | self.icon: str 27 | self.name: str 28 | self.state: Union[str, int, float] = "" 29 | self.type: str 30 | self.unique_id: str 31 | self.unit_of_measurement: str = "" 32 | self.state_class: str = "" 33 | self.entity_category: str = "" 34 | self.type: str 35 | # Signal name (halinuxcompanion.dbus) and it's callback 36 | self.signals: Dict[str, Callable] = {} 37 | Sensor.instances.append(self) 38 | 39 | # TODO: Should be async 40 | def updater(self) -> None: 41 | """To be called every time update is called""" 42 | pass 43 | 44 | def update(self) -> dict: 45 | """Payload to update the sensor""" 46 | self.updater() 47 | return { 48 | "attributes": self.attributes, 49 | "icon": self.icon, 50 | "state": self.state, 51 | "type": self.type, 52 | "unique_id": self.unique_id, 53 | } 54 | 55 | def register(self) -> dict: 56 | self.updater() 57 | """Payload to register the sensor""" 58 | data = { 59 | "attributes": self.attributes, 60 | "device_class": self.device_class, 61 | "icon": self.icon, 62 | "name": self.name, 63 | "state": self.state, 64 | "type": self.type, 65 | "unique_id": self.unique_id, 66 | "unit_of_measurement": self.unit_of_measurement, 67 | "state_class": self.state_class, 68 | "entity_category": self.entity_category, 69 | } 70 | pop = [] 71 | for key in data: 72 | if data[key] == "": 73 | pop.append(key) 74 | [data.pop(key) for key in pop] 75 | return data 76 | 77 | def update(self) -> dict: 78 | """Payload to update the sensor""" 79 | self.updater() 80 | return { 81 | "attributes": self.attributes, 82 | "icon": self.icon, 83 | "state": self.state, 84 | "type": self.type, 85 | "unique_id": self.unique_id, 86 | } 87 | 88 | 89 | class SensorManager: 90 | """Manages sensors registration, and updates to Home Assistant""" 91 | 92 | api: API 93 | update_counter: int = 0 94 | sensors: List[Sensor] = [] 95 | dbus: Dbus 96 | 97 | def __init__(self, api: API, sensors: List[Sensor], dbus: Dbus) -> None: 98 | self.api = api 99 | self.sensors = sensors 100 | self.dbus = dbus 101 | 102 | async def register_sensors(self) -> bool: 103 | """Register all sensors with Home Assisntat 104 | If all have been registered successfully, register each sensor signals 105 | """ 106 | res = await asyncio.gather(*[self._register_sensor(s) for s in self.sensors]) 107 | if all(res): 108 | # If all sensors registered successfully, register their signals 109 | await self.register_signals() 110 | return True 111 | 112 | return False 113 | 114 | async def _register_sensor(self, sensor: Sensor) -> bool: 115 | """Register a sensor with Home Assisntat 116 | If the registration fails it's a critical error and the program should exit. 117 | 118 | :param sensor: The sensor to register 119 | :return: True if the registration was successful, False otherwise 120 | """ 121 | data = {"data": sensor.register(), "type": "register_sensor"} 122 | sname = sensor.config_name 123 | data = json.dumps(data) 124 | logger.info("Registering sensor:%s payload:%s", sname, data) 125 | res = await self.api.webhook_post("register_sensor", data=data) 126 | 127 | if res.ok or res.status == SC_REGISTER_SENSOR: 128 | logger.info("Sensor registration successful: %s", sname) 129 | return True 130 | else: 131 | logger.error('Sensor registration failed with status code:%s sensor:%s', res.status, sensor.unique_id) 132 | return False 133 | 134 | async def update_sensors(self, sensors: List[Sensor] = []) -> bool: 135 | """Update the given sensors with Home Assisntat 136 | If the update fails it's an error and it should be retried by the caller. 137 | 138 | :param sensors: The sensors to update, if empty all sensors will be updated 139 | :return: True if the update was successful, False otherwise 140 | """ 141 | sensors = sensors or self.sensors 142 | self.update_counter += 1 143 | data = { 144 | "type": "update_sensor_states", 145 | "data": [sensor.update() for sensor in sensors], 146 | } 147 | snames = [sensor.config_name for sensor in sensors] 148 | logger.info("Sensors update %s with sensors: %s", self.update_counter, snames) 149 | logger.debug( 150 | "Sensors update %s with sensors: %s payload: %s", 151 | self.update_counter, 152 | snames, 153 | data, 154 | ) 155 | try: 156 | res = await self.api.webhook_post("update_sensors", data=json.dumps(data)) 157 | if res.ok or res.status == SC_REGISTER_SENSOR: 158 | logger.info("Sensors update %s successful", self.update_counter) 159 | return True 160 | else: 161 | logger.error( 162 | "Sensors update %s failed with status code:%s", 163 | self.update_counter, 164 | res.status, 165 | ) 166 | except ClientError as e: 167 | logger.error( 168 | "Sensors update %s failed with error:%s", self.update_counter, e 169 | ) 170 | 171 | return False 172 | 173 | async def _signal_handler( 174 | self, signal_alias: str, signal_handler: Callable, sensor: Sensor, *args 175 | ) -> None: 176 | """Signal handler for the sensor manager 177 | Each sensor can have multiple signals, at the moment defined in halinuxcompanion.dbus, the callback provided for 178 | the signal is this function wrapped in a functools.partial this allows for the SensorManager to be in charge of 179 | actually calling the sensor callback and therefore be able to know when to update it. 180 | 181 | :param sensor: The sensor that the signal belongs to 182 | :param signal_alias: The signal alias (defined in halinuxcompanion.dbus) 183 | :param signal_handler: The signal handler (defined by the sensor in sensor.signals) 184 | :param args: The arguments to pass to the signal handler (coming from the dbus signal) 185 | """ 186 | logger.info("Signal %s received for sensor:%s", signal_alias, sensor.unique_id) 187 | await signal_handler(sensor, *args) 188 | await self.update_sensors([sensor]) 189 | 190 | async def register_signals(self) -> None: 191 | """Register all signals from all sensors. 192 | Each sensor defines signals with a name and callback, which is called by self._signal_handler 193 | """ 194 | for sensor in self.sensors: 195 | for signal_alias, signal_handler in sensor.signals.items(): 196 | callback = partial(self._signal_handler, signal_alias, signal_handler) 197 | callback = MethodType(update_wrapper(callback, signal_handler), sensor) 198 | await self.dbus.register_signal(signal_alias, callback) 199 | -------------------------------------------------------------------------------- /halinuxcompanion/companion.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import platform 4 | import uuid 5 | import logging 6 | from pydantic import BaseModel 7 | from typing import Dict, List, Optional, Tuple, TYPE_CHECKING 8 | 9 | SC_INTEGRATION_DELETED = 410 10 | 11 | if TYPE_CHECKING: 12 | from halinuxcompanion.api import API 13 | 14 | CONFIG_KEYS = [ 15 | ("ha_url", True), 16 | ("ha_token", True), 17 | ("device_id", True), 18 | ("device_name", False), 19 | ("manufacturer", False), 20 | ("model", False), 21 | ("computer_ip", True), 22 | ("computer_port", True), 23 | ("refresh_interval", False), 24 | ("services", True), 25 | ("sensors", True), 26 | ] 27 | 28 | 29 | class CommandConfig(BaseModel): 30 | name: str 31 | command: List[str] 32 | 33 | 34 | class NotificationServiceConfig(BaseModel): 35 | enabled: bool 36 | url_program: str 37 | commands: Dict[str, CommandConfig] 38 | 39 | 40 | class ServicesConfig(BaseModel): 41 | notifications: Optional[NotificationServiceConfig] 42 | 43 | 44 | class SensorConfig(BaseModel): 45 | enabled: bool 46 | name: str 47 | 48 | 49 | class CompanionConfig(BaseModel): 50 | ha_url: str 51 | ha_token: str 52 | device_id: str 53 | device_name: Optional[str] 54 | manufacturer: Optional[str] 55 | model: Optional[str] 56 | computer_ip: str 57 | computer_port: int 58 | refresh_interval: Optional[int] 59 | sensors: Dict[str, SensorConfig] 60 | services: Optional[ServicesConfig] 61 | 62 | 63 | logger = logging.getLogger("halinuxcompanion") 64 | 65 | 66 | class Companion: 67 | """Class encolsing a companion instance 68 | https://developers.home-assistant.io/docs/api/native-app-integration/setup 69 | """ 70 | 71 | # TODO: This class is just a huge pile of things 72 | # TODO: Revisit this device_id which must be unique, used for notification events 73 | device_id: str = platform.node() 74 | # TODO: Get the default values from something that helps sets releases. 75 | app_name: str = "Linux Companion" 76 | app_version: str = "0.0.1" 77 | app_id: str = app_name.replace(" ", "_") + app_version 78 | device_name: str = platform.node() 79 | manufacturer: str = platform.system() 80 | model: str = "Computer" 81 | os_name: str = platform.system() 82 | os_version: str = platform.release() 83 | # TODO: Encryption requires https://github.com/jedisct1/libsodium 84 | encryption_key: str = "NOT IMPLEMENTED" 85 | supports_encryption: bool = False 86 | app_data: dict = {} 87 | notifier: bool = False 88 | refresh_interval: int = 15 89 | computer_ip: str = "" 90 | computer_port: int = 8400 91 | ha_url: str = "http://localhost:8123" 92 | ha_token: str 93 | url_program: str = "" 94 | commands: Dict[str, CommandConfig] = {} 95 | sensors: Dict[str, bool] = {} 96 | 97 | def __init__(self, config: dict): 98 | # Load only allowed values 99 | parsed = CompanionConfig.model_validate(config) 100 | self.load_config_from_model(parsed) 101 | 102 | def load_config_from_model(self, config: CompanionConfig): 103 | self.ha_url = config.ha_url.rstrip("/") 104 | self.ha_token = config.ha_token 105 | self.device_id = config.device_id 106 | self.device_name = ( 107 | config.device_name if config.device_name else self.device_name 108 | ) 109 | self.manufacturer = ( 110 | config.manufacturer if config.manufacturer else self.manufacturer 111 | ) 112 | self.model = config.model if config.model else self.model 113 | self.computer_ip = config.computer_ip 114 | self.computer_port = config.computer_port 115 | self.refresh_interval = ( 116 | config.refresh_interval 117 | if config.refresh_interval 118 | else self.refresh_interval 119 | ) 120 | 121 | from halinuxcompanion.sensors import __all__ as all_sensors 122 | 123 | for name, sensor in config.sensors.items(): 124 | if name not in all_sensors: 125 | logger.error("Sensor %s doesn't exist", name) 126 | exit(1) 127 | else: 128 | self.sensors[name] = sensor.enabled 129 | 130 | if ( 131 | config.services 132 | and config.services.notifications 133 | and config.services.notifications.enabled 134 | ): 135 | # Let's generate the push token derived from the device_id 136 | import hashlib 137 | push_token = f"push_token_{self.device_id}_halinuxcompanion" 138 | push_token = hashlib.sha256(push_token.encode()).hexdigest() 139 | 140 | self.notifier = True 141 | self.app_data = { 142 | "push_token": push_token, # TODO: Random generation, and store it in state 143 | "push_url": f"http://{self.computer_ip}:{self.computer_port}/notify", 144 | } 145 | self.url_program = config.services.notifications.url_program 146 | self.commands = config.services.notifications.commands 147 | 148 | def registration_payload(self) -> dict: 149 | return { 150 | "device_id": self.device_id, 151 | "app_id": self.app_id, 152 | "app_name": self.app_name, 153 | "app_version": self.app_version, 154 | "device_name": self.device_name, 155 | "manufacturer": self.manufacturer, 156 | "model": self.model, 157 | "os_name": self.os_name, 158 | "os_version": self.os_version, 159 | "supports_encryption": self.supports_encryption, 160 | "app_data": self.app_data, 161 | } 162 | 163 | async def check_registration(self, api: "API", data: dict) -> bool: 164 | """ 165 | Check if the current registration is still valid 166 | """ 167 | logger.info("Checking if device is already registered") 168 | api.process_registration_data(data) 169 | res = await api.webhook_post("get_config", data=json.dumps({"type": "get_config"})) 170 | if res.status == 200: 171 | return True 172 | elif res.status == SC_INTEGRATION_DELETED: 173 | logger.info("Device registration has been deleted, need to register again") 174 | return False 175 | else: 176 | logger.error("Device registration failed with status code %s", res.status) 177 | raise Exception("Device registration failed " + str(res.status)) 178 | 179 | async def register(self, api: "API") -> Tuple[bool, dict]: 180 | """Register the companion with Home Assisntat 181 | If the registration fails it's a critical error and the program should exit. 182 | 183 | :return: (True, registration_data) if successful, (False, {}) otherwise 184 | """ 185 | register_data = json.dumps(self.registration_payload()) 186 | logger.info("Registering companion device with payload:%s", register_data) 187 | res = await api.post("/api/mobile_app/registrations", data=register_data) 188 | 189 | if res.ok: 190 | data = await res.json() 191 | logger.info("Device Registration successful: %s", data) 192 | self.save_registration_data(data) 193 | return True, data 194 | else: 195 | text = await res.text() 196 | logger.critical( 197 | "Device Registration failed with status code %s, text: %s", 198 | res.status, 199 | text, 200 | ) 201 | return False, {} 202 | 203 | async def load_or_register(self, api: "API") -> Tuple[bool, dict]: 204 | """ 205 | Load registration data from disk or register the companion APP 206 | """ 207 | registration_data = self.load_registration_data() 208 | if registration_data: 209 | logger.info("Loaded existing registration data from disk %s", registration_data) 210 | if await self.check_registration(api, registration_data): 211 | logger.info("Device already registered") 212 | return True, registration_data 213 | else: 214 | logger.info("Device registration data is invalid, re-registering") 215 | return await self.register(api) 216 | 217 | def save_registration_data(self, data: dict): 218 | # store data in $XDG_STATE_HOME/halinuxcompanion/registration.json 219 | state_home = os.getenv("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) 220 | if not os.path.exists(state_home): 221 | os.makedirs(state_home) 222 | 223 | app_state_dir = os.path.join(state_home, "halinuxcompanion") 224 | if not os.path.exists(app_state_dir): 225 | os.makedirs(app_state_dir) 226 | 227 | registration_path = os.path.join(app_state_dir, "registration.json") 228 | 229 | with open(registration_path, "w") as f: 230 | f.write(json.dumps(data)) 231 | 232 | def load_registration_data(self) -> Optional[dict]: 233 | state_home = os.getenv("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) 234 | registration_path = os.path.join(state_home, "halinuxcompanion", "registration.json") 235 | 236 | if os.path.exists(registration_path): 237 | with open(registration_path, "r") as f: 238 | return json.load(f) 239 | return None 240 | -------------------------------------------------------------------------------- /halinuxcompanion/notifier.py: -------------------------------------------------------------------------------- 1 | from halinuxcompanion.companion import CommandConfig, Companion 2 | from halinuxcompanion.api import API, Server 3 | from halinuxcompanion.dbus import Dbus 4 | 5 | import asyncio 6 | from aiohttp.web import Response, json_response 7 | from aiohttp import ClientError 8 | from dbus_next.aio import ProxyInterface 9 | from dbus_next.signature import Variant 10 | from importlib.resources import files 11 | from collections import OrderedDict 12 | from typing import Dict, List 13 | import json 14 | import re 15 | import logging 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | APP_NAME = "halinuxcompanion" 20 | HA = "Home Assistant" 21 | HA_ICON = files("halinuxcompanion.resources").joinpath("home-assistant-favicon.png") 22 | 23 | # Urgency levels 24 | # https://people.gnome.org/~mccann/docs/notification-spec/notification-spec-latest.html#urgency-levels 25 | URGENCY_LOW = Variant("u", 0) 26 | URGENCY_NORMAL = Variant("u", 1) 27 | URGENCY_CRITICAL = Variant("u", 2) 28 | NOTIFY_LEVELS = { 29 | "min": URGENCY_LOW, 30 | "low": URGENCY_LOW, 31 | "default": URGENCY_NORMAL, 32 | "high": URGENCY_CRITICAL, 33 | "max": URGENCY_CRITICAL, 34 | } 35 | 36 | EVENTS_ENPOINT = { 37 | "closed": "/api/events/mobile_app_notification_cleared", 38 | "action": "/api/events/mobile_app_notification_action", 39 | } 40 | 41 | RESPONSES = { 42 | "invalid_token": json.dumps( 43 | { 44 | "error": "push_token does not match", 45 | "errorMessage": "Sent token that does not match to halinuxcompaion munrig", 46 | } 47 | ).encode("ascii"), 48 | "ok": json.dumps({"success": True, "message": "Notification queued"}).encode( 49 | "ascii" 50 | ), 51 | } 52 | 53 | EMPTY_DICT = {} 54 | 55 | 56 | class Notifier: 57 | """Class that handles the lifetime of notifications 58 | 1. It receives a notification by registering a handler to the web server spawned by the application. 59 | 2. It transforms the notification to the format dbus uses. 60 | 3. It sets up the proxy object to send dbus notifications, and listen to events related to this notifications. 61 | 4. It sends the notification to dbus. 62 | 5. Listens to the dbus events related to this notification. 63 | 6. When dbus events are generated, it emits the event to Home Assistant (if appropieate). 64 | 7. Some action events perform a local action like opening a url. 65 | """ 66 | 67 | # Only keeping the last 20 notifications and popping everytime a new one is added 68 | history: OrderedDict[int, dict] = OrderedDict( 69 | (x, EMPTY_DICT) for x in range(-1, -21, -1) 70 | ) 71 | tagtoid: Dict[str, int] = {} # Lookup id from tag 72 | interface: ProxyInterface 73 | api: API 74 | push_token: str 75 | url_program: str 76 | commands: Dict[str, CommandConfig] 77 | ha_url: str 78 | 79 | def __init__(self): 80 | # The initialization is done in the init function 81 | pass 82 | 83 | async def init( 84 | self, dbus: Dbus, api: API, webserverver: Server, companion: Companion 85 | ) -> None: 86 | """Function to initialize the notifier. 87 | 1. Gets the dbus interface to send notifications and listen to events. 88 | 2. Registers an http handler to the webserver for Home Assistant notifications. 89 | 3. Register callbacks for dbus events (on_action_invoked and on_notification_closed). 90 | 4. Keeps a reference to the API for firing events in Home Assistant. 91 | 5. Sets the push_token used to check if the notification is for this device. 92 | 6. Sets the url_program used to open urls. 93 | 94 | :param dbus: The Dbus class abstraction 95 | """ 96 | # Get the interface 97 | interface = await dbus.get_interface("org.freedesktop.Notifications") 98 | 99 | if interface is None: 100 | logger.warning( 101 | "Could not find org.freedesktop.Notifications interface, disabling notification support." 102 | ) 103 | return 104 | 105 | self.interface = interface 106 | # Setup dbus callbacks 107 | self.interface.on_action_invoked(self.on_action) 108 | self.interface.on_notification_closed(self.on_close) 109 | 110 | # Setup http server route handler for incoming notifications 111 | webserverver.app.router.add_route("POST", "/notify", self.on_ha_notification) 112 | 113 | # API and necessary data 114 | self.api = api 115 | self.push_token = companion.app_data["push_token"] 116 | self.url_program = companion.url_program 117 | self.commands = companion.commands 118 | self.ha_url = companion.ha_url 119 | 120 | # Entrypoint to the Class logic 121 | async def on_ha_notification(self, request) -> Response: 122 | """Function that handles the notification POST request by Home Assistant. 123 | 124 | This is the only entry point to start logic in this class. 125 | This function is called by the http server when a notification is received. The notification is transformed 126 | to the format dbus uses, and sent to dbus. 127 | 128 | :param request: The request object 129 | :return: The response object 130 | """ 131 | notification: dict = await request.json() 132 | push_token = notification.get("push_token") 133 | logger.info("Received notification request:%s", notification) 134 | 135 | # Check if the notification is for this device 136 | if push_token != self.push_token: 137 | logger.error( 138 | "Notification push_token does not match: %s != %s", 139 | push_token, 140 | self.push_token, 141 | ) 142 | return json_response(body=RESPONSES["invalid_token"], status=400) 143 | 144 | # Transform the notification to the format dbus uses 145 | notification = self.notification_transform(notification) 146 | 147 | if notification["is_command"]: 148 | command_id = notification["message"] 149 | command = self.commands.get(command_id) 150 | if command: 151 | # It's not a notification, but a command, therefore no dbus_notify 152 | logger.info( 153 | "Received notification command: id:%s name:%s", command_id, command.name 154 | ) 155 | logger.info("Scheduling notification command: %s", command.command) 156 | asyncio.create_task( 157 | asyncio.create_subprocess_exec( 158 | *command.command, 159 | stdout=asyncio.subprocess.DEVNULL, 160 | stderr=asyncio.subprocess.DEVNULL, 161 | ) 162 | ) 163 | else: 164 | # Got notificatoin command but none defined 165 | logger.error( 166 | "Received notification command %s, but no command is defined", 167 | command_id, 168 | ) 169 | else: 170 | asyncio.create_task( 171 | self.dbus_notify(self.notification_transform(notification)) 172 | ) 173 | 174 | return json_response(body=RESPONSES["ok"], status=201) 175 | 176 | async def ha_event_trigger( 177 | self, event: str, action: str = "", notification: dict = {} 178 | ) -> bool: 179 | """Function to trigger the Home Assistant event given an event type and notification dictionary. 180 | Actions are first handled in on_action which decides wether to emit the event or not. 181 | 182 | :param event: The event type 183 | :param action: The action that was invoked (if any) 184 | :param notification: The notification dictionary 185 | :return: True if the event was triggered, False otherwise 186 | """ 187 | endpoint = EVENTS_ENPOINT[event] 188 | 189 | if notification: 190 | data = { 191 | "title": notification.get("title", ""), 192 | "message": notification.get("message", ""), 193 | **notification.get("event_actions", {}), 194 | **notification["data"], 195 | } 196 | # Replaced by event_actions 197 | if "actions" in data: 198 | del data["actions"] 199 | 200 | if event == "action": 201 | data["action"] = action 202 | 203 | try: 204 | res = await self.api.post(endpoint, json.dumps(data)) 205 | logger.info( 206 | "Sent Home Assistant event:%s data:%s response:%s", 207 | endpoint, 208 | data, 209 | res.status, 210 | ) 211 | return True 212 | except ClientError as e: 213 | logger.error("Error sending Home Assistant event: %s", e) 214 | 215 | return False 216 | 217 | def notification_transform(self, notification: dict) -> dict: 218 | """Function to convert a Home Assistant notification to a dbus notification. 219 | This is done in a best effort manner, as the homeassistant notification format can't be fully translated. 220 | This function mutates the notification dict. 221 | 222 | :param notification: The notification to convert (mutated) 223 | :return: The mutated notification passed with the necessary fields to invoke a dbus notification. 224 | """ 225 | # Add the data, avoids the need to check (branching) ahead 226 | data: dict = notification.setdefault("data", {}) 227 | tag: str = data.setdefault("tag", "") 228 | actions: List[str] = ["default", "Default"] 229 | hints: Dict[str, Variant] = {} 230 | icon: str = HA_ICON # Icon path 231 | timeout: int = -1 # -1 means notification server decides how long to show 232 | replace_id: int = 0 233 | if notification["message"].startswith("command_"): 234 | # This is a command notification, short circuit the rest of the logic, no need to format the notification 235 | # since it won't be stored nor emitted by dbus. 236 | notification["is_command"] = True 237 | return notification 238 | 239 | elif data: 240 | # Actions 241 | # Home Assisnant actions require seome transformation 242 | # https://companion.home-assistant.io/docs/notifications/actionable-notifications 243 | # https://people.gnome.org/~mccann/docs/notification-spec/notification-spec-latest.html#basic-design 244 | 245 | # Dbus notification structure [id, name, id, name, ...] 246 | event_actions = ( 247 | {} 248 | ) # Format the actions as necessary for on_close an on_action events 249 | counter = 1 250 | for a in data.get("actions", []): 251 | actions.extend([a["action"], a["title"]]) 252 | # This is necessary when sending event data on_closed, on_action 253 | event_actions[f"action_{counter}_key"] = a["action"] 254 | event_actions[f"action_{counter}_title"] = a["title"] 255 | 256 | notification["event_actions"] = event_actions 257 | # Uri for the default action 258 | uri = data.get("url", "") or data.get("clickAction", "") 259 | # check if uri starts with /lovelace or lovelace using regex 260 | if uri and re.match(r"^/?lovelace", uri): 261 | uri = f'{self.ha_url}/{uri.lstrip("/")}' 262 | notification["default_action_uri"] = uri 263 | 264 | # Hints: 265 | # Importance -> Urgency 266 | # https://people.gnome.org/~mccann/docs/notification-spec/notification-spec-latest.html#urgency-levels 267 | # https://companion.home-assistant.io/docs/notifications/notifications-basic/#notification-channel-importance 268 | urgency = URGENCY_NORMAL # Normal level 269 | if "importance" in data: 270 | urgency = NOTIFY_LEVELS.get(data["importance"], URGENCY_NORMAL) 271 | hints["urgency"] = urgency 272 | 273 | # Timeout, convert milliseconds to seconds 274 | if "timeout" in data: 275 | try: 276 | timeout = int(data["timeout"]) * 1000 277 | except ValueError: 278 | pass 279 | 280 | # Replaces id: 281 | # Using the notification tag, check if it should replace an existing notification 282 | replace_id = self.tagtoid.get(tag, 0) 283 | 284 | # Dismiss/clear notification 285 | if notification["message"] == "clear_notification": 286 | logger.info("Clearing notification: %s", notification) 287 | # Replace the notification and hide it in 1 millisecond, workaround for dbus notifications 288 | timeout = 1 289 | 290 | notification.update( 291 | { 292 | "title": notification.get("title", HA), 293 | "actions": actions, 294 | "hints": hints, 295 | "timeout": timeout, 296 | "icon": icon, # TODO: Support custom icons 297 | "replace_id": replace_id, 298 | "is_command": False, 299 | } 300 | ) 301 | logger.debug("Converted notification: %s", notification) 302 | 303 | return notification 304 | 305 | async def dbus_notify(self, notification: dict) -> None: 306 | """Function to send a native dbus notification. 307 | According to the following link: 308 | Section org.freedesktop.Notifications.Notify 309 | https://people.gnome.org/~mccann/docs/notification-spec/notification-spec-latest.html#protocol 310 | 311 | :param notification: The notification to send, at this point it should be transformed to the format dbus uses, 312 | from the format Home Assistant sends. 313 | :return: None 314 | """ 315 | logger.info("Sending dbus notification") 316 | id = await self.interface.call_notify( 317 | APP_NAME, 318 | notification["replace_id"], 319 | str(notification["icon"]), 320 | notification["title"], 321 | notification["message"], 322 | notification["actions"], 323 | notification["hints"], 324 | notification["timeout"], 325 | ) 326 | logger.info("Dbus notification dispatched id:%s", id) 327 | 328 | # History management: Add the new notification, and remove the oldest one. 329 | # Storage 330 | self.history[id] = notification 331 | tag: str = notification["data"].get("tag", None) 332 | if tag: 333 | self.tagtoid[tag] = id 334 | 335 | # Removal 336 | _, old_not = self.history.popitem(last=False) 337 | otag = old_not.get("data", {}).get("tag", "") 338 | if otag in self.tagtoid: 339 | self.tagtoid.pop(otag) 340 | 341 | async def on_action(self, id: int, action: str) -> None: 342 | """Function to handle the dbus notification action event 343 | If a notifications is found, and the action is not the default action, an event is triggered to home assistant. 344 | (This is how the android app handles actions). 345 | 346 | :param id: The dbus id of the notification 347 | :param action: The action that was invoked 348 | """ 349 | logger.info( 350 | "Notification action dbus event received: id:%s, action:%s", id, action 351 | ) 352 | notification: dict = self.history.get(id, {}) 353 | if not notification: 354 | logger.info( 355 | "No notification found for id:%s, doesn't belong to this applicaton", id 356 | ) 357 | return 358 | 359 | actions: List[dict] = notification["data"].get("actions", {}) 360 | if actions or action == "default": 361 | uri: str 362 | emit_event: bool = True 363 | # actions is a list of dictionaries {"action": "turn_off", "title": "Turn off House", "uri": "http://..."} 364 | if action == "default": 365 | uri = notification.get("default_action_uri", "") 366 | emit_event = False 367 | else: 368 | uri = next(filter(lambda dic: dic["action"] == action, actions)).get( 369 | "uri", "" 370 | ) 371 | 372 | if uri.startswith("http") and self.url_program != "": 373 | asyncio.create_task( 374 | asyncio.create_subprocess_exec(self.url_program, uri) 375 | ) 376 | logger.info("Launched action:%s uri:%s", action, uri) 377 | 378 | if emit_event: 379 | asyncio.create_task( 380 | self.ha_event_trigger("action", action, notification) 381 | ) 382 | 383 | async def on_close(self, id: int, reason: str) -> None: 384 | """Function to handle the dbus notification close event 385 | Sends the data to ha_event_trigger, where the event is created and sent to Home Assistant. 386 | 387 | :param id: The dbus id of the notification 388 | :param reason: The reason the notification was closed 389 | """ 390 | logger.info( 391 | "Notification closed dbus event received: id:%s, reason:%s", id, reason 392 | ) 393 | notification = self.history.get(id, {}) 394 | if notification: 395 | asyncio.create_task( 396 | self.ha_event_trigger(event="closed", notification=notification) 397 | ) 398 | else: 399 | logger.info( 400 | "No notification found for id:%s, doesn't belong to this applicaton", id 401 | ) 402 | --------------------------------------------------------------------------------