Skip to content

Commit

Permalink
[feat] Initial implementation for play (#9)
Browse files Browse the repository at this point in the history
implements #8
  • Loading branch information
xx4h authored Oct 3, 2024
1 parent 5f98436 commit 4283b75
Show file tree
Hide file tree
Showing 13 changed files with 400 additions and 4 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ hctl is a tool to control your Home Assistant (and maybe more in the future) dev
## Features

- Support for Home Assistant
- Play local and remote music files
- List all Domains & Domain-Services
- Turn on/off, or toggle all capable devices
- Completion for `bash`, `zsh`, `fish` and `powershell`, auto completing all capable devices
Expand Down Expand Up @@ -106,6 +107,8 @@ hctl on floor1
# Toggle a switch called "some-switch"
hctl toggle some_switch

# Play a local music file
hctl play myplayer ~/path/to/some.mp3
```

### Completion Short Names
Expand Down Expand Up @@ -159,7 +162,7 @@ handling:
fuzz: false
```
## Roadmap
## What's Next / Roadmap
- [ ] Add more actions (like `press` e.g. Buttons, `trigger` e.g. Automations, or `lock` and `unlock` a Lock)
- [ ] Add `config` command to actively set config options in the config file
Expand Down
44 changes: 44 additions & 0 deletions cmd/play.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright 2024 Fabian `xx4h` Sylvester
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"io"

"github.com/spf13/cobra"

"github.com/xx4h/hctl/pkg"
)

// toggleCmd represents the toggle command
func newPlayCmd(h *pkg.Hctl, _ io.Writer) *cobra.Command {
cmd := &cobra.Command{
Use: "play",
Short: "Play music from url on media player",
Aliases: []string{"p"},
Args: cobra.MatchAll(cobra.ExactArgs(2)),
ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return noMoreArgsComp()
}
return compListStates(toComplete, args, "play_media", "", h)
},
Run: func(_ *cobra.Command, args []string) {
h.PlayMusic(args[0], args[1])
},
}

return cmd
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func newRootCmd(h *pkg.Hctl, out io.Writer, _ []string) *cobra.Command {
newCompletionCmd(),
newOnCmd(h),
newOffCmd(h, out),
newPlayCmd(h, out),
)

cmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", zerolog.ErrorLevel.String(), "Set the log level")
Expand Down
16 changes: 16 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type Config struct {
Completion Completion `yaml:"completion"`
Handling Handling `yaml:"handling"`
Logging Logging `yaml:"logging"`
Serve Serve `yaml:"serve"`
Viper *viper.Viper
}

Expand All @@ -46,6 +47,11 @@ type Logging struct {
LogLevel string `yaml:"log_level"`
}

type Serve struct {
IP string `yaml:"ip"`
Port int `yaml:"port"`
}

func NewConfig() (*Config, error) {
v := viper.New()

Expand All @@ -61,11 +67,13 @@ func NewConfig() (*Config, error) {
cfg.Completion.ShortNames = true
cfg.Handling.Fuzz = true
cfg.Logging.LogLevel = "error"
cfg.Serve.Port = 1337

// use defaults for viper as well
v.SetDefault("completion", &cfg.Completion)
v.SetDefault("handling", &cfg.Handling)
v.SetDefault("logging", &cfg.Logging)
v.SetDefault("serve", &cfg.Serve)

if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
Expand All @@ -92,3 +100,11 @@ func NewConfig() (*Config, error) {

return cfg, nil
}

func (c *Config) GetServeIP() string {
return c.Serve.IP
}

func (c *Config) GetServePort() int {
return c.Serve.Port
}
45 changes: 45 additions & 0 deletions pkg/hctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package pkg

import (
"fmt"
"os"

"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
Expand All @@ -24,6 +25,8 @@ import (
i "github.com/xx4h/hctl/pkg/init"
o "github.com/xx4h/hctl/pkg/output"
"github.com/xx4h/hctl/pkg/rest"
"github.com/xx4h/hctl/pkg/serve"
"github.com/xx4h/hctl/pkg/util"
)

type Hctl struct {
Expand Down Expand Up @@ -113,6 +116,48 @@ func (h *Hctl) DumpStates(domains []string) {
}
}

func (h *Hctl) PlayMusic(obj string, mediaURL string) {
// handle url or file system path
if ok := util.IsURL(mediaURL); ok {
// if we already have a url, just play it

obj, state, sub, err := h.GetRest().PlayMusic(obj, mediaURL, mediaURL)
if err != nil {
o.PrintError(err)
}

o.PrintSuccessAction(obj, state)
log.Debug().Msgf("Result: %s(%s) to %s", obj, sub, state)

} else {
// if we don't have a url but a filepath

// check if file exists
_, err := os.Stat(mediaURL)
if err != nil {
o.PrintError(err)
}

// get new Media instance
s := serve.NewMedia(h.cfg.GetServeIP(), h.cfg.GetServePort(), mediaURL)
// start instance and wait until ready
s.FileHandler()
if err := s.WaitForHTTPReady(); err != nil {
log.Fatal().Msgf("HTTP server ready error: %v", err)
}

// we are ready and send the url to play
obj, state, sub, err := h.GetRest().PlayMusic(obj, s.GetURL(), s.GetMediaName())
if err != nil {
o.PrintError(err)
}

o.PrintSuccessAction(obj, state)
log.Debug().Msgf("Result: %s(%s) to %s", obj, sub, state)
s.WaitAndClose()
}
}

func (h *Hctl) SetLogging(level string) error {
lvl, err := zerolog.ParseLevel(level)
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions pkg/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
package output

import (
"os"

"github.com/pterm/pterm"
"github.com/pterm/pterm/putils"
)
Expand All @@ -35,6 +37,7 @@ func PrintSuccessAction(obj string, state string) {

func PrintError(err error) {
pterm.Error.Println(err)
os.Exit(1)
}

func PrintThreeLevelFlatTree(name string, tree map[string][]string) error {
Expand Down
48 changes: 48 additions & 0 deletions pkg/rest/play.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright 2024 Fabian `xx4h` Sylvester
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package rest

import "fmt"

func (h *Hass) play(mediaURL string, mediaType string, sub string, obj string) error {
hasDomain, err := h.hasDomainWithService(sub, "play_media")
if err != nil {
return err
} else if !hasDomain {
return fmt.Errorf("No such Domain with Service: %s with %s", sub, "play_media")
}
if !h.hasEntityInDomain(obj, sub) {
return fmt.Errorf("No such Entity in Domain: %s in %s", obj, sub)
}
payload := map[string]any{"entity_id": fmt.Sprintf("%s.%s", sub, obj), "media_content_id": mediaURL, "media_content_type": mediaType}
res, err := h.api("POST", fmt.Sprintf("/services/%s/play_media", sub), payload)
if err != nil {
return err
}

if err := h.getResult(res); err != nil {
return err
}

return nil
}

func (h *Hass) PlayMusic(obj string, mediaURL string, name string) (string, string, string, error) {
sub, obj, err := h.entityArgHandler([]string{obj}, "play_media")
if err != nil {
return "", "", "", err
}
return obj, fmt.Sprintf("playing %s", name), sub, h.play(mediaURL, "music", sub, obj)
}
2 changes: 1 addition & 1 deletion pkg/rest/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func (h *Hass) preflight() error {
return nil
}

func (h *Hass) api(meth string, path string, payload map[string]string) ([]byte, error) {
func (h *Hass) api(meth string, path string, payload map[string]any) ([]byte, error) {
if err := h.preflight(); err != nil {
return []byte{}, err
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/rest/toggle.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func (h *Hass) toggle(sub string, obj string) error {
if !h.hasEntityInDomain(obj, sub) {
return fmt.Errorf("No such Entity in Domain: %s in %s", obj, sub)
}
payload := map[string]string{"entity_id": fmt.Sprintf("%s.%s", sub, obj)}
payload := map[string]any{"entity_id": fmt.Sprintf("%s.%s", sub, obj)}
res, err := h.api("POST", fmt.Sprintf("/services/%s/toggle", sub), payload)
if err != nil {
return err
Expand Down
2 changes: 1 addition & 1 deletion pkg/rest/turn.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func (h *Hass) turn(state string, sub string, obj string) error {
if !h.hasEntityInDomain(obj, sub) {
return fmt.Errorf("No such Entity in Domain: %s in %s", obj, sub)
}
payload := map[string]string{"entity_id": fmt.Sprintf("%s.%s", sub, obj)}
payload := map[string]any{"entity_id": fmt.Sprintf("%s.%s", sub, obj)}
res, err := h.api("POST", fmt.Sprintf("/services/%s/turn_%s", sub, state), payload)
if err != nil {
return err
Expand Down
64 changes: 64 additions & 0 deletions pkg/serve/cwatch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2024 Fabian `xx4h` Sylvester
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package serve

import (
"net"
"net/http"
"sync"
)

/*
Big thanks to https://stackoverflow.com/a/62766994/1922402
*/

type ConnectionWatcher struct {
// mu protects remaining fields
mu sync.Mutex

// open connections are keys in the map
m map[net.Conn]struct{}
}

// OnStateChange records open connections in response to connection
// state changes. Set net/http Server.ConnState to this method
// as value.
func (cw *ConnectionWatcher) OnStateChange(conn net.Conn, state http.ConnState) {
switch state {
case http.StateNew:
cw.mu.Lock()
if cw.m == nil {
cw.m = make(map[net.Conn]struct{})
}
cw.m[conn] = struct{}{}
cw.mu.Unlock()
case http.StateHijacked, http.StateClosed:
cw.mu.Lock()
delete(cw.m, conn)
cw.mu.Unlock()
}
}

// Connections returns the open connections at the time
// the call.
func (cw *ConnectionWatcher) Connections() []net.Conn {
var result []net.Conn
cw.mu.Lock()
for conn := range cw.m {
result = append(result, conn)
}
cw.mu.Unlock()
return result
}
Loading

0 comments on commit 4283b75

Please sign in to comment.