1
2
Self-hosted power outage tracker for a single Ukrainian address,
3
built to survive the war-driven blackouts. Combines live grid
4
sensor data from Home Assistant with emergency outage
5
announcements from the DTEK utility API.
6
7
Pushes Telegram alerts when power flips or DTEK announces an
8
outage, and serves a live PWA dashboard via Server-Sent Events.
9
Runs on a Raspberry Pi 5, packaged as both a standalone binary
10
and a Home Assistant add-on.
11
12
13
14
┌┐
15
┌─┼┼─┐ grid
16
─┴─┼┼─┴─ ●────●──╮ ╭───────▶
17
││ │ │
18
││ ● ╭───────╮ │
19
─┴┴─ │ │ │─────────╯ telegram
20
├●────●──▶│ pi5 │
21
● │ ▪ ▪ │─────────╮
22
│ ╰───────╯ │
23
╭────────╮ │ │
24
│██████░░├┤ ◀────●─╰ ╰───────▶
25
╰────────╯
26
deye 76% ↑
27
status page
28
30
31
go func() {
32
log().Info("polling started", "interval", pollInterval)
33
ticker := time.NewTicker(pollInterval)
34
defer ticker.Stop()
35
36
for {
37
select {
38
case <-ctx.Done():
39
log().Info("polling stopped")
40
return
41
case <-ticker.C:
42
}
43
44
outage, err := getOutage(ctx, client, dtekBaseURL, region, city, street, building)
45
if err != nil {
46
log().Error("poll failed", "error", err)
47
} else if !prevOutage.Equal(outage) {
48
prevOutage = outage
49
outageUpdates <- outage
50
}
51
}
52
}()
53
54
return prevOutage, nil
55
}
56
57
58
59
60
61
62
63
go func() {
log().Info("polling started", "interval", pollInterval)
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log().Info("polling stopped")
return
case <-ticker.C:
}
outage, err := getOutage(ctx, client, dtekBaseURL, region, city, street, building)
if err != nil {
log().Error("poll failed", "error", err)
} else if !prevOutage.Equal(outage) {
prevOutage = outage
outageUpdates <- outage
}
}
}()
return prevOutage, nil
}
func GetGridState(ctx context.Context, homeAssistantURL, token string) (string, error) {
client := http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", homeAssistantURL, http.NoBody)
if err != nil {
return "", fmt.Errorf("cannot construct HA request: %w", err)
}
req.Header.Add("Authorization", "Bearer "+token)
res, err := client.Do(req) //nolint:gosec // URL is from server config, not user input
if err != nil {
return "", fmt.Errorf("failed to GET grid state: %w", err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
return "", fmt.Errorf("unexpected status code from HA: %d", res.StatusCode)
}
var body struct {
State string `json:"state"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
return "", fmt.Errorf("failed to decode HA response: %w", err)
}
if body.State != "on" && body.State != "off" {
return "", fmt.Errorf("unexpected grid state value: %s", body.State)
}
return body.State, nil
}
func debounce(
timeout time.Duration,
action func(WebhookPayload),
) func(WebhookPayload) {
var mu sync.Mutex
var timer *time.Timer
return func(payload WebhookPayload) {
mu.Lock()
defer mu.Unlock()
if timer != nil {
timer.Stop()
}
timer = time.AfterFunc(timeout, func() {
action(payload)
})
}
}
func (dt datetime) MarshalJSON() ([]byte, error) {
if dt.IsZero() {
return nil, nil
}
return []byte(`"` + dt.Format("15:04 02.01.2006") + `"`), nil
}
func (dt *datetime) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
if s == "" || s == "null" {
return nil
}
parsed, err := time.ParseInLocation("15:04 02.01.2006", s, kyivLocation)
if err != nil {
return err
}
dt.Time = parsed
return nil
}