├── .air.toml ├── .devcontainer ├── Dockerfile ├── devcontainer.json ├── docker-compose.yml └── postcreate.sh ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ ├── feature_request.md │ └── new-sensor-s-.md ├── dependabot.yml └── workflows │ ├── analysis-codeql.yml │ ├── analysis-nilaway.yml │ ├── build.yml │ ├── scorecard.yml │ └── test.yml ├── .gitignore ├── .golangci-ci.yaml ├── .golangci.yaml ├── .markdownlint.json ├── .nfpm.yaml ├── .pre-commit-config.yaml ├── .prettierignore ├── .prettierrc ├── .release-please-manifest.json ├── .vscode ├── launch.json ├── ltex.dictionary.en-AU.txt ├── settings.json └── tasks.json ├── BREAKING_CHANGES.md ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── agent ├── agent.go ├── assets │ ├── icon.png │ └── icon.svg ├── entity_workers.go ├── entity_workers_linux.go ├── mqtt_workers.go ├── mqtt_workers_linux.go └── workers │ ├── connection_latency.go │ ├── external_ip.go │ ├── mqtt │ ├── commands │ │ ├── commands.go │ │ ├── commands_test.go │ │ └── testdata │ │ │ ├── invalid │ │ │ └── commands.toml │ │ │ └── valid │ │ │ └── commands.toml │ ├── config.go │ └── mqtt.go │ ├── scripts.go │ ├── setup_linux.go │ ├── version.go │ └── workers.go ├── assets ├── go-hass-agent.service ├── postinstall.sh ├── screenshots │ ├── preferences.png │ └── registration-form.png └── start-go-hass-agent.desktop ├── cli ├── cli.go ├── config.go ├── register.go ├── run.go └── version.go ├── codecov.yml ├── config └── config.go ├── cosign.key ├── cosign.pub ├── deployments └── mosquitto │ └── config │ └── mosquitto.conf.example ├── device ├── config.go ├── info.go └── info_linux.go ├── eslint.config.js ├── go.mod ├── go.sum ├── hass ├── api │ ├── rest.gen.go │ ├── rest.go │ └── websocket.go ├── client.go ├── config.go ├── event │ └── event.go ├── location │ └── location.go ├── registration.go ├── registry │ ├── registry.go │ ├── registry_gob.go │ ├── registry_gob_test.go │ ├── registry_test.go │ ├── util.go │ └── util_test.go ├── sensor │ └── sensor.go └── tracker │ ├── tracker.go │ └── tracker_test.go ├── id ├── id.go └── id_generated.go ├── logging ├── logging.go └── profiling.go ├── main.go ├── models ├── class │ ├── deviceclass.gen.go │ ├── deviceclass.go │ ├── stateclass.gen.go │ └── stateclass.go ├── context.go ├── entity.go ├── event.go ├── event │ └── event.go ├── location.go ├── location │ └── location.go ├── message.go ├── models.gen.go ├── models.go ├── sensor.go ├── sensor │ └── sensor.go └── worker.go ├── osv-scanner.toml ├── package.json ├── pkg └── linux │ ├── dbusx │ ├── busType_strings.go │ ├── dbus.go │ ├── dbus_test.go │ ├── introspect.go │ ├── method.go │ ├── props.go │ ├── validation.go │ └── watch.go │ ├── hwmon │ ├── examples │ │ └── getAllSensors.go │ ├── hwmon.go │ ├── hwmon_MonitorType_generated.go │ ├── hwmon_test.go │ └── testdata │ │ ├── hwmon0 │ │ ├── name │ │ ├── temp1_crit │ │ ├── temp1_crit_alarm │ │ ├── temp1_input │ │ ├── temp1_label │ │ └── temp1_max │ │ └── hwmon1 │ │ ├── device │ │ └── model │ │ ├── name │ │ ├── temp1_crit │ │ ├── temp1_highest │ │ ├── temp1_input │ │ ├── temp1_lcrit │ │ ├── temp1_lowest │ │ ├── temp1_max │ │ └── temp1_min │ ├── pipewire │ └── pipewire.go │ ├── pulseaudiox │ ├── examples │ │ └── main.go │ └── pulseaudio.go │ └── whichdistro │ ├── testdata │ ├── os-release-fedora │ ├── os-release-opensuse-tumbleweed │ └── os-release-ubuntu │ ├── whichdistro.go │ └── whichdistro_test.go ├── platform └── linux │ ├── battery │ ├── battery.go │ ├── sensor.go │ ├── types.go │ ├── types_generated.go │ └── worker.go │ ├── checks.go │ ├── context.go │ ├── cpu │ ├── frequency.go │ ├── load_average.go │ ├── preferences.go │ └── usage.go │ ├── desktop │ ├── apps.go │ ├── desktop.go │ └── preferences.go │ ├── device.go │ ├── device_test.go │ ├── disk │ ├── ioSensors.go │ ├── ioSensors_generated.go │ ├── ioStats.go │ ├── ioStats_generated.go │ ├── ioWorker.go │ ├── preferences.go │ ├── smart.go │ └── usage.go │ ├── linux.go │ ├── location │ └── location.go │ ├── media │ ├── camera.go │ ├── microphone.go │ ├── mpris.go │ ├── preferences.go │ ├── volume.go │ └── webcam.go │ ├── mem │ ├── memStats.go │ ├── memStats_generated.go │ ├── memStats_test.go │ ├── memUsage.go │ ├── oomEvents.go │ ├── preferences.go │ └── testdata │ │ ├── meminfowithoutswap │ │ └── meminfowithswap │ ├── net │ ├── connection.go │ ├── connectionStateSensor.go │ ├── connectionState_generated.go │ ├── dbusConnectionState.go │ ├── link.go │ ├── preferences.go │ ├── stats.go │ ├── stats_generated.go │ └── wifiSensor.go │ ├── power │ ├── dbus.go │ ├── inhibit.go │ ├── laptop.go │ ├── powerControl.go │ ├── powerProfile.go │ ├── powerState.go │ ├── preferences.go │ ├── screenLock.go │ └── screenLockControl.go │ ├── rate.go │ └── system │ ├── chrony.go │ ├── dbusCommand.go │ ├── fwupdmgr.go │ ├── hsi_generated.go │ ├── hwmon.go │ ├── info.go │ ├── lastBoot.go │ ├── preferences.go │ ├── problems.go │ ├── uptime.go │ ├── users.go │ └── vulnerabilities.go ├── release-please-config.json ├── scheduler └── scheduler.go ├── schema ├── generate.go ├── models-cfg.yaml ├── models.yaml ├── rest-cfg.yaml ├── rest.yaml ├── websocket-cfg.yaml └── websocket.yaml ├── server ├── forms │ └── forms.go ├── handlers │ ├── handlers.go │ ├── landing.go │ ├── preferences.go │ └── register.go ├── middlewares │ ├── csrf.go │ └── htmx.go └── server.go ├── validation └── validation.go └── web ├── assets ├── htmx.js ├── scripts.js └── styles.css ├── content ├── favicon.ico ├── favicon.png ├── favicon.svg ├── fonts │ ├── Inter-Black.woff2 │ ├── Inter-BlackItalic.woff2 │ ├── Inter-Bold.woff2 │ ├── Inter-BoldItalic.woff2 │ ├── Inter-ExtraBold.woff2 │ ├── Inter-ExtraBoldItalic.woff2 │ ├── Inter-ExtraLight.woff2 │ ├── Inter-ExtraLightItalic.woff2 │ ├── Inter-Italic.woff2 │ ├── Inter-Light.woff2 │ ├── Inter-LightItalic.woff2 │ ├── Inter-Medium.woff2 │ ├── Inter-MediumItalic.woff2 │ ├── Inter-Regular.woff2 │ ├── Inter-SemiBold.woff2 │ ├── Inter-SemiBoldItalic.woff2 │ ├── Inter-Thin.woff2 │ ├── Inter-ThinItalic.woff2 │ ├── InterDisplay-Black.woff2 │ ├── InterDisplay-BlackItalic.woff2 │ ├── InterDisplay-Bold.woff2 │ ├── InterDisplay-BoldItalic.woff2 │ ├── InterDisplay-ExtraBold.woff2 │ ├── InterDisplay-ExtraBoldItalic.woff2 │ ├── InterDisplay-ExtraLight.woff2 │ ├── InterDisplay-ExtraLightItalic.woff2 │ ├── InterDisplay-Italic.woff2 │ ├── InterDisplay-Light.woff2 │ ├── InterDisplay-LightItalic.woff2 │ ├── InterDisplay-Medium.woff2 │ ├── InterDisplay-MediumItalic.woff2 │ ├── InterDisplay-Regular.woff2 │ ├── InterDisplay-SemiBold.woff2 │ ├── InterDisplay-SemiBoldItalic.woff2 │ ├── InterDisplay-Thin.woff2 │ ├── InterDisplay-ThinItalic.woff2 │ ├── InterVariable-Italic.woff2 │ ├── InterVariable.woff2 │ └── inter.css ├── go-hass-agent.png ├── go-hass-agent.svg └── manifest.json └── templates ├── notifications.templ ├── notifications_templ.go ├── page.templ ├── page_templ.go ├── preferences.templ ├── preferences_templ.go ├── registration.templ ├── registration_templ.go ├── templates.templ └── templates_templ.go /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | tmp_dir = "tmp" 3 | 4 | [build] 5 | pre_cmd = [ 6 | "bun run build:css", 7 | "bun run build:js", 8 | "go run github.com/a-h/templ/cmd/templ@latest generate -path web", 9 | ] 10 | cmd = 'go build -ldflags="-s -w -X github.com/joshuar/go-hass-agent/config.AppVersion=$(git describe --tags --always --long --dirty)" -o ./tmp/go-hass-agent .' 11 | args_bin = [ 12 | "--profile='heapprofile=deployments/heap.prof;cpuprofile=deployments/cpu.prof;webui=true'", 13 | "run", 14 | ] 15 | full_bin = "export $(grep -v '^#' .devcontainer/.env | xargs) && tmp/go-hass-agent" 16 | bin = "tmp/go-hass-agent" 17 | cdelay = 1000 18 | exclude_dir = [ 19 | "bin", 20 | "dist", 21 | "node_modules", 22 | "web/content", 23 | "tmp", 24 | "tests", 25 | "vendor", 26 | "_archive", 27 | "schema", 28 | ] 29 | exclude_regex = ["_test\\.go", "_templ\\.go"] 30 | exclude_unchanged = false 31 | follow_symlink = false 32 | include_ext = ["go", "templ", "html", "json", "js", "ts", "css", "scss"] 33 | kill_delay = 500 34 | log = "deployments/build-errors-air.log" 35 | poll = false 36 | poll_interval = 500 37 | rerun = false 38 | rerun_delay = 500 39 | send_interrupt = true 40 | stop_on_error = true 41 | 42 | [log] 43 | main_only = false 44 | time = true 45 | 46 | [color] 47 | build = "yellow" 48 | main = "magenta" 49 | runner = "green" 50 | watcher = "cyan" 51 | 52 | [misc] 53 | clean_on_exit = true 54 | 55 | [screen] 56 | clear_on_rebuild = false 57 | keep_scroll = true 58 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Joshua Rich . 2 | # SPDX-License-Identifier: MIT 3 | 4 | ARG GO_VERSION 5 | 6 | FROM docker.io/alpine AS builder 7 | 8 | # Copy go from official image 9 | COPY --from=docker.io/golang:1.25.1-alpine /usr/local/go/ /usr/local/go/ 10 | 11 | # Install additional packages 12 | RUN apk update && apk add sudo openssh curl git bash fish micro graphviz nodejs npm mosquitto-clients 13 | 14 | # Install starship 15 | RUN cd /tmp && curl -sS https://starship.rs/install.sh | sh -s -- -y || exit -1 16 | 17 | # Add non-root user 18 | ARG USER_NAME=vscode 19 | ARG USER_UID=1000 20 | ARG USER_GID=$USER_UID 21 | 22 | RUN addgroup --gid $USER_GID $USER_NAME \ 23 | && adduser --uid $USER_UID --ingroup $USER_NAME --shell /usr/bin/fish $USER_NAME \ 24 | --disabled-password --gecos "" \ 25 | && mkdir -p /etc/sudoers.d \ 26 | && echo "$USER_NAME ALL=(ALL:ALL) NOPASSWD: ALL" > /etc/sudoers.d/$USER_NAME \ 27 | && chmod 0440 /etc/sudoers.d/$USER_NAME 28 | 29 | USER $USER_NAME 30 | 31 | # Install bun 32 | RUN curl -fsSL https://bun.com/install | bash 33 | 34 | RUN <> ~/.config/fish/config.fish 37 | echo 'set --export PATH $BUN_INSTALL/bin $PATH' >> ~/.config/fish/config.fish 38 | echo 'set --export PATH "$HOME/go/bin" /go/bin /usr/local/go/bin $PATH' >> ~/.config/fish/config.fish 39 | EOF 40 | 41 | # The base container sets XDG_CACHE_HOME XDG_CONFIG_HOME specifically for the root user, we can't unset them in a way that vscode will pick up, so we set them to values for the new user. 42 | # Installing go extensions via vscode use these paths so if we just leave it set to /root/.cache we'll get permission errors. 43 | ENV XDG_CONFIG_HOME=/home/$USER_NAME/.config 44 | ENV XDG_CACHE_HOME=/home/$USER_NAME/.cache 45 | 46 | ENTRYPOINT ["/usr/bin/fish"] 47 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "containerUser": "vscode", 3 | "containerEnv": { 4 | "HOME": "/home/vscode", 5 | "XDG_RUNTIME_DIR": "/tmp", 6 | "WAYLAND_DISPLAY": "${env:WAYLAND_DISPLAY}", 7 | "APPIMAGE_EXTRACT_AND_RUN": "1", 8 | "DBUS_SESSION_BUS_ADDRESS": "${env:DBUS_SESSION_BUS_ADDRESS}" 9 | }, 10 | "remoteEnv": { 11 | "PODMAN_COMPOSE_WARNING_LOGS": "false", 12 | "TZ": "${localEnv:TZ:Australia/Brisbane}" 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "vivaxy.vscode-conventional-commits", 18 | "brunodavi.conventional-snippets", 19 | "golang.go", 20 | "ymotongpoo.licenser", 21 | "esbenp.prettier-vscode", 22 | "aaron-bond.better-comments", 23 | "github.vscode-github-actions", 24 | "yzhang.markdown-all-in-one", 25 | "bierner.markdown-emoji", 26 | "a-h.templ", 27 | "bradlc.vscode-tailwindcss", 28 | "elijah-potter.harper" 29 | ] 30 | } 31 | }, 32 | "dockerComposeFile": "docker-compose.yml", 33 | "name": "Go Hass Agent Development", 34 | "postCreateCommand": "bash .devcontainer/postcreate.sh", 35 | "service": "devcontainer", 36 | "shutdownAction": "stopCompose", 37 | "updateRemoteUserUID": true, 38 | "workspaceFolder": "/workspace" 39 | } 40 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | devcontainer: 3 | hostname: devcontainer 4 | env_file: 5 | - .env 6 | devices: 7 | - /dev/dri 8 | environment: 9 | - WAYLAND_DISPLAY=${WAYLAND_DISPLAY} 10 | - XDG_RUNTIME_DIR=/tmp 11 | build: 12 | context: . 13 | dockerfile: Dockerfile 14 | args: 15 | - GO_VERSION=$GO_VERSION 16 | volumes: 17 | - ..:/workspace:cached 18 | - "${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}:/tmp/${WAYLAND_DISPLAY}" 19 | - /etc/localtime:/etc/localtime:ro,cached 20 | - /run/dbus:/run/dbus:ro 21 | - /run/user/1000/bus:/run/user/1000/bus 22 | user: vscode 23 | cap_add: 24 | - SYS_PTRACE 25 | security_opt: 26 | - seccomp:unconfined 27 | - label:disable 28 | command: -c 'sleep infinity' 29 | 30 | home-assistant: 31 | hostname: home-assistant 32 | privileged: true 33 | cap_add: 34 | - NET_ADMIN 35 | - NET_RAW 36 | env_file: 37 | - .env 38 | image: ghcr.io/home-assistant/home-assistant:$HA_VERSION 39 | volumes: 40 | - ../deployments/home-assistant/config:/config 41 | - /run/dbus:/run/dbus:ro 42 | ports: 43 | - 8123:8123 44 | 45 | mosquitto: 46 | hostname: mosquitto 47 | env_file: 48 | - .env 49 | image: docker.io/eclipse-mosquitto:$MOSQUITTO_VERSION 50 | volumes: 51 | - ../deployments/mosquitto/config:/mosquitto/config 52 | - ../deployments/mosquitto/data:/mosquitto/data 53 | - ../deployments/mosquitto/log:/mosquitto/log 54 | - /etc/localtime:/etc/localtime:ro,cached 55 | ports: 56 | - 1883:1883 57 | - 8883:8883 58 | -------------------------------------------------------------------------------- /.devcontainer/postcreate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | # Copyright 2025 Joshua Rich . 3 | # SPDX-License-Identifier: MIT 4 | 5 | set -x 6 | 7 | # Add starship to fish shell. 8 | mkdir -p ~/.config/fish 9 | echo "starship init fish | source" >>~/.config/fish/config.fish 10 | 11 | # Add starship to bash shell. 12 | echo 'eval "$(starship init bash)"' >>~/.bashrc 13 | 14 | cd /workspace 15 | 16 | # Update JS packages with bun. 17 | bun update || exit -1 18 | 19 | # Install Go tools. 20 | go install github.com/air-verse/air@latest 21 | go install github.com/a-h/templ/cmd/templ@latest 22 | 23 | exit 0 24 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @joshuar 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | ko_fi: astralcars # Replace with a single Ko-fi username 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve go-hass-agent 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Go Hass Agent Version** 11 | Retrieve with `go-hass-agent version`. 12 | 13 | **Describe the bug** 14 | A clear and concise description of what the bug is. 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behavior: 18 | 19 | 1. Do X. 20 | 2. Do Y. 21 | 3. … 22 | 4. See error. 23 | 24 | **Expected behavior** 25 | A clear and concise description of what you expected to happen. 26 | 27 | **Screenshots** 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Logs** 31 | A log will be very helpful to look into this bug report. To get the log: 32 | 33 | 1. Run `go-hass-agent` from a terminal or command-line with the `--log-level debug` flag set: 34 | 35 | ```shell 36 | # for package/binary installs: 37 | go-hass-agent --log-level=debug run 38 | # for containers: 39 | # (adjust options as appropriate) 40 | podman run --hostname _HOSTNAME_ --name go-hass-agent \ 41 | --network host \ 42 | --volume go-hass-agent:/home/go-hass-agent:U \ 43 | # add any additional container options here. 44 | --device /dev/video0:/dev/video0 45 | ghcr.io/joshuar/go-hass-agent:VERSION \ 46 | # add any Go Hass Agent options here. 47 | ``` 48 | 49 | 2. Try to reproduce the issue. 50 | 3. After you have reproduced the issue, please (compress and) attach the 51 | `go-hass-agent.log` file `~/.config/go-hass-agent/go-hass-agent.log`. 52 | 53 | *(While I have made efforts to not log sensitive information, please check the log before uploading to GitHub and remove any information you do not want to share).* 54 | 55 | **Desktop (please complete the following information):** 56 | 57 | - OS: [e.g., Linux] 58 | - Distribution [for Linux, e.g., Fedora] 59 | - Version [e.g., 11] 60 | 61 | **Additional context** 62 | Add any other context about the problem here. 63 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for go-hass-agent 4 | title: "[FEATURE REQUEST]" 5 | labels: enhancement 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 concern 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/new-sensor-s-.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New Sensor(s) 3 | about: Suggest additional sensor/sensors for Go Hass Agent 4 | title: "[SENSOR]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What are the sensors you would like to see in Go Hass Agent? What do they measure? Why are they useful? How would you use them in Home Assistant?** 11 | 12 | - A clear description of the sensor(s) you would like to see added. 13 | - What is measured/recorded by the sensor(s)? 14 | - How you would use them in Home Assistant? 15 | 16 | **How do you access the sensor(s) currently?** 17 | 18 | - How or where are the values recorded? 19 | - Are there any tools/libraries/APIs that can access the values? 20 | 21 | **Additional context** 22 | 23 | Add any other context about the request here. 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for Go 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | commit-message: 9 | prefix: "build(go): ⬆️" 10 | 11 | # Maintain dependencies for build tools 12 | - package-ecosystem: "gomod" 13 | directory: "/tools" 14 | schedule: 15 | interval: "weekly" 16 | commit-message: 17 | prefix: "build(go): ⬆️" 18 | 19 | # Maintain dependencies for GitHub Actions 20 | - package-ecosystem: "github-actions" 21 | directory: "/" 22 | schedule: 23 | interval: "weekly" 24 | commit-message: 25 | prefix: "ci(github): ⬆️" 26 | 27 | # Enable version updates for bun 28 | - package-ecosystem: "bun" 29 | # Look for `package.json` and `lock` files in the `root` directory 30 | directory: "/" 31 | # Check the npm registry for updates every day (weekdays) 32 | schedule: 33 | interval: "weekly" 34 | commit-message: 35 | prefix: "build(bun): ⬆️" 36 | 37 | # Enable version updates for Docker 38 | - package-ecosystem: "docker" 39 | # Look for a `Dockerfile` in the `root` directory 40 | directory: "/" 41 | # Check for updates once a week 42 | schedule: 43 | interval: "weekly" 44 | commit-message: 45 | prefix: "build(container): ⬆️" 46 | -------------------------------------------------------------------------------- /.github/workflows/analysis-codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL Analysis" 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | schedule: 9 | - cron: "38 13 * * 4" 10 | 11 | permissions: 12 | contents: read 13 | 14 | env: 15 | TARGETPLATFORM: linux/amd64 16 | 17 | jobs: 18 | codeql: 19 | name: Analyze Go 20 | runs-on: "ubuntu-22.04" 21 | permissions: 22 | security-events: write 23 | packages: read 24 | actions: read 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | include: 29 | - language: go 30 | build-mode: manual 31 | steps: 32 | - name: Harden Runner 33 | uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 34 | with: 35 | egress-policy: audit 36 | allowed-endpoints: > 37 | github.com:443 38 | azure.archive.ubuntu.com:80 39 | esm.ubuntu.com:443 40 | ghcr.io:443 41 | api.github.com:443 42 | github.com:443 43 | golang.org:443 44 | motd.ubuntu.com:443 45 | objects.githubusercontent.com:443 46 | packages.microsoft.com:443 47 | pkg-containers.githubusercontent.com:443 48 | proxy.golang.org:443 49 | raw.githubusercontent.com:443 50 | storage.googleapis.com:443 51 | uploads.github.com:443 52 | release-assets.githubusercontent.com:443 53 | - name: Checkout repository 54 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 55 | with: 56 | fetch-depth: 0 57 | - name: Setup Go 58 | id: setup_go 59 | uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v5 60 | with: 61 | go-version-file: "go.mod" 62 | - name: Setup bun 63 | uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 64 | - name: Install frontend tools 65 | run: bun install 66 | - name: Bundle javascript 67 | run: bunx esbuild ./web/assets/scripts.js --bundle --sourcemap --outdir=./web/content/ 68 | - name: Bundle css 69 | run: bunx tailwindcss -i ./web/assets/styles.css -o ./web/content/styles.css --minify --map 70 | - name: Initialize CodeQL 71 | uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.29.5 72 | with: 73 | languages: ${{ matrix.language }} 74 | build-mode: ${{ matrix.build-mode }} 75 | packs: githubsecuritylab/codeql-${{ matrix.language }}-queries 76 | - if: matrix.build-mode == 'manual' 77 | name: Build 78 | run: go build -ldflags="-X github.com/joshuar/go-hass-agent/config.AppVersion=$(git describe --tags --always --long --dirty)" 79 | - name: Perform CodeQL Analysis 80 | uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.29.5 81 | with: 82 | category: "/language:${{matrix.language}}" 83 | -------------------------------------------------------------------------------- /.github/workflows/analysis-nilaway.yml: -------------------------------------------------------------------------------- 1 | name: "Nilaway Analysis" 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | nilaway: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Harden Runner 17 | uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 18 | with: 19 | disable-sudo: true 20 | egress-policy: block 21 | allowed-endpoints: > 22 | github.com:443 23 | - name: Checkout repository 24 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 25 | with: 26 | fetch-depth: 0 27 | - name: Nil panic checks 28 | uses: qbaware/nilaway-action@18bae1177e15ef2cc9583baa872c43c50ad84d9e # v0.0.14 29 | with: 30 | package-to-scan: ./... 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | permissions: 10 | contents: read 11 | 12 | concurrency: 13 | group: "tests" 14 | cancel-in-progress: false 15 | 16 | env: 17 | MAGEARGS: -d build/magefiles -w . 18 | TARGETPLATFORM: linux/amd64 19 | 20 | jobs: 21 | test: 22 | runs-on: ubuntu-22.04 23 | steps: 24 | - name: Harden Runner 25 | uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 26 | with: 27 | egress-policy: block 28 | allowed-endpoints: > 29 | api.codecov.io:443 30 | api.github.com:443 31 | azure.archive.ubuntu.com:80 32 | cli.codecov.io:443 33 | esm.ubuntu.com:443 34 | github.com:443 35 | ingest.codecov.io:443 36 | keybase.io:443 37 | motd.ubuntu.com:443 38 | objects.githubusercontent.com:443 39 | packages.microsoft.com:443 40 | proxy.golang.org:443 41 | raw.githubusercontent.com:443 42 | storage.googleapis.com:443 43 | sum.golang.org:443 44 | release-assets.githubusercontent.com:443 45 | - name: Checkout 46 | id: checkout 47 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 48 | with: 49 | fetch-depth: 0 50 | - name: Set up Go 51 | id: setup_go 52 | uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v5 53 | with: 54 | go-version-file: "go.mod" 55 | - name: Run tests 56 | run: go test -ldflags="-w -s -X github.com/joshuar/go-hass-agent/config.AppVersion=$(git describe --tags --always --long --dirty)" -coverprofile=coverage.txt -v ./... 57 | - name: Upload Coverage 58 | id: upload_coverage 59 | uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v4 60 | continue-on-error: true 61 | with: 62 | token: ${{secrets.CODECOV_TOKEN}} 63 | file: ./coverage.txt 64 | fail_ci_if_error: false 65 | 66 | golangci: 67 | runs-on: ubuntu-22.04 68 | permissions: 69 | pull-requests: read # Use with `only-new-issues` option. 70 | steps: 71 | - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 72 | with: 73 | egress-policy: block 74 | allowed-endpoints: > 75 | api.github.com:443 76 | azure.archive.ubuntu.com:80 77 | esm.ubuntu.com:443 78 | github.com:443 79 | motd.ubuntu.com:443 80 | objects.githubusercontent.com:443 81 | packages.microsoft.com:443 82 | proxy.golang.org:443 83 | raw.githubusercontent.com:443 84 | storage.googleapis.com:443 85 | sum.golang.org:443 86 | golangci-lint.run:443 87 | release-assets.githubusercontent.com:443 88 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 89 | - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 90 | with: 91 | go-version-file: "go.mod" 92 | cache: false # golangci-lint maintains its own cache 93 | - name: golangci-lint 94 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 95 | with: 96 | only-new-issues: true 97 | github-token: ${{ secrets.GITHUB_TOKEN }} 98 | args: --config=.golangci-ci.yaml --issues-exit-code=0 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Allowlisting gitignore template for GO projects prevents us 2 | # from adding various unwanted local files, such as generated 3 | # files, developer configurations or IDE-specific files etc. 4 | # 5 | # Recommended: Go.AllowList.gitignore 6 | 7 | # Ignore everything 8 | * 9 | 10 | # Except these... 11 | !/.gitignore 12 | !*.go 13 | !*.templ 14 | !go.sum 15 | !go.mod 16 | !*.yaml 17 | !*.yml 18 | !*.toml 19 | !*.md 20 | !*.json 21 | !cosign.* 22 | !web/assets/* 23 | !web/content/* 24 | web/content/*.js 25 | web/content/*.js.map 26 | web/content/*.css 27 | web/content/*.css.map 28 | !web/content/fonts/* 29 | !assets/**/* 30 | !agent/assets/* 31 | !**/testdata/** 32 | !.prettierrc 33 | !.prettierignore 34 | !.parcelrc 35 | !.postcssrc 36 | !eslint.config.js 37 | !.vscode/tasks.json 38 | !.vscode/launch.json 39 | !.devcontainer/* 40 | !LICENSE 41 | !deployments/mosquitto/config/mosquitto.conf.example 42 | # ...even if they are in subdirectories 43 | !*/ 44 | 45 | # always ignore these 46 | tmp/ 47 | .devcontainer/.env 48 | dist/ 49 | deployments/ 50 | node_modules/ 51 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: all 4 | disable: 5 | - cyclop 6 | - depguard 7 | - exhaustive 8 | - exhaustruct 9 | - funlen 10 | - gochecknoglobals 11 | - gochecknoinits 12 | - gocognit 13 | - gocyclo 14 | - godox 15 | - ireturn 16 | - lll 17 | - mnd 18 | - nlreturn 19 | - tagalign 20 | - tagliatelle 21 | - wsl 22 | - wsl_v5 23 | settings: 24 | gosec: 25 | excludes: 26 | - G104 # made redundant by errcheck 27 | lll: 28 | tab-width: 4 29 | line-length: 120 30 | revive: 31 | enable-all-rules: false 32 | rules: 33 | - name: exported 34 | arguments: 35 | - disable-checks-on-variables 36 | sloglint: 37 | kv-only: false 38 | no-mixed-args: true 39 | static-msg: false 40 | attr-only: true 41 | no-raw-keys: false 42 | args-on-sep-lines: true 43 | varnamelen: 44 | ignore-names: 45 | - err 46 | - wg 47 | - tt 48 | - id 49 | - ok 50 | - mu 51 | - fd 52 | - to 53 | exclusions: 54 | rules: 55 | - linters: 56 | - lll 57 | source: "^//go:generate " 58 | - path: '(.+)_test\.go' 59 | linters: 60 | - dupl 61 | - funlen 62 | - paralleltest 63 | - testpackage 64 | - varnamelen 65 | formatters: 66 | enable: 67 | - gofumpt 68 | - goimports 69 | settings: 70 | goimports: 71 | local-prefixes: 72 | - github.com/joshuar/go-hass-agent 73 | run: 74 | timeout: "5m" 75 | issues-exit-code: 1 76 | tests: true 77 | allow-parallel-runners: false 78 | allow-serial-runners: false 79 | go: "1.24" 80 | relative-path-mode: wd 81 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD013": false, 4 | "MD024": false, 5 | "MD033": false, 6 | "MD041": false, 7 | "MD049": false 8 | } -------------------------------------------------------------------------------- /.nfpm.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Joshua Rich . 2 | # SPDX-License-Identifier: MIT 3 | 4 | name: "go-hass-agent" 5 | version: ${APPVERSION} 6 | section: "default" 7 | priority: "extra" 8 | maintainer: Joshua Rich 9 | description: | 10 | A Home Assistant, native app for desktop/laptop devices. 11 | vendor: org.github.joshuar 12 | homepage: https://github.com/joshuar/go-hass-agent 13 | license: MIT 14 | arch: ${NFPM_ARCH} 15 | contents: 16 | - src: dist/go-hass-agent 17 | dst: /usr/bin/go-hass-agent 18 | expand: true 19 | - src: LICENSE 20 | dst: /usr/share/licenses/go-hass-agent/LICENSE 21 | - src: README.md 22 | dst: /usr/share/doc/go-hass-agent/README.md 23 | - src: web/content/go-hass-agent.png 24 | dst: /usr/share/pixmaps/go-hass-agent.png 25 | - src: assets/start-go-hass-agent.desktop 26 | dst: /usr/share/applications/start-go-hass-agent.desktop 27 | - src: assets/go-hass-agent.service 28 | dst: /usr/lib/systemd/user/go-hass-agent.service 29 | scripts: 30 | postinstall: assets/postinstall.sh 31 | overrides: 32 | rpm: 33 | depends: 34 | # - libXcursor 35 | # - libXrandr 36 | # - mesa-libGL 37 | # - libXi 38 | # - libXinerama 39 | # - libXxf86vm 40 | - dbus-x11 41 | recommends: 42 | - libcap 43 | deb: 44 | depends: 45 | # - libgl1-mesa-dri 46 | # - libgl1 47 | # - libx11-6 48 | # - libglx0 49 | # - libglvnd0 50 | # - libxcb1 51 | # - libxau6 52 | # - libxdmcp6 53 | - dbus-x11 54 | recommends: 55 | - libcap2 56 | archlinux: 57 | # depends: 58 | # - xorg-server 59 | # - libxcursor 60 | # - libxrandr 61 | # - libxinerama 62 | # - libxi 63 | recommends: 64 | - libcap 65 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/compilerla/conventional-pre-commit 3 | rev: v3.3.0 4 | hooks: 5 | - id: conventional-pre-commit 6 | stages: [commit-msg] 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v4.6.0 9 | hooks: 10 | - id: trailing-whitespace 11 | - id: end-of-file-fixer 12 | - repo: https://github.com/dnephin/pre-commit-golang 13 | rev: v0.5.1 14 | hooks: 15 | - id: go-mod-tidy 16 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore static files. 2 | static/ 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": false, 6 | "plugins": ["prettier-plugin-toml"], 7 | "proseWrap": "always" 8 | } 9 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "14.1.0" 3 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${fileDirname}", 13 | "args": ["--debug","--profile","--debugID","com.github.joshuar.go-hass-agent-debug"] 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /.vscode/ltex.dictionary.en-AU.txt: -------------------------------------------------------------------------------- 1 | Fyne 2 | Hass 3 | websocket 4 | variadic 5 | inode 6 | goroutine 7 | ABRT 8 | D-Bus 9 | ProcFS 10 | SysFS 11 | NetworkManager 12 | Cron 13 | cron -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "MQTT: Watch 'homeassistant/#'", 8 | "type": "shell", 9 | "command": "mosquitto_sub -h mosquitto -v -t \"homeassistant/#\"", 10 | "group": "none", 11 | "presentation": { 12 | "reveal": "always", 13 | "panel": "new" 14 | }, 15 | "problemMatcher": [] 16 | }, 17 | { 18 | "label": "View Log: Mosquitto", 19 | "type": "shell", 20 | "command": "tail -F deployments/mosquitto/log/mosquitto.log", 21 | "group": "none", 22 | "presentation": { 23 | "echo": true, 24 | "reveal": "always", 25 | "focus": false, 26 | "panel": "dedicated", 27 | "showReuseMessage": true, 28 | "clear": false 29 | }, 30 | "problemMatcher": [] 31 | }, 32 | { 33 | "label": "View Log: Home Assistant", 34 | "type": "shell", 35 | "command": "tail -F deployments/home-assistant/config/home-assistant.log", 36 | "group": "none", 37 | "presentation": { 38 | "echo": true, 39 | "reveal": "always", 40 | "focus": false, 41 | "panel": "dedicated", 42 | "showReuseMessage": true, 43 | "clear": false 44 | }, 45 | "problemMatcher": [] 46 | }, 47 | { 48 | "label": "agent: run", 49 | "type": "shell", 50 | "command": "air -c .air.toml", 51 | "problemMatcher": [], 52 | "presentation": { 53 | "echo": true, 54 | "reveal": "always", 55 | "focus": false, 56 | "panel": "dedicated", 57 | "showReuseMessage": true, 58 | "clear": false 59 | } 60 | } 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | # 🧑‍🤝‍🧑 Contributing 7 | 8 | Thanks for taking an interest in Go Hass Agent! There are **lots** of ways you can contribute to this project: 9 | 10 | - Helping with development. 11 | - Helping with translations. 12 | - Just using the agent and providing feedback. 13 | 14 | ## Development Contributions 15 | 16 | I would welcome your contribution! If you find any improvement or issue you want 17 | to fix, feel free to send a pull request! 18 | 19 | Some documentation for development can be found in 20 | the [docs](docs/README.md). There is information for developing 21 | Go Hass Agent for different operating systems as well as adding additional 22 | sensors. This might help anyone to look to contribute, extend or fork this tool. 23 | 24 | ## Translations 25 | 26 | While this application does not have many points where text is displayed to 27 | the end user (logging aside), translation is supported through the `language` 28 | and `message` packages that are part of 29 | [golang.org/x/text](https://pkg.go.dev/golang.org/x/text). 30 | 31 | I would welcome pull requests for translations! 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Joshua Rich . 2 | # SPDX-License-Identifier: MIT 3 | 4 | FROM --platform=$BUILDPLATFORM docker.io/alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1 AS builder 5 | 6 | ARG TARGETOS 7 | ARG TARGETARCH 8 | ARG APPVERSION 9 | 10 | WORKDIR /usr/src/app 11 | 12 | # Copy go from official image. 13 | COPY --from=docker.io/golang:1.25.1-alpine@sha256:b6ed3fd0452c0e9bcdef5597f29cc1418f61672e9d3a2f55bf02e7222c014abd /usr/local/go/ /usr/local/go/ 14 | ENV PATH="/root/go/bin:/usr/local/go/bin:/usr/local/bin:${PATH}" 15 | 16 | # install build deps 17 | RUN < 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 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | # Security Policy 7 | 8 | Thanks for helping to make Go Hass Agent a safe and useful application for everyone. 9 | 10 | ## Supported Versions 11 | 12 | Only the latest released version of Go Hass Agent will be supported with security updates. 13 | 14 | ## Reporting a Vulnerability 15 | 16 | Security issues and vulnerabilities can be reported privately by following the 17 | GitHub documentation: [Privately reporting a security 18 | vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability). 19 | 20 | **Please do not report security vulnerabilities through public GitHub issues, 21 | discussions, or pull requests.** 22 | 23 | Please include as much of the information listed below as you can to help us 24 | better understand and resolve the issue: 25 | 26 | - The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) 27 | - Full paths of source file(s) related to the manifestation of the issue 28 | - The location of the affected source code (tag/branch/commit or direct URL) 29 | - Any special configuration required to reproduce the issue 30 | - Step-by-step instructions to reproduce the issue 31 | - Proof-of-concept or exploit code (if possible) 32 | - Impact of the issue, including how an attacker might exploit the issue 33 | 34 | This information will help us triage your report more quickly. 35 | 36 | Security issues and vulnerabilities will be addressed with reasonable effort but no guarantees are made with regards to 37 | resolution of reports within any time frame or a fix at all. 38 | 39 | ## Permissions and Capabilities 40 | 41 | ### Cannot be run as root user 42 | 43 | Go Hass Agent cannot be run as root or a user with effective root permissions. Go Hass Agent will detect this situation 44 | and refuse to start. 45 | 46 | ### Arbitrary script/commands 47 | 48 | Some features provide the ability to execute arbitrary scripts and commands on the device running the agent: 49 | 50 | - [custom script sensors](./README.md#other-custom-commands). 51 | - [MQTT commands](./README.md#-mqtt-sensors-and-controls). 52 | 53 | These may or may not represent a significant security issue for some users. They are not enabled by default and require 54 | manual configuration to use. 55 | 56 | ### Sensors requiring additional capabilities 57 | 58 | Some sensors require additional capabilities on the Go Hass Agent binary: 59 | 60 | - SMART disk monitoring: requires `cap_sys_rawio,cap_sys_admin,cap_mknod,cap_dac_override=+ep` 61 | 62 | When installed via packages (RPM/DEB/ARCH) or using the [official container 63 | image](https://github.com/joshuar/go-hass-agent/pkgs/container/go-hass-agent), the binary will have the required 64 | capabilities by default. 65 | 66 | If this is not desired, the binary should be modified or a custom image used that removes these capabilities. This will 67 | of course result in the sensors requiring those capabilities to be unavailable, but otherwise Go Hass Agent will 68 | continue to run. For example with `setcap -r /path/to/go-hass-agent`. 69 | -------------------------------------------------------------------------------- /agent/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/agent/assets/icon.png -------------------------------------------------------------------------------- /agent/entity_workers.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package agent 5 | 6 | import ( 7 | "context" 8 | "log/slog" 9 | 10 | slogctx "github.com/veqryn/slog-context" 11 | 12 | "github.com/joshuar/go-hass-agent/agent/workers" 13 | ) 14 | 15 | // CreateDeviceEntityWorkers sets up all device-specific entity workers. 16 | func CreateDeviceEntityWorkers(ctx context.Context, restAPIURL string) []workers.EntityWorker { 17 | var deviceWorkers []workers.EntityWorker 18 | 19 | // Initialize and add connection latency sensor w. 20 | if w, err := workers.NewConnectionLatencyWorker(ctx, restAPIURL); err != nil { 21 | slogctx.FromCtx(ctx).Warn("Could not set up worker.", 22 | slog.Any("error", err)) 23 | } else { 24 | deviceWorkers = append(deviceWorkers, w) 25 | } 26 | // Initialize and add external IP address sensor workezr. 27 | if w, err := workers.NewExternalIPWorker(ctx); err != nil { 28 | slogctx.FromCtx(ctx).Warn("Could not init agent worker.", 29 | slog.Any("error", err)) 30 | } else { 31 | deviceWorkers = append(deviceWorkers, w) 32 | } 33 | // Initialize and add external version sensor w. 34 | if w, err := workers.NewVersionWorker(ctx); err != nil { 35 | slogctx.FromCtx(ctx).Warn("Could not init agent worker.", 36 | slog.Any("error", err)) 37 | } else { 38 | deviceWorkers = append(deviceWorkers, w) 39 | } 40 | 41 | // Initialize and add scripts w. 42 | if w, err := workers.NewScriptsWorker(ctx); err != nil { 43 | slogctx.FromCtx(ctx).Warn("Could not init agent worker.", 44 | slog.Any("error", err)) 45 | } else { 46 | deviceWorkers = append(deviceWorkers, w) 47 | } 48 | 49 | return deviceWorkers 50 | } 51 | -------------------------------------------------------------------------------- /agent/entity_workers_linux.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package agent 5 | 6 | import ( 7 | "context" 8 | "log/slog" 9 | "slices" 10 | 11 | slogctx "github.com/veqryn/slog-context" 12 | 13 | "github.com/joshuar/go-hass-agent/agent/workers" 14 | "github.com/joshuar/go-hass-agent/device" 15 | "github.com/joshuar/go-hass-agent/platform/linux/battery" 16 | "github.com/joshuar/go-hass-agent/platform/linux/cpu" 17 | "github.com/joshuar/go-hass-agent/platform/linux/desktop" 18 | "github.com/joshuar/go-hass-agent/platform/linux/disk" 19 | "github.com/joshuar/go-hass-agent/platform/linux/location" 20 | "github.com/joshuar/go-hass-agent/platform/linux/media" 21 | "github.com/joshuar/go-hass-agent/platform/linux/mem" 22 | "github.com/joshuar/go-hass-agent/platform/linux/net" 23 | "github.com/joshuar/go-hass-agent/platform/linux/power" 24 | "github.com/joshuar/go-hass-agent/platform/linux/system" 25 | ) 26 | 27 | var linuxWorkers = []func(ctx context.Context) (workers.EntityWorker, error){ 28 | battery.NewBatteryWorker, 29 | disk.NewIOWorker, 30 | disk.NewUsageWorker, 31 | disk.NewSmartWorker, 32 | cpu.NewUsageWorker, 33 | cpu.NewLoadAvgWorker, 34 | cpu.NewFreqWorker, 35 | desktop.NewAppStateWorker, 36 | desktop.NewDesktopWorker, 37 | media.NewMicUsageWorker, 38 | media.NewWebcamUsageWorker, 39 | mem.NewUsageWorker, 40 | mem.NewOOMEventsWorker, 41 | net.NewConnectionWorker, 42 | net.NewNetlinkWorker, 43 | net.NewNetStatsWorker, 44 | power.NewProfileWorker, 45 | power.NewStateWorker, 46 | power.NewScreenLockWorker, 47 | system.NewChronyWorker, 48 | system.NewfwupdWorker, 49 | system.NewHWMonWorker, 50 | system.NewInfoWorker, 51 | system.NewLastBootWorker, 52 | system.NewProblemsWorker, 53 | system.NewUptimeTimeWorker, 54 | system.NewUserSessionEventsWorker, 55 | system.NewCPUVulnerabilityWorker, 56 | } 57 | 58 | // linuxLaptopWorkers are sensor workers that should only be run on laptops. 59 | var linuxLaptopWorkers = []func(ctx context.Context) (workers.EntityWorker, error){ 60 | power.NewLaptopWorker, location.NewLocationWorker, 61 | } 62 | 63 | // CreateOSEntityWorkers sets up all OS-specific entity workers. 64 | func CreateOSEntityWorkers(ctx context.Context) []workers.EntityWorker { 65 | osWorkers := make([]workers.EntityWorker, 0, len(linuxWorkers)+len(linuxLaptopWorkers)) 66 | 67 | for workerInit := range slices.Values(linuxWorkers) { 68 | worker, err := workerInit(ctx) 69 | if err != nil { 70 | slogctx.FromCtx(ctx).Warn("Could not init worker.", 71 | slog.Any("error", err)) 72 | 73 | continue 74 | } 75 | 76 | osWorkers = append(osWorkers, worker) 77 | } 78 | 79 | // Get the type of device we are running on. 80 | chassis, _ := device.Chassis() 81 | laptops := []string{"Portable", "Laptop", "Notebook"} 82 | // If running on a laptop chassis, add laptop specific sensor 83 | if slices.Contains(laptops, chassis) { 84 | for _, workerInit := range linuxLaptopWorkers { 85 | worker, err := workerInit(ctx) 86 | if err != nil { 87 | slogctx.FromCtx(ctx).Warn("Could not init worker.", 88 | slog.Any("error", err)) 89 | 90 | continue 91 | } 92 | 93 | osWorkers = append(osWorkers, worker) 94 | } 95 | } 96 | 97 | return osWorkers 98 | } 99 | -------------------------------------------------------------------------------- /agent/mqtt_workers.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package agent 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "log/slog" 11 | 12 | slogctx "github.com/veqryn/slog-context" 13 | 14 | "github.com/joshuar/go-hass-agent/agent/workers" 15 | "github.com/joshuar/go-hass-agent/agent/workers/mqtt" 16 | "github.com/joshuar/go-hass-agent/agent/workers/mqtt/commands" 17 | ) 18 | 19 | // CreateDeviceMQTTWorkers sets up the device-specific MQTT workers. 20 | func CreateDeviceMQTTWorkers(ctx context.Context) ([]workers.MQTTWorker, error) { 21 | var mqttWorkers []workers.MQTTWorker 22 | // Set up custom MQTT commands worker. 23 | device, err := mqtt.Device() 24 | if err != nil { 25 | return nil, fmt.Errorf("unable to create device MQTT workers: %w", err) 26 | } 27 | customCommandsWorker, err := commands.NewCommandsWorker(ctx, device) 28 | if err != nil { 29 | if !errors.Is(err, commands.ErrNoCommands) { 30 | slogctx.FromCtx(ctx).Warn("Could not setup custom MQTT commands.", 31 | slog.Any("error", err)) 32 | } 33 | } else { 34 | mqttWorkers = append(mqttWorkers, customCommandsWorker) 35 | } 36 | 37 | return mqttWorkers, nil 38 | } 39 | -------------------------------------------------------------------------------- /agent/workers/mqtt/commands/testdata/invalid/commands.toml: -------------------------------------------------------------------------------- 1 | [[button 2 | name "notify-send" 3 | exec = 'notify-send "hello"' 4 | icon = "mdi:chat" 5 | -------------------------------------------------------------------------------- /agent/workers/mqtt/commands/testdata/valid/commands.toml: -------------------------------------------------------------------------------- 1 | [[button]] 2 | name = "notify-send" 3 | exec = 'notify-send "hello"' 4 | icon = "mdi:chat" 5 | 6 | [[switch]] 7 | name = "a switch" 8 | exec = 'echo ${1:-ON}' 9 | 10 | 11 | [[number]] 12 | name = "d6 roll" 13 | exec = 'bash -c echo $((1 + $RANDOM % 6))' 14 | icon = "mdi:random" 15 | display = "slider" 16 | min = 1 17 | max = 6 18 | step = 1 19 | 20 | [[number]] 21 | name = "random float" 22 | exec = "bash -c 'printf \"%d04.%d04\\n\" $RANDOM $RANDOM'" 23 | icon = "mdi:random" 24 | type = "float" 25 | min = 0.1 26 | max = 99.99 27 | step = 0.1 28 | -------------------------------------------------------------------------------- /agent/workers/mqtt/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package mqtt 5 | 6 | import ( 7 | "fmt" 8 | 9 | mqtthass "github.com/joshuar/go-hass-anything/v12/pkg/hass" 10 | 11 | "github.com/joshuar/go-hass-agent/config" 12 | "github.com/joshuar/go-hass-agent/device" 13 | "github.com/joshuar/go-hass-agent/validation" 14 | ) 15 | 16 | const ( 17 | // ConfigPrefix is the prefix in the configuration file for MQTT preferences. 18 | ConfigPrefix = "mqtt" 19 | ) 20 | 21 | // Config represents MQTT preferences. 22 | type Config struct { 23 | MQTTServer string `toml:"server,omitempty" form:"mqtt_server" validate:"required,uri" kong:"required,help='MQTT server URI. Required.',placeholder='scheme://some.host:port'"` //nolint:lll 24 | MQTTUser string `toml:"user,omitempty" form:"mqtt_user" validate:"omitempty" kong:"optional,help='MQTT username.'"` 25 | MQTTPassword string `toml:"password,omitempty" form:"mqtt_password" validate:"omitempty" kong:"optional,help='MQTT password.'"` 26 | MQTTTopicPrefix string `toml:"topic_prefix,omitempty" form:"mqtt_topic_prefix" validate:"required,ascii" kong:"optional,help='MQTT topic prefix.'"` 27 | MQTTEnabled bool `toml:"enabled" form:"mqtt_enabled" validate:"boolean" kong:"-"` 28 | } 29 | 30 | func (c *Config) Server() string { 31 | return c.MQTTServer 32 | } 33 | 34 | func (c *Config) User() string { 35 | return c.MQTTUser 36 | } 37 | 38 | func (c *Config) Password() string { 39 | return c.MQTTPassword 40 | } 41 | 42 | func (c *Config) TopicPrefix() string { 43 | return c.MQTTTopicPrefix 44 | } 45 | 46 | // Valid will check the MQTT preferences are valid. 47 | func (c *Config) Valid() (bool, error) { 48 | err := validation.Validate.Struct(c) 49 | if err != nil { 50 | return false, fmt.Errorf("%w: %s", validation.ErrValidation, validation.ParseValidationErrors(err)) 51 | } 52 | 53 | return true, nil 54 | } 55 | 56 | // Sanitise will sanitise the values of the MQTT preferences. 57 | func (c *Config) Sanitise() error { 58 | return nil 59 | } 60 | 61 | // Origin defines Go Hass Agent as the origin for MQTT functionality. 62 | func Origin() *mqtthass.Origin { 63 | return &mqtthass.Origin{ 64 | Name: config.AppName, 65 | Version: config.AppVersion, 66 | URL: config.AppURL, 67 | } 68 | } 69 | 70 | // Device will return a device that is needed for MQTT functionality. 71 | func Device() (*mqtthass.Device, error) { 72 | // Retrieve the hardware model and manufacturer. 73 | model, manufacturer, _ := device.GetHWProductInfo() 74 | 75 | id, err := config.Get[string]("device.id") 76 | if err != nil { 77 | return nil, fmt.Errorf("unable to load device config: %w", err) 78 | } 79 | 80 | name, err := config.Get[string]("device.name") 81 | if err != nil { 82 | return nil, fmt.Errorf("unable to load device config: %w", err) 83 | } 84 | 85 | return &mqtthass.Device{ 86 | Name: name, 87 | URL: config.AppURL, 88 | SWVersion: config.AppVersion, 89 | Manufacturer: manufacturer, 90 | Model: model, 91 | Identifiers: []string{name, id}, 92 | }, nil 93 | } 94 | -------------------------------------------------------------------------------- /agent/workers/mqtt/mqtt.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package mqtt 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "log/slog" 10 | 11 | mqttapi "github.com/joshuar/go-hass-anything/v12/pkg/mqtt" 12 | slogctx "github.com/veqryn/slog-context" 13 | 14 | "github.com/joshuar/go-hass-agent/config" 15 | "github.com/joshuar/go-hass-agent/models" 16 | ) 17 | 18 | // WorkerData contains the configs, subscriptions and message channels for an 19 | // MQTT worker or workers. 20 | type WorkerData struct { 21 | Configs []*models.MQTTConfig 22 | Subscriptions []*models.MQTTSubscription 23 | Msgs <-chan models.MQTTMsg 24 | } 25 | 26 | // Start will connect to MQTT, publish worker configs and subscriptions, then 27 | // start a goroutine to listen for messages from workers to publish through the 28 | // client. If the client connection fails, a non-nil error is returned. 29 | func Start(ctx context.Context, data *WorkerData) error { 30 | // Load the mqtt config. 31 | var mqttcfg Config 32 | if err := config.Load(ConfigPrefix, &mqttcfg); err != nil { 33 | return fmt.Errorf("unable to start MQTT: %w", err) 34 | } 35 | // Create a new connection to the MQTT broker, publish subscriptions and 36 | // configs. 37 | client, err := mqttapi.NewClient(ctx, &mqttcfg, data.Subscriptions, data.Configs) 38 | if err != nil { 39 | return fmt.Errorf("could not start MQTT client: %w", err) 40 | } 41 | // Listen for worker MQTT messages and publish them through the client. 42 | go func() { 43 | for { 44 | select { 45 | case msg := <-data.Msgs: 46 | if err := client.Publish(ctx, &msg); err != nil { 47 | slogctx.FromCtx(ctx).Warn("Unable to publish message to MQTT.", 48 | slog.String("topic", msg.Topic), 49 | slog.Any("msg", msg.Message)) 50 | } 51 | case <-ctx.Done(): 52 | slogctx.FromCtx(ctx).Debug("Stopped listening for messages to publish to MQTT.") 53 | return 54 | } 55 | } 56 | }() 57 | 58 | return nil 59 | } 60 | 61 | // Reset will connect to MQTT and unpublish worker configs. If there is an 62 | // problem, a non-nil error is returned. 63 | func Reset(ctx context.Context, configs []*models.MQTTConfig) error { 64 | // Load the mqtt config. 65 | var mqttcfg Config 66 | if err := config.Load(ConfigPrefix, &mqttcfg); err != nil { 67 | return fmt.Errorf("could not reset MQTT preferences: %w", err) 68 | } 69 | client, err := mqttapi.NewClient(ctx, &mqttcfg, nil, nil) 70 | if err != nil { 71 | return fmt.Errorf("could not reset MQTT preferences: %w", err) 72 | } 73 | if err := client.Unpublish(ctx, configs...); err != nil { 74 | return fmt.Errorf("could not reset MQTT preferences: %w", err) 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /agent/workers/setup_linux.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package workers 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/joshuar/go-hass-agent/platform/linux" 10 | ) 11 | 12 | // SetupCtx sets up a context for Linux systems. This is a wrapper around the 13 | // OS-specific method to set up a context and is only used on Linux systems (via 14 | // file filtering when building). 15 | func SetupCtx(ctx context.Context) context.Context { 16 | return linux.NewContext(ctx) 17 | } 18 | -------------------------------------------------------------------------------- /agent/workers/version.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package workers 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/joshuar/go-hass-agent/config" 11 | "github.com/joshuar/go-hass-agent/models" 12 | "github.com/joshuar/go-hass-agent/models/sensor" 13 | ) 14 | 15 | const ( 16 | versionWorkerID = "agent_version" 17 | versionWorkerDesc = "Go Hass Agent version" 18 | ) 19 | 20 | var _ EntityWorker = (*Version)(nil) 21 | 22 | var ErrVersion = errors.New("version worker error") 23 | 24 | type Version struct { 25 | prefs *CommonWorkerPrefs 26 | *models.WorkerMetadata 27 | } 28 | 29 | func (w *Version) IsDisabled() bool { 30 | return w.prefs.IsDisabled() 31 | } 32 | 33 | func (w *Version) Start(ctx context.Context) (<-chan models.Entity, error) { 34 | sensorCh := make(chan models.Entity) 35 | 36 | go func() { 37 | defer close(sensorCh) 38 | 39 | sensorCh <- sensor.NewSensor(ctx, 40 | sensor.WithName("Go Hass Agent Version"), 41 | sensor.WithID("agent_version"), 42 | sensor.AsDiagnostic(), 43 | sensor.WithIcon("mdi:face-agent"), 44 | sensor.WithState(config.AppVersion), 45 | ) 46 | }() 47 | 48 | return sensorCh, nil 49 | } 50 | 51 | func NewVersionWorker(_ context.Context) (EntityWorker, error) { 52 | worker := &Version{ 53 | WorkerMetadata: models.SetWorkerMetadata(versionWorkerID, versionWorkerDesc), 54 | } 55 | 56 | defaultPrefs := &CommonWorkerPrefs{} 57 | var err error 58 | worker.prefs, err = LoadWorkerPreferences("sensors.agent.version", defaultPrefs) 59 | if err != nil { 60 | return nil, errors.Join(ErrVersion, err) 61 | } 62 | 63 | return worker, nil 64 | } 65 | -------------------------------------------------------------------------------- /assets/go-hass-agent.service: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Joshua Rich . 2 | # SPDX-License-Identifier: MIT 3 | 4 | [Unit] 5 | Wants=network-online.target 6 | After=network-online.target nss-lookup.target 7 | 8 | [Service] 9 | ExecStart=/usr/bin/go-hass-agent run 10 | Type=simple 11 | Restart=always 12 | RestartSec=30 13 | 14 | [Install] 15 | WantedBy=default.target 16 | -------------------------------------------------------------------------------- /assets/postinstall.sh: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Joshua Rich . 2 | # SPDX-License-Identifier: MIT 3 | 4 | #!/bin/sh 5 | 6 | # Set capabilities required for some workers if `setcap` is available. If not, some workers will not be able to run. 7 | setCapabilities() { 8 | if type setcap >/dev/null; then 9 | setcap 'cap_sys_rawio,cap_sys_admin,cap_mknod,cap_dac_override=+ep' /usr/bin/go-hass-agent 10 | fi 11 | } 12 | 13 | setCapabilities 14 | -------------------------------------------------------------------------------- /assets/screenshots/preferences.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/assets/screenshots/preferences.png -------------------------------------------------------------------------------- /assets/screenshots/registration-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/assets/screenshots/registration-form.png -------------------------------------------------------------------------------- /assets/start-go-hass-agent.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Start Go Hass Agent 3 | Comment=A Home Assistant, native app for desktop/laptop devices. 4 | Path=/usr/bin 5 | Exec=go-hass-agent run 6 | Icon=go-hass-agent 7 | Terminal=false 8 | Categories=System;Monitor;TrayIcon; 9 | Keywords=home;assistant;hass; 10 | Type=Application 11 | -------------------------------------------------------------------------------- /cli/cli.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package cli contains methods for handling the command-line of the agent. 5 | package cli 6 | 7 | import ( 8 | "embed" 9 | ) 10 | 11 | // Opts are the global command-line options common across all commands. 12 | type Opts struct { 13 | Path string 14 | StaticContent embed.FS 15 | } 16 | -------------------------------------------------------------------------------- /cli/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package cli 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "log/slog" 10 | "os" 11 | "os/signal" 12 | "syscall" 13 | 14 | slogctx "github.com/veqryn/slog-context" 15 | 16 | "github.com/joshuar/go-hass-agent/agent/workers/mqtt" 17 | "github.com/joshuar/go-hass-agent/config" 18 | ) 19 | 20 | // Config represents the options for the `config` command. 21 | type Config struct { 22 | mqtt.Config 23 | } 24 | 25 | // Run processes the config command. 26 | func (c *Config) Run(opts *Opts) error { 27 | ctx, cancelFunc := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 28 | defer cancelFunc() 29 | ctx = slogctx.NewCtx(ctx, slog.Default()) 30 | 31 | // Validate config options. 32 | valid, err := c.Valid() 33 | if !valid || err != nil { 34 | return fmt.Errorf("unable to register: %w", err) 35 | } 36 | 37 | err = config.Save(mqtt.ConfigPrefix, c.Config) 38 | if err != nil { 39 | return fmt.Errorf("unable to save preferences: %w", err) 40 | } 41 | slogctx.FromCtx(ctx).Info("Agent registered!") 42 | 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /cli/register.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package cli 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "log/slog" 10 | "os" 11 | "os/signal" 12 | "syscall" 13 | 14 | slogctx "github.com/veqryn/slog-context" 15 | 16 | "github.com/joshuar/go-hass-agent/agent" 17 | "github.com/joshuar/go-hass-agent/device" 18 | "github.com/joshuar/go-hass-agent/hass" 19 | ) 20 | 21 | // Register represents the options for the `register` command. 22 | type Register struct { 23 | hass.RegistrationRequest 24 | 25 | Force bool `help:"Force registration."` 26 | } 27 | 28 | // Run processes the register command. 29 | func (r *Register) Run(opts *Opts) error { 30 | ctx, cancelFunc := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 31 | defer cancelFunc() 32 | ctx = slogctx.NewCtx(ctx, slog.Default()) 33 | 34 | // Create an agent instance. 35 | agent, err := agent.New() 36 | if err != nil { 37 | return fmt.Errorf("unable to run: %w", err) 38 | } 39 | 40 | // Don't continue if agent is registered unless force option is set. 41 | if agent.IsRegistered() && !r.Force { 42 | slogctx.FromCtx(ctx).Warn("Already registered and force not set.") 43 | return nil 44 | } 45 | 46 | // Validate registration options. 47 | valid, err := r.Valid() 48 | if !valid || err != nil { 49 | return fmt.Errorf("unable to register: %w", err) 50 | } 51 | 52 | // Get the device config. 53 | deviceCfg, err := device.GetConfig() 54 | if err != nil { 55 | return fmt.Errorf("unable to register: get device details failed: %w", err) 56 | } 57 | 58 | // Perform registration. 59 | err = hass.Register(ctx, deviceCfg.ID, &r.RegistrationRequest) 60 | if err != nil { 61 | return fmt.Errorf("unable to register: %w", err) 62 | } 63 | 64 | // If force option set, reset the agent. 65 | if r.Force { 66 | err := hass.Reset() 67 | if err != nil { 68 | slogctx.FromCtx(ctx).Warn("Could not reset registry state.", 69 | slog.Any("error", err)) 70 | } 71 | agent.Reset() 72 | } 73 | 74 | // Register the agent. 75 | agent.Register() 76 | 77 | slogctx.FromCtx(ctx).Info("Agent registered!") 78 | 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /cli/run.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package cli 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "log/slog" 11 | "os" 12 | "os/signal" 13 | "syscall" 14 | 15 | slogctx "github.com/veqryn/slog-context" 16 | 17 | "github.com/joshuar/go-hass-agent/agent" 18 | "github.com/joshuar/go-hass-agent/config" 19 | "github.com/joshuar/go-hass-agent/scheduler" 20 | "github.com/joshuar/go-hass-agent/server" 21 | ) 22 | 23 | // Run is the command-line option for running the agent. 24 | type Run struct{} 25 | 26 | // Help shows a help message about the run command. 27 | func (r *Run) Help() string { 28 | return "Run Go Hass Agent with the given options." 29 | } 30 | 31 | // Run starts the agent. 32 | func (r *Run) Run(opts *Opts) error { 33 | ctx, cancelFunc := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 34 | defer cancelFunc() 35 | ctx = slogctx.NewCtx(ctx, slog.Default()) 36 | 37 | err := config.Init() 38 | if err != nil && !errors.Is(err, config.ErrLoadConfig) { 39 | return fmt.Errorf("unable to run: %w", err) 40 | } 41 | 42 | // Start scheduler. 43 | err = scheduler.Start(ctx) 44 | if err != nil { 45 | return fmt.Errorf("unable to run: %w", err) 46 | } 47 | 48 | // Configure agent. 49 | agent, err := agent.New() 50 | if err != nil { 51 | return fmt.Errorf("unable to run: %w", err) 52 | } 53 | 54 | // Configure web server. 55 | server, err := server.New(opts.StaticContent, agent) 56 | if err != nil { 57 | return fmt.Errorf("unable to run: %w", err) 58 | } 59 | 60 | // Start web server. 61 | err = server.Start(ctx) 62 | if err != nil { 63 | return fmt.Errorf("unable to run: %w", err) 64 | } 65 | 66 | // Start agent. 67 | err = agent.Run(ctx) 68 | if err != nil { 69 | return fmt.Errorf("unable to run: %w", err) 70 | } 71 | 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /cli/version.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package cli 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | 10 | "github.com/joshuar/go-hass-agent/config" 11 | ) 12 | 13 | // Version is the command-line option for showing the agent version. 14 | type Version struct{} 15 | 16 | // Run will run the version command. 17 | func (r *Version) Run(_ *Opts) error { 18 | _, err := fmt.Fprintf(os.Stdout, "%s: %s\n", config.AppName, config.AppVersion) 19 | if err != nil { 20 | return fmt.Errorf("unable to show version: %w", err) 21 | } 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "internal/agent/ui/*.go" # const/interface/utility code 3 | - "*_generated.go" # generated with stringer 4 | - "*_test.go" # tests 5 | - "internal/hass/sensor/types" # all generated 6 | - "*Strings.go" # generated with stringer 7 | - "**/testing" # test files 8 | - "internal/translations/catalog.go" # generated with gotext 9 | - internal/device/device.go # const/utility code 10 | - build/magefiles/* # ignore build system 11 | - tools/tools.go # ignore tools hack 12 | -------------------------------------------------------------------------------- /cosign.key: -------------------------------------------------------------------------------- 1 | -----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY----- 2 | eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjo2NTUzNiwiciI6 3 | OCwicCI6MX0sInNhbHQiOiIwWk1nSE1KNWY1NjFxZk4wVVpjRENQc0d3UHpRQUxQ 4 | bU1ER1NCNGJZa1UwPSJ9LCJjaXBoZXIiOnsibmFtZSI6Im5hY2wvc2VjcmV0Ym94 5 | Iiwibm9uY2UiOiJyMm5nZWMyaXJCMWRNTk5acnVkOEFaai94Sm9DRjVPYiJ9LCJj 6 | aXBoZXJ0ZXh0IjoicEJtNDFXTUtPbThDOHkrcVFvY2N2T3QzczhxYlVVSWN5dW9S 7 | QkxuK1BKeFRXQnRvT0w5TlRPd3Ntd3NvTml3OVRRVWN1K1dXZ0VVMllzTERlUTho 8 | TS9SNWNjVXl3VlRJdWxFOTIzVkQ5Z2pNUitrS21ET0JLbjlBU29MUU5DTVRWQ1Mv 9 | VThYbWExODRYMGFURFJpU0FocHZHaW9qVW4rcmdvTWtmSXVpcElmaEwzKzAwSkR0 10 | eU5EQTlENmRjOThTUFVMK1Y1Vk82OEUwL1E9PSJ9 11 | -----END ENCRYPTED SIGSTORE PRIVATE KEY----- 12 | -------------------------------------------------------------------------------- /cosign.pub: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0pghyUSPvfS5pRMH5D5DTgBOqB8Y 3 | 3eajA1fYO5Hn7zGh7vSh+9fPBsi2mTbKuTJuYHBU2SZzn6IdLjrRhIfkzA== 4 | -----END PUBLIC KEY----- 5 | -------------------------------------------------------------------------------- /deployments/mosquitto/config/mosquitto.conf.example: -------------------------------------------------------------------------------- 1 | user mosquitto 2 | 3 | # unencrypted only on localhost 4 | listener 1883 5 | 6 | # encrypted on network 7 | #listener 8883 8 | #certfile /mosquitto/config/ssl.crt 9 | #keyfile /mosquitto/config/ssl.key 10 | 11 | #Allow connection without authentication 12 | allow_anonymous true 13 | 14 | log_dest file /mosquitto/log/mosquitto.log 15 | log_dest stdout 16 | log_timestamp_format %Y-%m-%dT%H:%M:%S 17 | log_type debug 18 | -------------------------------------------------------------------------------- /device/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package device 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/joshuar/go-hass-agent/config" 10 | ) 11 | 12 | // ConfigPrefix is the path prefix in the config for device settings. 13 | const ConfigPrefix = "device" 14 | 15 | // Config contains the values that define the device that will be registered with Home Assistant. 16 | type Config struct { 17 | ID string `toml:"id"` 18 | Name string `toml:"name"` 19 | } 20 | 21 | // NewConfig returns a new device config. 22 | func NewConfig() error { 23 | id, err := NewDeviceID() 24 | if err != nil { 25 | return fmt.Errorf("unable to generate new device id: %w", err) 26 | } 27 | name, err := GetHostname() 28 | if err != nil { 29 | return fmt.Errorf("unable to generate device hostname: %w", err) 30 | } 31 | 32 | err = config.Save(ConfigPrefix, &Config{ID: id, Name: name}) 33 | if err != nil { 34 | return fmt.Errorf("unable to save device config: %w", err) 35 | } 36 | 37 | return nil 38 | } 39 | 40 | // GetConfig returns the device config. 41 | func GetConfig() (*Config, error) { 42 | cfg := &Config{} 43 | if err := config.Load(ConfigPrefix, cfg); err != nil { 44 | return nil, fmt.Errorf("unable to load agent config: %w", err) 45 | } 46 | return cfg, nil 47 | } 48 | -------------------------------------------------------------------------------- /device/info.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Joshua Rich 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | 6 | package device 7 | 8 | import ( 9 | "errors" 10 | "fmt" 11 | "os" 12 | "strings" 13 | 14 | "github.com/gofrs/uuid/v5" 15 | "github.com/jaypipes/ghw" 16 | ) 17 | 18 | const ( 19 | unknownVendor = "Unknown Vendor" 20 | unknownModel = "Unknown Model" 21 | unknownDistro = "Unknown Distro" 22 | unknownDistroVersion = "Unknown Version" 23 | unknownValue = "unknown" 24 | defaultHostname = "localhost" 25 | ) 26 | 27 | // ErrUnsupportedHardware is returned when the hardware does not support the method or action. 28 | var ErrUnsupportedHardware = errors.New("unsupported hardware") 29 | 30 | // Chassis will return the chassis type of the machine, such as "desktop" or 31 | // "laptop". If this cannot be retrieved, it will return "unknown". 32 | func Chassis() (string, error) { 33 | chassisInfo, err := ghw.Chassis(ghw.WithDisableWarnings()) 34 | if err != nil || chassisInfo == nil { 35 | return unknownValue, fmt.Errorf("could not determine chassis type: %w", err) 36 | } 37 | 38 | return chassisInfo.TypeDescription, nil 39 | } 40 | 41 | // GetHostname retrieves the hostname of the device running the agent, or 42 | // localhost if that doesn't work. 43 | func GetHostname() (string, error) { 44 | hostname, err := os.Hostname() 45 | if err != nil { 46 | return defaultHostname, fmt.Errorf("could not retrieve hostname: %w", err) 47 | } 48 | 49 | shortHostname, _, _ := strings.Cut(hostname, ".") 50 | 51 | return shortHostname, nil 52 | } 53 | 54 | // GetHWProductInfo retrieves the model and vendor of the machine. If these 55 | // cannot be retrieved or cannot be found, they will be set to default unknown 56 | // strings. 57 | func GetHWProductInfo() (string, string, error) { 58 | product, err := ghw.Product(ghw.WithDisableWarnings()) 59 | if err != nil || product == nil { 60 | return unknownModel, unknownVendor, fmt.Errorf("could not retrieve hardware information: %w", err) 61 | } 62 | 63 | return product.Name, product.Vendor, nil 64 | } 65 | 66 | // NewDeviceID create a new device ID. It will be a randomly generated UUIDv4. 67 | func NewDeviceID() (string, error) { 68 | deviceID, err := uuid.NewV4() 69 | if err != nil { 70 | return "", fmt.Errorf("could not retrieve a machine ID: %w", err) 71 | } 72 | 73 | return deviceID.String(), nil 74 | } 75 | -------------------------------------------------------------------------------- /device/info_linux.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Joshua Rich 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | 6 | package device 7 | 8 | import ( 9 | "fmt" 10 | "syscall" 11 | 12 | "github.com/joshuar/go-hass-agent/pkg/linux/whichdistro" 13 | ) 14 | 15 | // GetOSID will retrieve the distribution ID and version ID. These are 16 | // suitable for usage as part of identifiers and variables. See also 17 | // GetDistroDetails. 18 | func GetOSID() (string, string, error) { 19 | var distroName, distroVersion string 20 | 21 | osReleaseInfo, err := whichdistro.GetOSRelease() 22 | if err != nil { 23 | return unknownDistro, unknownDistroVersion, 24 | fmt.Errorf("could not read /etc/os-release: %w", err) 25 | } 26 | 27 | if v, ok := osReleaseInfo.GetValue("ID"); !ok { 28 | distroName = unknownDistro 29 | } else { 30 | distroName = v 31 | } 32 | 33 | if v, ok := osReleaseInfo.GetValue("VERSION_ID"); !ok { 34 | distroVersion = unknownDistroVersion 35 | } else { 36 | distroVersion = v 37 | } 38 | 39 | return distroName, distroVersion, nil 40 | } 41 | 42 | // GetOSDetails will retrieve the distribution name and version. The values 43 | // are pretty-printed and may not be suitable for usage as identifiers and 44 | // variables. See also GetDistroID. 45 | func GetOSDetails() (string, string, error) { 46 | var ( 47 | distroName, distroVersion string 48 | value string 49 | found bool 50 | ) 51 | 52 | osReleaseInfo, err := whichdistro.GetOSRelease() 53 | if err != nil { 54 | return unknownDistro, unknownDistroVersion, 55 | fmt.Errorf("could not read /etc/os-release: %w", err) 56 | } 57 | 58 | if value, found = osReleaseInfo.GetValue("NAME"); found { 59 | distroName = value 60 | } else if value, found = osReleaseInfo.GetValue("ID"); found { 61 | distroName = value 62 | } else { 63 | distroName = unknownDistro 64 | } 65 | 66 | if value, found = osReleaseInfo.GetValue("VERSION"); found { 67 | distroVersion = value 68 | } else if value, found = osReleaseInfo.GetValue("VERSION_ID"); found { 69 | distroVersion = value 70 | } else { 71 | distroVersion = unknownDistroVersion 72 | } 73 | 74 | return distroName, distroVersion, nil 75 | } 76 | 77 | // GetKernelVersion will retrieve the kernel version. 78 | // 79 | //nolint:prealloc 80 | func GetKernelVersion() (string, error) { 81 | var utsname syscall.Utsname 82 | 83 | var versionBytes []byte 84 | 85 | err := syscall.Uname(&utsname) 86 | if err != nil { 87 | return unknownValue, fmt.Errorf("could not retrieve kernel version: %w", err) 88 | } 89 | 90 | for _, v := range utsname.Release { 91 | if v == 0 { 92 | continue 93 | } 94 | 95 | versionBytes = append(versionBytes, uint8(v)) // #nosec: G115 96 | } 97 | 98 | return string(versionBytes), nil 99 | } 100 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import { defineConfig } from "eslint/config"; 4 | import eslintConfigPrettier from "eslint-config-prettier/flat"; 5 | 6 | export default defineConfig([ 7 | eslintConfigPrettier, 8 | { 9 | files: ["**/*.{js,mjs,cjs}"], 10 | plugins: { js }, 11 | extends: ["js/recommended"], 12 | }, 13 | { 14 | files: ["**/*.{js,mjs,cjs}"], 15 | languageOptions: { globals: globals.browser }, 16 | }, 17 | ]); 18 | -------------------------------------------------------------------------------- /hass/api/rest.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package api contains methods and objects for interacting with the Home Assistant REST API. 5 | package api 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "net/http" 11 | "strings" 12 | "time" 13 | 14 | "github.com/go-resty/resty/v2" 15 | ) 16 | 17 | const ( 18 | defaultTimeout = 30 * time.Second 19 | defaultRetryWait = 5 * time.Second 20 | defaultRetryCount = 5 21 | defaultRetryMaxWait = 20 * time.Second 22 | ) 23 | 24 | // ErrUnknown is returned when an error occurred but the reason/cause could not be determined or was unexpected. 25 | var ErrUnknown = errors.New("an unknown error occurred") 26 | 27 | // Error allows an APIError to satisfy the Go Error interface. 28 | func (e *APIError) Error() string { 29 | var msg []string 30 | if e.Code != "" { 31 | msg = append(msg, e.Code) 32 | } 33 | 34 | if e.Message != "" { 35 | msg = append(msg, e.Message) 36 | } 37 | 38 | if len(msg) == 0 { 39 | msg = append(msg, "unknown error") 40 | } 41 | 42 | return strings.Join(msg, ": ") 43 | } 44 | 45 | // HasError determines whether the response status indicates an error condition 46 | // has occurred. 47 | func (r *ResponseStatus) HasError() error { 48 | if r.Error.IsSpecified() { 49 | apiErr, err := r.Error.Get() 50 | if err != nil { 51 | return fmt.Errorf("%w: %w", ErrUnknown, err) 52 | } 53 | 54 | return &apiErr 55 | } 56 | 57 | if r.Error.IsNull() { 58 | return ErrUnknown 59 | } 60 | 61 | return nil 62 | } 63 | 64 | // HasSuccess determines whether the response status indicates the request was 65 | // successful. 66 | func (r *ResponseStatus) HasSuccess() (bool, error) { 67 | if r.IsSuccess.IsSpecified() { 68 | return r.IsSuccess.Get() //nolint:wrapcheck 69 | } 70 | 71 | if r.IsSuccess.IsNull() { 72 | return true, nil 73 | } 74 | 75 | return false, nil 76 | } 77 | 78 | // SensorDisabled determines whether the response status indicates the sensor was disabled. 79 | func (r *ResponseStatus) SensorDisabled() bool { 80 | if r.IsDisabled.IsSpecified() { 81 | if disabled, err := r.IsDisabled.Get(); err == nil { 82 | return disabled 83 | } 84 | } 85 | 86 | return false 87 | } 88 | 89 | // NewClient creates a new resty client that can be used to communicate with the 90 | // Home Assistant REST API. 91 | func NewClient() *resty.Client { 92 | return resty.New(). 93 | SetTimeout(defaultTimeout). 94 | SetRetryCount(defaultRetryCount). 95 | SetRetryWaitTime(defaultRetryWait). 96 | SetRetryMaxWaitTime(defaultRetryMaxWait). 97 | AddRetryCondition(func(r *resty.Response, _ error) bool { 98 | return r.StatusCode() == http.StatusTooManyRequests 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /hass/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package hass 5 | 6 | import ( 7 | "errors" 8 | "sync" 9 | 10 | "github.com/joshuar/go-hass-agent/hass/api" 11 | ) 12 | 13 | const ( 14 | ConfigPrefix = "hass" 15 | ConfigAPIURL = "apiurl" 16 | ConfigWebsocketURL = "websocketurl" 17 | ConfigWebhookID = "webhook_id" 18 | ConfigSecret = "secret" 19 | ) 20 | 21 | type Config struct { 22 | mu sync.Mutex `toml:"-"` 23 | APIURL string `toml:"apiurl" validate:"required"` 24 | Secret string `toml:"secret"` 25 | WebHookID string `toml:"webhook_id" validate:"required"` 26 | WebsocketURL string `toml:"websocketurl" validate:"required"` 27 | remote *api.ConfigResponse `toml:"-"` 28 | } 29 | 30 | var ( 31 | ErrInvalidEntityConfig = errors.New("entity has invalid config") 32 | ErrInvalidConfig = errors.New("invalid config") 33 | ) 34 | 35 | func (c *Config) Update(newConfig *api.ConfigResponse) { 36 | c.mu.Lock() 37 | defer c.mu.Unlock() 38 | 39 | c.remote = newConfig 40 | } 41 | 42 | // GetVersion returns the version of Home Assistant from the config response, or 43 | // "Unknown" if it was not set. 44 | func (c *Config) GetVersion() string { 45 | return c.remote.Version 46 | } 47 | 48 | // IsEntityDisabled returns whether the entity with the given ID has been 49 | // disabled in Home Assistant. If the disabled status could not be determined, 50 | // it will return a non-nil error. 51 | func (c *Config) IsEntityDisabled(id string) (bool, error) { 52 | c.mu.Lock() 53 | defer c.mu.Unlock() 54 | 55 | // If there is no entities list, assume not disabled. 56 | if c.remote.Entities.IsNull() { 57 | return false, nil 58 | } 59 | 60 | entities, err := c.remote.Entities.Get() 61 | if err != nil { 62 | return false, errors.Join(ErrInvalidConfig, err) 63 | } 64 | 65 | if v, ok := entities[id]["disabled"]; ok { 66 | disabledState, ok := v.(bool) 67 | if !ok { 68 | return false, nil 69 | } 70 | 71 | return disabledState, nil 72 | } 73 | 74 | return false, nil 75 | } 76 | -------------------------------------------------------------------------------- /hass/event/event.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package event contains code for processing events from workers through the Home Assistant API. 5 | package event 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | slogctx "github.com/veqryn/slog-context" 12 | 13 | "github.com/joshuar/go-hass-agent/hass/api" 14 | "github.com/joshuar/go-hass-agent/models" 15 | "github.com/joshuar/go-hass-agent/validation" 16 | ) 17 | 18 | type clientAPI interface { 19 | SendRequest(ctx context.Context, url string, req api.Request) (api.Response, error) 20 | RestAPIURL() string 21 | } 22 | 23 | // NewEventRequest takes event data and creates an event request. 24 | func newEventRequest(event *models.Event) (*api.Request, error) { 25 | if valid, problems := validation.ValidateStruct(event); !valid { 26 | return nil, fmt.Errorf("could not marshal event data: %w", problems) 27 | } 28 | 29 | req := &api.Request{ 30 | Type: api.FireEvent, 31 | Data: api.Request_Data{}, 32 | Retryable: event.Retryable, 33 | } 34 | 35 | // Add the sensor registration into the request. 36 | err := req.Data.FromEvent(*event) 37 | if err != nil { 38 | return nil, fmt.Errorf("could not marshal event data: %w", err) 39 | } 40 | 41 | return req, nil 42 | } 43 | 44 | // Handler handles sending event data as a request to Home Assistant and 45 | // processing the response. 46 | func Handler(ctx context.Context, client clientAPI, event models.Event) error { 47 | req, err := newEventRequest(&event) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | resp, err := client.SendRequest(ctx, client.RestAPIURL(), *req) 53 | if err != nil { 54 | return fmt.Errorf("could not send event data: %w", err) 55 | } 56 | 57 | status, err := resp.AsResponseStatus() 58 | if err != nil { 59 | return fmt.Errorf("could not marshal event response: %w", err) 60 | } 61 | 62 | if err := status.HasError(); err != nil { 63 | return fmt.Errorf("could not determine response status: %w", err) 64 | } 65 | 66 | slogctx.FromCtx(ctx).Debug("Event sent.", event.LogAttributes()) 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /hass/location/location.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package location contains code for processing location requests through the Home Assistant API. 5 | package location 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | slogctx "github.com/veqryn/slog-context" 12 | 13 | "github.com/joshuar/go-hass-agent/hass/api" 14 | "github.com/joshuar/go-hass-agent/models" 15 | "github.com/joshuar/go-hass-agent/validation" 16 | ) 17 | 18 | type clientAPI interface { 19 | SendRequest(ctx context.Context, url string, req api.Request) (api.Response, error) 20 | RestAPIURL() string 21 | } 22 | 23 | // newLocationRequest takes location data and creates a location request. 24 | func newLocationRequest(location *models.Location) (*api.Request, error) { 25 | if valid, problems := validation.ValidateStruct(location); !valid { 26 | return nil, fmt.Errorf("could not marshal location data: %w", problems) 27 | } 28 | 29 | req := &api.Request{ 30 | Type: api.UpdateLocation, 31 | } 32 | 33 | // Add the sensor registration into the request. 34 | err := req.Data.FromLocation(*location) 35 | if err != nil { 36 | return nil, fmt.Errorf("could not marshal location data: %w", err) 37 | } 38 | 39 | return req, nil 40 | } 41 | 42 | // Handler handles sending location data as a request to Home Assistant and 43 | // processing the response. 44 | func Handler(ctx context.Context, client clientAPI, location models.Location) error { 45 | req, err := newLocationRequest(&location) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | resp, err := client.SendRequest(ctx, client.RestAPIURL(), *req) 51 | if err != nil { 52 | return fmt.Errorf("could not send location request: %w", err) 53 | } 54 | 55 | status, err := resp.AsResponseStatus() 56 | if err != nil { 57 | return fmt.Errorf("could not marshal location response: %w", err) 58 | } 59 | 60 | if err := status.HasError(); err != nil { 61 | return fmt.Errorf("could not determine location response status: %w", err) 62 | } 63 | 64 | slogctx.FromCtx(ctx).Debug("Location sent.") 65 | 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /hass/registry/registry.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package registry handles managing a sensor registry locally on disk. 5 | package registry 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "os" 11 | "path/filepath" 12 | ) 13 | 14 | var ( 15 | // ErrNotFound is returned when a sensor could not be found in the registry. 16 | ErrNotFound = errors.New("sensor not found") 17 | // ErrInvalidMetadata is returned when the sensor data in the registry is invalid. 18 | ErrInvalidMetadata = errors.New("invalid sensor metadata") 19 | ) 20 | 21 | type metadata struct { 22 | Registered bool `json:"registered"` 23 | Disabled bool `json:"disabled"` 24 | } 25 | 26 | // Reset will handle resetting the registry. 27 | func Reset(registryPath string) error { 28 | registryPath = filepath.Join(registryPath, "sensorRegistry") 29 | 30 | _, err := os.Stat(registryPath) 31 | if os.IsNotExist(err) { 32 | return fmt.Errorf("registry not found: %w", err) 33 | } 34 | 35 | err = os.RemoveAll(registryPath) 36 | if err != nil { 37 | return fmt.Errorf("failed to remove registry: %w", err) 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /hass/registry/registry_gob.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package registry 5 | 6 | import ( 7 | "encoding/gob" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "log/slog" 12 | "os" 13 | "path/filepath" 14 | "sync" 15 | ) 16 | 17 | const ( 18 | registryFile = "sensor.reg" 19 | defaultFilePerms = 0o640 20 | ) 21 | 22 | // GobRegistry is a registry based on gob binary data. 23 | type GobRegistry struct { 24 | sensors map[string]metadata 25 | file string 26 | mu sync.Mutex 27 | } 28 | 29 | func (g *GobRegistry) IsDisabled(id string) bool { 30 | g.mu.Lock() 31 | defer g.mu.Unlock() 32 | 33 | sensor, ok := g.sensors[id] 34 | if !ok { 35 | slog.Debug("Sensor not found in registry.", slog.String("sensor_id", id)) 36 | 37 | return false 38 | } 39 | 40 | return sensor.Disabled 41 | } 42 | 43 | func (g *GobRegistry) IsRegistered(id string) bool { 44 | g.mu.Lock() 45 | defer g.mu.Unlock() 46 | 47 | sensor, ok := g.sensors[id] 48 | if !ok { 49 | slog.Debug("Sensor not found in registry.", slog.String("sensor_id", id)) 50 | 51 | return false 52 | } 53 | 54 | return sensor.Registered 55 | } 56 | 57 | func (g *GobRegistry) SetDisabled(id string, value bool) error { 58 | g.mu.Lock() 59 | defer g.mu.Unlock() 60 | 61 | m := g.sensors[id] 62 | m.Disabled = value 63 | g.sensors[id] = m 64 | 65 | if err := g.write(); err != nil { 66 | return fmt.Errorf("could not write to registry: %w", err) 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func (g *GobRegistry) SetRegistered(id string, value bool) error { 73 | g.mu.Lock() 74 | defer g.mu.Unlock() 75 | 76 | m := g.sensors[id] 77 | m.Registered = value 78 | g.sensors[id] = m 79 | 80 | if err := g.write(); err != nil { 81 | return fmt.Errorf("could not write to registry: %w", err) 82 | } 83 | 84 | return nil 85 | } 86 | 87 | // Load will load the registry from disk. 88 | func Load(path string) (*GobRegistry, error) { 89 | registryPath := filepath.Join(path, "sensorRegistry", registryFile) 90 | 91 | reg := &GobRegistry{ 92 | sensors: make(map[string]metadata), 93 | mu: sync.Mutex{}, 94 | file: registryPath, 95 | } 96 | 97 | if err := checkPath(filepath.Dir(reg.file)); err != nil { 98 | return nil, fmt.Errorf("could not load registry: %w", err) 99 | } 100 | 101 | if err := reg.read(); err != nil { 102 | return nil, fmt.Errorf("could not read from registry: %w", err) 103 | } 104 | 105 | return reg, nil 106 | } 107 | 108 | func (g *GobRegistry) write() error { 109 | regFS, err := os.OpenFile(g.file, os.O_RDWR|os.O_CREATE, defaultFilePerms) 110 | if err != nil { 111 | return fmt.Errorf("could not open registry for writing: %w", err) 112 | } 113 | 114 | enc := gob.NewEncoder(regFS) 115 | 116 | err = enc.Encode(&g.sensors) 117 | if err != nil { 118 | return fmt.Errorf("could not encode registry data: %w", err) 119 | } 120 | 121 | return nil 122 | } 123 | 124 | func (g *GobRegistry) read() error { 125 | g.mu.Lock() 126 | defer g.mu.Unlock() 127 | 128 | regFS, err := os.OpenFile(g.file, os.O_RDWR|os.O_CREATE, defaultFilePerms) 129 | if err != nil { 130 | return fmt.Errorf("could not open registry for reading: %w", err) 131 | } 132 | 133 | dec := gob.NewDecoder(regFS) 134 | 135 | err = dec.Decode(&g.sensors) 136 | if err != nil && !errors.Is(err, io.EOF) { 137 | return fmt.Errorf("could not decode registry data: %w", err) 138 | } 139 | 140 | return nil 141 | } 142 | -------------------------------------------------------------------------------- /hass/registry/registry_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package registry 5 | 6 | import ( 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func TestReset(t *testing.T) { 12 | validPath := t.TempDir() 13 | newMockReg(t, validPath) 14 | 15 | type args struct { 16 | path string 17 | } 18 | tests := []struct { 19 | name string 20 | args args 21 | wantErr bool 22 | }{ 23 | { 24 | name: "valid path", 25 | args: args{path: validPath}, 26 | }, 27 | { 28 | name: "invalid path", 29 | args: args{path: filepath.Join(t.TempDir(), "nonexistent")}, 30 | wantErr: true, 31 | }, 32 | } 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | if err := Reset(tt.args.path); (err != nil) != tt.wantErr { 36 | t.Errorf("Reset() error = %v, wantErr %v", err, tt.wantErr) 37 | } 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /hass/registry/util.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package registry 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | ) 10 | 11 | func checkPath(path string) error { 12 | _, err := os.Stat(path) 13 | if os.IsNotExist(err) { 14 | err := os.MkdirAll(path, 0o750) 15 | if err != nil { 16 | return fmt.Errorf("unable to create new directory: %w", err) 17 | } 18 | } 19 | 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /hass/registry/util_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package registry 5 | 6 | import ( 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func Test_checkPath(t *testing.T) { 12 | type args struct { 13 | path string 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | wantErr bool 19 | }{ 20 | { 21 | name: "exists", 22 | args: args{path: t.TempDir()}, 23 | }, 24 | { 25 | name: "does not exist", 26 | args: args{path: filepath.Join(t.TempDir(), "notexists")}, 27 | }, 28 | { 29 | name: "unwriteable", 30 | args: args{path: "/notexists"}, 31 | wantErr: true, 32 | }, 33 | } 34 | for _, tt := range tests { 35 | t.Run(tt.name, func(t *testing.T) { 36 | if err := checkPath(tt.args.path); (err != nil) != tt.wantErr { 37 | t.Errorf("checkPath() error = %v, wantErr %v", err, tt.wantErr) 38 | } 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /hass/tracker/tracker.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tracker 5 | 6 | import ( 7 | "errors" 8 | "sort" 9 | "sync" 10 | 11 | "github.com/joshuar/go-hass-agent/models" 12 | ) 13 | 14 | var ( 15 | ErrTrackerNotReady = errors.New("tracker not ready") 16 | ErrSensorNotFound = errors.New("sensor not found in tracker") 17 | ) 18 | 19 | // Tracker holds details about the last state from all known sensor entities. 20 | type Tracker struct { 21 | sync.Mutex 22 | sensor map[models.UniqueID]*models.Sensor 23 | } 24 | 25 | // NewTracker creates a new tracker object. 26 | func NewTracker() *Tracker { 27 | return &Tracker{ 28 | sensor: make(map[models.UniqueID]*models.Sensor), 29 | } 30 | } 31 | 32 | // Get fetches a sensors current tracked state. 33 | func (t *Tracker) Get(id models.UniqueID) (*models.Sensor, error) { 34 | t.Lock() 35 | defer t.Unlock() 36 | 37 | if t.sensor[id] != nil { 38 | return t.sensor[id], nil 39 | } 40 | 41 | return nil, ErrSensorNotFound 42 | } 43 | 44 | // SensorList returns a list of entity IDs of all tracked sensor entities. 45 | func (t *Tracker) SensorList() []models.UniqueID { 46 | t.Lock() 47 | defer t.Unlock() 48 | 49 | if t.sensor == nil { 50 | return nil 51 | } 52 | 53 | sortedEntities := make([]models.UniqueID, 0, len(t.sensor)) 54 | 55 | for name := range t.sensor { 56 | sortedEntities = append(sortedEntities, name) 57 | } 58 | 59 | sort.Strings(sortedEntities) 60 | 61 | return sortedEntities 62 | } 63 | 64 | // Add creates a new sensor in the tracker based on a received state update. 65 | func (t *Tracker) Add(details *models.Sensor) error { 66 | t.Lock() 67 | defer t.Unlock() 68 | 69 | if t.sensor == nil { 70 | return ErrTrackerNotReady 71 | } 72 | 73 | t.sensor[details.UniqueID] = details 74 | 75 | return nil 76 | } 77 | 78 | // Reset will remove all tracked sensor entity details. 79 | func (t *Tracker) Reset() { 80 | if t.sensor != nil { 81 | t.sensor = nil 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /id/id.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package id contains methods for generating universally unique IDs. 5 | package id 6 | 7 | //go:generate go tool golang.org/x/tools/cmd/stringer -type=Prefix -linecomment -output id_generated.go 8 | 9 | import ( 10 | "fmt" 11 | "strings" 12 | 13 | nanoid "github.com/matoous/go-nanoid" 14 | 15 | "github.com/joshuar/go-hass-agent/models" 16 | ) 17 | 18 | const ( 19 | // Unknown represents an unknown prefix. 20 | Unknown Prefix = iota // unknown 21 | // ScriptJob prefix is for scheduler jobs for scripts. 22 | ScriptJob // script_job 23 | // HassJob prefix is for scheduler jobs for the hass backend. 24 | HassJob // hass_job 25 | // Worker prefix is for entity/mqtt workers. 26 | Worker // worker 27 | ) 28 | 29 | // Prefix represents a type of ID. Specific types share a common prefix. 30 | type Prefix int 31 | 32 | // NewID generates a new unique ID for the given type option. If an ID cannot be 33 | // generated, a non-nil error is returned. 34 | func NewID(option Prefix) (models.ID, error) { 35 | id, err := nanoid.Nanoid() 36 | if err != nil { 37 | return "", fmt.Errorf("could not generate username: %w", err) 38 | } 39 | 40 | return option.String() + "_" + id, nil 41 | } 42 | 43 | // IdentifyID takes an ID and returns the type of ID it represents. 44 | func IdentifyID(id models.ID) Prefix { 45 | idParts := strings.Split(id, "_") 46 | switch idParts[0] { 47 | case HassJob.String(): 48 | return HassJob 49 | case ScriptJob.String(): 50 | return ScriptJob 51 | default: 52 | return Unknown 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /id/id_generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=Prefix -linecomment -output id_generated.go"; DO NOT EDIT. 2 | 3 | package id 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[Unknown-0] 12 | _ = x[ScriptJob-1] 13 | _ = x[HassJob-2] 14 | _ = x[Worker-3] 15 | } 16 | 17 | const _Prefix_name = "unknownscript_jobhass_jobworker" 18 | 19 | var _Prefix_index = [...]uint8{0, 7, 17, 25, 31} 20 | 21 | func (i Prefix) String() string { 22 | if i < 0 || i >= Prefix(len(_Prefix_index)-1) { 23 | return "Prefix(" + strconv.FormatInt(int64(i), 10) + ")" 24 | } 25 | return _Prefix_name[_Prefix_index[i]:_Prefix_index[i+1]] 26 | } 27 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package main 5 | 6 | import ( 7 | "embed" 8 | "log/slog" 9 | "os" 10 | "syscall" 11 | 12 | "github.com/alecthomas/kong" 13 | 14 | "github.com/joshuar/go-hass-agent/cli" 15 | "github.com/joshuar/go-hass-agent/config" 16 | "github.com/joshuar/go-hass-agent/device" 17 | "github.com/joshuar/go-hass-agent/logging" 18 | ) 19 | 20 | //go:embed all:web/content 21 | var content embed.FS 22 | 23 | // CLI contains all of the commands and common options for running Go Hass 24 | // Agent. 25 | var CLI struct { 26 | Run cli.Run `cmd:"" help:"Run Go Hass Agent."` 27 | // Reset cmd.Reset `cmd:"" help:"Reset Go Hass Agent."` 28 | Version cli.Version `cmd:"" help:"Show the Go Hass Agent version."` 29 | // Upgrade cmd.Upgrade `cmd:"" help:"Attempt to upgrade from previous version."` 30 | ProfileFlags logging.ProfileFlags `name:"profile" help:"Set profiling flags."` 31 | Config cli.Config `cmd:"" help:"Configure Go Hass Agent."` 32 | Register cli.Register `cmd:"" help:"Register with Home Assistant."` 33 | Path string `name:"path" default:"${defaultPath}" help:"Specify a custom path to store preferences/logs/data (for debugging)."` 34 | 35 | logging.Options 36 | } 37 | 38 | func init() { 39 | // Following is copied from https://git.kernel.org/pub/scm/libs/libcap/libcap.git/tree/goapps/web/web.go 40 | // ensureNotEUID aborts the program if it is running setuid something, 41 | // or being invoked by root. 42 | euid := syscall.Geteuid() 43 | uid := syscall.Getuid() 44 | egid := syscall.Getegid() 45 | gid := syscall.Getgid() 46 | 47 | if uid != euid || gid != egid || uid == 0 { 48 | slog.Error("go-hass-agent should not be run with additional privileges or as root.") 49 | os.Exit(-1) 50 | } 51 | } 52 | 53 | func main() { 54 | // Set some string. 55 | kong.Name(config.AppName) 56 | kong.Description(config.AppDescription) 57 | // Parse the command-line. 58 | cmdCtx := kong.Parse(&CLI, kong.Bind(), kong.Vars{"defaultPath": config.GetPath()}) 59 | config.SetPath(CLI.Path) 60 | // Set up the logger. 61 | logging.New(logging.Options{LogLevel: CLI.LogLevel, NoLogFile: CLI.NoLogFile}) 62 | // Enable profiling if requested. 63 | if CLI.ProfileFlags != nil { 64 | if err := logging.StartProfiling(CLI.ProfileFlags); err != nil { 65 | slog.Warn("Problem starting profiling.", 66 | slog.Any("error", err)) 67 | } 68 | } 69 | // Initialise the config. 70 | err := config.Init() 71 | if err != nil { 72 | slog.Error("Unable to start.", 73 | slog.Any("error", err)) 74 | os.Exit(-1) 75 | } 76 | // Generate device details if required. 77 | if !config.Exists(device.ConfigPrefix) { 78 | err := device.NewConfig() 79 | if err != nil { 80 | slog.Error("Unable to start.", 81 | slog.Any("error", err)) 82 | os.Exit(-1) 83 | } 84 | } 85 | // Run the requested command with the provided options. 86 | if err := cmdCtx.Run(&cli.Opts{Path: CLI.Path, StaticContent: content}); err != nil { 87 | slog.Error("Command failed.", 88 | slog.String("command", cmdCtx.Command()), 89 | slog.Any("error", err)) 90 | } 91 | // If profiling was enabled, clean up. 92 | if CLI.ProfileFlags != nil { 93 | if err := logging.StopProfiling(CLI.ProfileFlags); err != nil { 94 | slog.Error("Problem stopping profiling.", 95 | slog.Any("error", err)) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /models/class/stateclass.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=SensorStateClass -output stateclass.gen.go -linecomment"; DO NOT EDIT. 2 | 3 | package class 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[StateClassMin-0] 12 | _ = x[StateMeasurement-1] 13 | _ = x[StateTotal-2] 14 | _ = x[StateTotalIncreasing-3] 15 | _ = x[StateClassMax-4] 16 | } 17 | 18 | const _SensorStateClass_name = "measurementtotaltotal_increasing" 19 | 20 | var _SensorStateClass_index = [...]uint8{0, 0, 11, 16, 32, 32} 21 | 22 | func (i SensorStateClass) String() string { 23 | if i < 0 || i >= SensorStateClass(len(_SensorStateClass_index)-1) { 24 | return "SensorStateClass(" + strconv.FormatInt(int64(i), 10) + ")" 25 | } 26 | return _SensorStateClass_name[_SensorStateClass_index[i]:_SensorStateClass_index[i+1]] 27 | } 28 | -------------------------------------------------------------------------------- /models/class/stateclass.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package class 5 | 6 | //go:generate go tool golang.org/x/tools/cmd/stringer -type=SensorStateClass -output stateclass.gen.go -linecomment 7 | const ( 8 | StateClassMin SensorStateClass = iota // 9 | StateMeasurement // measurement 10 | StateTotal // total 11 | StateTotalIncreasing // total_increasing 12 | StateClassMax // 13 | ) 14 | 15 | // SensorStateClass reflects the HA state class of the sensor. 16 | type SensorStateClass int 17 | 18 | // Valid returns whether the SensorStateClass is valid. 19 | func (c SensorStateClass) Valid() bool { 20 | return c > StateClassMin && c < StateClassMax 21 | } 22 | -------------------------------------------------------------------------------- /models/context.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package models 5 | 6 | import "context" 7 | 8 | const ( 9 | csrfTokenCtxKey contextKey = "csrfToken" 10 | ) 11 | 12 | type contextKey string 13 | 14 | func CSRFTokenToCtx(ctx context.Context, token string) context.Context { 15 | return context.WithValue(ctx, csrfTokenCtxKey, token) 16 | } 17 | 18 | func CSRFTokenFromCtx(ctx context.Context) string { 19 | if token, ok := ctx.Value(csrfTokenCtxKey).(string); ok { 20 | return token 21 | } 22 | return "" 23 | } 24 | -------------------------------------------------------------------------------- /models/entity.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package models 5 | 6 | // Valid returns whether the entity contains valid data. This checks only 7 | // whether the entity data is empty. To check validity of a specific type of 8 | // entity, the data should extracted (with an As* method) and then the Valid 9 | // method called on the data type. 10 | func (e *Entity) Valid() bool { 11 | return e.union != nil 12 | } 13 | -------------------------------------------------------------------------------- /models/event.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package models 5 | 6 | import ( 7 | "fmt" 8 | "log/slog" 9 | "strings" 10 | 11 | "github.com/joshuar/go-hass-agent/validation" 12 | ) 13 | 14 | // String returns a string representation of an event. 15 | func (e *Event) String() string { 16 | var b strings.Builder 17 | 18 | fmt.Fprintf(&b, "Event Type: %s", e.Type) 19 | fmt.Fprintf(&b, "Event Data: %v", e.Data) 20 | 21 | return b.String() 22 | } 23 | 24 | // LogAttributes returns an slog.Group of log attributes for an event entity. 25 | func (e *Event) LogAttributes() slog.Attr { 26 | return slog.Group("event", 27 | slog.String("type", e.Type), 28 | ) 29 | } 30 | 31 | // Valid returns whether the event data is valid. 32 | func (e *Event) Valid() bool { 33 | if err := validation.Validate.Struct(e); err != nil { 34 | return false 35 | } 36 | 37 | return true 38 | } 39 | -------------------------------------------------------------------------------- /models/event/event.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package event provides a method and options for creating an event entity. 5 | package event 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/joshuar/go-hass-agent/models" 11 | ) 12 | 13 | // NewEvent creates an event entity with the given options. 14 | func NewEvent(eventType string, eventData map[string]any) (models.Entity, error) { 15 | event := models.Event{ 16 | Type: eventType, 17 | Data: eventData, 18 | } 19 | 20 | entity := models.Entity{} 21 | 22 | err := entity.FromEvent(event) 23 | if err != nil { 24 | return entity, fmt.Errorf("could not generate event entity: %w", err) 25 | } 26 | 27 | return entity, nil 28 | } 29 | -------------------------------------------------------------------------------- /models/location.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package models 5 | 6 | import ( 7 | "github.com/joshuar/go-hass-agent/validation" 8 | ) 9 | 10 | // Valid returns whether the location data is valid. 11 | func (e *Location) Valid() bool { 12 | if err := validation.Validate.Struct(e); err != nil { 13 | return false 14 | } 15 | 16 | return true 17 | } 18 | -------------------------------------------------------------------------------- /models/location/location.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package location provides a method and options for creating model.Location 5 | // objects wrapped as a model.Entity. 6 | package location 7 | 8 | import ( 9 | "context" 10 | 11 | "github.com/joshuar/go-hass-agent/models" 12 | ) 13 | 14 | // Option is a functional option for a location. 15 | type Option models.Option[*models.Location] 16 | 17 | // WithGPSCoords sets the latitude and longitude GPS coordinates for the location. 18 | func WithGPSCoords(latitude float32, longitude float32) Option { 19 | return func(l *models.Location) { 20 | l.Gps = []float32{latitude, longitude} 21 | } 22 | } 23 | 24 | // WithGPSAccuracy option sets the GPS accuracy value for the location. 25 | func WithGPSAccuracy(accuracy int) Option { 26 | return func(l *models.Location) { 27 | l.GpsAccuracy = accuracy 28 | } 29 | } 30 | 31 | // WithSpeed option sets the speed value for the location. 32 | func WithSpeed(speed int) Option { 33 | return func(l *models.Location) { 34 | l.Speed = speed 35 | } 36 | } 37 | 38 | // WithAltitude option sets the altitude value for the location. 39 | func WithAltitude(altitude int) Option { 40 | return func(l *models.Location) { 41 | l.Altitude = altitude 42 | } 43 | } 44 | 45 | // NewLocation provides a way to build a location entity with the given options. 46 | func NewLocation(_ context.Context, options ...Option) models.Entity { 47 | location := models.Location{} 48 | 49 | for _, option := range options { 50 | option(&location) 51 | } 52 | 53 | entity := models.Entity{} 54 | entity.FromLocation(location) 55 | return entity 56 | } 57 | -------------------------------------------------------------------------------- /models/message.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package models 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | ) 10 | 11 | // NewSuccessMessage creates a new Message indicating success with the given summary and (optional) details. 12 | func NewSuccessMessage(summary string, details string) *Message { 13 | return &Message{ 14 | Status: MessageStatusSuccess, 15 | Summary: summary, 16 | Details: details, 17 | } 18 | } 19 | 20 | // NewErrorMessage creates a new Message indicating an error with the given summary and (optional) details. 21 | func NewErrorMessage(summary string, details string) *Message { 22 | return &Message{ 23 | Status: MessageStatusError, 24 | Summary: summary, 25 | Details: details, 26 | } 27 | } 28 | 29 | // NewWarningMessage creates a new Message indicating a warning with the given summary and (optional) details. 30 | func NewWarningMessage(summary string, details string) *Message { 31 | return &Message{ 32 | Status: MessageStatusWarning, 33 | Summary: summary, 34 | Details: details, 35 | } 36 | } 37 | 38 | // NewInfoMessage creates a new Message indicating informational details with the given summary and (optional) details. 39 | func NewInfoMessage(summary string, details string) *Message { 40 | return &Message{ 41 | Status: MessageStatusInfo, 42 | Summary: summary, 43 | Details: details, 44 | } 45 | } 46 | 47 | // HasDetails returns a boolean indicating whether the message has additional details. 48 | func (msg *Message) HasDetails() bool { 49 | return msg.Details != "" 50 | } 51 | 52 | // String returns the message as a formatted string. This allows Message to satisfy the Stringer interface. 53 | func (msg *Message) String() string { 54 | var str strings.Builder 55 | str.WriteString(fmt.Sprintf("%s: %s", strings.ToTitle(string(msg.Status)), msg.Summary)) 56 | if msg.Details != "" { 57 | str.WriteString("\n" + msg.Details) 58 | } 59 | return str.String() 60 | } 61 | 62 | // IsSuccess returns true when the message indicates success. 63 | func (msg *Message) IsSuccess() bool { 64 | return msg.Status == MessageStatusSuccess 65 | } 66 | 67 | // IsError returns true when the message indicates an error. 68 | func (msg *Message) IsError() bool { 69 | return msg.Status == MessageStatusError 70 | } 71 | 72 | // IsWarning returns true when the message indicates a warning. 73 | func (msg *Message) IsWarning() bool { 74 | return msg.Status == MessageStatusWarning 75 | } 76 | 77 | // IsInfo returns true when the message indicates informational status. 78 | func (msg *Message) IsInfo() bool { 79 | return msg.Status == MessageStatusInfo 80 | } 81 | -------------------------------------------------------------------------------- /models/models.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package models contains the common objects and methods on these objects, used 5 | // by the agent and its internal packages. 6 | package models 7 | 8 | import ( 9 | "cmp" 10 | "time" 11 | ) 12 | 13 | // Option is a functional option for type T. Any concrete type can define its 14 | // own functional options using this. 15 | // 16 | // type MyType Option[*MyType]. 17 | type Option[T any] func(T) 18 | 19 | // StateValue is a constraint on the types of values for an entity state. 20 | type StateValue interface { 21 | cmp.Ordered | time.Time 22 | } 23 | -------------------------------------------------------------------------------- /models/sensor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package models 5 | 6 | import ( 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "log/slog" 11 | 12 | "github.com/joshuar/go-hass-agent/validation" 13 | ) 14 | 15 | var ( 16 | ErrMarshalSensor = errors.New("could not marshal entity data") 17 | ErrUnmarshalSensor = errors.New("could not unmarshal entity data") 18 | ErrInvalidSensor = errors.New("sensor data is invalid") 19 | ) 20 | 21 | // Valid returns a boolean indicating whether the SensorState date is valid. 22 | func (s *SensorState) Valid() (bool, error) { 23 | if err := validation.Validate.Struct(s); err != nil { 24 | return false, fmt.Errorf("%w: %s", ErrInvalidSensor, validation.ParseValidationErrors(err)) 25 | } 26 | 27 | return true, nil 28 | } 29 | 30 | // Valid returns a boolean indicating whether the SensorRegistration data is valid. 31 | func (s *SensorRegistration) Valid() (bool, error) { 32 | if err := validation.Validate.Struct(s); err != nil { 33 | return false, fmt.Errorf("%w: %s", ErrInvalidSensor, validation.ParseValidationErrors(err)) 34 | } 35 | 36 | return true, nil 37 | } 38 | 39 | // // String returns a string representation of a sensor. 40 | // func (s *Sensor) String() string { 41 | // var b strings.Builder 42 | 43 | // fmt.Fprintf(&b, "Name: %s ", s.Name) 44 | // fmt.Fprintf(&b, "ID: %s ", s.UniqueID) 45 | // fmt.Fprintf(&b, "Name: %s ", s.Name) 46 | 47 | // if s.UnitOfMeasurement != nil { 48 | // fmt.Fprintf(&b, "State: %v %s", s.State, *s.UnitOfMeasurement) 49 | // } else { 50 | // fmt.Fprintf(&b, "State: %v", s.State) 51 | // } 52 | 53 | // return b.String() 54 | // } 55 | 56 | // LogAttributes returns an slog.Group of log attributes for a sensor entity. 57 | func (s *Sensor) LogAttributes() slog.Attr { 58 | var state string 59 | if s.UnitOfMeasurement != "" { 60 | state = fmt.Sprintf("%v %s", s.State, s.UnitOfMeasurement) 61 | } else { 62 | state = fmt.Sprintf("%v", s.State) 63 | } 64 | 65 | return slog.Group("sensor", 66 | slog.String("name", s.Name), 67 | slog.String("id", s.UniqueID), 68 | slog.String("state", state), 69 | ) 70 | } 71 | 72 | // AsState returns the Sensor data as a SensorState object, which can be sent to 73 | // Home Assistant as a sensor update request. 74 | func (s *Sensor) AsState() (*SensorState, error) { 75 | // Marshal the sensor data to json. 76 | data, err := json.Marshal(s) 77 | if err != nil { 78 | return nil, errors.Join(ErrMarshalSensor, err) 79 | } 80 | 81 | state := SensorState{} 82 | 83 | // Unmarshal the sensor data back into a sensor state. 84 | err = json.Unmarshal(data, &state) 85 | if err != nil { 86 | return nil, errors.Join(ErrUnmarshalSensor, err) 87 | } 88 | 89 | if valid, err := state.Valid(); !valid { 90 | return nil, err 91 | } 92 | 93 | return &state, nil 94 | } 95 | 96 | // AsRegistration returns the Sensor data as a SensorRegistration object, which can be sent to 97 | // Home Assistant as a sensor registration request. 98 | func (s *Sensor) AsRegistration() (*SensorRegistration, error) { 99 | // Marshal the sensor data to json. 100 | data, err := json.Marshal(s) 101 | if err != nil { 102 | return nil, errors.Join(ErrMarshalSensor, err) 103 | } 104 | 105 | registration := SensorRegistration{} 106 | 107 | // Unmarshal the sensor data back into a sensor state. 108 | err = json.Unmarshal(data, ®istration) 109 | if err != nil { 110 | return nil, errors.Join(ErrUnmarshalSensor, err) 111 | } 112 | 113 | if valid, err := registration.Valid(); !valid { 114 | return nil, err 115 | } 116 | 117 | return ®istration, nil 118 | } 119 | -------------------------------------------------------------------------------- /models/worker.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package models 5 | 6 | func (w *WorkerMetadata) ID() string { 7 | return w.WorkerID 8 | } 9 | 10 | func (w *WorkerMetadata) Description() string { 11 | return w.WorkerDescription 12 | } 13 | 14 | func SetWorkerMetadata(id, desc string) *WorkerMetadata { 15 | return &WorkerMetadata{WorkerID: id, WorkerDescription: desc} 16 | } 17 | -------------------------------------------------------------------------------- /osv-scanner.toml: -------------------------------------------------------------------------------- 1 | [[IgnoredVulns]] 2 | id = "GO-2022-0646" 3 | reason = "Agent does not use AWS SDK." 4 | 5 | [[IgnoredVulns]] 6 | id = "GO-2024-2698" 7 | reason = "Agent does not require any archives access." 8 | 9 | [[IgnoredVulns]] 10 | id = "GO-2021-0064" 11 | reason = "Agent does not use k8s." 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "browserslist": "> 0.5%, last 2 versions, not dead", 3 | "dependencies": { 4 | "daisyui": "^5.1.27", 5 | "htmx.org": "^2.0.7", 6 | "hyperscript.org": "^0.9.14", 7 | "tailwindcss": "^4.1.14" 8 | }, 9 | "description": " A Home Assistant, native app for desktop/laptop devices.", 10 | "devDependencies": { 11 | "@eslint/js": "^9.37.0", 12 | "@prettier/plugin-xml": "^3.4.2", 13 | "@tailwindcss/cli": "^4.1.14", 14 | "@tailwindcss/typography": "^0.5.19", 15 | "esbuild": "^0.25.10", 16 | "eslint": "^9.37.0", 17 | "eslint-config-prettier": "^10.1.8", 18 | "globals": "^16.4.0", 19 | "prettier": "^3.6.2", 20 | "prettier-plugin-tailwindcss": "^0.6.14", 21 | "prettier-plugin-toml": "^2.0.6" 22 | }, 23 | "license": "MIT", 24 | "name": "go-hass-agent", 25 | "scripts": { 26 | "build:js": "bunx esbuild ./web/assets/scripts.js --bundle --sourcemap --outdir=./web/content/", 27 | "build:css": "bunx tailwindcss -i ./web/assets/styles.css -o ./web/content/styles.css --minify --map" 28 | }, 29 | "version": "0.0.0" 30 | } 31 | -------------------------------------------------------------------------------- /pkg/linux/dbusx/busType_strings.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=dbusType -output busType_strings.go"; DO NOT EDIT. 2 | 3 | package dbusx 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[SessionBus-0] 12 | _ = x[SystemBus-1] 13 | } 14 | 15 | const _dbusType_name = "SessionBusSystemBus" 16 | 17 | var _dbusType_index = [...]uint8{0, 10, 19} 18 | 19 | func (i dbusType) String() string { 20 | if i < 0 || i >= dbusType(len(_dbusType_index)-1) { 21 | return "dbusType(" + strconv.FormatInt(int64(i), 10) + ")" 22 | } 23 | return _dbusType_name[_dbusType_index[i]:_dbusType_index[i+1]] 24 | } 25 | -------------------------------------------------------------------------------- /pkg/linux/dbusx/introspect.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package dbusx 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "slices" 10 | "strings" 11 | 12 | "github.com/godbus/dbus/v5/introspect" 13 | ) 14 | 15 | // ErrMethodNotFound is returned when the requested method cannot be executed. 16 | var ErrMethodNotFound = errors.New("method not found") 17 | 18 | // Introspection represents a D-Bus introspection request. 19 | type Introspection introspect.Node 20 | 21 | // GetMethod returns details about the given method (if it exists), or a non-nil error if it cannot be found. 22 | func (i Introspection) GetMethod(name string) (*Method, error) { 23 | for _, intr := range i.Interfaces { 24 | found := slices.IndexFunc(intr.Methods, func(e introspect.Method) bool { 25 | return strings.HasSuffix(name, e.Name) 26 | }) 27 | 28 | if found != -1 { 29 | return &Method{ 30 | name: name, 31 | intr: intr.Name, 32 | path: i.Name, 33 | obj: &intr.Methods[found], 34 | }, nil 35 | } 36 | } 37 | 38 | return nil, ErrMethodNotFound 39 | } 40 | 41 | // NewIntrospection starts a new introspection request. 42 | func NewIntrospection(bus *Bus, intr, path string) (*Introspection, error) { 43 | obj := bus.getObject(intr, path) 44 | 45 | node, err := introspect.Call(obj) 46 | if err != nil { 47 | return nil, fmt.Errorf("unable to introspect: %w", err) 48 | } 49 | 50 | nodeObj := Introspection(*node) 51 | 52 | return &nodeObj, nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/linux/dbusx/validation.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package dbusx 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | 10 | "github.com/go-playground/validator/v10" 11 | ) 12 | 13 | type validationError struct { 14 | Namespace string `json:"namespace"` // can differ when a custom TagNameFunc is registered or 15 | Field string `json:"field"` // by passing alt name to ReportError like below 16 | StructNamespace string `json:"structNamespace"` 17 | StructField string `json:"structField"` 18 | Tag string `json:"tag"` 19 | ActualTag string `json:"actualTag"` 20 | Kind string `json:"kind"` 21 | Type string `json:"type"` 22 | Value string `json:"value"` 23 | Param string `json:"param"` 24 | Message string `json:"message"` 25 | } 26 | 27 | var ErrValidationError = errors.New("internal validation error") 28 | 29 | var validate *validator.Validate 30 | 31 | func init() { 32 | validate = validator.New() 33 | } 34 | 35 | func getValidationProblems(validationErrors validator.ValidationErrors) map[string]string { 36 | problems := make(map[string]string) 37 | 38 | for _, err := range validationErrors { 39 | errInfo := validationError{ 40 | Namespace: err.Namespace(), 41 | Field: err.Field(), 42 | StructNamespace: err.StructNamespace(), 43 | StructField: err.StructField(), 44 | Tag: err.Tag(), 45 | ActualTag: err.ActualTag(), 46 | Kind: fmt.Sprintf("%v", err.Kind()), 47 | Type: fmt.Sprintf("%v", err.Type()), 48 | Value: fmt.Sprintf("%v", err.Value()), 49 | Param: err.Param(), 50 | Message: err.Error(), 51 | } 52 | 53 | problems[errInfo.Field] = errInfo.Message 54 | } 55 | 56 | // from here you can create your own error messages in whatever language you wish 57 | return problems 58 | } 59 | 60 | //nolint:errorlint,forcetypeassert 61 | func valid[T any](obj *T) error { 62 | err := validate.Struct(obj) 63 | if err != nil { 64 | switch { 65 | case errors.Is(err, &validator.InvalidValidationError{}): 66 | return ErrValidationError 67 | case errors.Is(err, validator.ValidationErrors{}): 68 | var errs error 69 | for field, problem := range getValidationProblems(err.(validator.ValidationErrors)) { 70 | errs = errors.Join(errs, fmt.Errorf("%s: %s", field, problem)) //nolint:err113 71 | } 72 | 73 | return errs 74 | } 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/examples/getAllSensors.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | //nolint:all 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "log/slog" 10 | "os" 11 | "runtime" 12 | "runtime/pprof" 13 | "runtime/trace" 14 | 15 | "github.com/joshuar/go-hass-agent/pkg/linux/hwmon" 16 | ) 17 | 18 | func main() { 19 | slog.SetLogLoggerLevel(slog.LevelDebug) 20 | 21 | cpu, err := os.Create("cpu.prof") 22 | if err != nil { 23 | slog.Warn("Cannot create CPU profile.", "error", err.Error()) 24 | } 25 | if err := pprof.StartCPUProfile(cpu); err != nil { 26 | slog.Warn("Could not start CPU profiling.", "error", err.Error()) 27 | } 28 | trc, err := os.Create("trace.prof") 29 | if err != nil { 30 | slog.Warn("Cannot create trace profile.", "error", err.Error()) 31 | } 32 | if err = trace.Start(trc); err != nil { 33 | slog.Warn("Could not start trace profiling.", "error", err.Error()) 34 | } 35 | sensors, err := hwmon.GetAllSensors() 36 | if err != nil && len(sensors) > 0 { 37 | slog.Warn("Errors fetching some chip/sensor values.", "error", err.Error()) 38 | } 39 | if err != nil && len(sensors) == 0 { 40 | slog.Error("Could not retrieve any chip/sensor values.", "error", err.Error()) 41 | os.Exit(-1) 42 | } 43 | for _, s := range sensors { 44 | println(s.String()) 45 | } 46 | 47 | pprof.StopCPUProfile() 48 | trace.Stop() 49 | 50 | heap, err := os.Create("heap.prof") 51 | if err != nil { 52 | slog.Warn("Cannot create heap profile.", "error", err.Error()) 53 | } 54 | 55 | var ms runtime.MemStats 56 | runtime.ReadMemStats(&ms) 57 | // printMemStats(&ms) 58 | 59 | if err := pprof.WriteHeapProfile(heap); err != nil { 60 | slog.Warn("Cannot write heap profile.", "error", err.Error()) 61 | } 62 | _ = heap.Close() 63 | } 64 | 65 | // func printMemStats(ms *runtime.MemStats) { 66 | // log.Info().Msgf("Mem stats: alloc=%s total_alloc=%s sys=%s "+ 67 | // "heap_alloc=%s heap_sys=%s heap_idle=%s heap_released=%s heap_in_use=%s "+ 68 | // "stack_in_use=%s stack_sys=%s "+ 69 | // "mspan_sys=%s mcache_sys=%s buck_hash_sys=%s gc_sys=%s other_sys=%s "+ 70 | // "mallocs_n=%d frees_n=%d heap_objects_n=%d gc_cpu_fraction=%.2f", 71 | // formatMemory(ms.Alloc), formatMemory(ms.TotalAlloc), formatMemory(ms.Sys), 72 | // formatMemory(ms.HeapAlloc), formatMemory(ms.HeapSys), 73 | // formatMemory(ms.HeapIdle), formatMemory(ms.HeapReleased), formatMemory(ms.HeapInuse), 74 | // formatMemory(ms.StackInuse), formatMemory(ms.StackSys), 75 | // formatMemory(ms.MSpanSys), formatMemory(ms.MCacheSys), formatMemory(ms.BuckHashSys), 76 | // formatMemory(ms.GCSys), formatMemory(ms.OtherSys), 77 | // ms.Mallocs, ms.Frees, ms.HeapObjects, ms.GCCPUFraction) 78 | // } 79 | 80 | //nolint:varnamelen,wsl,nlreturn 81 | //revive:disable:unexported-naming 82 | func formatMemory(memBytes uint64) string { 83 | const Kb = 1024 84 | const Mb = Kb * 1024 85 | 86 | if memBytes < Kb { 87 | return fmt.Sprintf("%db", memBytes) 88 | } 89 | if memBytes < Mb { 90 | return fmt.Sprintf("%dkb", memBytes/Kb) 91 | } 92 | return fmt.Sprintf("%dmb", memBytes/Mb) 93 | } 94 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/hwmon_MonitorType_generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=MonitorType -output hwmon_MonitorType_generated.go"; DO NOT EDIT. 2 | 3 | package hwmon 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[Unknown-0] 12 | _ = x[Temp-1] 13 | _ = x[Fan-2] 14 | _ = x[Voltage-3] 15 | _ = x[PWM-4] 16 | _ = x[Current-5] 17 | _ = x[Power-6] 18 | _ = x[Energy-7] 19 | _ = x[Humidity-8] 20 | _ = x[Frequency-9] 21 | _ = x[Alarm-10] 22 | _ = x[Intrusion-11] 23 | } 24 | 25 | const _MonitorType_name = "UnknownTempFanVoltagePWMCurrentPowerEnergyHumidityFrequencyAlarmIntrusion" 26 | 27 | var _MonitorType_index = [...]uint8{0, 7, 11, 14, 21, 24, 31, 36, 42, 50, 59, 64, 73} 28 | 29 | func (i MonitorType) String() string { 30 | if i < 0 || i >= MonitorType(len(_MonitorType_index)-1) { 31 | return "MonitorType(" + strconv.FormatInt(int64(i), 10) + ")" 32 | } 33 | return _MonitorType_name[_MonitorType_index[i]:_MonitorType_index[i+1]] 34 | } 35 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon0/name: -------------------------------------------------------------------------------- 1 | coretemp 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon0/temp1_crit: -------------------------------------------------------------------------------- 1 | 100000 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon0/temp1_crit_alarm: -------------------------------------------------------------------------------- 1 | 0 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon0/temp1_input: -------------------------------------------------------------------------------- 1 | 36000 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon0/temp1_label: -------------------------------------------------------------------------------- 1 | Package id 0 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon0/temp1_max: -------------------------------------------------------------------------------- 1 | 80000 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon1/device/model: -------------------------------------------------------------------------------- 1 | CT1000MX500SSD1 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon1/name: -------------------------------------------------------------------------------- 1 | drivetemp 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon1/temp1_crit: -------------------------------------------------------------------------------- 1 | 100000 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon1/temp1_highest: -------------------------------------------------------------------------------- 1 | 33000 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon1/temp1_input: -------------------------------------------------------------------------------- 1 | 31000 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon1/temp1_lcrit: -------------------------------------------------------------------------------- 1 | 0 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon1/temp1_lowest: -------------------------------------------------------------------------------- 1 | 23000 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon1/temp1_max: -------------------------------------------------------------------------------- 1 | 100000 2 | -------------------------------------------------------------------------------- /pkg/linux/hwmon/testdata/hwmon1/temp1_min: -------------------------------------------------------------------------------- 1 | 0 2 | -------------------------------------------------------------------------------- /pkg/linux/pipewire/pipewire.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package pipewire 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "log/slog" 13 | "os/exec" 14 | "slices" 15 | 16 | pwmonitor "github.com/ConnorsApps/pipewire-monitor-go" 17 | slogctx "github.com/veqryn/slog-context" 18 | 19 | "github.com/joshuar/go-hass-agent/logging" 20 | ) 21 | 22 | // Monitor handles monitoring pipewire for events and dispatching events to registered listeners as appropriate. 23 | type Monitor struct { 24 | listeners []*Listener 25 | } 26 | 27 | // NewMonitor creates a new pipewire monitor. 28 | // 29 | //nolint:gocognit 30 | func NewMonitor(ctx context.Context) (*Monitor, error) { 31 | // Set up pw-dump command. 32 | cmd := exec.CommandContext(ctx, "pw-dump", "--monitor", "--no-colors") 33 | stdout, err := cmd.StdoutPipe() 34 | if err != nil { 35 | return nil, fmt.Errorf("error starting pw-dump: %w", err) 36 | } 37 | // Start pw-dump. 38 | if err := cmd.Start(); err != nil { 39 | return nil, fmt.Errorf("error starting pw-dump: %w", err) 40 | } 41 | // Create monitor 42 | monitor := &Monitor{ 43 | listeners: make([]*Listener, 0), 44 | } 45 | 46 | // Decode pw-dump stdout as json stream. 47 | dec := json.NewDecoder(stdout) 48 | go func() { 49 | for { 50 | _, err := dec.Token() 51 | if err != nil && !errors.Is(err, io.ErrClosedPipe) { 52 | slogctx.FromCtx(ctx).Log(ctx, logging.LevelTrace, "pw-dump: failed to read JSON token.", 53 | slog.Any("error", err)) 54 | } 55 | 56 | // Read pw-dump output. 57 | for dec.More() { 58 | var event pwmonitor.Event 59 | if err = dec.Decode(&event); err == io.EOF { 60 | break 61 | } else if err != nil { 62 | slogctx.FromCtx(ctx).Log(ctx, logging.LevelTrace, "Error decoding pw-dump output.", 63 | slog.Any("error", err)) 64 | } 65 | // Filter the event through all listeners and send the event to whichever listeners want it. 66 | for listener := range slices.Values(monitor.listeners) { 67 | if listener.filterFunc(&event) { 68 | go func() { 69 | listener.eventCh <- event 70 | }() 71 | } 72 | } 73 | } 74 | 75 | _, err = dec.Token() 76 | if err != nil && !errors.Is(err, io.ErrClosedPipe) { 77 | slogctx.FromCtx(ctx).Log(ctx, logging.LevelTrace, "pw-dump: failed to read JSON token.", 78 | slog.Any("error", err)) 79 | } 80 | } 81 | }() 82 | 83 | go func() { 84 | <-ctx.Done() 85 | for listener := range slices.Values(monitor.listeners) { 86 | close(listener.eventCh) 87 | } 88 | }() 89 | 90 | return monitor, nil 91 | } 92 | 93 | func (m *Monitor) AddListener(ctx context.Context, filterFunc func(*pwmonitor.Event) bool) chan pwmonitor.Event { 94 | eventCh := make(chan pwmonitor.Event) 95 | m.listeners = append(m.listeners, &Listener{ 96 | filterFunc: filterFunc, 97 | eventCh: eventCh, 98 | }) 99 | return eventCh 100 | } 101 | 102 | // Listener contains the data for goroutine that wants to listen for pipewire events. 103 | type Listener struct { 104 | filterFunc func(*pwmonitor.Event) bool 105 | eventCh chan pwmonitor.Event 106 | } 107 | -------------------------------------------------------------------------------- /pkg/linux/pulseaudiox/examples/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Joshua Rich 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | 6 | package main 7 | 8 | import ( 9 | "context" 10 | "log/slog" 11 | 12 | "github.com/joshuar/go-hass-agent/pkg/linux/pulseaudiox" 13 | ) 14 | 15 | func main() { 16 | client, err := pulseaudiox.NewPulseClient(context.Background()) 17 | if err != nil { 18 | panic(err) 19 | } 20 | 21 | err = client.SetVolume(20) 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | for { 27 | <-client.EventCh 28 | 29 | repl, err := client.GetState() 30 | if err != nil { 31 | slog.Error("failed to parse reply: %w", slog.Any("error", err)) 32 | } 33 | 34 | volPct := pulseaudiox.ParseVolume(repl) 35 | 36 | switch { 37 | case repl.Mute != client.Mute: 38 | slog.Info("mute changed", slog.Bool("state", repl.Mute)) 39 | client.Mute = repl.Mute 40 | case volPct != client.Vol: 41 | slog.Info("volume changed.", slog.Float64("state", volPct)) 42 | client.Vol = volPct 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/linux/whichdistro/testdata/os-release-fedora: -------------------------------------------------------------------------------- 1 | NAME="Fedora Linux" 2 | VERSION="40 (KDE Plasma)" 3 | ID=fedora 4 | VERSION_ID=40 5 | VERSION_CODENAME="" 6 | PLATFORM_ID="platform:f40" 7 | PRETTY_NAME="Fedora Linux 40 (KDE Plasma)" 8 | ANSI_COLOR="0;38;2;60;110;180" 9 | LOGO=fedora-logo-icon 10 | CPE_NAME="cpe:/o:fedoraproject:fedora:40" 11 | DEFAULT_HOSTNAME="fedora" 12 | HOME_URL="https://fedoraproject.org/" 13 | DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f40/system-administrators-guide/" 14 | SUPPORT_URL="https://ask.fedoraproject.org/" 15 | BUG_REPORT_URL="https://bugzilla.redhat.com/" 16 | REDHAT_BUGZILLA_PRODUCT="Fedora" 17 | REDHAT_BUGZILLA_PRODUCT_VERSION=40 18 | REDHAT_SUPPORT_PRODUCT="Fedora" 19 | REDHAT_SUPPORT_PRODUCT_VERSION=40 20 | SUPPORT_END=2025-05-13 21 | VARIANT="KDE Plasma" 22 | VARIANT_ID=kde 23 | -------------------------------------------------------------------------------- /pkg/linux/whichdistro/testdata/os-release-opensuse-tumbleweed: -------------------------------------------------------------------------------- 1 | NAME="openSUSE Tumbleweed" 2 | # VERSION="20250128" 3 | ID="opensuse-tumbleweed" 4 | ID_LIKE="opensuse suse" 5 | VERSION_ID="20250128" 6 | PRETTY_NAME="openSUSE Tumbleweed" 7 | ANSI_COLOR="0;32" 8 | # CPE 2.3 format, boo#1217921 9 | CPE_NAME="cpe:2.3:o:opensuse:tumbleweed:20250128:*:*:*:*:*:*:*" 10 | #CPE 2.2 format 11 | #CPE_NAME="cpe:/o:opensuse:tumbleweed:20250128" 12 | BUG_REPORT_URL="https://bugzilla.opensuse.org" 13 | SUPPORT_URL="https://bugs.opensuse.org" 14 | HOME_URL="https://www.opensuse.org" 15 | DOCUMENTATION_URL="https://en.opensuse.org/Portal:Tumbleweed" 16 | LOGO="distributor-logo-Tumbleweed" 17 | -------------------------------------------------------------------------------- /pkg/linux/whichdistro/testdata/os-release-ubuntu: -------------------------------------------------------------------------------- 1 | PRETTY_NAME="Ubuntu 22.04.4 LTS" 2 | NAME="Ubuntu" 3 | VERSION_ID="22.04" 4 | VERSION="22.04.4 LTS (Jammy Jellyfish)" 5 | VERSION_CODENAME=jammy 6 | ID=ubuntu 7 | ID_LIKE=debian 8 | HOME_URL="https://www.ubuntu.com/" 9 | SUPPORT_URL="https://help.ubuntu.com/" 10 | BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" 11 | PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" 12 | UBUNTU_CODENAME=jammy 13 | -------------------------------------------------------------------------------- /pkg/linux/whichdistro/whichdistro.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package whichdistro provides methods to determine the Linux distribution from the os-release file. 5 | package whichdistro 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "os" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | const ( 16 | // UnknownValue is used when the value could not otherwise be determined. 17 | UnknownValue = "Unknown" 18 | ) 19 | 20 | var ( 21 | // OSReleaseFile is the canonical location of the os-release file. 22 | OSReleaseFile = "/etc/os-release" 23 | // OSReleaseAltFile is a canonical alternative location of the os-release file. 24 | OSReleaseAltFile = "/usr/lib/os-release" 25 | ) 26 | 27 | // OSRelease is a map of the OS Release file keys and values. See the 28 | // os-release(5) manpage for information on what keys and their values might be 29 | // available. 30 | type OSRelease map[string]string 31 | 32 | // GetOSRelease will fetch the OS Release info from the canonical file 33 | // locations. If the OS Release info cannot be read, an error will be returned 34 | // containing details of why. 35 | func GetOSRelease() (OSRelease, error) { 36 | info := make(OSRelease) 37 | 38 | file, err := readOSRelease() 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | lines := bytes.Split(file, []byte("\n")) 44 | 45 | for _, line := range lines { 46 | if bytes.Equal(line, []byte("")) { 47 | continue 48 | } 49 | 50 | fields := bytes.FieldsFunc(line, func(r rune) bool { 51 | return r == '=' 52 | }) 53 | if len(fields) == 2 { 54 | info[string(fields[0])] = string(fields[1]) 55 | } 56 | } 57 | 58 | return info, nil 59 | } 60 | 61 | func readOSRelease() ([]byte, error) { 62 | var contents []byte 63 | 64 | var err error 65 | 66 | contents, err = os.ReadFile(OSReleaseFile) 67 | if err == nil { 68 | return contents, nil 69 | } 70 | 71 | contents, err = os.ReadFile(OSReleaseAltFile) 72 | if err == nil { 73 | return contents, nil 74 | } 75 | 76 | return nil, fmt.Errorf("unable to read OSRelease file: %w", err) 77 | } 78 | 79 | // GetValue will retrieve the value of the given key from an OSRelease map. It 80 | // will perform some cleanup on the raw value to make it easier to use. 81 | func (r OSRelease) GetValue(key string) (string, bool) { 82 | value, ok := r[key] 83 | if !ok { 84 | return UnknownValue, false 85 | } 86 | 87 | if strings.ContainsAny(value, `"`) { 88 | unquoted, err := strconv.Unquote(value) 89 | if err != nil { 90 | return UnknownValue, false 91 | } 92 | 93 | value = unquoted 94 | } 95 | 96 | return value, true 97 | } 98 | -------------------------------------------------------------------------------- /platform/linux/battery/types.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | //go:generate go tool golang.org/x/tools/cmd/stringer -type=sensorType,level,chargingState,typeDescription -output types_generated.go -linecomment 5 | package battery 6 | 7 | const ( 8 | typeDesc sensorType = iota // Battery Type 9 | typePercentage // Battery Level 10 | typeTemp // Battery Temperature 11 | typeVoltage // Battery Voltage 12 | typeEnergy // Battery Energy 13 | typeEnergyRate // Battery Power 14 | typeState // Battery State 15 | typeNativePath // Battery Path 16 | typeLevel // Battery Level 17 | typeModel // Battery Model 18 | ) 19 | 20 | // sensorType is the type of sensor for a battery (e.g., battery level, state, 21 | // power, etc.). 22 | type sensorType int 23 | 24 | const ( 25 | levelUnknown level = iota // Unknown 26 | levelNone // None 27 | _ 28 | levelLow // Low 29 | levelCrit // Critical 30 | _ 31 | levelNorm // Normal 32 | levelHigh // High 33 | levelFull // Full 34 | ) 35 | 36 | // level is a description of the approximate charge level of a battery. 37 | type level uint32 38 | 39 | const ( 40 | stateUnknown chargingState = iota // Unknown 41 | stateCharging // Charging 42 | stateDischarging // Discharging 43 | stateEmpty // Empty 44 | stateFullyCharged // Fully Charged 45 | statePendingCharge // Pending Charge 46 | statePendingDischarge // Pending Discharge 47 | ) 48 | 49 | // chargingState is a description of the current charging state of a battery. 50 | type chargingState uint32 51 | 52 | const ( 53 | linePowerType typeDescription = iota + 1 // Line Power 54 | batteryType // Battery 55 | upsType // UPS 56 | monitorType // Monitor 57 | mouseType // Mouse 58 | keyboardType // Keyboard 59 | pdaType // Pda 60 | phoneType // Phone 61 | ) 62 | 63 | // typeDescription is a description of what kind of battery a battery is (e.g., 64 | // UPS, Phone, Line Power, etc.) 65 | type typeDescription uint32 66 | -------------------------------------------------------------------------------- /platform/linux/checks.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package linux 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "log/slog" 10 | "os" 11 | "slices" 12 | 13 | "kernel.org/pub/linux/libs/security/libcap/cap" 14 | ) 15 | 16 | var ErrChecksFailed = errors.New("process checks failed") 17 | 18 | // Checks contains system checks that are required to pass before a worker can start. 19 | type Checks struct { 20 | // Groups is a list of group ids the user running Go Hass Agent needs to belong to. 21 | Groups []int 22 | // Capabilities is the capabilities the Go Hass Agent binary needs. 23 | Capabilities []cap.Value 24 | } 25 | 26 | // Passed will perform all checks and return a boolean indicating whether they passed (true) or failed (false). On 27 | // failure, on non-nil error will also be returned. 28 | func (c *Checks) Passed() (bool, error) { 29 | groupsOK, err := c.hasGroups() 30 | if err != nil { 31 | return false, fmt.Errorf("%w: %w", ErrChecksFailed, err) 32 | } 33 | if !groupsOK { 34 | return false, fmt.Errorf("%w: required groups missing", ErrChecksFailed) 35 | } 36 | capsOK, err := c.hasCapabilities() 37 | if err != nil { 38 | return false, fmt.Errorf("%w: %w", ErrChecksFailed, err) 39 | } 40 | if !capsOK { 41 | return false, fmt.Errorf("%w: capabilities missing", ErrChecksFailed) 42 | } 43 | return true, nil 44 | } 45 | 46 | // hasGroups returns a boolean indicating whether Go Hass Agent is running with the required group permissions. 47 | func (c *Checks) hasGroups() (bool, error) { 48 | gids, err := os.Getgroups() 49 | if err != nil { 50 | return false, fmt.Errorf("could not determine groups: %w", err) 51 | } 52 | for gid := range slices.Values(c.Groups) { 53 | if !slices.Contains(gids, gid) { 54 | return false, nil 55 | } 56 | } 57 | return true, nil 58 | } 59 | 60 | // hasCapabilities returns a boolean indicating whether Go Hass Agent has the required capabilties set. 61 | func (c *Checks) hasCapabilities() (bool, error) { 62 | current := cap.GetProc() 63 | slog.Debug("Checking capabilities.", 64 | slog.String("current set", current.String()), 65 | ) 66 | for required := range slices.Values(c.Capabilities) { 67 | found, err := current.GetFlag(cap.Permitted, required) 68 | if err != nil { 69 | return false, fmt.Errorf("could not parse required capability %s: %w", c.Capabilities, err) 70 | } 71 | if !found { 72 | return false, fmt.Errorf("%w: required capability missing: %s", ErrChecksFailed, required.String()) 73 | } 74 | } 75 | 76 | return true, nil 77 | } 78 | -------------------------------------------------------------------------------- /platform/linux/cpu/preferences.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package cpu 5 | 6 | import ( 7 | "github.com/joshuar/go-hass-agent/agent/workers" 8 | ) 9 | 10 | const ( 11 | prefPrefix = "sensors.cpu." 12 | ) 13 | 14 | // FreqPrefs are the preferences for the CPU frequency worker. 15 | type FreqPrefs struct { 16 | workers.CommonWorkerPrefs 17 | 18 | UpdateInterval string `toml:"update_interval"` 19 | } 20 | 21 | // UsagePrefs are the preferences for the CPU usage worker. 22 | type UsagePrefs struct { 23 | workers.CommonWorkerPrefs 24 | 25 | UpdateInterval string `toml:"update_interval"` 26 | } 27 | -------------------------------------------------------------------------------- /platform/linux/desktop/preferences.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package desktop 5 | 6 | import ( 7 | "github.com/joshuar/go-hass-agent/agent/workers" 8 | ) 9 | 10 | const ( 11 | prefPrefix = "sensors.desktop." 12 | ) 13 | 14 | type WorkerPrefs struct { 15 | workers.CommonWorkerPrefs 16 | } 17 | -------------------------------------------------------------------------------- /platform/linux/device.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Joshua Rich 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | 6 | package linux 7 | 8 | import ( 9 | "bufio" 10 | "errors" 11 | "fmt" 12 | "os" 13 | "strconv" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | const ( 19 | UptimeFile = "/proc/uptime" 20 | ) 21 | 22 | var ( 23 | ErrDesktopPortalMissing = errors.New("no portal present") 24 | ErrUptimeInvalid = errors.New("invalid uptime") 25 | ) 26 | 27 | // findPortal is a helper function to work out which portal interface should be 28 | // used for getting information on running apps. 29 | func findPortal() (string, error) { 30 | desktop := os.Getenv("XDG_CURRENT_DESKTOP") 31 | 32 | switch { 33 | case strings.Contains(desktop, "KDE"): 34 | return "org.freedesktop.impl.portal.desktop.kde", nil 35 | case strings.Contains(desktop, "GNOME"): 36 | return "org.freedesktop.impl.portal.desktop.gtk", nil 37 | default: 38 | return "", ErrDesktopPortalMissing 39 | } 40 | } 41 | 42 | func getBootTime() (time.Time, error) { 43 | data, err := os.Open(UptimeFile) 44 | if err != nil { 45 | return time.Now(), fmt.Errorf("unable to read uptime: %w", err) 46 | } 47 | 48 | defer data.Close() //nolint:errcheck 49 | 50 | line := bufio.NewScanner(data) 51 | line.Split(bufio.ScanWords) 52 | 53 | if !line.Scan() { 54 | return time.Now(), ErrUptimeInvalid 55 | } 56 | 57 | uptimeValue, err := strconv.ParseFloat(line.Text(), 64) 58 | if err != nil { 59 | return time.Now(), ErrUptimeInvalid 60 | } 61 | 62 | uptime := time.Duration(uptimeValue * 1000000000) //nolint:mnd 63 | 64 | return time.Now().Add(-1 * uptime), nil 65 | } 66 | -------------------------------------------------------------------------------- /platform/linux/device_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package linux 5 | 6 | import ( 7 | "testing" 8 | ) 9 | 10 | func TestFindPortal(t *testing.T) { 11 | type args struct { 12 | setup func() 13 | } 14 | 15 | tests := []struct { 16 | name string 17 | args args 18 | want string 19 | wantErr bool 20 | }{ 21 | { 22 | name: "KDE", 23 | args: args{ 24 | setup: func() { t.Setenv("XDG_CURRENT_DESKTOP", "KDE") }, 25 | }, 26 | want: "org.freedesktop.impl.portal.desktop.kde", 27 | }, 28 | { 29 | name: "GNOME", 30 | args: args{ 31 | setup: func() { t.Setenv("XDG_CURRENT_DESKTOP", "GNOME") }, 32 | }, 33 | want: "org.freedesktop.impl.portal.desktop.gtk", 34 | }, 35 | { 36 | name: "Unknown", 37 | args: args{ 38 | setup: func() { t.Setenv("XDG_CURRENT_DESKTOP", "UNKNOWN") }, 39 | }, 40 | want: "", 41 | wantErr: true, 42 | }, 43 | } 44 | 45 | for _, tt := range tests { 46 | t.Run(tt.name, func(t *testing.T) { 47 | tt.args.setup() 48 | 49 | got, err := findPortal() 50 | if got != tt.want { 51 | t.Errorf("FindPortal() = %v, want %v", got, tt.want) 52 | } 53 | 54 | if (err != nil) != tt.wantErr { 55 | t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) 56 | 57 | return 58 | } 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /platform/linux/disk/ioSensors_generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=ioSensor -output ioSensors_generated.go -linecomment"; DO NOT EDIT. 2 | 3 | package disk 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[diskReads-0] 12 | _ = x[diskWrites-1] 13 | _ = x[diskReadRate-2] 14 | _ = x[diskWriteRate-3] 15 | _ = x[diskIOInProgress-4] 16 | } 17 | 18 | const _ioSensor_name = "Disk ReadsDisk WritesDisk Read RateDisk Write RateDisk IOs In Progress" 19 | 20 | var _ioSensor_index = [...]uint8{0, 10, 21, 35, 50, 70} 21 | 22 | func (i ioSensor) String() string { 23 | if i < 0 || i >= ioSensor(len(_ioSensor_index)-1) { 24 | return "ioSensor(" + strconv.FormatInt(int64(i), 10) + ")" 25 | } 26 | return _ioSensor_name[_ioSensor_index[i]:_ioSensor_index[i+1]] 27 | } 28 | -------------------------------------------------------------------------------- /platform/linux/disk/ioStats_generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=stat -output ioStats_generated.go -linecomment"; DO NOT EDIT. 2 | 3 | package disk 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[TotalReads-0] 12 | _ = x[TotalReadsMerged-1] 13 | _ = x[TotalSectorsRead-2] 14 | _ = x[TotalTimeReading-3] 15 | _ = x[TotalWrites-4] 16 | _ = x[TotalWritesMerged-5] 17 | _ = x[TotalSectorsWritten-6] 18 | _ = x[TotalTimeWriting-7] 19 | _ = x[ActiveIOs-8] 20 | _ = x[ActiveIOTime-9] 21 | _ = x[ActiveIOTimeWeighted-10] 22 | _ = x[TotalDiscardsCompleted-11] 23 | _ = x[TotalDiscardsMerged-12] 24 | _ = x[TotalSectorsDiscarded-13] 25 | _ = x[TotalTimeDiscarding-14] 26 | _ = x[TotalFlushRequests-15] 27 | _ = x[TotalTimeFlushing-16] 28 | } 29 | 30 | const _stat_name = "Total reads completedTotal reads mergedTotal sectors readTotal milliseconds spent readingTotal writes completedTotal writes mergedTotal sectors writtenTotal milliseconds spent writingI/Os currently in progressMilliseconds elapsed spent doing I/OsMilliseconds elapsed spent doing I/Os (weighted)Total discards completedTotal discards mergedTotal sectors discardedTotal milliseconds spent discardingTotal flush requests completedTotal milliseconds spent flushing" 31 | 32 | var _stat_index = [...]uint16{0, 21, 39, 57, 89, 111, 130, 151, 183, 209, 246, 294, 318, 339, 362, 397, 427, 460} 33 | 34 | func (i stat) String() string { 35 | if i < 0 || i >= stat(len(_stat_index)-1) { 36 | return "stat(" + strconv.FormatInt(int64(i), 10) + ")" 37 | } 38 | return _stat_name[_stat_index[i]:_stat_index[i+1]] 39 | } 40 | -------------------------------------------------------------------------------- /platform/linux/disk/preferences.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package disk 5 | 6 | import "github.com/joshuar/go-hass-agent/agent/workers" 7 | 8 | const ( 9 | prefPrefix = "sensors.disk." 10 | ioWorkerPreferencesID = prefPrefix + "rates" 11 | usageWorkerPreferencesID = prefPrefix + "usage" 12 | smartWorkerPreferencesID = prefPrefix + "smart" 13 | ) 14 | 15 | type WorkerPrefs struct { 16 | workers.CommonWorkerPrefs 17 | 18 | UpdateInterval string `toml:"update_interval"` 19 | } 20 | -------------------------------------------------------------------------------- /platform/linux/linux.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package linux 5 | 6 | import "os" 7 | 8 | const ( 9 | // DataSrcDBus indicates that the source of this data is from D-Bus. 10 | DataSrcDBus = "D-Bus" 11 | // DataSrcProcFS indicates that the source of this data is from the /proc filesystem. 12 | DataSrcProcFS = "ProcFS" 13 | // DataSrcSysFS indicates that the source of this data is from the /sys filesystem. 14 | DataSrcSysFS = "SysFS" 15 | // DataSrcNetlink indicates that the source of this data is from Netlink. 16 | DataSrcNetlink = "Netlink" 17 | ) 18 | 19 | const ( 20 | envProcFSRoot = "PROCFS_ROOT" 21 | envDevFSRoot = "DEVFS_ROOT" 22 | envSysFSRoot = "SYSFS_ROOT" 23 | ) 24 | 25 | var ( 26 | // ProcFSRoot is where the agent expects the /proc filesystem to be mounted. 27 | ProcFSRoot = "/proc" 28 | // DevFSRoot is where the agent expects the /dev filesystem to be mounted. 29 | DevFSRoot = "/dev" 30 | // SysFSRoot is where the agent expects the /sys filesystem to be mounted. 31 | SysFSRoot = "/sys" 32 | ) 33 | 34 | func init() { 35 | var ( 36 | value string 37 | found bool 38 | ) 39 | 40 | value, found = os.LookupEnv(envProcFSRoot) 41 | if found { 42 | ProcFSRoot = value 43 | } 44 | 45 | value, found = os.LookupEnv(envDevFSRoot) 46 | if found { 47 | DevFSRoot = value 48 | } 49 | 50 | value, found = os.LookupEnv(envSysFSRoot) 51 | if found { 52 | SysFSRoot = value 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /platform/linux/media/microphone.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package media 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | 11 | pwmonitor "github.com/ConnorsApps/pipewire-monitor-go" 12 | 13 | "github.com/joshuar/go-hass-agent/agent/workers" 14 | "github.com/joshuar/go-hass-agent/models" 15 | "github.com/joshuar/go-hass-agent/models/sensor" 16 | "github.com/joshuar/go-hass-agent/platform/linux" 17 | ) 18 | 19 | var _ workers.EntityWorker = (*micUsageWorker)(nil) 20 | 21 | var ( 22 | ErrInitMicUsageWorker = errors.New("could not init mic usage worker") 23 | ErrNewMicUsageSensor = errors.New("could not create mic usage sensor") 24 | ) 25 | 26 | const ( 27 | micUsageWorkerID = "microphone_usage_sensor" 28 | micUsageWorkerDesc = "Microphone usage detection" 29 | ) 30 | 31 | type micUsageWorker struct { 32 | prefs *workers.CommonWorkerPrefs 33 | pwEventChan chan pwmonitor.Event 34 | inUse bool 35 | *models.WorkerMetadata 36 | } 37 | 38 | func NewMicUsageWorker(ctx context.Context) (workers.EntityWorker, error) { 39 | worker := &micUsageWorker{ 40 | WorkerMetadata: models.SetWorkerMetadata(micUsageWorkerID, micUsageWorkerDesc), 41 | } 42 | 43 | defaultPrefs := &workers.CommonWorkerPrefs{} 44 | var err error 45 | worker.prefs, err = workers.LoadWorkerPreferences(micUsagePrefID, defaultPrefs) 46 | if err != nil { 47 | return nil, errors.Join(ErrInitMicUsageWorker, err) 48 | } 49 | 50 | monitor, found := linux.CtxGetPipewireMonitor(ctx) 51 | if !found { 52 | return nil, fmt.Errorf("%w: no pipewire monitor in context", ErrInitMicUsageWorker) 53 | } 54 | worker.pwEventChan = monitor.AddListener(ctx, micPipewireEventFilter) 55 | 56 | return worker, nil 57 | } 58 | 59 | func (w *micUsageWorker) Start(ctx context.Context) (<-chan models.Entity, error) { 60 | outCh := make(chan models.Entity) 61 | 62 | go func() { 63 | defer close(outCh) 64 | 65 | for event := range w.pwEventChan { 66 | w.parsePWState(*event.Info.State) 67 | outCh <- sensor.NewSensor(ctx, 68 | sensor.WithName("Microphone In Use"), 69 | sensor.WithID("microphone_in_use"), 70 | sensor.AsTypeBinarySensor(), 71 | sensor.WithIcon(micUseIcon(w.inUse)), 72 | sensor.WithState(w.inUse), 73 | sensor.WithDataSourceAttribute(linux.DataSrcSysFS), 74 | ) 75 | } 76 | }() 77 | 78 | return outCh, nil 79 | } 80 | 81 | func (w *micUsageWorker) IsDisabled() bool { 82 | return w.prefs.IsDisabled() 83 | } 84 | 85 | // parsePWState parses a pipewire state value into the appropriate boolean value. 86 | func (w *micUsageWorker) parsePWState(state pwmonitor.State) { 87 | switch state { 88 | case pwmonitor.StateRunning: 89 | w.inUse = true 90 | case pwmonitor.StateIdle, pwmonitor.StateSuspended: 91 | fallthrough 92 | default: 93 | w.inUse = false 94 | } 95 | } 96 | 97 | // micPipewireEventFilter filters the pipewire events. For mic monitoring, we are only 98 | // interested in events of type EventNode that have the audio source media type. 99 | func micPipewireEventFilter(e *pwmonitor.Event) bool { 100 | if e.Type == pwmonitor.EventNode || e.IsRemovalEvent() { 101 | // Parse props. 102 | props, err := e.NodeProps() 103 | if err != nil { 104 | return false 105 | } 106 | // Filter for audio stream events. 107 | return props.MediaClass == pwmonitor.MediaAudioSource 108 | } 109 | 110 | return false 111 | } 112 | 113 | func micUseIcon(value bool) string { 114 | switch value { 115 | case true: 116 | return "mdi:microphone" 117 | default: 118 | return "mdi:microphone-off" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /platform/linux/media/preferences.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package media 5 | 6 | import "github.com/joshuar/go-hass-agent/agent/workers" 7 | 8 | const ( 9 | prefPrefix = "sensors.media." 10 | mprisPrefID = prefPrefix + "mpris" 11 | webcamUsagePrefID = prefPrefix + "webcam_in_use" 12 | micUsagePrefID = prefPrefix + "microphone_in_use" 13 | ) 14 | 15 | type WorkerPrefs struct { 16 | *workers.CommonWorkerPrefs 17 | 18 | UpdateInterval string `toml:"update_interval"` 19 | } 20 | -------------------------------------------------------------------------------- /platform/linux/mem/memStats_generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=memStatID -output memStats_generated.go -linecomment"; DO NOT EDIT. 2 | 3 | package mem 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[memTotal-0] 12 | _ = x[memFree-1] 13 | _ = x[memAvailable-2] 14 | _ = x[memBuffered-3] 15 | _ = x[memCached-4] 16 | _ = x[swapCached-5] 17 | _ = x[memActive-6] 18 | _ = x[memInactive-7] 19 | _ = x[memAnonActive-8] 20 | _ = x[memAnonInactive-9] 21 | _ = x[memFileActive-10] 22 | _ = x[memFileInactive-11] 23 | _ = x[memUnevictable-12] 24 | _ = x[memLocked-13] 25 | _ = x[swapTotal-14] 26 | _ = x[swapFree-15] 27 | _ = x[zswapTotal-16] 28 | _ = x[zswapUsed-17] 29 | _ = x[memDirty-18] 30 | _ = x[memWriteback-19] 31 | _ = x[memAnonPages-20] 32 | _ = x[memMapped-21] 33 | _ = x[memShmem-22] 34 | _ = x[memKReclaimable-23] 35 | _ = x[memSlab-24] 36 | _ = x[memSReclaimable-25] 37 | _ = x[memSUnreclaim-26] 38 | _ = x[memKernelStack-27] 39 | _ = x[memPageTables-28] 40 | _ = x[memSecPageTables-29] 41 | _ = x[memNFSUnstable-30] 42 | _ = x[memBounce-31] 43 | _ = x[memWritebackTmp-32] 44 | _ = x[memCommitLimit-33] 45 | _ = x[memCommittedAS-34] 46 | _ = x[vmallocTotal-35] 47 | _ = x[vmallocUsed-36] 48 | _ = x[vmallocChunk-37] 49 | _ = x[memPercpu-38] 50 | _ = x[memCorrupted-39] 51 | _ = x[memAnonHugePages-40] 52 | _ = x[memShmemHugePages-41] 53 | _ = x[memShmemPmdMapped-42] 54 | _ = x[memFileHugePages-43] 55 | _ = x[memFilePmdMapped-44] 56 | _ = x[memCmaTotal-45] 57 | _ = x[memCmaFree-46] 58 | _ = x[memUnaccepted-47] 59 | _ = x[memHugePagesTotal-48] 60 | _ = x[memHugePagesFree-49] 61 | _ = x[memHugePagesRsvd-50] 62 | _ = x[memHugePagesSurp-51] 63 | _ = x[memHugepagesize-52] 64 | _ = x[memHugetlb-53] 65 | _ = x[memDirectMap4k-54] 66 | _ = x[memDirectMap2M-55] 67 | _ = x[memDirectMap1G-56] 68 | } 69 | 70 | const _memStatID_name = "Memory TotalMemory FreeMemory AvailableMemory BufferedMemory CachedSwap CachedMemory ActiveMemory InactiveAnonymous Memory ActiveAnonymous Memory InactiveFile Active MemoryFile Inactive MemoryUnevictable MemoryLocked MemorySwap TotalSwap FreeZswap TotalZswapped UsedDirty MemoryWriteback MemoryAnonymous Page Tables Memorymmap Memoryshmem MemoryKernel Memory ReclaimableKernel Slab MemoryKernel Slab Memory ReclaimableKernel Slab Memory UnreclaimableKernel Stack MemoryPage Tables MemorySecure Page Tables MemoryNFS Pages MemoryBlock Device Bounce Buffer MemoryFUSE Temporary Writeback Buffer MemoryCommit Limit TotalCommit Limit AllocatedVmalloc Total MemoryVmalloc Used MemoryVmalloc Largest Unused ChunkPercpu MemoryMemory CorruptedAnonymouse Huge Pages Memoryshmem Huge Pages Memoryshmem User Space Huge Pages MemoryFile Huge Pages MemoryFile User Space Huge Pages MemoryContiguous Memory Allocator Pages TotalContiguous Memory Allocator Pages FreeUnaccepted MemoryHuge Pages TotalHuge Pages FreeHuge Pages ReservedHuge Pages SurplusHuge Page SizeHuge Page TLBKernel 4kB PagesKernel 2MB PagesKernel 1GB Pages" 71 | 72 | var _memStatID_index = [...]uint16{0, 12, 23, 39, 54, 67, 78, 91, 106, 129, 154, 172, 192, 210, 223, 233, 242, 253, 266, 278, 294, 322, 333, 345, 370, 388, 418, 450, 469, 487, 512, 528, 561, 599, 617, 639, 659, 678, 706, 719, 735, 763, 786, 820, 842, 875, 914, 952, 969, 985, 1000, 1019, 1037, 1051, 1064, 1080, 1096, 1112} 73 | 74 | func (i memStatID) String() string { 75 | if i < 0 || i >= memStatID(len(_memStatID_index)-1) { 76 | return "memStatID(" + strconv.FormatInt(int64(i), 10) + ")" 77 | } 78 | return _memStatID_name[_memStatID_index[i]:_memStatID_index[i+1]] 79 | } 80 | -------------------------------------------------------------------------------- /platform/linux/mem/memStats_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Joshua Rich 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | 6 | package mem 7 | 8 | import ( 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func Test_getMemStats(t *testing.T) { 15 | type args struct { 16 | file string 17 | } 18 | tests := []struct { 19 | want memoryStats 20 | name string 21 | args args 22 | wantErr bool 23 | }{ 24 | { 25 | name: "with swap", 26 | args: args{file: "testdata/meminfowithswap"}, 27 | want: memoryStats{ 28 | memTotal: &memStat{value: 32572792 * 1000, units: "B"}, 29 | memFree: &memStat{value: 1396256 * 1000, units: "B"}, 30 | memAvailable: &memStat{value: 13353280 * 1000, units: "B"}, 31 | swapTotal: &memStat{value: 8388604 * 1000, units: "B"}, 32 | swapCached: &memStat{value: 8 * 1000, units: "B"}, 33 | swapFree: &memStat{value: 8387836 * 1000, units: "B"}, 34 | }, 35 | }, 36 | { 37 | name: "without swap", 38 | args: args{file: "testdata/meminfowithoutswap"}, 39 | want: memoryStats{ 40 | memTotal: &memStat{value: 32572792 * 1000, units: "B"}, 41 | memFree: &memStat{value: 1396256 * 1000, units: "B"}, 42 | memAvailable: &memStat{value: 13353280 * 1000, units: "B"}, 43 | }, 44 | }, 45 | { 46 | name: "unavailable", 47 | args: args{file: "/nonexistent"}, 48 | wantErr: true, 49 | }, 50 | } 51 | for _, tt := range tests { 52 | t.Run(tt.name, func(t *testing.T) { 53 | memStatFile = tt.args.file 54 | got, err := getMemStats() 55 | if (err != nil) != tt.wantErr { 56 | t.Errorf("getMemStats() error = %v, wantErr %v", err, tt.wantErr) 57 | return 58 | } 59 | if !tt.wantErr { 60 | assert.Equal(t, tt.want[memTotal], got[memTotal]) 61 | assert.Equal(t, tt.want[swapTotal], got[swapTotal]) 62 | } 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /platform/linux/mem/preferences.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package mem 5 | 6 | import "github.com/joshuar/go-hass-agent/agent/workers" 7 | 8 | const ( 9 | prefPrefix = "sensors.memory." 10 | ) 11 | 12 | type WorkerPreferences struct { 13 | workers.CommonWorkerPrefs 14 | 15 | UpdateInterval string `toml:"update_interval"` 16 | } 17 | -------------------------------------------------------------------------------- /platform/linux/mem/testdata/meminfowithoutswap: -------------------------------------------------------------------------------- 1 | MemTotal: 32572792 kB 2 | MemFree: 1427044 kB 3 | MemAvailable: 13383964 kB 4 | Buffers: 2812 kB 5 | Cached: 11118316 kB 6 | -------------------------------------------------------------------------------- /platform/linux/mem/testdata/meminfowithswap: -------------------------------------------------------------------------------- 1 | MemTotal: 32572792 kB 2 | MemFree: 1396256 kB 3 | MemAvailable: 13353280 kB 4 | Buffers: 2812 kB 5 | Cached: 11118288 kB 6 | SwapCached: 8 kB 7 | SwapTotal: 8388604 kB 8 | SwapFree: 8387836 kB 9 | Zswap: 12 kB 10 | Zswapped: 12 kB 11 | -------------------------------------------------------------------------------- /platform/linux/net/connectionStateSensor.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Joshua Rich 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | 6 | //go:generate go tool golang.org/x/tools/cmd/stringer -type=connState,connIcon -output connectionState_generated.go -linecomment 7 | package net 8 | 9 | import ( 10 | "context" 11 | "errors" 12 | "fmt" 13 | 14 | "github.com/godbus/dbus/v5" 15 | "github.com/iancoleman/strcase" 16 | 17 | "github.com/joshuar/go-hass-agent/models" 18 | "github.com/joshuar/go-hass-agent/models/sensor" 19 | "github.com/joshuar/go-hass-agent/pkg/linux/dbusx" 20 | "github.com/joshuar/go-hass-agent/platform/linux" 21 | ) 22 | 23 | const ( 24 | connUnknown connState = iota // Unknown 25 | connActivating // Activating 26 | connOnline // Online 27 | connDeactivating // Deactivating 28 | connOffline // Offline 29 | ) 30 | 31 | // connState represents the connection state. 32 | type connState uint32 33 | 34 | const ( 35 | iconUnknown connIcon = iota // mdi:help-network 36 | iconActivating // mdi:plus-network 37 | iconOnline // mdi:network 38 | iconDeactivating // mdi:network-minus 39 | iconOffline // mdi:network-off 40 | ) 41 | 42 | // connIcon is an icon representation of the connection state. 43 | type connIcon uint32 44 | 45 | var ErrNewConnStateSensor = errors.New("could not create connection state sensor") 46 | 47 | // connectionStateSensor tracks properties about a connection. 48 | type connectionStateSensor struct { 49 | name string 50 | state string 51 | icon string 52 | stateProp *dbusx.Property[connState] 53 | } 54 | 55 | func (c *connectionStateSensor) generateEntity(ctx context.Context) models.Entity { 56 | return sensor.NewSensor(ctx, 57 | sensor.WithName(c.name+" Connection State"), 58 | sensor.WithID(strcase.ToSnake(c.name)+"_connection_state"), 59 | sensor.WithDataSourceAttribute(linux.DataSrcDBus), 60 | sensor.WithState(c.state), 61 | sensor.WithIcon(c.icon), 62 | ) 63 | } 64 | 65 | func (c *connectionStateSensor) setState(state any) error { 66 | switch value := state.(type) { 67 | case dbus.Variant: 68 | state, err := dbusx.VariantToValue[connState](value) 69 | if err != nil { 70 | return fmt.Errorf("could not parse updated connection state: %w", err) 71 | } 72 | 73 | c.state = state.String() 74 | c.icon = connIcon(state).String() 75 | case uint32: 76 | c.state = connState(value).String() 77 | c.icon = connIcon(value).String() 78 | default: 79 | return ErrUnsupportedValue 80 | } 81 | return nil 82 | } 83 | 84 | func (c *connectionStateSensor) updateState() error { 85 | state, err := c.stateProp.Get() 86 | if err != nil { 87 | return fmt.Errorf("cannot update state: %w", err) 88 | } 89 | c.state = state.String() 90 | c.icon = connIcon(state).String() 91 | return nil 92 | } 93 | 94 | func newConnectionStateSensor(bus *dbusx.Bus, connectionPath, connectionName string) (*connectionStateSensor, error) { 95 | conn := &connectionStateSensor{ 96 | name: connectionName, 97 | stateProp: dbusx.NewProperty[connState](bus, connectionPath, dBusNMObj, connectionStateProp), 98 | } 99 | if err := conn.updateState(); err != nil { 100 | return nil, fmt.Errorf("cannot create connection sensor: %w", err) 101 | } 102 | return conn, nil 103 | } 104 | -------------------------------------------------------------------------------- /platform/linux/net/connectionState_generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=connState,connIcon -output connectionState_generated.go -linecomment"; DO NOT EDIT. 2 | 3 | package net 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[connUnknown-0] 12 | _ = x[connActivating-1] 13 | _ = x[connOnline-2] 14 | _ = x[connDeactivating-3] 15 | _ = x[connOffline-4] 16 | } 17 | 18 | const _connState_name = "UnknownActivatingOnlineDeactivatingOffline" 19 | 20 | var _connState_index = [...]uint8{0, 7, 17, 23, 35, 42} 21 | 22 | func (i connState) String() string { 23 | if i >= connState(len(_connState_index)-1) { 24 | return "connState(" + strconv.FormatInt(int64(i), 10) + ")" 25 | } 26 | return _connState_name[_connState_index[i]:_connState_index[i+1]] 27 | } 28 | func _() { 29 | // An "invalid array index" compiler error signifies that the constant values have changed. 30 | // Re-run the stringer command to generate them again. 31 | var x [1]struct{} 32 | _ = x[iconUnknown-0] 33 | _ = x[iconActivating-1] 34 | _ = x[iconOnline-2] 35 | _ = x[iconDeactivating-3] 36 | _ = x[iconOffline-4] 37 | } 38 | 39 | const _connIcon_name = "mdi:help-networkmdi:plus-networkmdi:networkmdi:network-minusmdi:network-off" 40 | 41 | var _connIcon_index = [...]uint8{0, 16, 32, 43, 60, 75} 42 | 43 | func (i connIcon) String() string { 44 | if i >= connIcon(len(_connIcon_index)-1) { 45 | return "connIcon(" + strconv.FormatInt(int64(i), 10) + ")" 46 | } 47 | return _connIcon_name[_connIcon_index[i]:_connIcon_index[i+1]] 48 | } 49 | -------------------------------------------------------------------------------- /platform/linux/net/preferences.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package net 5 | 6 | import ( 7 | "github.com/joshuar/go-hass-agent/agent/workers" 8 | ) 9 | 10 | const ( 11 | prefPrefix = "sensors.network." 12 | ) 13 | 14 | var defaultIgnoredDevices = []string{"lo", "veth", "podman", "docker", "vnet"} 15 | 16 | // CommonPreferences represents common preferences across all net workers. All workers support being disabled and setting a 17 | // list of devices to filter. 18 | type CommonPreferences struct { 19 | workers.CommonWorkerPrefs 20 | 21 | IgnoredDevices []string `toml:"ignored_devices"` 22 | } 23 | -------------------------------------------------------------------------------- /platform/linux/net/stats_generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=netStatsType -output stats_generated.go -linecomment"; DO NOT EDIT. 2 | 3 | package net 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[bytesSent-0] 12 | _ = x[bytesRecv-1] 13 | _ = x[bytesSentRate-2] 14 | _ = x[bytesRecvRate-3] 15 | } 16 | 17 | const _netStatsType_name = "Bytes SentBytes ReceivedBytes Sent ThroughputBytes Received Throughput" 18 | 19 | var _netStatsType_index = [...]uint8{0, 10, 24, 45, 70} 20 | 21 | func (i netStatsType) String() string { 22 | if i < 0 || i >= netStatsType(len(_netStatsType_index)-1) { 23 | return "netStatsType(" + strconv.FormatInt(int64(i), 10) + ")" 24 | } 25 | return _netStatsType_name[_netStatsType_index[i]:_netStatsType_index[i+1]] 26 | } 27 | -------------------------------------------------------------------------------- /platform/linux/power/dbus.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Joshua Rich 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | 6 | package power 7 | 8 | const ( 9 | loginBasePath = "/org/freedesktop/login1" 10 | loginSessionPath = loginBasePath + "/Session" 11 | loginBaseInterface = "org.freedesktop.login1" 12 | managerInterface = loginBaseInterface + ".Manager" 13 | sessionInterface = loginBaseInterface + ".Session" 14 | sessionLockSignal = "Lock" 15 | sessionUnlockSignal = "Unlock" 16 | sessionLockedProp = "LockedHint" 17 | sessionIdleProp = "IdleHint" 18 | sessionIdleTimeProp = "IdleSinceHint" 19 | ) 20 | -------------------------------------------------------------------------------- /platform/linux/power/preferences.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package power 5 | 6 | const ( 7 | sensorsPrefPrefix = "sensors.power." 8 | controlsPrefPrefix = "controls.power." 9 | ) 10 | -------------------------------------------------------------------------------- /platform/linux/rate.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package linux 5 | 6 | import ( 7 | "time" 8 | ) 9 | 10 | // RateValue represents a value that is a rate (i.e. changes with time). 11 | type RateValue[T ~uint64 | ~float64] struct { 12 | prevValue T 13 | } 14 | 15 | // Calculate will work out the current rate based on the given value and time passed since last measured. 16 | func (r *RateValue[T]) Calculate(currValue T, delta time.Duration) T { 17 | var rate T 18 | 19 | if T(delta.Seconds()) > 0 { 20 | rate = ((currValue - r.prevValue) / T(delta.Seconds())) 21 | } 22 | 23 | r.prevValue = currValue 24 | 25 | return rate 26 | } 27 | -------------------------------------------------------------------------------- /platform/linux/system/dbusCommand.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Joshua Rich 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | 6 | package system 7 | 8 | import ( 9 | "context" 10 | "encoding/json" 11 | "errors" 12 | "log/slog" 13 | 14 | "github.com/eclipse/paho.golang/paho" 15 | slogctx "github.com/veqryn/slog-context" 16 | 17 | mqtthass "github.com/joshuar/go-hass-anything/v12/pkg/hass" 18 | mqttapi "github.com/joshuar/go-hass-anything/v12/pkg/mqtt" 19 | 20 | "github.com/joshuar/go-hass-agent/agent/workers" 21 | "github.com/joshuar/go-hass-agent/pkg/linux/dbusx" 22 | "github.com/joshuar/go-hass-agent/platform/linux" 23 | ) 24 | 25 | const ( 26 | dbusCmdPreferencesID = controlsPrefPrefix + "dbus_commands" 27 | ) 28 | 29 | var ErrInitDBusCommands = errors.New("could not init D-Bus commands worker") 30 | 31 | type dbusCommandMsg struct { 32 | Bus string `json:"bus"` 33 | Destination string `json:"destination"` 34 | Path string `json:"path"` 35 | Method string `json:"method"` 36 | Args []any `json:"args"` 37 | UseSessionPath bool `json:"use_session_path"` 38 | } 39 | 40 | type dbusCmdWorker struct { 41 | prefs *workers.CommonWorkerPrefs 42 | } 43 | 44 | func NewDBusCommandSubscription(ctx context.Context, device *mqtthass.Device) (*mqttapi.Subscription, error) { 45 | worker := &dbusCmdWorker{} 46 | 47 | defaultPrefs := &workers.CommonWorkerPrefs{} 48 | var err error 49 | worker.prefs, err = workers.LoadWorkerPreferences(dbusCmdPreferencesID, defaultPrefs) 50 | if err != nil { 51 | return nil, errors.Join(ErrInitDBusCommands, err) 52 | } 53 | 54 | //nolint:nilnil 55 | if worker.prefs.IsDisabled() { 56 | return nil, nil 57 | } 58 | 59 | systemBus, ok := linux.CtxGetSystemBus(ctx) 60 | if !ok { 61 | return nil, errors.Join(ErrInitDBusCommands, linux.ErrNoSystemBus) 62 | } 63 | 64 | sessionBus, ok := linux.CtxGetSessionBus(ctx) 65 | if !ok { 66 | return nil, errors.Join(ErrInitDBusCommands, linux.ErrNoSessionBus) 67 | } 68 | 69 | busMap := map[string]*dbusx.Bus{"session": sessionBus, "system": systemBus} 70 | 71 | return &mqttapi.Subscription{ 72 | Callback: func(packet *paho.Publish) { 73 | var ( 74 | dbusMsg dbusCommandMsg 75 | err error 76 | ) 77 | 78 | logger := slogctx.FromCtx(ctx) 79 | 80 | // Unmarshal the request. 81 | if err = json.Unmarshal(packet.Payload, &dbusMsg); err != nil { 82 | logger.Error("Could not unmarshal D-Bus MQTT message.", slog.Any("error", err)) 83 | 84 | return 85 | } 86 | // Check which bus type was requested. 87 | bus, ok := busMap[dbusMsg.Bus] 88 | if !ok { 89 | logger.Error("Unsupported D-Bus type.") 90 | 91 | return 92 | } 93 | // Fetch the session path if requested. 94 | if dbusMsg.UseSessionPath { 95 | dbusMsg.Path, err = busMap["session"].GetSessionPath() 96 | if err != nil { 97 | logger.Error("Could not determine session path.", slog.Any("error", err)) 98 | 99 | return 100 | } 101 | } 102 | 103 | logger.With( 104 | slog.String("bus", dbusMsg.Bus), 105 | slog.String("destination", dbusMsg.Destination), 106 | slog.String("path", dbusMsg.Path), 107 | slog.String("method", dbusMsg.Method), 108 | ).Info("Dispatching D-Bus command.") 109 | 110 | // Call the method. 111 | err = dbusx.NewMethod(bus, dbusMsg.Destination, dbusMsg.Path, dbusMsg.Method).Call(ctx, dbusMsg.Args...) 112 | if err != nil { 113 | logger.Warn("Error dispatching D-Bus command.", slog.Any("error", err)) 114 | } 115 | }, 116 | Topic: "gohassagent/" + device.Name + "/dbuscommand", 117 | }, 118 | nil 119 | } 120 | -------------------------------------------------------------------------------- /platform/linux/system/hsi_generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=hsiResult,hsiLevel -output hsi_generated.go -linecomment"; DO NOT EDIT. 2 | 3 | package system 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[ResultUnknown-0] 12 | _ = x[ResultEnabled-1] 13 | _ = x[ResultNotEnabled-2] 14 | _ = x[ResultValid-3] 15 | _ = x[ResultNotValid-4] 16 | _ = x[ResultLocked-5] 17 | _ = x[ResultNotLocked-6] 18 | _ = x[ResultEncrypted-7] 19 | _ = x[ResultNotEncrypted-8] 20 | _ = x[ResultTainted-9] 21 | _ = x[ResultNotTainted-10] 22 | _ = x[ResultFound-11] 23 | _ = x[ResultNotFound-12] 24 | _ = x[ResultSupported-13] 25 | _ = x[ResultNotSupported-14] 26 | } 27 | 28 | const _hsiResult_name = "Not KnownEnabledNot EnabledValidNot ValidLockedNot LockedEncryptedNot EncryptedTaintedNot TaintedFoundNot FoundSupportedNot Supported" 29 | 30 | var _hsiResult_index = [...]uint8{0, 9, 16, 27, 32, 41, 47, 57, 66, 79, 86, 97, 102, 111, 120, 133} 31 | 32 | func (i hsiResult) String() string { 33 | if i >= hsiResult(len(_hsiResult_index)-1) { 34 | return "hsiResult(" + strconv.FormatInt(int64(i), 10) + ")" 35 | } 36 | return _hsiResult_name[_hsiResult_index[i]:_hsiResult_index[i+1]] 37 | } 38 | func _() { 39 | // An "invalid array index" compiler error signifies that the constant values have changed. 40 | // Re-run the stringer command to generate them again. 41 | var x [1]struct{} 42 | _ = x[hsi0-0] 43 | _ = x[hsi1-1] 44 | _ = x[hsi2-2] 45 | _ = x[hsi3-3] 46 | _ = x[hsi4-4] 47 | _ = x[hsi5-5] 48 | } 49 | 50 | const _hsiLevel_name = "HSI:0 (Insecure State)HSI:1 (Critical State)HSI:2 (Risky State)HSI:3 (Protected State)HSI:4 (Secure State)HSI:5 (Secure Proven State)" 51 | 52 | var _hsiLevel_index = [...]uint8{0, 22, 44, 63, 86, 106, 133} 53 | 54 | func (i hsiLevel) String() string { 55 | if i >= hsiLevel(len(_hsiLevel_index)-1) { 56 | return "hsiLevel(" + strconv.FormatInt(int64(i), 10) + ")" 57 | } 58 | return _hsiLevel_name[_hsiLevel_index[i]:_hsiLevel_index[i+1]] 59 | } 60 | -------------------------------------------------------------------------------- /platform/linux/system/info.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Joshua Rich 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | 6 | //revive:disable:unused-receiver 7 | package system 8 | 9 | import ( 10 | "context" 11 | "errors" 12 | "fmt" 13 | "log/slog" 14 | 15 | slogctx "github.com/veqryn/slog-context" 16 | 17 | "github.com/joshuar/go-hass-agent/agent/workers" 18 | "github.com/joshuar/go-hass-agent/device" 19 | "github.com/joshuar/go-hass-agent/models" 20 | "github.com/joshuar/go-hass-agent/models/sensor" 21 | "github.com/joshuar/go-hass-agent/platform/linux" 22 | ) 23 | 24 | const ( 25 | infoWorkerID = "system_info" 26 | infoWorkerDesc = "General system information" 27 | infoWorkerPreferencesID = sensorsPrefPrefix + "info_sensors" 28 | ) 29 | 30 | var _ workers.EntityWorker = (*infoWorker)(nil) 31 | 32 | var ErrNewInfoSensor = errors.New("could not create info sensor") 33 | 34 | type infoWorker struct { 35 | OutCh chan models.Entity 36 | prefs *workers.CommonWorkerPrefs 37 | *models.WorkerMetadata 38 | } 39 | 40 | func (w *infoWorker) IsDisabled() bool { 41 | return w.prefs.IsDisabled() 42 | } 43 | 44 | func (w *infoWorker) Execute(ctx context.Context) error { 45 | var warnings error 46 | 47 | // Get distribution name and version. 48 | distro, version, err := device.GetOSDetails() 49 | if err != nil { 50 | return fmt.Errorf("could not retrieve distro details: %w", err) 51 | } 52 | 53 | w.OutCh <- sensor.NewSensor(ctx, 54 | sensor.WithName("Distribution Name"), 55 | sensor.WithID("distribution_name"), 56 | sensor.AsDiagnostic(), 57 | sensor.WithIcon("mdi:linux"), 58 | sensor.WithState(distro), 59 | sensor.WithDataSourceAttribute(linux.DataSrcProcFS), 60 | ) 61 | 62 | w.OutCh <- sensor.NewSensor(ctx, 63 | sensor.WithName("Distribution Version"), 64 | sensor.WithID("distribution_version"), 65 | sensor.AsDiagnostic(), 66 | sensor.WithIcon("mdi:numeric"), 67 | sensor.WithState(version), 68 | sensor.WithDataSourceAttribute(linux.DataSrcProcFS), 69 | ) 70 | 71 | // Get kernel version. 72 | kernelVersion, err := device.GetKernelVersion() 73 | if err != nil { 74 | return fmt.Errorf("could not retrieve kernel version: %w", err) 75 | } 76 | 77 | w.OutCh <- sensor.NewSensor(ctx, 78 | sensor.WithName("Kernel Version"), 79 | sensor.WithID("kernel_version"), 80 | sensor.AsDiagnostic(), 81 | sensor.WithIcon("mdi:chip"), 82 | sensor.WithState(kernelVersion), 83 | sensor.WithDataSourceAttribute(linux.DataSrcProcFS), 84 | ) 85 | 86 | return warnings 87 | } 88 | 89 | func (w *infoWorker) Start(ctx context.Context) (<-chan models.Entity, error) { 90 | w.OutCh = make(chan models.Entity) 91 | go func() { 92 | defer close(w.OutCh) 93 | if err := w.Execute(ctx); err != nil { 94 | slogctx.FromCtx(ctx).Warn("Failed to send info details", 95 | slog.Any("error", err)) 96 | } 97 | }() 98 | return w.OutCh, nil 99 | } 100 | 101 | func NewInfoWorker(_ context.Context) (workers.EntityWorker, error) { 102 | worker := &infoWorker{ 103 | WorkerMetadata: models.SetWorkerMetadata(infoWorkerID, infoWorkerDesc), 104 | } 105 | 106 | defaultPrefs := &workers.CommonWorkerPrefs{} 107 | var err error 108 | worker.prefs, err = workers.LoadWorkerPreferences(infoWorkerPreferencesID, defaultPrefs) 109 | if err != nil { 110 | return nil, fmt.Errorf("could not start info worker: %w", err) 111 | } 112 | 113 | return worker, nil 114 | } 115 | -------------------------------------------------------------------------------- /platform/linux/system/lastBoot.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package system 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "log/slog" 11 | "time" 12 | 13 | slogctx "github.com/veqryn/slog-context" 14 | 15 | "github.com/joshuar/go-hass-agent/agent/workers" 16 | "github.com/joshuar/go-hass-agent/models" 17 | "github.com/joshuar/go-hass-agent/models/class" 18 | "github.com/joshuar/go-hass-agent/models/sensor" 19 | "github.com/joshuar/go-hass-agent/platform/linux" 20 | ) 21 | 22 | const ( 23 | lastBootWorkerID = "boot_time_sensor" 24 | lastBootWorkerDesc = "Last boot time" 25 | lastBootWorkerPrefID = infoWorkerPreferencesID 26 | ) 27 | 28 | var _ workers.EntityWorker = (*lastBootWorker)(nil) 29 | 30 | var ErrInitLastBootWorker = errors.New("could not init last boot worker") 31 | 32 | type lastBootWorker struct { 33 | lastBoot time.Time 34 | OutCh chan models.Entity 35 | prefs *workers.CommonWorkerPrefs 36 | *models.WorkerMetadata 37 | } 38 | 39 | func (w *lastBootWorker) IsDisabled() bool { 40 | return w.prefs.IsDisabled() 41 | } 42 | 43 | func (w *lastBootWorker) Execute(ctx context.Context) error { 44 | w.OutCh <- sensor.NewSensor(ctx, 45 | sensor.WithName("Last Reboot"), 46 | sensor.WithID("last_reboot"), 47 | sensor.AsDiagnostic(), 48 | sensor.WithDeviceClass(class.SensorClassTimestamp), 49 | sensor.WithIcon("mdi:restart"), 50 | sensor.WithState(w.lastBoot.Format(time.RFC3339)), 51 | sensor.WithDataSourceAttribute(linux.ProcFSRoot), 52 | ) 53 | return nil 54 | } 55 | 56 | func (w *lastBootWorker) Start(ctx context.Context) (<-chan models.Entity, error) { 57 | w.OutCh = make(chan models.Entity) 58 | go func() { 59 | defer close(w.OutCh) 60 | if err := w.Execute(ctx); err != nil { 61 | slogctx.FromCtx(ctx).Warn("Failed to send info details", 62 | slog.Any("error", err)) 63 | } 64 | }() 65 | return w.OutCh, nil 66 | } 67 | 68 | func NewLastBootWorker(ctx context.Context) (workers.EntityWorker, error) { 69 | lastBoot, found := linux.CtxGetBoottime(ctx) 70 | if !found { 71 | return nil, errors.Join(ErrInitLastBootWorker, 72 | fmt.Errorf("%w: no lastBoot value", linux.ErrInvalidCtx)) 73 | } 74 | 75 | worker := &lastBootWorker{ 76 | WorkerMetadata: models.SetWorkerMetadata(lastBootWorkerID, lastBootWorkerDesc), 77 | lastBoot: lastBoot, 78 | } 79 | 80 | defaultPrefs := &workers.CommonWorkerPrefs{} 81 | var err error 82 | worker.prefs, err = workers.LoadWorkerPreferences(lastBootWorkerPrefID, defaultPrefs) 83 | if err != nil { 84 | return nil, errors.Join(ErrInitLastBootWorker, err) 85 | } 86 | 87 | return worker, nil 88 | } 89 | -------------------------------------------------------------------------------- /platform/linux/system/preferences.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package system 5 | 6 | import "github.com/joshuar/go-hass-agent/agent/workers" 7 | 8 | const ( 9 | sensorsPrefPrefix = "sensors.system." 10 | controlsPrefPrefix = "controls.system." 11 | ) 12 | 13 | // HWMonPrefs are the preferences for the hwmon sensor worker. 14 | type HWMonPrefs struct { 15 | workers.CommonWorkerPrefs 16 | 17 | UpdateInterval string `toml:"update_interval"` 18 | } 19 | 20 | // ProblemsPrefs are the preferences for the abrt problems sensor worker. 21 | type ProblemsPrefs struct { 22 | workers.CommonWorkerPrefs 23 | 24 | UpdateInterval string `toml:"update_interval"` 25 | } 26 | 27 | // ChronyPrefs are the preferences for the chrony sensor worker. 28 | type ChronyPrefs struct { 29 | workers.CommonWorkerPrefs 30 | 31 | UpdateInterval string `toml:"update_interval"` 32 | } 33 | 34 | // UptimePrefs are the preferences for the system uptime sensor. 35 | type UptimePrefs struct { 36 | workers.CommonWorkerPrefs 37 | 38 | UpdateInterval string `toml:"update_interval"` 39 | } 40 | 41 | // UserSessionsPrefs are the preferences for the user sessions worker. 42 | type UserSessionsPrefs struct { 43 | workers.CommonWorkerPrefs 44 | } 45 | -------------------------------------------------------------------------------- /platform/linux/system/vulnerabilities.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package system 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "log/slog" 10 | "os" 11 | "path/filepath" 12 | "slices" 13 | "strings" 14 | 15 | slogctx "github.com/veqryn/slog-context" 16 | 17 | "github.com/joshuar/go-hass-agent/agent/workers" 18 | "github.com/joshuar/go-hass-agent/models" 19 | "github.com/joshuar/go-hass-agent/models/class" 20 | "github.com/joshuar/go-hass-agent/models/sensor" 21 | "github.com/joshuar/go-hass-agent/platform/linux" 22 | ) 23 | 24 | const ( 25 | cpuVulnWorkerID = "cpu_vulnerabilities" 26 | cpuVulnWorkerDesc = "Potential CPU vulnerabilities reported by the kernel" 27 | cpuVulnPreferencesID = cpuVulnWorkerID 28 | cpuVulnPath = "devices/system/cpu/vulnerabilities" 29 | ) 30 | 31 | var _ workers.EntityWorker = (*cpuVulnWorker)(nil) 32 | 33 | var ( 34 | ErrNewVulnSensor = errors.New("could not create vulnerabilities sensor") 35 | ErrInitVulnWorker = errors.New("could not init vulnerabilities worker") 36 | ) 37 | 38 | type cpuVulnWorker struct { 39 | path string 40 | prefs *workers.CommonWorkerPrefs 41 | OutCh chan models.Entity 42 | *models.WorkerMetadata 43 | } 44 | 45 | func (w *cpuVulnWorker) Execute(ctx context.Context) error { 46 | var ( 47 | cpuVulnerabilitiesFound bool 48 | err error 49 | ) 50 | 51 | vulnerabilities, err := filepath.Glob(w.path + "/*") 52 | if err != nil { 53 | return errors.Join(ErrNewVulnSensor, err) 54 | } 55 | 56 | attrs := make(map[string]any) 57 | 58 | for vulnerability := range slices.Values(vulnerabilities) { 59 | var data []byte 60 | 61 | data, err = os.ReadFile(vulnerability) 62 | if err != nil { 63 | continue 64 | } 65 | 66 | name := filepath.Base(vulnerability) 67 | details := strings.TrimSpace(string(data)) 68 | 69 | if strings.Contains(details, "Vulnerable") { 70 | cpuVulnerabilitiesFound = true 71 | } 72 | 73 | attrs[name] = details 74 | } 75 | 76 | w.OutCh <- sensor.NewSensor(ctx, 77 | sensor.WithName("CPU Vulnerabilities"), 78 | sensor.WithID("cpu_vulnerabilities"), 79 | sensor.AsTypeBinarySensor(), 80 | sensor.WithDeviceClass(class.BinaryClassProblem), 81 | sensor.AsDiagnostic(), 82 | sensor.WithIcon("mdi:security"), 83 | sensor.WithState(cpuVulnerabilitiesFound), 84 | sensor.WithAttributes(attrs), 85 | ) 86 | 87 | return nil 88 | } 89 | 90 | func (w *cpuVulnWorker) IsDisabled() bool { 91 | return w.prefs.IsDisabled() 92 | } 93 | 94 | func (w *cpuVulnWorker) Start(ctx context.Context) (<-chan models.Entity, error) { 95 | w.OutCh = make(chan models.Entity) 96 | go func() { 97 | defer close(w.OutCh) 98 | if err := w.Execute(ctx); err != nil { 99 | slogctx.FromCtx(ctx).Warn("Failed to send cpu vulnerability details", 100 | slog.Any("error", err)) 101 | } 102 | }() 103 | return w.OutCh, nil 104 | } 105 | 106 | func NewCPUVulnerabilityWorker(_ context.Context) (workers.EntityWorker, error) { 107 | worker := &cpuVulnWorker{ 108 | WorkerMetadata: models.SetWorkerMetadata(cpuVulnWorkerID, cpuVulnWorkerDesc), 109 | path: filepath.Join(linux.SysFSRoot, cpuVulnPath), 110 | } 111 | 112 | defaultPrefs := &workers.CommonWorkerPrefs{} 113 | var err error 114 | worker.prefs, err = workers.LoadWorkerPreferences(infoWorkerPreferencesID, defaultPrefs) 115 | if err != nil { 116 | return nil, errors.Join(ErrInitVulnWorker, err) 117 | } 118 | 119 | return worker, nil 120 | } 121 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "changelog-path": "CHANGELOG.md", 5 | "release-type": "go", 6 | "bump-minor-pre-major": false, 7 | "bump-patch-for-minor-pre-major": false, 8 | "draft": false, 9 | "prerelease": false, 10 | "pull-request-title-pattern": "chore${scope}: :tada: release${component} ${version}" 11 | } 12 | }, 13 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 14 | } -------------------------------------------------------------------------------- /schema/generate.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | //revive:disable:package-comments 5 | package api 6 | 7 | //go:generate go tool oapi-codegen -config models-cfg.yaml models.yaml 8 | //go:generate go tool oapi-codegen -config rest-cfg.yaml rest.yaml 9 | -------------------------------------------------------------------------------- /schema/models-cfg.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json# Copyright 2025 Joshua Rich . 2 | 3 | # Copyright 2025 Joshua Rich . 4 | # SPDX-License-Identifier: MIT 5 | 6 | package: models 7 | output: ../models/models.gen.go 8 | generate: 9 | models: true 10 | output-options: 11 | skip-prune: true 12 | nullable-type: true 13 | prefer-skip-optional-pointer: true 14 | prefer-skip-optional-pointer-with-omitzero: true 15 | compatibility: 16 | always-prefix-enum-values: true 17 | -------------------------------------------------------------------------------- /schema/rest-cfg.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json 2 | 3 | # Copyright 2025 Joshua Rich . 4 | # SPDX-License-Identifier: MIT 5 | 6 | package: api 7 | output: ../hass/api/rest.gen.go 8 | generate: 9 | models: true 10 | output-options: 11 | skip-prune: true 12 | nullable-type: true 13 | prefer-skip-optional-pointer: true 14 | prefer-skip-optional-pointer-with-omitzero: true 15 | import-mapping: 16 | models.yaml: 'github.com/joshuar/go-hass-agent/models' 17 | -------------------------------------------------------------------------------- /schema/websocket-cfg.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json 2 | 3 | # Copyright 2025 Joshua Rich . 4 | # SPDX-License-Identifier: MIT 5 | 6 | package: api 7 | output: ../hass/api/websocket.gen.go 8 | generate: 9 | models: true 10 | output-options: 11 | skip-prune: true 12 | nullable-type: true 13 | prefer-skip-optional-pointer: true 14 | prefer-skip-optional-pointer-with-omitzero: true 15 | import-mapping: 16 | rest.yaml: '-' 17 | -------------------------------------------------------------------------------- /schema/websocket.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/OAI/OpenAPI-Specification/refs/heads/main/schemas/v3.0/schema.yaml 2 | 3 | # Copyright 2025 Joshua Rich . 4 | # SPDX-License-Identifier: MIT 5 | 6 | openapi: "3.0.3" 7 | info: 8 | version: 1.0.0 9 | title: Home Assistant websocket API 10 | description: Schema and models for using the Home Assistant websocket API. 11 | paths: {} 12 | components: 13 | schemas: 14 | WebSocketRequest: 15 | description: > 16 | is a request made over the Home Assistant websocket connection. 17 | type: object 18 | required: 19 | - type 20 | properties: 21 | type: 22 | $ref: 'rest.yaml#/components/schemas/RequestType' 23 | webhook_id: 24 | $ref: 'rest.yaml#/components/schemas/WebhookID' 25 | access_token: 26 | type: string 27 | id: 28 | type: string 29 | support_confirm: 30 | type: string 31 | WebSocketResponse: 32 | description: > 33 | is a response from Home Assistant over the websocket connection. 34 | type: object 35 | required: 36 | - type 37 | properties: 38 | type: 39 | $ref: 'rest.yaml#/components/schemas/RequestType' 40 | 41 | -------------------------------------------------------------------------------- /server/forms/forms.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package forms contains methods for handling form decoding and encoding. 5 | package forms 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "net/http" 11 | "net/url" 12 | 13 | "github.com/go-playground/form/v4" 14 | ) 15 | 16 | var ( 17 | // ErrDecode indicates an error occurred during decoding. 18 | ErrDecode = errors.New("error in decoding") 19 | // ErrEncode indicates an error occurred during encoding. 20 | ErrEncode = errors.New("error in encoding") 21 | // ErrValidation indicates an error occurred during validation. 22 | ErrValidation = errors.New("validation failed") 23 | // ErrSanitise indicates an error occurred during sanitisation. 24 | ErrSanitise = errors.New("sanitisation failed") 25 | ) 26 | 27 | var ( 28 | decoder = form.NewDecoder() 29 | encoder = form.NewEncoder() 30 | ) 31 | 32 | // FormInput represents form input data. It has methods to test if the data is valid and to sanitise the input data. 33 | type FormInput interface { 34 | Valid() (bool, error) 35 | Sanitise() error 36 | } 37 | 38 | // DecodeForm will decode submitted form contents into the passed in type. It 39 | // will perform validation of the type and will return the type and a boolean 40 | // true if it is valid. If decoding the form submission fails, a non-nill error 41 | // is returned. 42 | func DecodeForm[T FormInput](req *http.Request) (T, bool, error) { 43 | var obj T 44 | // Parse form values in request. 45 | if err := req.ParseForm(); err != nil { 46 | return obj, false, fmt.Errorf("%w: %w", ErrDecode, err) 47 | } 48 | // Decode the form values. 49 | err := decoder.Decode(&obj, req.Form) 50 | if err != nil { 51 | return obj, false, fmt.Errorf("%w: %w", ErrDecode, err) 52 | } 53 | // Sanitise the object. 54 | if err := obj.Sanitise(); err != nil { 55 | return obj, false, fmt.Errorf("%w: %w", ErrSanitise, err) 56 | } 57 | // Validate the object. 58 | if ok, err := obj.Valid(); !ok { 59 | return obj, false, fmt.Errorf("%w: %w", ErrValidation, err) 60 | } 61 | return obj, true, nil 62 | } 63 | 64 | // EncodeForm will encode the given object as url.Values, using the struct tags 65 | // where possible. It will perform validation of the object before attempting 66 | // encoding. If the object cannot be encoded or validation fails, a non-nil 67 | // error is returned. 68 | func EncodeForm[T FormInput](obj T) (url.Values, error) { 69 | // Sanitise the object. 70 | if err := obj.Sanitise(); err != nil { 71 | return nil, fmt.Errorf("%w: %w", ErrSanitise, err) 72 | } 73 | // Validate the object. 74 | if ok, err := obj.Valid(); !ok { 75 | return nil, fmt.Errorf("%w: %w", ErrValidation, err) 76 | } 77 | values, err := encoder.Encode(&obj) 78 | if err != nil { 79 | return nil, errors.Join(ErrEncode, err) 80 | } 81 | return values, nil 82 | } 83 | -------------------------------------------------------------------------------- /server/handlers/handlers.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package handlers 5 | 6 | import ( 7 | "log/slog" 8 | "net/http" 9 | 10 | "github.com/a-h/templ" 11 | "github.com/go-chi/chi/v5" 12 | "github.com/go-chi/chi/v5/middleware" 13 | slogctx "github.com/veqryn/slog-context" 14 | 15 | "github.com/joshuar/go-hass-agent/web/templates" 16 | ) 17 | 18 | func StaticFileServerHandler(fs http.FileSystem) http.Handler { 19 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | // Check, if the requested file is existing. 21 | _, err := fs.Open(r.URL.Path) 22 | if err != nil { 23 | // If file is not found, return HTTP 404 error. 24 | http.NotFound(w, r) 25 | return 26 | } 27 | // File is found, return to standard http.FileServer. 28 | http.FileServer(fs).ServeHTTP(w, r) 29 | }) 30 | } 31 | 32 | // routeLogger decorates the logger in the request context with routing information. 33 | func routeLogger(next http.Handler) http.Handler { 34 | return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 35 | ctx := slogctx.With(req.Context(), 36 | slog.String("route", chi.RouteContext(req.Context()).RoutePattern()), 37 | slog.String("method", req.Method), 38 | ) 39 | ctx = slogctx.With(ctx, slog.Group("req", slog.String("id", middleware.GetReqID(ctx)))) 40 | next.ServeHTTP(res, req.WithContext(ctx)) 41 | }) 42 | } 43 | 44 | // renderPage will render the given template as a full page. It handles htmx and non-htmx requests, rendering the 45 | // appropriate full or partial HTML response as appropriate. 46 | func renderPage(template templ.Component, title string) http.Handler { 47 | return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 48 | if template == nil { 49 | // If there is no response, return 204: No Content. 50 | res.WriteHeader(http.StatusNoContent) 51 | return 52 | } 53 | // Write the response template. 54 | if IsHTMX(req) { 55 | if IsHistoryRestoreRequest(req) { 56 | templ.Handler(templates.Page(title, template)).ServeHTTP(res, req) 57 | return 58 | } else if title != "" { 59 | // Update the page title if set. 60 | template = templ.Join(template, templates.SetPageTitle(title)) 61 | } 62 | template = templ.Join(template, templates.UpdateCSRFToken()) 63 | target := templates.FragmentKey(req.Header.Get("HX-Target")) 64 | if target == "" { 65 | target = templates.FragmentContent 66 | } 67 | templ.Handler(template, templ.WithFragments(target)).ServeHTTP(res, req) 68 | } else { 69 | template = templates.Page(title, template) 70 | err := template.Render(req.Context(), res) 71 | if err != nil { 72 | slogctx.FromCtx(req.Context()).Error("Failed to render page template.", slog.Any("error", err)) 73 | http.Error(res, "Failed to render page template.", http.StatusInternalServerError) 74 | return 75 | } 76 | } 77 | }) 78 | } 79 | 80 | // renderPartial will render the given template, optionally updating the page title if one is given. 81 | func renderPartial(template templ.Component) http.Handler { 82 | return templ.Handler(templ.Join(template, templates.UpdateCSRFToken())) 83 | } 84 | 85 | func IsHTMX(req *http.Request) bool { 86 | return req.Header.Get("HX-Request") == "true" 87 | } 88 | 89 | func IsHistoryRestoreRequest(req *http.Request) bool { 90 | return req.Header.Get("HX-History-Restore-Request") == "true" 91 | } 92 | -------------------------------------------------------------------------------- /server/handlers/landing.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package handlers 5 | 6 | import ( 7 | "net/http" 8 | 9 | "github.com/justinas/alice" 10 | 11 | "github.com/joshuar/go-hass-agent/agent" 12 | ) 13 | 14 | func Landing(agent *agent.Agent) http.HandlerFunc { 15 | return alice.New( 16 | routeLogger, 17 | ).ThenFunc(func(res http.ResponseWriter, req *http.Request) { 18 | if agent.IsRegistered() { 19 | res.WriteHeader(http.StatusNotImplemented) 20 | } else { 21 | http.Redirect(res, req, "/register", http.StatusTemporaryRedirect) 22 | } 23 | }).ServeHTTP 24 | } 25 | -------------------------------------------------------------------------------- /server/handlers/preferences.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package handlers 5 | 6 | import ( 7 | "net/http" 8 | 9 | "github.com/a-h/templ" 10 | "github.com/justinas/alice" 11 | 12 | "github.com/joshuar/go-hass-agent/agent/workers/mqtt" 13 | "github.com/joshuar/go-hass-agent/config" 14 | "github.com/joshuar/go-hass-agent/models" 15 | "github.com/joshuar/go-hass-agent/server/forms" 16 | "github.com/joshuar/go-hass-agent/web/templates" 17 | ) 18 | 19 | // ShowPreferences handles showing a form for editing the agent preferences. 20 | func ShowPreferences() http.HandlerFunc { 21 | return alice.New( 22 | routeLogger, 23 | ).ThenFunc(func(res http.ResponseWriter, req *http.Request) { 24 | prefs := templates.NewPreferences() 25 | err := config.Load(mqtt.ConfigPrefix, prefs.MQTT) 26 | if err != nil { 27 | template := templ.Join( 28 | templates.PreferencesForm(prefs), 29 | templates.Notification(models.NewErrorMessage("Error retrieving preferences.", err.Error()))) 30 | renderPartial(template).ServeHTTP(res, req) 31 | } 32 | renderPage(templates.PreferencesForm(prefs), "Preferences - Go Hass Agent").ServeHTTP(res, req) 33 | }).ServeHTTP 34 | } 35 | 36 | // SavePreferences handles extracting the new preferences from the request and saving them to the configuration file. 37 | func SavePreferences() http.HandlerFunc { 38 | return alice.New( 39 | routeLogger, 40 | ).ThenFunc(func(res http.ResponseWriter, req *http.Request) { 41 | prefs, valid, err := forms.DecodeForm[*templates.Preferences](req) 42 | if err != nil || !valid { 43 | template := templ.Join( 44 | templates.PreferencesForm(prefs), 45 | templates.Notification(models.NewErrorMessage("Invalid details.", err.Error()))) 46 | renderPartial(template).ServeHTTP(res, req) 47 | return 48 | } 49 | err = config.Save(mqtt.ConfigPrefix, prefs.MQTT) 50 | if err != nil { 51 | template := templ.Join( 52 | templates.PreferencesForm(prefs), 53 | templates.Notification(models.NewErrorMessage("Failed to save preferences.", err.Error()))) 54 | renderPartial(template).ServeHTTP(res, req) 55 | } 56 | template := templ.Join( 57 | templates.PreferencesForm(prefs), 58 | templates.Notification( 59 | models.NewSuccessMessage("Preferences saved.", "Remember to restart the agent to use the new settings."))) 60 | renderPartial(template).ServeHTTP(res, req) 61 | }).ServeHTTP 62 | } 63 | -------------------------------------------------------------------------------- /server/middlewares/csrf.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package middlewares 5 | 6 | import ( 7 | "net/http" 8 | 9 | "github.com/justinas/nosurf" 10 | 11 | "github.com/joshuar/go-hass-agent/models" 12 | ) 13 | 14 | // SaveCSRFToken will save a new CSRF token for this request. 15 | func SaveCSRFToken(next http.Handler) http.Handler { 16 | return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 17 | next.ServeHTTP(res, req.WithContext(models.CSRFTokenToCtx(req.Context(), nosurf.Token(req)))) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /server/middlewares/htmx.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package middlewares 5 | 6 | import ( 7 | "net/http" 8 | 9 | "github.com/joshuar/go-hass-agent/server/handlers" 10 | ) 11 | 12 | // SetupHTMX middleware performs general setup for serving htmx-powered content. 13 | func SetupHTMX(next http.Handler) http.Handler { 14 | return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 15 | res.Header().Add("Vary", "HX-Request") 16 | res.Header().Add("Vary", "HX-History-Restore-Request") 17 | next.ServeHTTP(res, req) 18 | }) 19 | } 20 | 21 | // RequireHTMX middleware will only pass control to the next handler if the request is htmx powered. If not, it will 22 | // return 403: Forbidden response. 23 | func RequireHTMX(next http.Handler) http.Handler { 24 | return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 25 | if !handlers.IsHTMX(req) { 26 | http.Error(res, "HTMX Required", http.StatusForbidden) 27 | return 28 | } 29 | next.ServeHTTP(res, req) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /web/assets/htmx.js: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | import htmx from "htmx.org"; 5 | window.htmx = htmx 6 | -------------------------------------------------------------------------------- /web/assets/scripts.js: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | // htmx 5 | import 'htmx.org' 6 | import './htmx.js' 7 | // hyperscript 8 | import _hyperscript from 'hyperscript.org/dist/_hyperscript.js' 9 | _hyperscript.browserInit() 10 | -------------------------------------------------------------------------------- /web/assets/styles.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Joshua Rich . 3 | * SPDX-License-Identifier: MIT 4 | */ 5 | 6 | @import "tailwindcss" source("../templates/**/*.{html,templ}"); 7 | @plugin "@tailwindcss/typography"; 8 | 9 | @plugin 'daisyui' { 10 | themes: 11 | light, 12 | dark, 13 | synthwave, 14 | lofi, 15 | black, 16 | dracula --prefersdark, 17 | business, 18 | night, 19 | dim, 20 | nord --default; 21 | } 22 | 23 | /* Inter font styling */ 24 | /* https://tailwindcss.com/plus/ui-blocks/documentation#add-the-inter-font-family */ 25 | /* https://rsms.me/inter/#usage */ 26 | :root { 27 | font-family: Inter, sans-serif; 28 | font-feature-settings: 29 | "liga" 1, 30 | "calt" 1, 31 | "cv02" 1, 32 | "cv03" 1, 33 | "cv04" 1, 34 | "cv11" 1; 35 | --sidebar-width: var(--w-sm); 36 | } 37 | 38 | @supports (font-variation-settings: normal) { 39 | :root { 40 | font-family: InterVariable, sans-serif; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /web/content/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/favicon.ico -------------------------------------------------------------------------------- /web/content/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/favicon.png -------------------------------------------------------------------------------- /web/content/fonts/Inter-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-Black.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-BlackItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-Bold.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-BoldItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-ExtraBold.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-ExtraBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-ExtraBoldItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-ExtraLight.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-ExtraLight.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-ExtraLightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-ExtraLightItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-Italic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-Light.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-LightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-LightItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-Medium.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-MediumItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-MediumItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-Regular.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-SemiBold.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-SemiBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-SemiBoldItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-Thin.woff2 -------------------------------------------------------------------------------- /web/content/fonts/Inter-ThinItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/Inter-ThinItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-Black.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-BlackItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-Bold.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-BoldItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-ExtraBold.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-ExtraBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-ExtraBoldItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-ExtraLight.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-ExtraLight.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-ExtraLightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-ExtraLightItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-Italic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-Light.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-LightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-LightItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-Medium.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-MediumItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-MediumItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-Regular.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-SemiBold.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-SemiBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-SemiBoldItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-Thin.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterDisplay-ThinItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterDisplay-ThinItalic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterVariable-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterVariable-Italic.woff2 -------------------------------------------------------------------------------- /web/content/fonts/InterVariable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/fonts/InterVariable.woff2 -------------------------------------------------------------------------------- /web/content/go-hass-agent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuar/go-hass-agent/c94c7fed34da9748469edff3a9ffa12be8653be7/web/content/go-hass-agent.png -------------------------------------------------------------------------------- /web/content/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Go Hass Agent", 3 | "short_name": "Go-Hass-Agent", 4 | "description": "A Home Assistant, native app for desktop/laptop devices.", 5 | "background_color": "#FEFEF5", 6 | "theme_color": "#FEFEF5", 7 | "display": "standalone", 8 | "orientation": "portrait", 9 | "start_url": ".", 10 | "icons": [ 11 | { 12 | "src": "manifest-touch-icon.svg", 13 | "type": "image/svg+xml", 14 | "sizes": "any" 15 | } 16 | ], 17 | "screenshots": [ 18 | { 19 | "src": "manifest-desktop-screenshot.jpg", 20 | "sizes": "1280x720", 21 | "type": "image/jpeg", 22 | "form_factor": "wide", 23 | "label": "Desktop homescreen of My PWA Project" 24 | }, 25 | { 26 | "src": "manifest-mobile-screenshot.jpg", 27 | "sizes": "720x1280", 28 | "type": "image/jpeg", 29 | "form_factor": "narrow", 30 | "label": "Mobile homescreen of My PWA Project" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /web/templates/page.templ: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package templates 5 | 6 | import "github.com/joshuar/go-hass-agent/models" 7 | 8 | // SetPageTitle sets (or updates) the page's tag. If the title is an empty string, a default title will 9 | // be set instead. 10 | templ SetPageTitle(title string) { 11 | if title != "" { 12 | { title } 13 | } else { 14 | Go Hass Agent 15 | } 16 | } 17 | 18 | // Page renders a full HTML page with the given body content and title. 19 | templ Page(title string, body templ.Component) { 20 | 21 | 22 | 23 | 24 | // https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Viewport_meta_element#interactive-widget 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | // Site-wide htmx config. 36 | 53 | @SetPageTitle(title) 54 | 55 | 59 | 60 |
61 |
62 |
63 |
64 | @body 65 |
66 | 67 | 68 | } 69 | -------------------------------------------------------------------------------- /web/templates/templates.templ: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Joshua Rich . 2 | // SPDX-License-Identifier: MIT 3 | 4 | package templates 5 | 6 | import "github.com/joshuar/go-hass-agent/models" 7 | 8 | const ( 9 | FragmentContent FragmentKey = "content" 10 | ) 11 | 12 | type FragmentKey string 13 | 14 | type TemplatesCtxKey string 15 | 16 | templ UpdateCSRFToken() { 17 | 18 | } 19 | -------------------------------------------------------------------------------- /web/templates/templates_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.943 4 | // Copyright 2025 Joshua Rich . 5 | 6 | // SPDX-License-Identifier: MIT 7 | 8 | package templates 9 | 10 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 11 | 12 | import "github.com/a-h/templ" 13 | import templruntime "github.com/a-h/templ/runtime" 14 | 15 | import "github.com/joshuar/go-hass-agent/models" 16 | 17 | const ( 18 | FragmentContent FragmentKey = "content" 19 | ) 20 | 21 | type FragmentKey string 22 | 23 | type TemplatesCtxKey string 24 | 25 | func UpdateCSRFToken() templ.Component { 26 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 27 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 28 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 29 | return templ_7745c5c3_CtxErr 30 | } 31 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 32 | if !templ_7745c5c3_IsBuffer { 33 | defer func() { 34 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 35 | if templ_7745c5c3_Err == nil { 36 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 37 | } 38 | }() 39 | } 40 | ctx = templ.InitializeContext(ctx) 41 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 42 | if templ_7745c5c3_Var1 == nil { 43 | templ_7745c5c3_Var1 = templ.NopComponent 44 | } 45 | ctx = templ.ClearChildren(ctx) 46 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") 60 | if templ_7745c5c3_Err != nil { 61 | return templ_7745c5c3_Err 62 | } 63 | return nil 64 | }) 65 | } 66 | 67 | var _ = templruntime.GeneratedTemplate 68 | --------------------------------------------------------------------------------