You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

575 lines
14 KiB

2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
  1. package hue
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. lucifer3 "git.aiterp.net/lucifer3/server"
  7. "git.aiterp.net/lucifer3/server/device"
  8. "git.aiterp.net/lucifer3/server/events"
  9. "git.aiterp.net/lucifer3/server/internal/color"
  10. "git.aiterp.net/lucifer3/server/internal/gentools"
  11. "golang.org/x/sync/errgroup"
  12. "log"
  13. "math"
  14. "strings"
  15. "sync"
  16. "time"
  17. )
  18. func NewBridge(host string, client *Client) *Bridge {
  19. return &Bridge{
  20. client: client,
  21. host: host,
  22. ctx: context.Background(),
  23. cancel: func() {},
  24. resources: map[string]*ResourceData{},
  25. activeStates: map[string]device.State{},
  26. desiredStates: map[string]device.State{},
  27. colorFlags: map[string]device.ColorFlags{},
  28. hasSeen: map[string]bool{},
  29. triggerCongruenceCheckCh: make(chan struct{}, 2),
  30. }
  31. }
  32. type Bridge struct {
  33. mu sync.Mutex
  34. client *Client
  35. host string
  36. ctx context.Context
  37. cancel context.CancelFunc
  38. resources map[string]*ResourceData
  39. activeStates map[string]device.State
  40. desiredStates map[string]device.State
  41. colorFlags map[string]device.ColorFlags
  42. reachable map[string]bool
  43. hasSeen map[string]bool
  44. lastMotion map[string]time.Time
  45. lastButton map[string]time.Time
  46. triggerCongruenceCheckCh chan struct{}
  47. lastDiscoverCancel context.CancelFunc
  48. }
  49. func (b *Bridge) SearchDevices(timeout time.Duration) error {
  50. discoverCtx, cancel := context.WithCancel(b.ctx)
  51. b.mu.Lock()
  52. if b.lastDiscoverCancel != nil {
  53. b.lastDiscoverCancel()
  54. }
  55. b.lastDiscoverCancel = cancel
  56. b.mu.Unlock()
  57. if timeout <= time.Second*10 {
  58. timeout = time.Second * 10
  59. }
  60. // Spend half the time waiting for devices
  61. // TODO: Wait for v2 endpoint
  62. ctx, cancel := context.WithTimeout(discoverCtx, timeout/2)
  63. defer cancel()
  64. err := b.client.LegacyDiscover(ctx, "sensors")
  65. if err != nil {
  66. return err
  67. }
  68. <-ctx.Done()
  69. if discoverCtx.Err() != nil {
  70. return discoverCtx.Err()
  71. }
  72. // Spend half the time waiting for lights
  73. // TODO: Wait for v2 endpoint
  74. ctx, cancel = context.WithTimeout(discoverCtx, timeout/2)
  75. defer cancel()
  76. err = b.client.LegacyDiscover(ctx, "sensors")
  77. if err != nil {
  78. return err
  79. }
  80. <-ctx.Done()
  81. if discoverCtx.Err() != nil {
  82. return discoverCtx.Err()
  83. }
  84. // Let the main loop get the new light.
  85. return nil
  86. }
  87. func (b *Bridge) RefreshAll() ([]lucifer3.Event, error) {
  88. ctx, cancel := context.WithTimeout(b.ctx, time.Second*15)
  89. defer cancel()
  90. allResources, err := b.client.AllResources(ctx)
  91. if err != nil {
  92. return nil, err
  93. }
  94. resources := make(map[string]*ResourceData, len(allResources))
  95. for i := range allResources {
  96. resources[allResources[i].ID] = &allResources[i]
  97. }
  98. b.mu.Lock()
  99. hasSeen := b.hasSeen
  100. reachable := b.reachable
  101. lastMotion := b.lastMotion
  102. b.mu.Unlock()
  103. oldHasSeen := hasSeen
  104. hasSeen = gentools.CopyMap(hasSeen)
  105. reachable = gentools.CopyMap(reachable)
  106. lastMotion = gentools.CopyMap(lastMotion)
  107. colorFlags := make(map[string]device.ColorFlags)
  108. activeStates := make(map[string]device.State)
  109. newEvents := make([]lucifer3.Event, 0, 0)
  110. extraEvents := make([]lucifer3.Event, 0, 0)
  111. for id, res := range resources {
  112. if res.Owner != nil && !oldHasSeen[res.Owner.ID] {
  113. if res.Temperature != nil {
  114. extraEvents = append(extraEvents, events.TemperatureChanged{
  115. ID: b.fullId(*res),
  116. Temperature: res.Temperature.Temperature,
  117. })
  118. }
  119. if res.Motion != nil {
  120. if res.Motion.Motion {
  121. extraEvents = append(extraEvents, events.MotionSensed{
  122. ID: b.fullId(*res),
  123. SecondsSince: 0,
  124. })
  125. lastMotion[b.fullId(*res)] = time.Now()
  126. } else {
  127. extraEvents = append(extraEvents, events.MotionSensed{
  128. ID: b.fullId(*res),
  129. SecondsSince: 301,
  130. })
  131. lastMotion[b.fullId(*res)] = time.Now().Add(-time.Millisecond * 301)
  132. }
  133. }
  134. }
  135. if res.Type == "device" {
  136. hwState, hwEvent := res.GenerateEvent(b.host, resources)
  137. if hwState.SupportFlags == 0 {
  138. continue
  139. }
  140. newEvents = append(newEvents, hwState)
  141. if !hasSeen[id] {
  142. newEvents = append(newEvents, hwEvent, events.DeviceReady{ID: hwState.ID})
  143. hasSeen[id] = true
  144. }
  145. activeStates[id] = hwState.State
  146. colorFlags[id] = hwState.ColorFlags
  147. reachable[id] = !hwState.Unreachable
  148. }
  149. }
  150. b.mu.Lock()
  151. b.resources = resources
  152. b.hasSeen = hasSeen
  153. b.colorFlags = colorFlags
  154. b.activeStates = activeStates
  155. b.reachable = reachable
  156. b.lastMotion = lastMotion
  157. b.mu.Unlock()
  158. return append(newEvents, extraEvents...), nil
  159. }
  160. func (b *Bridge) ApplyPatches(date time.Time, resources []ResourceData) (eventList []lucifer3.Event, shouldRefresh bool) {
  161. b.mu.Lock()
  162. resourceMap := b.resources
  163. activeStates := b.activeStates
  164. reachable := b.reachable
  165. colorFlags := b.colorFlags
  166. lastMotion := b.lastMotion
  167. lastButton := b.lastButton
  168. b.mu.Unlock()
  169. mapCopy := gentools.CopyMap(resourceMap)
  170. activeStatesCopy := gentools.CopyMap(activeStates)
  171. reachableCopy := gentools.CopyMap(reachable)
  172. colorFlagsCopy := gentools.CopyMap(colorFlags)
  173. lastMotionCopy := gentools.CopyMap(lastMotion)
  174. lastButtonCopy := gentools.CopyMap(lastButton)
  175. for _, resource := range resources {
  176. if mapCopy[resource.ID] != nil {
  177. mapCopy[resource.ID] = mapCopy[resource.ID].WithPatch(resource)
  178. } else {
  179. eventList = append(eventList, events.Log{
  180. ID: b.fullId(resource),
  181. Level: "info",
  182. Code: "hue_patch_found_unknown_device",
  183. Message: "Refresh triggered, because of unknown device",
  184. })
  185. shouldRefresh = true
  186. }
  187. }
  188. for _, resource := range resources {
  189. if resource.Owner != nil && resource.Owner.Kind == "device" {
  190. if parent, ok := mapCopy[resource.Owner.ID]; ok {
  191. hwState, _ := parent.GenerateEvent(b.host, mapCopy)
  192. eventList = append(eventList, hwState)
  193. activeStatesCopy[resource.Owner.ID] = hwState.State
  194. reachableCopy[resource.Owner.ID] = !hwState.Unreachable
  195. if hwState.ColorFlags != 0 {
  196. colorFlagsCopy[resource.Owner.ID] = hwState.ColorFlags
  197. }
  198. if resource.Temperature != nil {
  199. eventList = append(eventList, events.TemperatureChanged{
  200. ID: b.fullId(resource),
  201. Temperature: resource.Temperature.Temperature,
  202. })
  203. }
  204. if resource.Motion != nil {
  205. if resource.Motion.Motion {
  206. eventList = append(eventList, events.MotionSensed{
  207. ID: b.fullId(resource),
  208. SecondsSince: 0,
  209. })
  210. lastMotionCopy[b.fullId(resource)] = time.Now()
  211. }
  212. }
  213. if resource.Button != nil {
  214. valid := false
  215. if resource.Button.LastEvent == "initial_press" {
  216. valid = true
  217. } else if resource.Button.LastEvent == "long_release" {
  218. valid = false
  219. } else if resource.Button.LastEvent == "repeat" {
  220. valid = date.Sub(lastButtonCopy[resource.ID]) >= time.Millisecond*500
  221. } else {
  222. valid = date.Sub(lastButtonCopy[resource.ID]) >= time.Millisecond*990
  223. }
  224. if valid {
  225. lastButtonCopy[resource.ID] = date
  226. owner := resourceMap[resource.Owner.ID]
  227. if owner != nil {
  228. index := owner.ServiceIndex("button", resource.ID)
  229. if index != -1 {
  230. eventList = append(eventList, events.ButtonPressed{
  231. ID: b.fullId(*owner),
  232. Name: []string{"On", "DimUp", "DimDown", "Off"}[index],
  233. })
  234. }
  235. }
  236. }
  237. }
  238. } else {
  239. shouldRefresh = true
  240. }
  241. }
  242. }
  243. b.mu.Lock()
  244. b.resources = mapCopy
  245. b.activeStates = activeStatesCopy
  246. b.reachable = reachableCopy
  247. b.colorFlags = colorFlagsCopy
  248. b.lastMotion = lastMotionCopy
  249. b.lastButton = lastButtonCopy
  250. b.mu.Unlock()
  251. return
  252. }
  253. func (b *Bridge) SetStates(patch map[string]device.State) {
  254. b.mu.Lock()
  255. desiredStates := b.desiredStates
  256. resources := b.resources
  257. b.mu.Unlock()
  258. desiredStates = gentools.CopyMap(desiredStates)
  259. prefix := "hue:" + b.host + ":"
  260. for id, state := range patch {
  261. if !strings.HasPrefix(id, prefix) {
  262. continue
  263. }
  264. id = id[len(prefix):]
  265. resource := resources[id]
  266. if resource == nil {
  267. continue
  268. }
  269. if !state.Empty() {
  270. desiredStates[id] = resource.FixState(state, resources)
  271. } else {
  272. delete(desiredStates, id)
  273. }
  274. }
  275. b.mu.Lock()
  276. b.desiredStates = desiredStates
  277. b.mu.Unlock()
  278. b.triggerCongruenceCheck()
  279. }
  280. func (b *Bridge) Run(ctx context.Context, bus *lucifer3.EventBus) interface{} {
  281. hwEvents, err := b.RefreshAll()
  282. if err != nil {
  283. return errors.New("failed to connect to bridge")
  284. }
  285. bus.RunEvent(events.Log{
  286. ID: "hue:" + b.host,
  287. Level: "info",
  288. Code: "hue_bridge_starting",
  289. Message: "Bridge is connecting...",
  290. })
  291. go b.makeCongruentLoop(ctx)
  292. bus.RunEvents(hwEvents)
  293. sse := b.client.SSE(ctx)
  294. step := time.NewTicker(time.Second * 30)
  295. defer step.Stop()
  296. quickStep := time.NewTicker(time.Second * 5)
  297. defer quickStep.Stop()
  298. for {
  299. select {
  300. case updates, ok := <-sse:
  301. {
  302. if !ok {
  303. return errors.New("SSE lost connection")
  304. }
  305. if len(updates) == 0 {
  306. continue
  307. }
  308. newEvents, shouldUpdate := b.ApplyPatches(
  309. updates[0].CreationTime,
  310. gentools.Flatten(gentools.Map(updates, func(update SSEUpdate) []ResourceData {
  311. return update.Data
  312. })),
  313. )
  314. bus.RunEvents(newEvents)
  315. if shouldUpdate {
  316. hwEvents, err := b.RefreshAll()
  317. if err != nil {
  318. return errors.New("failed to refresh states")
  319. }
  320. bus.RunEvents(hwEvents)
  321. }
  322. b.triggerCongruenceCheck()
  323. }
  324. case <-quickStep.C:
  325. b.mu.Lock()
  326. lastMotion := b.lastMotion
  327. b.mu.Unlock()
  328. for id, value := range lastMotion {
  329. since := time.Since(value)
  330. sinceMod := since % (time.Second * 30)
  331. if (since > time.Second*20) && (sinceMod >= time.Second*27 || sinceMod <= time.Second*3) {
  332. bus.RunEvent(events.MotionSensed{ID: id, SecondsSince: since.Seconds()})
  333. }
  334. }
  335. b.triggerCongruenceCheck()
  336. case <-step.C:
  337. hwEvents, err := b.RefreshAll()
  338. if err != nil {
  339. return nil
  340. }
  341. bus.RunEvents(hwEvents)
  342. b.triggerCongruenceCheck()
  343. case <-ctx.Done():
  344. {
  345. return nil
  346. }
  347. }
  348. }
  349. }
  350. func (b *Bridge) makeCongruentLoop(ctx context.Context) {
  351. for range b.triggerCongruenceCheckCh {
  352. if ctx.Err() != nil {
  353. break
  354. }
  355. // Make sure this loop doesn't spam too hard
  356. rateLimit := time.After(time.Second / 15)
  357. // Take states
  358. b.mu.Lock()
  359. resources := b.resources
  360. desiredStates := b.desiredStates
  361. activeStates := b.activeStates
  362. reachable := b.reachable
  363. colorFlags := b.colorFlags
  364. b.mu.Unlock()
  365. newActiveStates := make(map[string]device.State, 0)
  366. updates := make(map[string]ResourceUpdate)
  367. for id, desired := range desiredStates {
  368. active, activeOK := activeStates[id]
  369. lightID := resources[id].ServiceID("light")
  370. if !reachable[id] || !activeOK || lightID == nil {
  371. continue
  372. }
  373. light := resources[*lightID]
  374. if light == nil {
  375. continue
  376. }
  377. // Handle power first
  378. if desired.Power != nil && active.Power != nil && *desired.Power != *active.Power {
  379. updates["light/"+*lightID] = ResourceUpdate{
  380. Power: gentools.Ptr(*desired.Power),
  381. TransitionDuration: gentools.Ptr(time.Millisecond * 101),
  382. }
  383. newActiveState := activeStates[id]
  384. newActiveState.Power = gentools.Ptr(*desired.Power)
  385. newActiveStates[id] = newActiveState
  386. continue
  387. }
  388. if active.Power != nil && !*active.Power {
  389. // Don't do more with shut-off-light.
  390. continue
  391. }
  392. updated := false
  393. update := ResourceUpdate{}
  394. newActiveState := activeStates[id]
  395. if active.Color != nil && desired.Color != nil {
  396. ac := *active.Color
  397. dc := *desired.Color
  398. if !dc.IsKelvin() || !colorFlags[id].IsWarmWhite() {
  399. dc, _ = dc.ToXY()
  400. dc.XY = gentools.Ptr(light.Color.Gamut.Conform(*dc.XY))
  401. }
  402. if dc.XY != nil {
  403. if ac.K != nil {
  404. ac.K = gentools.Ptr(1000000 / (1000000 / *ac.K))
  405. }
  406. acXY, _ := ac.ToXY()
  407. dist := dc.XY.DistanceTo(*acXY.XY)
  408. if dist > 0.0002 {
  409. update.ColorXY = gentools.Ptr(*dc.XY)
  410. updated = true
  411. }
  412. } else {
  413. dcMirek := 1000000 / *dc.K
  414. if dcMirek < light.ColorTemperature.MirekSchema.MirekMinimum {
  415. dcMirek = light.ColorTemperature.MirekSchema.MirekMinimum
  416. } else if dcMirek > light.ColorTemperature.MirekSchema.MirekMaximum {
  417. dcMirek = light.ColorTemperature.MirekSchema.MirekMaximum
  418. }
  419. acMirek := 0
  420. if ac.K != nil {
  421. acMirek = 1000000 / *ac.K
  422. }
  423. if acMirek != dcMirek {
  424. newActiveState.Color = &color.Color{K: gentools.Ptr(*dc.K)}
  425. update.Mirek = &dcMirek
  426. updated = true
  427. }
  428. }
  429. }
  430. if active.Intensity != nil && desired.Intensity != nil {
  431. if math.Abs(*active.Intensity-*desired.Intensity) >= 0.01 {
  432. update.Brightness = gentools.Ptr(*desired.Intensity * 100)
  433. newActiveState.Intensity = gentools.Ptr(*desired.Intensity)
  434. updated = true
  435. }
  436. }
  437. if updated {
  438. update.TransitionDuration = gentools.Ptr(time.Millisecond * 101)
  439. updates["light/"+*lightID] = update
  440. newActiveStates[id] = newActiveState
  441. }
  442. }
  443. if len(updates) > 0 {
  444. timeout, cancel := context.WithTimeout(ctx, time.Second)
  445. eg, ctx := errgroup.WithContext(timeout)
  446. for key := range updates {
  447. update := updates[key]
  448. split := strings.SplitN(key, "/", 2)
  449. link := ResourceLink{Kind: split[0], ID: split[1]}
  450. eg.Go(func() error {
  451. return b.client.UpdateResource(ctx, link, update)
  452. })
  453. }
  454. err := eg.Wait()
  455. if err != nil {
  456. log.Println("Failed to run update", err)
  457. }
  458. b.mu.Lock()
  459. activeStates = b.activeStates
  460. b.mu.Unlock()
  461. activeStates = gentools.CopyMap(activeStates)
  462. for id, state := range newActiveStates {
  463. activeStates[id] = state
  464. }
  465. b.mu.Lock()
  466. b.activeStates = activeStates
  467. b.mu.Unlock()
  468. cancel()
  469. // Wait the remaining time for the rate limit
  470. <-rateLimit
  471. }
  472. }
  473. }
  474. func (b *Bridge) fullId(res ResourceData) string {
  475. id := res.ID
  476. if res.Owner != nil && res.Owner.Kind == "device" {
  477. id = res.Owner.ID
  478. }
  479. return fmt.Sprintf("hue:%s:%s", b.host, id)
  480. }
  481. func (b *Bridge) triggerCongruenceCheck() {
  482. select {
  483. case b.triggerCongruenceCheckCh <- struct{}{}:
  484. default:
  485. }
  486. }