Browse Source

add beginning of CLI app and some backend fixes.

pull/1/head
Gisle Aune 3 years ago
parent
commit
06fa73b916
  1. 43
      app/api/devices.go
  2. 136
      app/client/client.go
  3. 18
      app/services/events.go
  4. 183
      cmd/lucy/command.go
  5. 18
      cmd/lucy/help.go
  6. 115
      cmd/lucy/main.go
  7. 74
      cmd/lucy/tables.go
  8. 1
      go.mod
  9. 4
      go.sum
  10. 9
      internal/mysql/devicerepo.go
  11. 4
      models/colorvalue.go
  12. 27
      models/device.go
  13. 3
      models/shared.go

43
app/api/devices.go

@ -6,6 +6,7 @@ import (
"git.aiterp.net/lucifer/new-server/models"
"github.com/gin-gonic/gin"
"log"
"strconv"
"strings"
)
@ -14,9 +15,18 @@ func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error)
return config.DeviceRepository().FetchByReference(ctx, models.RKTag, fetchStr[4:])
} else if strings.HasPrefix(fetchStr, "bridge:") {
return config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, fetchStr[7:])
} else if strings.HasPrefix(fetchStr, "id:") {
return config.DeviceRepository().FetchByReference(ctx, models.RKDeviceID, fetchStr[7:])
} else if strings.HasPrefix(fetchStr, "name:") {
return config.DeviceRepository().FetchByReference(ctx, models.RKName, fetchStr[7:])
}else if fetchStr == "all" {
return config.DeviceRepository().FetchByReference(ctx, models.RKAll, "")
} else {
_, err := strconv.Atoi(fetchStr)
if err != nil {
return config.DeviceRepository().FetchByReference(ctx, models.RKName, fetchStr)
}
return config.DeviceRepository().FetchByReference(ctx, models.RKDeviceID, fetchStr)
}
}
@ -30,7 +40,7 @@ func Devices(r gin.IRoutes) {
return fetchDevices(ctxOf(c), c.Param("fetch"))
}))
r.PUT("/batch", handler(func(c *gin.Context) (interface{}, error) {
r.PUT("", handler(func(c *gin.Context) (interface{}, error) {
var body []struct {
Fetch string `json:"fetch"`
SetState models.NewDeviceState `json:"setState"`
@ -81,6 +91,34 @@ func Devices(r gin.IRoutes) {
return changed, nil
}))
r.PUT("/:fetch", handler(func(c *gin.Context) (interface{}, error) {
update := models.DeviceUpdate{}
err := parseBody(c, &update)
if err != nil {
return nil, err
}
devices, err := fetchDevices(ctxOf(c), c.Param("fetch"))
if err != nil {
return nil, err
}
if len(devices) == 0 {
return []models.Device{}, nil
}
for i := range devices {
devices[i].ApplyUpdate(update)
err := config.DeviceRepository().Save(context.Background(), &devices[i])
if err != nil {
log.Println("Failed to save device for state:", err)
continue
}
}
return devices, nil
}))
r.PUT("/:fetch/state", handler(func(c *gin.Context) (interface{}, error) {
state := models.NewDeviceState{}
err := parseBody(c, &state)
@ -159,6 +197,9 @@ func Devices(r gin.IRoutes) {
index = i
}
}
if index == -1 {
continue
}
device.Tags = append(device.Tags[:index], device.Tags[index+1:]...)
}

136
app/client/client.go

@ -0,0 +1,136 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"io"
"net"
"net/http"
"strings"
"time"
)
type Client struct {
APIRoot string
}
func (client *Client) GetDevices(ctx context.Context, fetchStr string) ([]models.Device, error) {
devices := make([]models.Device, 0, 16)
err := client.Fetch(ctx, "GET", "/api/devices/"+fetchStr, &devices, nil)
if err != nil {
return nil, err
}
return devices, nil
}
func (client *Client) PutDevice(ctx context.Context, fetchStr string, update models.DeviceUpdate) ([]models.Device, error) {
devices := make([]models.Device, 0, 16)
err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr, &devices, update)
if err != nil {
return nil, err
}
return devices, nil
}
func (client *Client) PutDeviceState(ctx context.Context, fetchStr string, update models.NewDeviceState) ([]models.Device, error) {
devices := make([]models.Device, 0, 16)
err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr+"/state", &devices, update)
if err != nil {
return nil, err
}
return devices, nil
}
func (client *Client) PutDeviceTags(ctx context.Context, fetchStr string, addTags []string, removeTags []string) ([]models.Device, error) {
devices := make([]models.Device, 0, 16)
err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr+"/tags", &devices, map[string][]string{
"add": addTags,
"remove": removeTags,
})
if err != nil {
return nil, err
}
return devices, nil
}
func (client *Client) FireEvent(ctx context.Context, event models.Event) error {
err := client.Fetch(ctx, "POST", "/api/events", nil, event)
if err != nil {
return err
}
return nil
}
func (client *Client) Fetch(ctx context.Context, method string, path string, dst interface{}, body interface{}) error {
var reqBody io.ReadWriter
if body != nil && method != "GET" {
reqBody = bytes.NewBuffer(make([]byte, 0, 512))
err := json.NewEncoder(reqBody).Encode(body)
if err != nil {
return err
}
}
req, err := http.NewRequest(method, client.APIRoot+path, reqBody)
if err != nil {
return err
}
res, err := httpClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer res.Body.Close()
if !strings.HasPrefix(res.Header.Get("Content-Type"), "application/json") {
return fmt.Errorf("%s: %s", path, res.Status)
}
var resJson struct {
Code int `json:"code"`
Message *string `json:"message"`
Data json.RawMessage `json:"data"`
}
err = json.NewDecoder(res.Body).Decode(&resJson)
if err != nil {
return err
}
if resJson.Code != 200 {
msg := ""
if resJson.Message != nil {
msg = *resJson.Message
}
return fmt.Errorf("%d: %s", resJson.Code, msg)
}
if dst == nil {
return nil
}
return json.Unmarshal(resJson.Data, dst)
}
var httpClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 16,
MaxIdleConnsPerHost: 16,
IdleConnTimeout: time.Minute,
},
Timeout: time.Minute,
}

18
app/services/events.go

@ -46,6 +46,8 @@ var loc, _ = time.LoadLocation("Europe/Oslo")
var ctx = context.Background()
func handleEvent(event models.Event) (responses []models.Event) {
var deadHandlers []models.EventHandler
startTime := time.Now()
defer func() {
duration := time.Since(startTime)
@ -102,6 +104,10 @@ func handleEvent(event models.Event) (responses []models.Event) {
continue
}
if handler.OneShot {
deadHandlers = append(deadHandlers, handler)
}
if handler.Priority > highestPriority {
highestPriority = handler.Priority
prioritizedEvent = handler.Actions.FireEvent
@ -159,6 +165,18 @@ func handleEvent(event models.Event) (responses []models.Event) {
wg.Done()
}(device)
}
for _, handler := range deadHandlers {
wg.Add(1)
go func(handler models.EventHandler) {
err := config.EventHandlerRepository().Delete(context.Background(), &handler)
if err != nil {
log.Println("Failed to delete spent one-shot event handler:", err)
}
wg.Done()
}(handler)
}
wg.Wait()

183
cmd/lucy/command.go

@ -0,0 +1,183 @@
package main
import (
"log"
"strconv"
"strings"
)
type Param struct {
Index int
Key string
Value string
}
func (p *Param) String() *string {
if p == nil {
return nil
}
return &p.Value
}
func (p *Param) StringOr(fallback string) string {
if p == nil {
return fallback
}
return p.Value
}
func (p *Param) Int() *int {
if p == nil {
return nil
}
n, err := strconv.Atoi(p.Value)
if err != nil {
return nil
}
return &n
}
func (p *Param) Float() *float64 {
if p == nil {
return nil
}
n, err := strconv.ParseFloat(p.Value, 64)
if err != nil {
return nil
}
return &n
}
func (p *Param) Bool() *bool {
if p == nil {
return nil
}
v := strings.ToLower(p.Value)
if v == "yes" || v == "true" || v == "on" {
r := true
return &r
} else if v == "no" || v == "false" || v == "off" {
r := false
return &r
}
return nil
}
type Params []Param
func (p Params) Get(key interface{}) *Param {
switch key := key.(type) {
case string:
for _, p := range p {
if p.Key == key {
return &p
}
}
case int:
for _, p := range p {
if p.Index == key {
return &p
}
}
default:
log.Panicf("Incorrect key type %T", key)
}
return nil
}
func (p Params) Subset(prefix string) Params {
if len(p) == 0 {
return Params{}
}
if len(prefix) > 0 && !strings.HasSuffix(prefix, ".") {
prefix += "."
}
res := make(Params, 0, len(p))
for _, param := range p {
if param.Index == -1 && strings.HasPrefix(param.Key, prefix) {
res = append(res, Param{Index: -1, Key: param.Key[len(prefix):], Value: param.Value})
}
}
return res
}
func (p Params) StringMap() map[string]string {
res := make(map[string]string)
for _, param := range p {
if param.Index == -1 {
res[param.Key] = param.Value
}
}
return res
}
func (p Params) StringPtrMap() map[string]*string {
res := make(map[string]*string)
for _, param := range p {
if param.Index == -1 {
if param.Value == "NULL" {
res[param.Key] = nil
} else {
res[param.Key] = &param.Value
}
}
}
return res
}
func (p Params) Strings(minIndex int) []string {
res := make([]string, 0, len(p))
for _, param := range p {
if param.Index >= minIndex {
res = append(res, param.Value)
}
}
return res
}
type Command struct {
Name string
Params Params
}
func parseCommand(args []string) Command {
if len(args) == 0 {
return Command{Name: "help"}
}
cmd := Command{
Name: args[0],
Params: make(Params, 0, len(args)-1),
}
nextIndex := 0
for _, arg := range args[1:] {
kv := strings.SplitN(arg, "=", 2)
if len(kv) == 2 {
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kv[0], Value: kv[1]})
} else {
cmd.Params = append(cmd.Params, Param{Index: nextIndex, Value: arg})
nextIndex += 1
}
}
return cmd
}

18
cmd/lucy/help.go

@ -0,0 +1,18 @@
package main
var helpString = `
lucy lucifer CLI
EXAMPLES
lucy set tag:Hexagon color=hs:35,1 intensity=0.3
lucy run SetProfile name=evening
DEVICE COMMANDS
list <search>
set <search> <power=B> <color=C> <intensity=F> <temperature=N>
tag <search> <[+/-]tag-name> -Tag
update <search> <name=S> <icon=S> <prop.*=S/NULL>
EVENT COMMANDS
run <event name> <*=S>
`[1:]

115
cmd/lucy/main.go

@ -0,0 +1,115 @@
package main
import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/app/client"
"git.aiterp.net/lucifer/new-server/models"
"log"
"os"
"strings"
)
func main() {
c := client.Client{APIRoot: "http://10.24.4.1:8000"}
cmd := parseCommand(os.Args[1:])
switch cmd.Name {
// DEVICE
case "list":
{
fetchStr := cmd.Params.Get(0).String()
if fetchStr == nil {
s := "all"
fetchStr = &s
}
devices, err := c.GetDevices(context.Background(), *fetchStr)
if err != nil {
log.Fatalln(err)
}
WriteDeviceStateTable(os.Stdout, devices)
}
case "set":
{
devices, err := c.PutDeviceState(context.Background(), cmd.Params.Get(0).StringOr("all"), models.NewDeviceState{
Power: cmd.Params.Get("power").Bool(),
Color: cmd.Params.Get("color").String(),
Intensity: cmd.Params.Get("intensity").Float(),
Temperature: cmd.Params.Get("temperature").Int(),
})
if err != nil {
log.Fatalln(err)
}
WriteDeviceStateTable(os.Stdout, devices)
}
case "tag":
{
var addTags []string
var removeTags []string
for _, tag := range cmd.Params.Strings(1) {
if strings.HasPrefix(tag, "+") {
addTags = append(addTags, tag[1:])
} else if strings.HasPrefix(tag, "-") {
removeTags = append(removeTags, tag[1:])
} else {
addTags = append(addTags, tag)
}
}
devices, err := c.PutDeviceTags(
context.Background(),
cmd.Params.Get(0).StringOr("all"),
addTags,
removeTags,
)
if err != nil {
log.Fatalln(err)
}
WriteDeviceInfoTable(os.Stdout, devices)
}
case "update":
{
devices, err := c.PutDevice(context.Background(), cmd.Params.Get(0).StringOr("all"), models.DeviceUpdate{
Name: cmd.Params.Get("name").String(),
Icon: cmd.Params.Get("icon").String(),
UserProperties: cmd.Params.Subset("prop.").StringPtrMap(),
})
if err != nil {
log.Fatalln(err)
}
WriteDeviceInfoTable(os.Stdout, devices)
}
// EVENT
case "run":
{
name := cmd.Params.Get(0).String()
if name == nil {
return
}
event := models.Event{
Name: *name,
Payload: cmd.Params.StringMap(),
}
err := c.FireEvent(context.Background(), event)
if err != nil {
log.Fatalln(err)
}
}
default:
if cmd.Name != "help" {
log.Println("Unknown command:", cmd.Name)
}
_, _ = fmt.Fprintln(os.Stderr, helpString[1:])
}
}

74
cmd/lucy/tables.go

@ -0,0 +1,74 @@
package main
import (
"git.aiterp.net/lucifer/new-server/models"
"github.com/olekukonko/tablewriter"
"io"
"strconv"
"strings"
)
func WriteDeviceStateTable(w io.Writer, devices []models.Device) {
table := tablewriter.NewWriter(w)
table.SetHeader([]string{"ID", "NAME", "POWER", "COLOR", "INTENSITY", "TEMPERATURE"})
table.SetReflowDuringAutoWrap(true)
for _, v := range devices {
powerStr := ""
if v.HasCapability(models.DCPower) {
powerStr = strconv.FormatBool(v.State.Power)
}
colorStr := ""
if v.HasCapability(models.DCColorHSK, models.DCColorHS, models.DCColorKelvin) {
colorStr = v.State.Color.String()
}
temperatureString := ""
if v.HasCapability(models.DCTemperatureControl, models.DCTemperatureSensor) {
temperatureString = strconv.FormatFloat(v.State.Temperature, 'f', -1, 64)
}
intensityString := ""
if v.HasCapability(models.DCIntensity) {
intensityString = strconv.FormatFloat(v.State.Intensity, 'f', -1, 64)
}
table.Append([]string{
strconv.Itoa(v.ID),
v.Name,
powerStr,
colorStr,
intensityString,
temperatureString,
})
}
table.Render()
}
func WriteDeviceInfoTable(w io.Writer, devices []models.Device) {
table := tablewriter.NewWriter(w)
table.SetHeader([]string{"ID", "NAME", "ICON", "BUTTONS", "TAGS", "USER PROPERTIES"})
table.SetReflowDuringAutoWrap(false)
for _, v := range devices {
propStr := ""
for key, value := range v.UserProperties {
propStr += key + "=" + value + " "
}
table.Append([]string{
strconv.Itoa(v.ID),
v.Name,
v.Icon,
strings.Join(v.ButtonNames, ","),
strings.Join(v.Tags, ","),
propStr,
})
}
table.Render()
}

1
go.mod

@ -10,6 +10,7 @@ require (
github.com/lib/pq v1.10.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/olekukonko/tablewriter v0.0.5
github.com/pkg/errors v0.9.1 // indirect
github.com/pressly/goose v2.7.0+incompatible
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 // indirect

4
go.sum

@ -38,6 +38,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@ -45,6 +47,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

9
internal/mysql/devicerepo.go

@ -2,6 +2,7 @@ package mysql
import (
"context"
"database/sql"
"encoding/json"
"git.aiterp.net/lucifer/new-server/models"
sq "github.com/Masterminds/squirrel"
@ -66,6 +67,8 @@ func (r *DeviceRepo) FetchByReference(ctx context.Context, kind models.Reference
q = q.Where(sq.Eq{"id": strings.Split(value, ",")})
case models.RKBridgeID:
q = q.Where(sq.Eq{"bridge_id": strings.Split(value, ",")})
case models.RKName:
q = q.Where(sq.Like{"name": value})
case models.RKTag:
allTags := strings.Split(strings.ReplaceAll(strings.ReplaceAll(value, "-", ",-"), "+", ",+"), ",")
optionalTags := make([]string, 0, len(allTags))
@ -91,8 +94,12 @@ func (r *DeviceRepo) FetchByReference(ctx context.Context, kind models.Reference
return []models.Device{}, nil
}
query, args, err := q.ToSql()
query, args, err := q.OrderBy("name", "id").ToSql()
if err != nil {
if err == sql.ErrNoRows {
return []models.Device{}, nil
}
return nil, dbErr(err)
}

4
models/colorvalue.go

@ -23,10 +23,10 @@ func (c *ColorValue) IsKelvin() bool {
func (c *ColorValue) String() string {
if c.Kelvin > 0 {
return fmt.Sprintf("kelvin:%d", c.Kelvin)
return fmt.Sprintf("k:%d", c.Kelvin)
}
return fmt.Sprintf("hsv:%f,%f", c.Hue, c.Saturation)
return fmt.Sprintf("hs:%g,%g", c.Hue, c.Saturation)
}
func ParseColorValue(raw string) (ColorValue, error) {

27
models/device.go

@ -19,6 +19,12 @@ type Device struct {
Tags []string `json:"tags"`
}
type DeviceUpdate struct {
Icon *string `json:"icon"`
Name *string `json:"name"`
UserProperties map[string]*string `json:"userProperties"`
}
// DeviceState contains optional state values that
// - Power: Whether the device is powered on
// - Color: Color value, if a color setting can be set on the device
@ -35,7 +41,7 @@ type NewDeviceState struct {
Power *bool `json:"power"`
Color *string `json:"color"`
Intensity *float64 `json:"intensity"`
Temperature int `json:"temperature"`
Temperature *int `json:"temperature"`
}
type DeviceCapability string
@ -79,6 +85,25 @@ var Capabilities = []DeviceCapability{
DCTemperatureSensor,
}
func (d *Device) ApplyUpdate(update DeviceUpdate) {
if update.Name != nil {
d.Name = *update.Name
}
if update.Icon != nil {
d.Icon = *update.Icon
}
if d.UserProperties == nil {
d.UserProperties = make(map[string]string)
}
for key, value := range update.UserProperties {
if value != nil {
d.UserProperties[key] = *value
} else {
delete(d.UserProperties, key)
}
}
}
func (d *Device) Validate() error {
d.Name = strings.Trim(d.Name, " \t\n ")
if d.Name == "" {

3
models/shared.go

@ -7,6 +7,5 @@ var (
RKBridgeID ReferenceKind = "BridgeID"
RKTag ReferenceKind = "Tag"
RKAll ReferenceKind = "All"
RKName ReferenceKind = "Name"
)
Loading…
Cancel
Save