Gisle Aune
3 years ago
13 changed files with 632 additions and 11 deletions
-
45app/api/devices.go
-
136app/client/client.go
-
18app/services/events.go
-
183cmd/lucy/command.go
-
18cmd/lucy/help.go
-
115cmd/lucy/main.go
-
74cmd/lucy/tables.go
-
1go.mod
-
4go.sum
-
9internal/mysql/devicerepo.go
-
4models/colorvalue.go
-
27models/device.go
-
3models/shared.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, |
|||
} |
@ -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] = ¶m.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 |
|||
} |
@ -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:] |
@ -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:]) |
|||
} |
|||
} |
@ -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() |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue