github.com/choria-io/go-choria@v0.28.1-0.20240416190746-b3bf9c7d5a45/aagent/watchers/homekitwatcher/homekit.go (about) 1 // Copyright (c) 2020-2022, R.I. Pienaar and the Choria Project contributors 2 // 3 // SPDX-License-Identifier: Apache-2.0 4 5 package homekitwatcher 6 7 import ( 8 "context" 9 "crypto/md5" 10 "fmt" 11 "path/filepath" 12 "strings" 13 "sync" 14 "time" 15 16 "github.com/brutella/hc" 17 "github.com/brutella/hc/accessory" 18 "github.com/choria-io/go-choria/aagent/model" 19 "github.com/choria-io/go-choria/aagent/util" 20 "github.com/choria-io/go-choria/aagent/watchers/event" 21 "github.com/choria-io/go-choria/aagent/watchers/watcher" 22 "golang.org/x/text/cases" 23 "golang.org/x/text/language" 24 ) 25 26 type State int 27 28 const ( 29 Unknown State = iota 30 On 31 Off 32 // used to indicate that an external event - rpc or other watcher - initiated a transition 33 OnNoTransition 34 OffNoTransition 35 36 wtype = "homekit" 37 version = "v1" 38 ) 39 40 var stateNames = map[State]string{ 41 Unknown: "unknown", 42 On: "on", 43 Off: "off", 44 } 45 46 type transport interface { 47 Stop() <-chan struct{} 48 Start() 49 } 50 51 type properties struct { 52 SerialNumber string `mapstructure:"serial_number"` 53 Model string 54 Pin string 55 SetupId string `mapstructure:"setup_id"` 56 ShouldOn []string `mapstructure:"on_when"` 57 ShouldOff []string `mapstructure:"off_when"` 58 ShouldDisable []string `mapstructure:"disable_when"` 59 InitialState State `mapstructure:"-"` 60 Path string `mapstructure:"-"` 61 Initial bool 62 } 63 64 type Watcher struct { 65 *watcher.Watcher 66 67 name string 68 machine model.Machine 69 previous State 70 interval time.Duration 71 hkt transport 72 ac *accessory.Switch 73 buttonPress chan State 74 started bool 75 properties *properties 76 mu *sync.Mutex 77 } 78 79 func New(machine model.Machine, name string, states []string, failEvent string, successEvent string, interval string, ai time.Duration, rawprop map[string]any) (any, error) { 80 var err error 81 82 hkw := &Watcher{ 83 name: name, 84 machine: machine, 85 interval: 5 * time.Second, 86 buttonPress: make(chan State, 1), 87 mu: &sync.Mutex{}, 88 properties: &properties{ 89 Model: "Autonomous Agent", 90 ShouldOn: []string{}, 91 ShouldOff: []string{}, 92 ShouldDisable: []string{}, 93 Path: filepath.Join(machine.Directory(), wtype, fmt.Sprintf("%x", md5.Sum([]byte(name)))), 94 }, 95 } 96 97 hkw.Watcher, err = watcher.NewWatcher(name, wtype, ai, states, machine, failEvent, successEvent) 98 if err != nil { 99 return nil, err 100 } 101 102 err = hkw.setProperties(rawprop) 103 if err != nil { 104 return nil, fmt.Errorf("could not set properties: %s", err) 105 } 106 107 return hkw, err 108 } 109 110 func (w *Watcher) Delete() { 111 w.mu.Lock() 112 defer w.mu.Unlock() 113 114 if w.hkt != nil { 115 <-w.hkt.Stop() 116 } 117 } 118 119 func (w *Watcher) Run(ctx context.Context, wg *sync.WaitGroup) { 120 defer wg.Done() 121 122 if w.ShouldWatch() { 123 w.ensureStarted() 124 125 w.Infof("homekit watcher for %s starting in state %s", w.name, stateNames[w.properties.InitialState]) 126 switch w.properties.InitialState { 127 case On: 128 w.buttonPress <- On 129 case Off: 130 w.buttonPress <- Off 131 default: 132 w.Watcher.StateChangeC() <- struct{}{} 133 } 134 } 135 136 for { 137 select { 138 // button call backs 139 case e := <-w.buttonPress: 140 err := w.handleStateChange(e) 141 if err != nil { 142 w.Errorf("Could not handle button %s press: %v", stateNames[e], err) 143 } 144 145 // rpc initiated state changes would trigger this 146 case <-w.Watcher.StateChangeC(): 147 mstate := w.machine.State() 148 149 switch { 150 case w.shouldBeOn(mstate): 151 w.ensureStarted() 152 w.buttonPress <- OnNoTransition 153 154 case w.shouldBeOff(mstate): 155 w.ensureStarted() 156 w.buttonPress <- OffNoTransition 157 158 case w.shouldBeDisabled(mstate): 159 w.ensureStopped() 160 } 161 162 case <-ctx.Done(): 163 w.Infof("Stopping on context interrupt") 164 w.ensureStopped() 165 return 166 } 167 } 168 } 169 170 func (w *Watcher) ensureStopped() { 171 w.mu.Lock() 172 defer w.mu.Unlock() 173 174 if !w.started { 175 return 176 } 177 178 w.Infof("Stopping homekit integration") 179 <-w.hkt.Stop() 180 w.Infof("Homekit integration stopped") 181 182 w.started = false 183 } 184 185 func (w *Watcher) ensureStarted() { 186 w.mu.Lock() 187 defer w.mu.Unlock() 188 189 if w.started { 190 return 191 } 192 193 w.Infof("Starting homekit integration") 194 195 // kind of want to just hk.Start() here but stop kills a context that 196 // start does not recreate so we have to go back to start 197 err := w.startAccessoryUnlocked() 198 if err != nil { 199 w.Errorf("Could not start homekit service: %s", err) 200 return 201 } 202 203 go w.hkt.Start() 204 205 w.started = true 206 } 207 208 func (w *Watcher) shouldBeOff(s string) bool { 209 for _, state := range w.properties.ShouldOff { 210 if state == s { 211 return true 212 } 213 } 214 215 return false 216 } 217 218 func (w *Watcher) shouldBeOn(s string) bool { 219 for _, state := range w.properties.ShouldOn { 220 if state == s { 221 return true 222 } 223 } 224 225 return false 226 } 227 228 func (w *Watcher) shouldBeDisabled(s string) bool { 229 for _, state := range w.properties.ShouldDisable { 230 if state == s { 231 return true 232 } 233 } 234 235 return false 236 } 237 238 func (w *Watcher) handleStateChange(s State) error { 239 if !w.ShouldWatch() { 240 return nil 241 } 242 243 switch s { 244 case On: 245 w.setPreviousState(s) 246 w.NotifyWatcherState(w.CurrentState()) 247 return w.SuccessTransition() 248 249 case OnNoTransition: 250 w.setPreviousState(On) 251 w.ac.Switch.On.SetValue(true) 252 w.NotifyWatcherState(w.CurrentState()) 253 return nil 254 255 case Off: 256 w.setPreviousState(s) 257 w.NotifyWatcherState(w.CurrentState()) 258 return w.FailureTransition() 259 260 case OffNoTransition: 261 w.setPreviousState(Off) 262 w.ac.Switch.On.SetValue(false) 263 w.machine.NotifyWatcherState(w.name, w.CurrentState()) 264 return nil 265 } 266 267 return fmt.Errorf("invalid state change event: %s", stateNames[s]) 268 } 269 270 func (w *Watcher) CurrentState() any { 271 w.mu.Lock() 272 defer w.mu.Unlock() 273 274 s := &StateNotification{ 275 Event: event.New(w.name, wtype, version, w.machine), 276 Path: w.properties.Path, 277 PreviousOutcome: stateNames[w.previous], 278 } 279 280 return s 281 } 282 283 func (w *Watcher) setPreviousState(s State) { 284 w.mu.Lock() 285 defer w.mu.Unlock() 286 287 w.previous = s 288 } 289 290 func (w *Watcher) startAccessoryUnlocked() error { 291 info := accessory.Info{ 292 Name: cases.Title(language.AmericanEnglish).String(strings.Replace(w.name, "_", " ", -1)), 293 SerialNumber: w.properties.SerialNumber, 294 Manufacturer: "Choria", 295 Model: w.properties.Model, 296 FirmwareRevision: w.machine.Version(), 297 } 298 w.ac = accessory.NewSwitch(info) 299 300 t, err := hc.NewIPTransport(hc.Config{Pin: w.properties.Pin, SetupId: w.properties.SetupId, StoragePath: w.properties.Path}, w.ac.Accessory) 301 if err != nil { 302 return err 303 } 304 305 hc.OnTermination(func() { 306 <-t.Stop() 307 }) 308 309 w.ac.Switch.On.OnValueRemoteUpdate(func(new bool) { 310 w.mu.Lock() 311 defer w.mu.Unlock() 312 313 w.Infof("Handling app button press: %v", new) 314 315 if !w.ShouldWatch() { 316 w.Infof("Ignoring event while in %s state", w.machine.State()) 317 // undo the button press 318 w.ac.Switch.On.SetValue(!new) 319 return 320 } 321 322 if new { 323 w.Infof("Setting state to On") 324 w.buttonPress <- On 325 } else { 326 w.Infof("Setting state to Off") 327 w.buttonPress <- Off 328 } 329 }) 330 331 w.ac.Switch.On.SetValue(w.previous == On) 332 333 w.hkt = t 334 335 return nil 336 } 337 338 func (w *Watcher) validate() error { 339 if len(w.properties.Pin) > 0 && len(w.properties.Pin) != 8 { 340 return fmt.Errorf("pin should be 8 characters long") 341 } 342 343 if len(w.properties.SetupId) > 0 && len(w.properties.SetupId) != 4 { 344 return fmt.Errorf("setup_id should be 4 characters long") 345 } 346 347 if w.properties.Path == "" { 348 return fmt.Errorf("machine path could not be determined") 349 } 350 351 return nil 352 } 353 354 func (w *Watcher) setProperties(props map[string]any) error { 355 if w.properties == nil { 356 w.properties = &properties{ 357 ShouldDisable: []string{}, 358 ShouldOff: []string{}, 359 ShouldOn: []string{}, 360 } 361 } 362 363 err := util.ParseMapStructure(props, w.properties) 364 if err != nil { 365 return err 366 } 367 368 _, set := props["initial"] 369 switch { 370 case !set: 371 w.properties.InitialState = Unknown 372 case w.properties.Initial: 373 w.properties.InitialState = On 374 default: 375 w.properties.InitialState = Off 376 377 } 378 379 return w.validate() 380 }