Files
Home-Assistant-Watchdog/main.go
T
phil 5e258b23e0 Added files
Add ha-watchdog v1.1.0

Go tool that lists Home Assistant apps, lets you choose which to
watch, and starts them again if they stop.

- Interactive menu to list apps (numbered, with state, version and
  available updates) and add or remove them from a watchlist
- Headless -daemon mode that monitors the saved watchlist
- Persistent JSON watchlist
- Retry cap (default 5 starts per 10 minutes) with a cooldown between
  attempts; gives up on apps that keep failing
- Persistent notifications on restart and when giving up
- Skips apps that are unavailable, detached or not installed
- Backends: Supervisor API, local ha CLI, and remote ha CLI over SSH
  for running from a separate monitoring machine
- Version and build date injected via ldflags (see Makefile)
- README covering build, install, remote monitoring and systemd setup
2026-11-09 00:25:21 +00:00

708 lines
18 KiB
Go

// ha-watchdog: list Home Assistant apps, pick which to watch, auto-restart them.
//
// Backend selection:
// - SUPERVISOR_TOKEN set -> Supervisor REST API (use when running as an app)
// - otherwise -> shells out to the `ha` CLI
//
// Usage:
// ha-watchdog interactive menu
// ha-watchdog -daemon headless: monitor the saved watchlist until killed
// ha-watchdog -version print version and build date
//
// Version info is injected at build time (see Makefile):
// -ldflags "-X main.version=1.1.0 -X main.buildDate=2026-11-09T12:00:00Z"
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"regexp"
"runtime"
"runtime/debug"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
// ---------- Version info ----------
var (
version = "1.1.0"
buildDate = "unknown" // set via -ldflags; falls back to VCS time if available
)
func versionString() string {
bd := buildDate
if bd == "unknown" {
if info, ok := debug.ReadBuildInfo(); ok {
for _, s := range info.Settings {
if s.Key == "vcs.time" && s.Value != "" {
bd = s.Value
}
}
}
}
return fmt.Sprintf("ha-watchdog %s (built %s, %s %s/%s)",
version, bd, runtime.Version(), runtime.GOOS, runtime.GOARCH)
}
// ---------- Types ----------
type Addon struct {
Slug string `json:"slug"`
Name string `json:"name"`
State string `json:"state"`
Version string `json:"version"`
VersionLatest string `json:"version_latest"`
UpdateAvailable bool `json:"update_available"`
Available bool `json:"available"`
Detached bool `json:"detached"`
}
type envelope struct {
Result string `json:"result"`
Message string `json:"message"`
Data struct {
Addons []Addon `json:"addons"`
} `json:"data"`
}
type Backend interface {
List() ([]Addon, error)
Start(slug string) error
// Notify raises a Home Assistant persistent notification.
// Backends that can't do this return an error; callers just log it.
Notify(title, message string) error
}
// ---------- Supervisor API backend ----------
type apiBackend struct {
base, token string
client *http.Client
}
func (a *apiBackend) do(method, path string, payload []byte) ([]byte, error) {
var body io.Reader
if payload != nil {
body = bytes.NewReader(payload)
}
req, err := http.NewRequest(method, a.base+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+a.token)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := a.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return out, fmt.Errorf("supervisor returned %s: %s", resp.Status, strings.TrimSpace(string(out)))
}
return out, nil
}
func (a *apiBackend) List() ([]Addon, error) {
out, err := a.do("GET", "/addons", nil)
if err != nil {
return nil, err
}
return parseList(out)
}
func (a *apiBackend) Start(slug string) error {
_, err := a.do("POST", "/addons/"+slug+"/start", nil)
return err
}
// Notify calls the HA Core API through the Supervisor proxy.
// Requires `homeassistant_api: true` in the app's config.yaml.
func (a *apiBackend) Notify(title, message string) error {
payload, _ := json.Marshal(map[string]string{
"title": title,
"message": message,
"notification_id": "ha_watchdog",
})
_, err := a.do("POST", "/core/api/services/persistent_notification/create", payload)
return err
}
// ---------- `ha` CLI backend ----------
type cliBackend struct{}
func (cliBackend) List() ([]Addon, error) {
out, err := exec.Command("ha", "addons", "--raw-json").Output()
if err != nil {
return nil, fmt.Errorf("ha addons: %w", err)
}
return parseList(out)
}
func (cliBackend) Start(slug string) error {
out, err := exec.Command("ha", "addons", "start", slug).CombinedOutput()
if err != nil {
return fmt.Errorf("ha addons start %s: %w: %s", slug, err, strings.TrimSpace(string(out)))
}
return nil
}
func (cliBackend) Notify(title, message string) error {
return errors.New("notifications need the Supervisor API (run as an HA app with homeassistant_api: true)")
}
func parseList(b []byte) ([]Addon, error) {
var env envelope
if err := json.Unmarshal(b, &env); err != nil {
return nil, err
}
if env.Result != "ok" {
return nil, errors.New("supervisor error: " + env.Message)
}
sort.Slice(env.Data.Addons, func(i, j int) bool {
return strings.ToLower(env.Data.Addons[i].Name) < strings.ToLower(env.Data.Addons[j].Name)
})
return env.Data.Addons, nil
}
// ---------- SSH (remote) backend ----------
//
// Runs the `ha` CLI on the Home Assistant host over SSH, so the watchdog can
// live on a separate monitoring machine. Notifications (optional) go through
// the normal Home Assistant REST API using a long-lived access token.
var slugRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
type sshBackend struct {
target string // user@host
port int
key string // optional identity file
haURL string // optional, e.g. http://homeassistant.local:8123
haToken string // optional long-lived access token
client *http.Client
}
func (s *sshBackend) run(remoteCmd string) ([]byte, error) {
args := []string{"-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-p", strconv.Itoa(s.port)}
if s.key != "" {
args = append(args, "-i", s.key)
}
args = append(args, s.target, remoteCmd)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ssh", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("ssh %s %q: %w: %s", s.target, remoteCmd, err, strings.TrimSpace(stderr.String()))
}
return out, nil
}
func (s *sshBackend) List() ([]Addon, error) {
out, err := s.run("ha addons --raw-json")
if err != nil {
return nil, err
}
return parseList(out)
}
func (s *sshBackend) Start(slug string) error {
if !slugRe.MatchString(slug) {
return fmt.Errorf("refusing to run with unexpected slug %q", slug)
}
_, err := s.run("ha addons start " + slug)
return err
}
func (s *sshBackend) Notify(title, message string) error {
if s.haURL == "" || s.haToken == "" {
return errors.New("remote notifications need -ha-url and -ha-token")
}
payload, _ := json.Marshal(map[string]string{
"title": title,
"message": message,
"notification_id": "ha_watchdog",
})
req, err := http.NewRequest("POST", strings.TrimRight(s.haURL, "/")+"/api/services/persistent_notification/create", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+s.haToken)
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("home assistant returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
return nil
}
type backendConfig struct {
sshTarget string
sshPort int
sshKey string
haURL string
haToken string
}
// newBackend picks a backend and returns it with a short description for logs.
func newBackend(c backendConfig) (Backend, string) {
if c.sshTarget != "" {
return &sshBackend{
target: c.sshTarget, port: c.sshPort, key: c.sshKey,
haURL: c.haURL, haToken: c.haToken,
client: &http.Client{Timeout: 30 * time.Second},
}, "remote over SSH (" + c.sshTarget + ")"
}
if tok := os.Getenv("SUPERVISOR_TOKEN"); tok != "" {
return &apiBackend{base: "http://supervisor", token: tok, client: &http.Client{Timeout: 60 * time.Second}},
"Supervisor API (local)"
}
return cliBackend{}, "ha CLI (local)"
}
// ---------- Watchlist (persisted) ----------
type Watchlist struct {
mu sync.Mutex
path string
slugs map[string]bool
}
func loadWatchlist(path string) *Watchlist {
w := &Watchlist{path: path, slugs: map[string]bool{}}
data, err := os.ReadFile(path)
if err != nil {
return w
}
var list []string
if json.Unmarshal(data, &list) == nil {
for _, s := range list {
w.slugs[s] = true
}
}
return w
}
func (w *Watchlist) saveLocked() {
list := make([]string, 0, len(w.slugs))
for s := range w.slugs {
list = append(list, s)
}
sort.Strings(list)
data, _ := json.MarshalIndent(list, "", " ")
if err := os.WriteFile(w.path, data, 0o644); err != nil {
log.Printf("warning: could not save watchlist: %v", err)
}
}
func (w *Watchlist) Add(slug string) {
w.mu.Lock()
defer w.mu.Unlock()
w.slugs[slug] = true
w.saveLocked()
}
func (w *Watchlist) Remove(slug string) {
w.mu.Lock()
defer w.mu.Unlock()
delete(w.slugs, slug)
w.saveLocked()
}
func (w *Watchlist) Has(slug string) bool {
w.mu.Lock()
defer w.mu.Unlock()
return w.slugs[slug]
}
func (w *Watchlist) Slugs() []string {
w.mu.Lock()
defer w.mu.Unlock()
list := make([]string, 0, len(w.slugs))
for s := range w.slugs {
list = append(list, s)
}
sort.Strings(list)
return list
}
// ---------- Monitor ----------
type Monitor struct {
be Backend
wl *Watchlist
interval time.Duration
cooldown time.Duration // minimum gap between start attempts for one app
maxRetries int // give up after this many attempts within retryWindow
window time.Duration
mu sync.Mutex
attempts map[string][]time.Time // recent start attempts per slug
gaveUp map[string]bool // apps we've stopped retrying
skipped map[string]bool // apps we've already logged as unstartable
}
func newMonitor(be Backend, wl *Watchlist, interval, cooldown time.Duration, maxRetries int, window time.Duration) *Monitor {
return &Monitor{
be: be, wl: wl, interval: interval, cooldown: cooldown,
maxRetries: maxRetries, window: window,
attempts: map[string][]time.Time{},
gaveUp: map[string]bool{},
skipped: map[string]bool{},
}
}
func (m *Monitor) notify(title, msg string) {
if err := m.be.Notify(title, msg); err != nil {
log.Printf("notify: %v", err)
}
}
// recentAttempts drops expired attempts and returns the remaining list. Caller holds m.mu.
func (m *Monitor) recentAttempts(slug string) []time.Time {
cutoff := time.Now().Add(-m.window)
var keep []time.Time
for _, t := range m.attempts[slug] {
if t.After(cutoff) {
keep = append(keep, t)
}
}
m.attempts[slug] = keep
return keep
}
func (m *Monitor) check() {
addons, err := m.be.List()
if err != nil {
log.Printf("monitor: list failed: %v", err)
return
}
state := map[string]Addon{}
for _, a := range addons {
state[a.Slug] = a
}
for _, slug := range m.wl.Slugs() {
a, ok := state[slug]
if !ok {
m.mu.Lock()
first := !m.skipped[slug]
m.skipped[slug] = true
m.mu.Unlock()
if first {
log.Printf("monitor: %s is on the watchlist but not installed, skipping", slug)
}
continue
}
// Can't start these, so don't try.
if !a.Available || a.Detached {
m.mu.Lock()
first := !m.skipped[slug]
m.skipped[slug] = true
m.mu.Unlock()
if first {
log.Printf("monitor: %s (%s) is unavailable or detached, skipping", a.Name, slug)
}
continue
}
m.mu.Lock()
delete(m.skipped, slug)
if a.State == "started" {
// Recovered. Allow a future give-up notification, but keep the attempt
// history so a flapping app still trips the cap quickly.
delete(m.gaveUp, slug)
m.mu.Unlock()
continue
}
if m.gaveUp[slug] {
m.mu.Unlock()
continue
}
recent := m.recentAttempts(slug)
if len(recent) >= m.maxRetries {
m.gaveUp[slug] = true
m.mu.Unlock()
msg := fmt.Sprintf("%s (%s) is %q and failed to stay up after %d start attempts in %s. "+
"Watchdog has stopped retrying. Check the app's logs.",
a.Name, slug, a.State, m.maxRetries, m.window)
log.Printf("monitor: GIVING UP on %s: %s", slug, msg)
m.notify("Watchdog gave up on "+a.Name, msg)
continue
}
if len(recent) > 0 && time.Since(recent[len(recent)-1]) < m.cooldown {
m.mu.Unlock()
continue
}
m.attempts[slug] = append(recent, time.Now())
n := len(m.attempts[slug])
m.mu.Unlock()
log.Printf("monitor: %s (%s) is %q, starting it (attempt %d/%d)", a.Name, slug, a.State, n, m.maxRetries)
if err := m.be.Start(slug); err != nil {
log.Printf("monitor: start %s failed: %v", slug, err)
} else {
m.notify("Watchdog restarted "+a.Name,
fmt.Sprintf("%s was %q and has been started again (attempt %d/%d).", a.Name, a.State, n, m.maxRetries))
}
}
}
// Reset clears give-up state and attempt history for all apps.
func (m *Monitor) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.attempts = map[string][]time.Time{}
m.gaveUp = map[string]bool{}
}
// Status returns slugs the monitor has given up on.
func (m *Monitor) GaveUp() []string {
m.mu.Lock()
defer m.mu.Unlock()
var out []string
for s := range m.gaveUp {
out = append(out, s)
}
sort.Strings(out)
return out
}
func (m *Monitor) Run(ctx context.Context) {
m.check()
t := time.NewTicker(m.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
m.check()
}
}
}
// ---------- Interactive UI ----------
func listApps(be Backend, wl *Watchlist) []Addon {
addons, err := be.List()
if err != nil {
fmt.Println("Error:", err)
return nil
}
fmt.Printf("\n%-4s %-30s %-9s %-16s %-16s %-7s %s\n", "#", "Name", "State", "Version", "Update", "Watched", "Notes")
for i, a := range addons {
upd := ""
if a.UpdateAvailable {
upd = "-> " + a.VersionLatest
}
watched := ""
if wl.Has(a.Slug) {
watched = "yes"
}
var notes []string
if !a.Available {
notes = append(notes, "unavailable")
}
if a.Detached {
notes = append(notes, "detached")
}
fmt.Printf("%-4d %-30.30s %-9s %-16.16s %-16.16s %-7s %s\n",
i+1, a.Name, a.State, a.Version, upd, watched, strings.Join(notes, ","))
}
return addons
}
func parseNumbers(s string, max int) []int {
var out []int
for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' }) {
n, err := strconv.Atoi(f)
if err != nil || n < 1 || n > max {
fmt.Printf("Ignoring %q\n", f)
continue
}
out = append(out, n-1)
}
return out
}
func interactive(be Backend, wl *Watchlist, m *Monitor) {
in := bufio.NewScanner(os.Stdin)
prompt := func(msg string) string {
fmt.Print(msg)
if !in.Scan() {
os.Exit(0)
}
return strings.TrimSpace(in.Text())
}
var cancel context.CancelFunc
running := func() bool { return cancel != nil }
for {
status := "OFF"
if running() {
status = "ON"
}
fmt.Printf("\n=== HA App Watchdog %s | monitor: %s | %d watched ===\n", version, status, len(wl.Slugs()))
if g := m.GaveUp(); len(g) > 0 {
fmt.Printf("!! Gave up on: %s (use option 6 to reset)\n", strings.Join(g, ", "))
}
fmt.Println("1) List installed apps")
fmt.Println("2) Add apps to watchlist")
fmt.Println("3) Remove apps from watchlist")
fmt.Println("4) Show watchlist")
fmt.Println("5) Start/stop monitoring")
fmt.Println("6) Reset retry counters / give-up state")
fmt.Println("v) Version info")
fmt.Println("q) Quit")
switch prompt("> ") {
case "1":
listApps(be, wl)
case "2":
addons := listApps(be, wl)
if addons == nil {
continue
}
for _, i := range parseNumbers(prompt("Numbers to add (e.g. 1,4,7): "), len(addons)) {
wl.Add(addons[i].Slug)
fmt.Println("Added", addons[i].Name)
}
case "3":
slugs := wl.Slugs()
if len(slugs) == 0 {
fmt.Println("Watchlist is empty.")
continue
}
for i, s := range slugs {
fmt.Printf("%d) %s\n", i+1, s)
}
for _, i := range parseNumbers(prompt("Numbers to remove: "), len(slugs)) {
wl.Remove(slugs[i])
fmt.Println("Removed", slugs[i])
}
case "4":
slugs := wl.Slugs()
if len(slugs) == 0 {
fmt.Println("Watchlist is empty.")
}
for i, s := range slugs {
fmt.Printf("%d) %s\n", i+1, s)
}
case "5":
if running() {
cancel()
cancel = nil
fmt.Println("Monitoring stopped.")
} else {
var ctx context.Context
ctx, cancel = context.WithCancel(context.Background())
go m.Run(ctx)
fmt.Printf("Monitoring started (every %s, max %d starts per %s). Logs print here.\n",
m.interval, m.maxRetries, m.window)
}
case "6":
m.Reset()
fmt.Println("Retry counters and give-up state cleared.")
case "v", "V":
fmt.Println(versionString())
case "q", "Q":
if running() {
cancel()
}
return
}
}
}
// ---------- main ----------
func defaultWatchlistPath() string {
if p := os.Getenv("WATCHDOG_FILE"); p != "" {
return p
}
if st, err := os.Stat("/data"); err == nil && st.IsDir() {
return "/data/watchlist.json" // persistent storage inside an HA app
}
return "watchlist.json"
}
func main() {
daemon := flag.Bool("daemon", false, "run headless, monitoring the saved watchlist")
showVersion := flag.Bool("version", false, "print version and build date, then exit")
interval := flag.Duration("interval", 30*time.Second, "how often to check app state")
cooldown := flag.Duration("cooldown", 60*time.Second, "minimum time between start attempts for one app")
maxRetries := flag.Int("max-retries", 5, "give up on an app after this many start attempts within -retry-window")
window := flag.Duration("retry-window", 10*time.Minute, "time window for counting start attempts")
file := flag.String("file", defaultWatchlistPath(), "watchlist file path")
sshTarget := flag.String("ssh", "", "monitor a remote Home Assistant host over SSH, e.g. [email protected]")
sshPort := flag.Int("ssh-port", 22, "SSH port on the Home Assistant host")
sshKey := flag.String("ssh-key", "", "SSH identity file (private key) to use")
haURL := flag.String("ha-url", "", "Home Assistant URL for remote notifications, e.g. http://homeassistant.local:8123")
haToken := flag.String("ha-token", os.Getenv("HA_TOKEN"), "long-lived access token for remote notifications (or set HA_TOKEN)")
flag.Parse()
if *showVersion {
fmt.Println(versionString())
return
}
be, backendDesc := newBackend(backendConfig{
sshTarget: *sshTarget, sshPort: *sshPort, sshKey: *sshKey,
haURL: *haURL, haToken: *haToken,
})
wl := loadWatchlist(*file)
m := newMonitor(be, wl, *interval, *cooldown, *maxRetries, *window)
if *daemon {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
log.Printf("%s", versionString())
log.Printf("backend: %s", backendDesc)
log.Printf("daemon mode: watching %v every %s (max %d starts per %s)",
wl.Slugs(), *interval, *maxRetries, *window)
m.Run(ctx)
return
}
fmt.Println("Backend:", backendDesc)
interactive(be, wl, m)
}