428 lines
9.2 KiB
Go
428 lines
9.2 KiB
Go
package ziggy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"git.tcp.direct/kayos/common/entropy"
|
|
tui "github.com/manifoldco/promptui"
|
|
"github.com/rs/zerolog"
|
|
"github.com/yunginnanet/huego"
|
|
"golang.org/x/net/proxy"
|
|
|
|
"git.tcp.direct/kayos/ziggs/internal/common"
|
|
"git.tcp.direct/kayos/ziggs/internal/config"
|
|
)
|
|
|
|
var log *zerolog.Logger
|
|
|
|
var errNoBridges = errors.New("no bridges available")
|
|
|
|
type Meta struct {
|
|
Bridges map[string]*Bridge
|
|
*sync.RWMutex
|
|
}
|
|
|
|
// Lucifer is the lightbringer.
|
|
var Lucifer = Meta{
|
|
Bridges: make(map[string]*Bridge),
|
|
RWMutex: &sync.RWMutex{},
|
|
}
|
|
|
|
// Bridge represents a zigbee light controller. Just hue for now.
|
|
type Bridge struct {
|
|
config *config.KnownBridge
|
|
Info *huego.Config
|
|
log *zerolog.Logger
|
|
HueLights []*HueLight
|
|
*huego.Bridge
|
|
*sync.RWMutex
|
|
}
|
|
|
|
func (c *Bridge) Run(e *zerolog.Event, level zerolog.Level, msg string) {
|
|
if level == zerolog.DebugLevel || level == zerolog.TraceLevel {
|
|
e.Str("caller", c.ID)
|
|
}
|
|
// e.Msgf(msg)
|
|
}
|
|
|
|
func (c *Bridge) Log() *zerolog.Logger {
|
|
return c.log
|
|
}
|
|
|
|
type HueSensor struct {
|
|
*huego.Sensor
|
|
controller *Bridge
|
|
}
|
|
|
|
func (hs *HueSensor) Rename(name string) error {
|
|
hnew := &huego.Sensor{Name: name}
|
|
_, err := hs.controller.UpdateSensor(hs.ID, hnew)
|
|
return err
|
|
}
|
|
|
|
type HueLight struct {
|
|
*huego.Light
|
|
controller *Bridge
|
|
}
|
|
|
|
type HueGroup struct {
|
|
*huego.Group
|
|
scenes map[string]*HueScene
|
|
controller *Bridge
|
|
}
|
|
|
|
type HueScene struct {
|
|
*huego.Scene
|
|
controller *Bridge
|
|
group *HueGroup
|
|
}
|
|
|
|
func (hl *HueLight) Scene(s string) error {
|
|
return hl.Scene(s)
|
|
}
|
|
|
|
func (hl *HueLight) Log() *zerolog.Logger {
|
|
l := log.With().
|
|
Int("caller", hl.ID).
|
|
Str("name", hl.Name).
|
|
Bool("on", hl.IsOn()).Logger()
|
|
return &l
|
|
}
|
|
|
|
func (hl *HueLight) GetPtr() (*huego.Light, error) {
|
|
return hl.controller.GetLight(hl.ID)
|
|
}
|
|
|
|
func getProxiedBridge(cridge *config.KnownBridge) *huego.Bridge {
|
|
cridge.Proxy = strings.TrimPrefix(cridge.Proxy, "socks5://")
|
|
newTransport := http.DefaultTransport.(*http.Transport).Clone()
|
|
proxyDialer, _ := proxy.SOCKS5("tcp", cridge.Proxy, nil, proxy.Direct)
|
|
newTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
return proxyDialer.Dial(network, addr)
|
|
}
|
|
newClient := http.DefaultClient
|
|
newClient.Transport = newTransport
|
|
return huego.NewWithClient(cridge.Hostname, cridge.Username, newClient)
|
|
}
|
|
|
|
func newController(cridge *config.KnownBridge) (*Bridge, error) {
|
|
c := &Bridge{
|
|
config: cridge,
|
|
RWMutex: &sync.RWMutex{},
|
|
}
|
|
if c.config.Proxy == "" {
|
|
c.Bridge = huego.New(c.config.Hostname, c.config.Username)
|
|
} else {
|
|
c.Bridge = getProxiedBridge(cridge)
|
|
}
|
|
|
|
var err error
|
|
c.Info, err = c.GetConfig()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
l := log.With().
|
|
// Uint8("zigbee_channel", c.Info.ZigbeeChannel).
|
|
// Str("ip", c.Info.IPAddress).
|
|
Logger().Hook(c)
|
|
c.log = &l
|
|
return c, nil
|
|
}
|
|
|
|
func GetControllers(bridges []config.KnownBridge) (br []*Bridge) {
|
|
for _, lightConfig := range bridges {
|
|
if lightConfig.Hostname == "" {
|
|
continue
|
|
}
|
|
log.Debug().Str("caller", lightConfig.Hostname).Str("proxy", lightConfig.Proxy).Msg("attempting connection")
|
|
c, err := newController(&lightConfig)
|
|
if err != nil {
|
|
log.Error().Str("caller", lightConfig.Hostname).Err(err).Msg("unsuccessful connection")
|
|
continue
|
|
}
|
|
c.Log().Debug().Str("caller", strings.Split(lightConfig.Hostname, "http://")[1]).Msg("connected")
|
|
br = append(br, c)
|
|
}
|
|
return
|
|
}
|
|
|
|
type ToggleMode uint8
|
|
|
|
const (
|
|
ToggleOn ToggleMode = iota
|
|
ToggleOff
|
|
ToggleRainbow
|
|
Toggle
|
|
)
|
|
|
|
type lCall func(light *HueLight) (checkFunc, error)
|
|
type checkFunc func(light *HueLight) bool
|
|
|
|
var lightCallbacks = map[ToggleMode]lCall{
|
|
ToggleOn: func(light *HueLight) (checkFunc, error) {
|
|
return func(light *HueLight) bool {
|
|
light.State = &huego.State{
|
|
On: true,
|
|
Bri: 100,
|
|
Effect: "none",
|
|
Scene: "none",
|
|
}
|
|
if !light.IsOn() {
|
|
return false
|
|
}
|
|
return light.IsOn()
|
|
},
|
|
light.On()
|
|
},
|
|
ToggleOff: func(light *HueLight) (checkFunc, error) {
|
|
return func(light *HueLight) bool {
|
|
light.State = &huego.State{
|
|
On: false,
|
|
Bri: 100,
|
|
Effect: "none",
|
|
Scene: "none",
|
|
}
|
|
if light.IsOn() {
|
|
return false
|
|
}
|
|
return !light.IsOn()
|
|
},
|
|
light.Off()
|
|
},
|
|
/* ToggleDim: func(light *HueLight) (checkFunc, error) {
|
|
return func(light *HueLight) bool {
|
|
if !light.IsOn() {
|
|
return false
|
|
}
|
|
if light.State.Bri
|
|
},
|
|
light.On()
|
|
},*/
|
|
ToggleRainbow: func(light *HueLight) (checkFunc, error) {
|
|
return func(light *HueLight) bool {
|
|
if !light.IsOn() {
|
|
return false
|
|
}
|
|
return light.State.Effect == "colorloop"
|
|
},
|
|
light.Effect("colorloop")
|
|
},
|
|
}
|
|
|
|
func Assert(ctx context.Context, l *HueLight, mode ToggleMode) error {
|
|
act, ok := lightCallbacks[mode]
|
|
if !ok {
|
|
panic("not implemented")
|
|
}
|
|
|
|
/* l, err := lo.GetPtr()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*/
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
check, err := act(l)
|
|
if err != nil && !check(l) {
|
|
log.Warn().Err(err).Msg("fail")
|
|
continue
|
|
}
|
|
if !check(l) {
|
|
continue
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func toggle(l *HueLight, mode ToggleMode) error {
|
|
on := func(l *HueLight) error {
|
|
log.Trace().Msg("turning light on...")
|
|
return l.On()
|
|
}
|
|
off := func(l *HueLight) error {
|
|
log.Trace().Msg("turning light off...")
|
|
return l.Off()
|
|
}
|
|
var err error
|
|
switch mode {
|
|
case ToggleOn:
|
|
err = on(l)
|
|
case ToggleOff:
|
|
err = off(l)
|
|
case Toggle:
|
|
if !l.IsOn() {
|
|
err = off(l)
|
|
break
|
|
}
|
|
err = on(l)
|
|
default:
|
|
//
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (c *Bridge) getLights() error {
|
|
var l []*HueLight
|
|
|
|
lit, err := c.GetLights()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, lght := range lit {
|
|
if lp, err := c.GetLight(lght.ID); err != nil {
|
|
log.Error().Err(err).Msg("failed to get light")
|
|
continue
|
|
} else {
|
|
l = append(l, &HueLight{Light: lp, controller: c})
|
|
}
|
|
}
|
|
if l == nil {
|
|
return fmt.Errorf("no lights found")
|
|
}
|
|
log.Info().Msgf("Found %d lights", len(l))
|
|
for _, light := range l {
|
|
lightPtr, err := c.GetLight(light.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newlight := &HueLight{
|
|
Light: lightPtr,
|
|
controller: c,
|
|
}
|
|
log.Debug().Interface("new light", newlight.Light).Msg("+")
|
|
c.HueLights = append(c.HueLights, newlight)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Bridge) Lights() []*HueLight {
|
|
if len(c.HueLights) > 0 {
|
|
return c.HueLights
|
|
}
|
|
_ = c.getLights()
|
|
return c.HueLights
|
|
}
|
|
|
|
func promptForUser(cnt *Bridge) bool {
|
|
log.Info().Msg("found new bridge")
|
|
confirmPrompt := tui.Select{
|
|
Label: "How should we authenticate?",
|
|
Items: []string{"Create new user", "Provide existing username"},
|
|
CursorPos: 0,
|
|
IsVimMode: false,
|
|
Pointer: common.ZiggsPointer,
|
|
}
|
|
choice, _, _ := confirmPrompt.Run()
|
|
switch choice {
|
|
case 0:
|
|
println("press the link button on your bridge, then press enter")
|
|
fmt.Scanln()
|
|
newuser, err := cnt.CreateUser("ziggs" + strconv.Itoa(entropy.RNG(5)))
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed")
|
|
return false
|
|
}
|
|
cnt.User = newuser
|
|
case 1:
|
|
userEntry := tui.Prompt{
|
|
Label: "Username",
|
|
Validate: func(s string) error {
|
|
if len(s) < 40 {
|
|
return errors.New("username must be at least 40 characters")
|
|
}
|
|
return nil
|
|
},
|
|
Mask: 'x',
|
|
HideEntered: false,
|
|
Pointer: common.ZiggsPointer,
|
|
}
|
|
var err error
|
|
var input string
|
|
input, err = userEntry.Run()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed")
|
|
}
|
|
cnt.User = strings.TrimSpace(input)
|
|
}
|
|
log.Info().Str("caller", cnt.Host).Msg("logging in...")
|
|
log.Trace().Msg("logging in using: " + cnt.User)
|
|
cnt.Bridge = cnt.Bridge.Login(cnt.User)
|
|
_, err := cnt.Bridge.GetCapabilities()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to verify that we are logged in")
|
|
return false
|
|
}
|
|
config.Snek.Set("bridges", map[string]interface{}{
|
|
"hostname": cnt.Host,
|
|
"username": cnt.User,
|
|
})
|
|
if err = config.Snek.WriteConfig(); err != nil {
|
|
log.Warn().Msg("failed to write config")
|
|
} else {
|
|
log.Info().Msg("configuration saved!")
|
|
}
|
|
return true
|
|
}
|
|
|
|
func getBridgeInfo(c *Bridge) error {
|
|
log.Trace().Msg("getting bridge config...")
|
|
conf, err := c.GetConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.Info = conf
|
|
c.config = &config.KnownBridge{
|
|
Hostname: conf.IPAddress,
|
|
}
|
|
return c.getLights()
|
|
}
|
|
|
|
func Setup() (known []*Bridge, err error) {
|
|
log = config.GetLogger()
|
|
log.Debug().Int("count", len(config.KnownBridges)).Msg("trying bridges...")
|
|
known = GetControllers(config.KnownBridges)
|
|
if len(known) < 1 {
|
|
err := promptForDiscovery()
|
|
if err != nil {
|
|
return []*Bridge{}, err
|
|
}
|
|
for _, cnt := range Lucifer.Bridges {
|
|
cnt.RLock()
|
|
log.Trace().Str("caller", cnt.Info.BridgeID).Int("lights", len(cnt.HueLights)).Msg("done")
|
|
cnt.RUnlock()
|
|
}
|
|
}
|
|
|
|
for _, bridge := range known {
|
|
bridge.Log().Trace().Str("caller", bridge.ID).Str("mac", bridge.Info.Mac).Msg("getting lights..")
|
|
err = bridge.getLights()
|
|
if err != nil {
|
|
bridge.Log().Warn().Err(err).Msg("failed to get lights")
|
|
continue
|
|
}
|
|
var caps *huego.Capabilities
|
|
caps, err = bridge.GetCapabilities()
|
|
if err != nil {
|
|
bridge.Log().Warn().Err(err).Msg("failed to get caps")
|
|
continue
|
|
}
|
|
bridge.Log().Trace().Interface("supported", caps).Msg("capabilities")
|
|
Lucifer.Lock()
|
|
Lucifer.Bridges[bridge.Info.IPAddress] = bridge
|
|
Lucifer.Unlock()
|
|
}
|
|
return
|
|
}
|