-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.go
More file actions
210 lines (188 loc) · 6.41 KB
/
Copy pathmain.go
File metadata and controls
210 lines (188 loc) · 6.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
// Command retouch-homekit is a ReTouch plugin that exposes the speaker to Apple
// Home over HomeKit (HAP). It is a separate binary — its own module and its own
// dependency on github.com/brutella/hap — so the core ReTouch binary stays
// stdlib-only and HomeKit is opt-in: installed and supervised by ReTouch's plugin
// host, not built in.
//
// ReTouch launches it with --speaker-host/--config-dir/--listen/--host-url,
// reverse-proxies /api/plugins/homekit/… to the loopback --listen port, and reads
// the served /manifest to render the settings UI generically. The HAP server
// itself listens on its own LAN port (:51827) for the Home app, independent of the
// proxied config API. The plugin exits on stdin EOF (ReTouch closes it on the way
// down) so it never outlives its host.
package main
import (
"context"
"encoding/json"
"flag"
"io"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"log/slog"
"github.com/stein155/retouch-homekit/speaker"
)
// version is set at build time via -ldflags "-X main.version=<tag>".
var version = "dev"
func main() {
speakerHost := flag.String("speaker-host", "127.0.0.1", "speaker host[:port] for the SoundTouch API")
configDir := flag.String("config-dir", ".", "directory for persisted state (HAP pairing)")
listen := flag.String("listen", "127.0.0.1:0", "loopback address for the proxied config API")
_ = flag.String("host-url", "", "ReTouch base URL (unused)") // accepted so flag parsing succeeds
hapAddr := flag.String("homekit-addr", ":51827", "TCP listen address for the HomeKit/HAP server")
pin := flag.String("homekit-pin", "", "8-digit setup code (default: derived from the device id)")
name := flag.String("homekit-name", "", "accessory name (default: the speaker's name)")
flag.Parse()
log := slog.New(slog.NewTextHandler(os.Stderr, nil)).With("comp", "homekit")
log.Info("retouch-homekit starting", "version", version)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go stopOnSignal(cancel, log)
go stopOnStdinEOF(cancel, log)
bc := speaker.New(*speakerHost)
info := awaitInfo(ctx, bc, log)
if info == nil {
return // ctx cancelled before the speaker answered
}
cfg := Config{
Pin: *pin,
Name: *name,
Addr: *hapAddr,
StorageDir: filepath.Join(*configDir, "hap"),
}
mgr := NewManager(ctx, bc, info, cfg, log)
mgr.Start()
defer mgr.Stop()
srv := &http.Server{
Addr: *listen,
Handler: newAPI(mgr),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
<-ctx.Done()
sh, c := context.WithTimeout(context.Background(), 3*time.Second)
defer c()
_ = srv.Shutdown(sh)
}()
log.Info("homekit plugin ready", "listen", *listen, "hap", *hapAddr, "code", mgr.Code())
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("config API stopped", "err", err)
}
}
// awaitInfo reads the speaker identity, retrying until it answers or ctx ends: the
// plugin can start before the speaker's local API is reachable.
func awaitInfo(ctx context.Context, bc *speaker.Client, log *slog.Logger) *speaker.Info {
for {
c, cancel := context.WithTimeout(ctx, 4*time.Second)
info, err := bc.Info(c)
cancel()
if err == nil {
return info
}
log.Warn("speaker not reachable yet; retrying", "err", err)
select {
case <-ctx.Done():
return nil
case <-time.After(2 * time.Second):
}
}
}
func stopOnSignal(cancel context.CancelFunc, log *slog.Logger) {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
<-ch
log.Info("signal received; shutting down")
cancel()
}
// stopOnStdinEOF exits when ReTouch closes the plugin's stdin — which the kernel
// does when ReTouch itself dies, so the plugin never survives as an orphan.
func stopOnStdinEOF(cancel context.CancelFunc, log *slog.Logger) {
_, _ = io.Copy(io.Discard, os.Stdin)
log.Info("stdin closed; shutting down")
cancel()
}
// ---- config API rendered by ReTouch's generic plugin UI ------------------
type manifest struct {
Title string `json:"title"`
Status *status `json:"status,omitempty"`
Sections []section `json:"sections"`
}
type status struct {
Level string `json:"level"` // ok | warn | err — drives the status dot
Text string `json:"text"`
}
type section struct {
Title string `json:"title,omitempty"`
Text string `json:"text,omitempty"`
Fields []field `json:"fields,omitempty"`
Actions []action `json:"actions,omitempty"`
}
type field struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // "qr" — a scannable code with its digits
Value string `json:"value"` // the payload the QR encodes (X-HM URI)
Code string `json:"code,omitempty"` // the human-typeable setup code
}
type action struct {
ID string `json:"id"`
Label string `json:"label"`
Style string `json:"style,omitempty"`
Confirm string `json:"confirm,omitempty"`
}
func newAPI(mgr *Manager) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /manifest", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, buildManifest(mgr, r.URL.Query().Get("lang")))
})
mux.HandleFunc("POST /action/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") == "reset" {
if err := mgr.Reset(); err != nil {
http.Error(w, "reset failed: "+err.Error(), http.StatusInternalServerError)
return
}
}
writeJSON(w, buildManifest(mgr, r.URL.Query().Get("lang")))
})
return mux
}
// buildManifest renders the settings UI in the language ReTouch forwards as ?lang
// (falling back to English). The title stays "Apple Home" — a brand name, and
// ReTouch already shows its own localised catalog title in the sheet header.
func buildManifest(mgr *Manager, lang string) manifest {
tx := textFor(lang)
return manifest{
Title: "Apple Home",
Status: &status{Level: "ok", Text: tx.Status},
Sections: []section{
{
Text: tx.SectionText,
Fields: []field{{
Key: "pair",
Label: tx.ScanLabel,
Type: "qr",
Value: mgr.SetupURI(),
Code: mgr.Code(),
}},
},
{
Actions: []action{{
ID: "reset",
Label: tx.ResetLabel,
Style: "danger",
Confirm: tx.ResetConfirm,
}},
},
},
}
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}