diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..ac479062198d362bd8d66b53757cd6c7fc6fa7a4 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,24 @@ +image: homeassistant/amd64-builder + +variables: + REGISTRY: gcr.io/hedenstroem-docker + +before_script: + - cat $GCR_CREDENTIALS | docker login -u _json_key --password-stdin https://gcr.io + +stages: + - build + +.build: &build + stage: build + only: + - master + - /^\d+[.]\d+[.]\d+$/ + script: + - /usr/bin/builder.sh -t $CI_PROJECT_DIR/${CI_JOB_NAME%%_*} --${CI_JOB_NAME#*_} -i ${CI_JOB_NAME%%_*}-addon-{arch} -d $REGISTRY + +"nipca-motion_aarch64": *build +"nipca-motion_amd64": *build +"nipca-motion_armv7": *build +"nipca-motion_armhf": *build +"nipca-motion_i386": *build diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000000000000000000000000000000000..d4692f10988d077cf8e462a308d696b2f23f84ac --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,29 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Start Home Assistant", + "type": "shell", + "command": "sudo ./.devcontainer/supervisor.sh", + "group":"test", + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Run Home Assistant CLI", + "type": "shell", + "command": "docker exec -ti hassio_cli /usr/bin/cli.sh", + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + } + ] +} diff --git a/nipca-motion/CHANGELOG.md b/nipca-motion/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..6629d09c78195037481f47d2eb05d26000802645 --- /dev/null +++ b/nipca-motion/CHANGELOG.md @@ -0,0 +1,3 @@ +## [0.1.0 2021-4-26] +### Fixes +- TBD diff --git a/nipca-motion/config.json b/nipca-motion/config.json index 62e77f46ceac4ea9e57a118703fb1d7ff10db19e..ddadb50bdb85e93a3510c3eecbd17866b7e58010 100644 --- a/nipca-motion/config.json +++ b/nipca-motion/config.json @@ -1,23 +1,28 @@ { "name": "NIPCA Motion", - "version": "0.3", + "version": "0.1", "slug": "nipca-motion", "description": "Motion and sound sensors for NIPCA-compatible cameras", - "url": "https://gitlab.hedenstroem.com/hassio/addons/nipca-motion", + "url": "https://gitlab.hedenstroem.com/home-assistant/addons/-/tree/master/nipca-motion", "init": false, "services": ["mqtt:need"], "hassio_api": true, "arch": ["aarch64", "amd64", "armhf", "armv7", "i386"], "options": { "log_level": "info", + "retry_delay": 60, + "keep_alive_timeout": 30, "cameras": [ { "url": "http://localhost", "username": "admin", "password": "password" } ] }, "schema": { "log_level": "list(trace|debug|info|warn|error|fatal|panic)", + "retry_delay": "int(5,300)", + "keep_alive_timeout": "int(5,300)", "cameras": [ { "url": "str", "username": "str", "password": "str" } ] - } + }, + "image": "gcr.io/hedenstroem-docker/nipca-motion-addon-{arch}" } diff --git a/nipca-motion/src/camera.go b/nipca-motion/src/camera.go new file mode 100644 index 0000000000000000000000000000000000000000..338deca27138eb303a72a03ccdcac90925929fa2 --- /dev/null +++ b/nipca-motion/src/camera.go @@ -0,0 +1,122 @@ +package main + +import ( + "bufio" + "errors" + "io/ioutil" + "net/http" + "strings" + "time" + + "github.com/spf13/viper" +) + +type Camera struct { + Name string + URL string + Username string + Password string +} + +type StreamResponse struct { + camera Camera + fields []string + err error +} + +func (c *Camera) getConfig() (*viper.Viper, error) { + + client := &http.Client{ + Timeout: time.Second * 10, + } + + req, err := http.NewRequest("GET", c.URL+"/common/info.cgi", nil) + if err != nil { + return nil, err + } + req.SetBasicAuth(c.Username, c.Password) + + res, err := client.Do(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + bytes, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + data := make(map[string]interface{}) + for _, line := range strings.Split(string(bytes), "\n") { + fields := strings.Split(strings.TrimSpace(string(line)), "=") + if len(fields) > 1 { + data[fields[0]] = fields[1] + } + } + + log.Debugf("%s info: %+v", c.URL, data) + + config := viper.New() + config.MergeConfigMap(data) + + c.Name = config.GetString("name") + + return config, nil +} + +func (c Camera) openStream(q chan StreamResponse) { + + client := &http.Client{ + Timeout: 0, + } + + req, err := http.NewRequest("GET", c.URL+"/config/notify_stream.cgi", nil) + if err != nil { + q <- StreamResponse{c, nil, err} + return + } + req.SetBasicAuth(c.Username, c.Password) + + res, err := client.Do(req) + if err != nil { + q <- StreamResponse{c, nil, err} + return + } + + log.Infof("%s stream open", c.Name) + + defer res.Body.Close() + reader := bufio.NewReader(res.Body) + + cf := make(chan []string, 1) + ce := make(chan error, 1) + + go func() { + for { + s, e := reader.ReadString('\n') + if e != nil { + ce <- e + return + } + cf <- strings.Split(strings.TrimSpace(s), "=") + } + }() + + delay := viper.GetDuration("keep_alive_timeout") * time.Second + for { + select { + case f := <-cf: + if len(f) > 0 && f[0] != "" { + q <- StreamResponse{c, f, nil} + } + case e := <-ce: + q <- StreamResponse{c, nil, e} + return + case <-time.After(delay): + q <- StreamResponse{c, nil, errors.New("keep alive timeout")} + return + } + } + +} diff --git a/nipca-motion/src/go.mod b/nipca-motion/src/go.mod index f79158f4e708769fd584ad4e6e66e8fe68f85dcb..37dd3e0d07c8a21fb26b7b2d054faa4c8878b815 100644 --- a/nipca-motion/src/go.mod +++ b/nipca-motion/src/go.mod @@ -3,7 +3,6 @@ module gitlab.hedenstroem.com/hassio/addons/nipca-motion/nipca-motion go 1.16 require ( - github.com/davecgh/go-spew v1.1.1 github.com/eclipse/paho.mqtt.golang v1.3.3 github.com/joho/godotenv v1.3.0 github.com/sirupsen/logrus v1.8.1 diff --git a/nipca-motion/src/hass.go b/nipca-motion/src/hass.go new file mode 100644 index 0000000000000000000000000000000000000000..b061b6cef225f92f26d8a0b4e94431fb060c829b --- /dev/null +++ b/nipca-motion/src/hass.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" +) + +type Device struct { + Identifiers string `json:"identifiers" mapstructure:"macaddr"` + Manufacturer string `json:"manufacturer" mapstructure:"brand"` + Model string `json:"model"` + Name string `json:"name"` + SoftwareVersion string `json:"sw_version" mapstructure:"version"` +} + +type Availability struct { + Topic string `json:"topic"` +} + +type Config struct { + Availability []Availability `json:"availability"` + AvailabilityMode string `json:"availability_mode"` + UniqueId string `json:"unique_id"` + Device Device `json:"device"` + DeviceClass string `json:"device_class"` + Name string `json:"name"` + StateTopic string `json:"state_topic"` +} + +func publishDiscoveryInformation(mqttClient MqttClient, camera *Camera) error { + + cameraConfig, err := camera.getConfig() + if err != nil { + return err + } + + device := Device{} + cameraConfig.Unmarshal(&device) + + prefix := fmt.Sprintf("%s/binary_sensor/%s", mqttClient.Config.DiscoveryPrefix, device.Name) + + mqttClient.Publish(prefix+"/availability", 1, true, "offline") + if err != nil { + return err + } + + config := Config{ + Availability: []Availability{ + {mqttClient.Config.DiscoveryPrefix + "/addon/nipca-motion/availability"}, + {prefix + "/availability"}, + }, + AvailabilityMode: "all", + Device: device, + } + + config.UniqueId = device.Name + "-MOTION" + config.DeviceClass = "motion" + config.Name = device.Name + " Motion" + config.StateTopic = prefix + "/Motion/state" + + log.Debugf("%s: %+v", config.Name, config) + + err = mqttClient.Publish(prefix+"/Motion/config", 1, true, config) + if err != nil { + return err + } + + config.UniqueId = device.Name + "-SOUND" + config.DeviceClass = "sound" + config.Name = device.Name + " Sound" + config.StateTopic = prefix + "/Sound/state" + + log.Debugf("%s: %+v", config.Name, config) + + return mqttClient.Publish(prefix+"/Sound/config", 1, true, config) + +} diff --git a/nipca-motion/src/main.go b/nipca-motion/src/main.go index fdb2867451d29f1ae5c83048fdd09e63cd452eaf..84bd530bc96232b03e799a15f8818cf8b169e243 100644 --- a/nipca-motion/src/main.go +++ b/nipca-motion/src/main.go @@ -1,11 +1,8 @@ package main import ( - "encoding/json" - "errors" "fmt" - "io/ioutil" - "net/http" + "strings" "time" mqtt "github.com/eclipse/paho.mqtt.golang" @@ -14,38 +11,8 @@ import ( "github.com/spf13/viper" ) -type MqttConfiguration struct { - Host string `json:"host"` - Port int `json:"port"` - SSL bool `json:"ssl"` - Protocol string `json:"protocol"` - Username string `json:"username"` - Password string `json:"password"` - Addon string `json:"addon"` - ClientID string `mapstructure:"client_id"` - DiscoveryPrefix string `mapstructure:"discovery_prefix"` -} - -type MqttService struct { - Result string `json:"result"` - Message string `json:"message"` - Config MqttConfiguration `json:"data"` -} - var log *logrus.Logger -type MqttLogger struct { - level logrus.Level -} - -func (logger MqttLogger) Println(args ...interface{}) { - log.Log(logger.level, args) -} - -func (logger MqttLogger) Printf(format string, args ...interface{}) { - log.Logf(logger.level, format, args) -} - func loadOptions() { godotenv.Load() viper.AutomaticEnv() @@ -58,6 +25,8 @@ func loadOptions() { viper.BindEnv("discovery_prefix", "MQTT_DISCOVERY_PREFIX") viper.RegisterAlias("token", "SUPERVISOR_TOKEN") viper.SetDefault("log_level", "info") + viper.SetDefault("retry_delay", 60) + viper.SetDefault("keep_alive_timeout", 30) viper.SetDefault("discovery_prefix", "homeassistant") viper.SetDefault("config_path", "/data") viper.SetDefault("config_name", "options") @@ -67,79 +36,11 @@ func loadOptions() { if err := viper.ReadInConfig(); err != nil { log.Warn(err) } - if viper.GetString("log_level") == "debug" { + if viper.GetString("log_level") == "trace" { viper.Debug() } } -func getMqttConfiguration() (MqttConfiguration, error) { - - service := MqttService{} - viper.Unmarshal(&service.Config) - - token := viper.GetString("token") - if token == "" { - return service.Config, nil - } - - client := &http.Client{ - Timeout: time.Second * 10, - } - - req, err := http.NewRequest("GET", "http://supervisor/services/mqtt", nil) - if err != nil { - return service.Config, err - } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - - res, err := client.Do(req) - if err != nil { - return service.Config, err - } - - defer res.Body.Close() - bytes, err := ioutil.ReadAll(res.Body) - if err != nil { - return service.Config, err - } - - err = json.Unmarshal(bytes, &service) - - if service.Result == "error" { - return service.Config, errors.New("MQTT Configuration: " + service.Message) - } - - return service.Config, err - -} - -func openMqttConnection(config MqttConfiguration) (mqtt.Client, error) { - scheme := "tcp" - if config.SSL { - scheme = "ssl" - } - broker := fmt.Sprintf("%s://%s:%d", scheme, config.Host, config.Port) - availabilityTopic := fmt.Sprintf("%s/addon/nipca-motion/availability", config.DiscoveryPrefix) - opts := mqtt.NewClientOptions() - opts.AddBroker(broker).SetClientID(config.ClientID) - opts.SetWill(availabilityTopic, "offline", 1, true) - opts.SetUsername(config.Username).SetPassword(config.Password) - client := mqtt.NewClient(opts) - token := client.Connect() - token.Wait() - err := token.Error() - if err != nil { - return nil, err - } - token = client.Publish(availabilityTopic, 1, true, "online") - token.Wait() - err = token.Error() - if err != nil { - return nil, err - } - return client, nil -} - func main() { log = logrus.New() @@ -148,11 +49,11 @@ func main() { if err == nil { log.SetLevel(level) } - log.Info("Log level: ", level) + log.Infof("Log level: %s", level) mqtt.ERROR = MqttLogger{level: logrus.ErrorLevel} mqtt.CRITICAL = MqttLogger{level: logrus.ErrorLevel} mqtt.WARN = MqttLogger{level: logrus.WarnLevel} - mqtt.DEBUG = MqttLogger{level: logrus.DebugLevel} + mqtt.DEBUG = MqttLogger{level: logrus.TraceLevel} mqttConfig, err := getMqttConfiguration() if err != nil { @@ -164,6 +65,57 @@ func main() { if err != nil { log.Fatal(err) } - mqttClient.IsConnected() + + var cameras []Camera + viper.UnmarshalKey("cameras", &cameras) + log.Debugf("Cameras: %+v", cameras) + + q := make(chan StreamResponse) + + for _, camera := range cameras { + err := publishDiscoveryInformation(mqttClient, &camera) + if err != nil { + log.Warnf("%s: %s", camera.URL, err) + } + go camera.openStream(q) + } + + delay := viper.GetDuration("retry_delay") * time.Second + for { + res := <-q + if res.err == nil { + log.Debugf("%s: %+v", res.camera.Name, res.fields) + switch res.fields[0] { + case "md1": + topic := fmt.Sprintf("%s/binary_sensor/%s/Motion/state", mqttClient.Config.DiscoveryPrefix, res.camera.Name) + mqttClient.Publish(topic, 0, true, strings.ToUpper(res.fields[1])) + case "audio_detected": + topic := fmt.Sprintf("%s/binary_sensor/%s/Sound/state", mqttClient.Config.DiscoveryPrefix, res.camera.Name) + mqttClient.Publish(topic, 0, true, strings.ToUpper(res.fields[1])) + case "cameraname": + topic := fmt.Sprintf("%s/binary_sensor/%s/availability", mqttClient.Config.DiscoveryPrefix, res.camera.Name) + mqttClient.Publish(topic, 1, true, "online") + case "keep_alive": + topic := fmt.Sprintf("%s/binary_sensor/%s/availability", mqttClient.Config.DiscoveryPrefix, res.camera.Name) + mqttClient.Publish(topic, 0, true, "online") + } + } else { + if res.camera.Name != "" { + log.Warnf("%s: %s, retrying in %s", res.camera.Name, res.err, delay) + topic := fmt.Sprintf("%s/binary_sensor/%s/availability", mqttClient.Config.DiscoveryPrefix, res.camera.Name) + mqttClient.Publish(topic, 1, true, "offline") + } else { + log.Warnf("%s: %s, retrying in %s", res.camera.URL, res.err, delay) + } + go func() { + time.Sleep(delay) + err := publishDiscoveryInformation(mqttClient, &res.camera) + if err != nil { + log.Warnf("%s: %s", res.camera.URL, err) + } + res.camera.openStream(q) + }() + } + } } diff --git a/nipca-motion/src/mqtt.go b/nipca-motion/src/mqtt.go new file mode 100644 index 0000000000000000000000000000000000000000..9ab429c053d304220176372707a26826cf538157 --- /dev/null +++ b/nipca-motion/src/mqtt.go @@ -0,0 +1,135 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "reflect" + "time" + + mqtt "github.com/eclipse/paho.mqtt.golang" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" +) + +type MqttConfig struct { + Host string `json:"host"` + Port int `json:"port"` + SSL bool `json:"ssl"` + Protocol string `json:"protocol"` + Username string `json:"username"` + Password string `json:"password"` + Addon string `json:"addon"` + ClientID string `mapstructure:"client_id"` + DiscoveryPrefix string `mapstructure:"discovery_prefix"` +} + +type MqttService struct { + Result string `json:"result"` + Message string `json:"message"` + Config MqttConfig `json:"data"` +} + +type MqttLogger struct { + level logrus.Level +} + +func (logger MqttLogger) Println(args ...interface{}) { + log.Log(logger.level, args) +} + +func (logger MqttLogger) Printf(format string, args ...interface{}) { + log.Logf(logger.level, format, args) +} + +type MqttClient struct { + Config MqttConfig + client mqtt.Client +} + +func (mc MqttClient) Publish(topic string, qos byte, retained bool, payload interface{}) (err error) { + msg := payload + if reflect.TypeOf(payload).String() != "string" { + msg, err = json.MarshalIndent(payload, "", " ") + if err != nil { + return + } + } + token := mc.client.Publish(topic, qos, retained, msg) + token.Wait() + return token.Error() +} + +func getMqttConfiguration() (MqttConfig, error) { + + service := MqttService{} + viper.Unmarshal(&service.Config) + + token := viper.GetString("token") + if token == "" { + return service.Config, nil + } + + client := &http.Client{ + Timeout: time.Second * 10, + } + + req, err := http.NewRequest("GET", "http://supervisor/services/mqtt", nil) + if err != nil { + return service.Config, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + res, err := client.Do(req) + if err != nil { + return service.Config, err + } + + defer res.Body.Close() + bytes, err := ioutil.ReadAll(res.Body) + if err != nil { + return service.Config, err + } + + err = json.Unmarshal(bytes, &service) + + if service.Result == "error" { + return service.Config, errors.New("MQTT Configuration: " + service.Message) + } + + return service.Config, err + +} + +func openMqttConnection(config MqttConfig) (MqttClient, error) { + + scheme := "tcp" + if config.SSL { + scheme = "ssl" + } + broker := fmt.Sprintf("%s://%s:%d", scheme, config.Host, config.Port) + availabilityTopic := config.DiscoveryPrefix + "/addon/nipca-motion/availability" + opts := mqtt.NewClientOptions() + opts.AddBroker(broker).SetClientID(config.ClientID) + opts.SetWill(availabilityTopic, "offline", 1, true) + opts.SetUsername(config.Username).SetPassword(config.Password) + + client := mqtt.NewClient(opts) + token := client.Connect() + token.Wait() + err := token.Error() + if err != nil { + return MqttClient{}, err + } + + c := MqttClient{config, client} + err = c.Publish(availabilityTopic, 1, true, "online") + if err != nil { + return c, err + } + + log.Infof("MQTT Client connected to %s", broker) + return c, nil +}